diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 8432852c3..668d418fe 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -29,18 +29,23 @@ jobs: steps: - name: Check out sources - uses: actions/checkout@v7 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false + - name: Check build supply chain + run: | + ./scripts/test-check-build-supply-chain.sh + ./scripts/check-build-supply-chain.sh + - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: temurin java-version: "21" - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - name: Run :shared unit tests and coverage gate shell: bash @@ -60,7 +65,7 @@ jobs: - name: Upload test reports if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: unit-test-reports path: | @@ -69,7 +74,7 @@ jobs: - name: Upload coverage reports if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: kover-coverage-reports path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f66b8bd64..ee3a2f850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -181,18 +181,23 @@ jobs: steps: - name: Check out sources - uses: actions/checkout@v7 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false + - name: Check build supply chain + run: | + ./scripts/test-check-build-supply-chain.sh + ./scripts/check-build-supply-chain.sh + - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: temurin java-version: "21" - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - name: Run unit tests shell: bash @@ -206,7 +211,7 @@ jobs: - name: Upload test reports if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: unit-test-reports path: | @@ -224,20 +229,20 @@ jobs: steps: - name: Check out sources - uses: actions/checkout@v7 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: temurin java-version: "21" - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: bundler-cache: true @@ -286,18 +291,18 @@ jobs: steps: - name: Check out sources - uses: actions/checkout@v7 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: temurin java-version: "21" - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - name: Decode release keystore shell: bash @@ -378,7 +383,7 @@ jobs: done - name: Upload release APKs - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: ${{ matrix.artifact-name }} path: release-artifacts/${{ matrix.artifact-name }}/*.apk @@ -398,7 +403,7 @@ jobs: steps: - name: Download APK artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: pattern: android-*-release-apks path: release-artifacts diff --git a/.superpowers/sdd/2026-07-27-pr108-slice-e-transactional-subtitles/final-review-fix-report.md b/.superpowers/sdd/2026-07-27-pr108-slice-e-transactional-subtitles/final-review-fix-report.md new file mode 100644 index 000000000..b478056f3 --- /dev/null +++ b/.superpowers/sdd/2026-07-27-pr108-slice-e-transactional-subtitles/final-review-fix-report.md @@ -0,0 +1,302 @@ +# Slice E final-review fix report + +## Status + +Implemented all three Important findings from `final-review-fix-brief.md`: + +1. final track-selection writes now carry the playback's captured + `PlaybackWriteScope` through the phone/TV adapter context into Room, which + rejects the write after a server, profile, credential-overlay, or auth + identity-generation switch; +2. phone and TV adapters now obtain ordering tickets from one process-global + `PlaybackTrackSelectionWriteCoordinator`, keyed by captured auth scope plus + content/file identity, so a retired adapter cannot overwrite a replacement + adapter's newer durable selection; +3. `PgsSupExtractor` now fails closed once one display set exceeds 16 MiB or + 512 segments, before allocating/reading the segment that crosses the bound. + +The pre-existing modification to +`docs/superpowers/plans/2026-07-27-pr108-slice-e-transactional-subtitles.md` +was preserved and excluded from this fix. + +## Design + +### Captured auth ownership + +- Added the scoped `UserItemStatePort.recordTrackSelection(...)` overload, + parallel to the existing scoped final-position API. +- `RoomUserItemStateRepository` compares server id, profile id, credential + generation, and identity generation against one current auth snapshot. A + mismatch returns `false` without writing either the old or current partition. +- `PlayerViewModel` and `TvPlayerViewModel` attach the scope captured at playback + load to every subtitle playback context. A context without a captured scope + fails closed and does not create a persistence request. + +### Cross-adapter latest-write-wins + +- Each persistence request captures a monotonically increasing ticket when the + committed selection is captured, not when its delayed coroutine happens to + run. +- The process coordinator retains per-key started/durable sequence state and a + per-key mutex. This preserves existing adapter FIFO/retry behavior, permits + unrelated content keys to proceed independently, and suppresses an older + retired-adapter ticket after a newer replacement-adapter ticket is durable. +- Phone and TV use the same coordinator implementation and the same process + singleton within their respective app process. + +### Bounded PGS framing + +- The extractor counts the container-shaped bytes and segments accumulated for + the current display set. +- Crossing either bound discards the incomplete display set, marks the + extractor failed closed, and returns end-of-input without reading the + offending payload. +- Complete display sets, timestamp/offset behavior, identity preservation, + truncation behavior, seek reset, and END-segment parsing are unchanged. + +## Files + +Production: + +- `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/UserItemStatePort.kt` +- `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepository.kt` +- `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTrackSelectionWriteCoordinator.kt` +- `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractor.kt` +- `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt` +- `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt` +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt` +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt` + +Tests: + +- `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepositoryTest.kt` +- `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackTrackSelectionWriteCoordinatorTest.kt` +- `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractorTest.kt` +- `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt` +- `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TestPlaybackWriteScope.kt` +- TV transactional test context helpers updated to provide an explicit captured + test scope: `SubtitleTransactionIntegrationTest`, + `TvSubtitleFinalRollbackTest`, `TvSubtitleMountDeadlineTest`, + `TvSubtitleRefreshOwnershipTest`, `TvSubtitleSettlementOwnershipTest`, and + `TvSubtitleTransactionAdapterTest`. + +## TDD evidence + +### RED: malformed PGS + +Command: + +```text +./gradlew :android-shared:testDebugUnitTest --tests 'org.prairieserver.prairie.common.player.subtitle.PgsSupExtractorTest' +``` + +Observed: + +```text +PgsSupExtractorTest > anOversizedDisplaySetWithoutEndFailsClosedBeforeConsumingTheStream FAILED +PgsSupExtractorTest > tooManySegmentsWithoutEndFailClosedBeforeConsumingTheStream FAILED +8 tests completed, 2 failed +BUILD FAILED +``` + +The byte-bound test showed the current extractor consumed the full oversized +stream. The segment-bound test hit the test drain guard because the extractor +continued accepting segments indefinitely. + +### RED: auth scope and process-global ordering + +Command: + +```text +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.data.repository.RoomUserItemStateRepositoryTest' \ + --tests 'org.prairieserver.prairie.common.player.PlaybackTrackSelectionWriteCoordinatorTest' +``` + +Observed compile RED: + +```text +RoomUserItemStateRepositoryTest: no applicable scoped recordTrackSelection +PlaybackTrackSelectionWriteCoordinatorTest: unresolved reference +Task :android-shared:compileDebugUnitTestKotlinAndroid FAILED +BUILD FAILED +``` + +This established that neither the scope-bound repository contract nor shared +ordering owner existed before implementation. + +## GREEN evidence + +Fresh focused verification command: + +```text +./gradlew \ + :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.data.repository.RoomUserItemStateRepositoryTest' \ + --tests 'org.prairieserver.prairie.common.player.PlaybackTrackSelectionWriteCoordinatorTest' \ + --tests 'org.prairieserver.prairie.common.player.subtitle.PgsSupExtractorTest' \ + :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.player.MobileSubtitleTransactionAdapterTest' \ + :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleTransactionAdapterTest' \ + --rerun-tasks +``` + +Observed: + +```text +RoomUserItemStateRepositoryTest: 33 tests, 0 failures/errors +PlaybackTrackSelectionWriteCoordinatorTest: 1 test, 0 failures/errors +PgsSupExtractorTest: 8 tests, 0 failures/errors +MobileSubtitleTransactionAdapterTest: 49 tests, 0 failures/errors +TvSubtitleTransactionAdapterTest: 79 tests, 0 failures/errors +BUILD SUCCESSFUL in 26s +118 actionable tasks: 118 executed +``` + +Total focused result: 170 tests, 0 failures, 0 errors. + +## Round 2 review fixes + +### Status and design + +- Phone and TV persistence ports now return the scoped Room write result. + A coordinator ticket becomes durable only when Room returns `true`; `false` + results are retried by the existing bounded adapter policy and remain + non-durable when every attempt is rejected. +- TV teardown now reserves its ordering ticket synchronously before + `invalidateAndSettleAsync`. The callback pairs that reserved ticket and + captured playback context with the exact committed subtitle identity visible + after settlement, preventing a retired TV adapter from overwriting a newer + replacement adapter. +- Coordinator state is retained only while tickets for a key remain + outstanding. Successful, suppressed, rejected, cancelled, and unqueued + requests resolve or abandon their ticket, allowing the per-key state to be + removed without weakening stale-write suppression. + +Deterministic tests cover rejected scoped writes, bounded retry/flush behavior, +two real TV adapters separated by a gated teardown callback, and reclamation +across 2,000 distinct coordinator keys. + +### RED evidence + +Command: + +```text +./gradlew \ + :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.player.PlaybackTrackSelectionWriteCoordinatorTest' \ + :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.player.MobileSubtitleTransactionAdapterTest' \ + :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleTransactionAdapterTest' +``` + +Observed compile RED: + +```text +PlaybackTrackSelectionWriteCoordinatorTest: unresolved reference activeKeyCount +Mobile/TV test persistence implementations returning Boolean were incompatible + with the Unit-returning persistence ports +TvSubtitleTransactionAdapterTest: unresolved durable reservation API +BUILD FAILED +``` + +### GREEN evidence + +Fresh focused verification command: + +```text +./gradlew \ + :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.data.repository.RoomUserItemStateRepositoryTest' \ + --tests 'org.prairieserver.prairie.common.player.PlaybackTrackSelectionWriteCoordinatorTest' \ + --tests 'org.prairieserver.prairie.common.player.subtitle.PgsSupExtractorTest' \ + :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.player.MobileSubtitleTransactionAdapterTest' \ + :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleTransactionAdapterTest' \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleSettlementOwnershipTest' \ + --rerun-tasks +``` + +Observed: + +```text +RoomUserItemStateRepositoryTest: 33 tests, 0 failures/errors +PlaybackTrackSelectionWriteCoordinatorTest: 2 tests, 0 failures/errors +PgsSupExtractorTest: 8 tests, 0 failures/errors +MobileSubtitleTransactionAdapterTest: 51 tests, 0 failures/errors +TvSubtitleTransactionAdapterTest: 82 tests, 0 failures/errors +TvSubtitleSettlementOwnershipTest: 32 tests, 0 failures/errors +BUILD SUCCESSFUL in 24s +118 actionable tasks: 118 executed +``` + +Round 2 focused result: 208 tests, 0 failures, 0 errors. + +## Round 3 review fix + +### Status and design + +The coordinator now counts every active or mutex-waiting `write` invocation +for a key. Registration is atomic with the ticket's unresolved check, and +unregistration runs in an exception/cancellation-safe `finally` block. The +key's state and mutex are removed only after both its outstanding-ticket count +and write-invocation count reach zero. A replacement capture therefore reuses +the old mutex until every abandoned primary/fallback invocation has exited. + +The deterministic regression test gates an old primary inside persistence, +queues a same-ticket fallback, abandons the ticket twice, and begins a +replacement write. It verifies the replacement cannot enter persistence before +the primary is released, the abandoned fallback never persists, the final +durable value is the replacement's `B`, and the state is reclaimed afterward. + +### RED evidence + +Command: + +```text +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.player.PlaybackTrackSelectionWriteCoordinatorTest' +``` + +Observed: + +```text +PlaybackTrackSelectionWriteCoordinatorTest > + abandonedBlockedWriteKeepsReplacementSerializedUntilOldWriteExits FAILED +3 tests completed, 1 failed +BUILD FAILED in 2s +``` + +The replacement entered persistence while the old primary was still gated, +demonstrating that abandonment had created a second mutex for the same key. + +### GREEN evidence + +Fresh focused verification command: + +```text +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.player.PlaybackTrackSelectionWriteCoordinatorTest' \ + --rerun-tasks +``` + +Observed: + +```text +PlaybackTrackSelectionWriteCoordinatorTest: 3 tests, 0 failures/errors +BUILD SUCCESSFUL in 15s +65 actionable tasks: 65 executed +``` + +## Concerns + +- Coordinator memory is bounded by unresolved tickets and active/waiting write + invocations rather than all keys seen during the process. A genuinely + outstanding request keeps its small per-key ordering record until the ticket + is resolved and every invocation using its mutex has exited. +- Existing Gradle/Kotlin deprecation and opt-in warnings remain; the focused + run introduced no new warning from the changed production/test files. +- No push, merge, or PR action was performed. 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 new file mode 100644 index 000000000..5508e5c20 --- /dev/null +++ b/.superpowers/sdd/2026-07-27-pr108-slice-f-watch-together/task-1-report.md @@ -0,0 +1,260 @@ +# Slice F Task 1 report + +## Status + +Implemented realtime transport semantics and credential boundaries only: + +- explicit decoded `room_closed` remains the terminal + `RoomRealtimeEvent.Closed` protocol event; +- writable handshakes publish `RoomRealtimeEvent.Opened`; +- physical EOF, handshake failure, and socket I/O failure publish typed + `RoomRealtimeEvent.TransportTerminated`; +- connection-owner cancellation is rethrown without publishing either close + event; +- all client and repository send methods return actual Boolean delivery; +- socket session assignment and cleanup are guarded by a mutex and connection + identity, so an old finalizer cannot clear its replacement; +- the websocket URL encodes room id as one path segment, removes the access + JWT, and retains only the server-required room/profile query fields; +- `ws://` and `wss://` canonicalize to their corresponding HTTP origins for + same-origin and cleartext-consent checks. + +No repository reconnect/lease redesign, `RoomSession`, controller, or UI work +was implemented. + +## Design + +### Testable physical socket boundary + +`DefaultWatchTogetherRealtimeClient` uses a small common socket connector seam. +The production connector is a real Ktor `webSocketSession`; common tests use +controlled connections to deterministically exercise EOF, receive/open +failure, cancellation, send failure, concurrent replacement, and cleanup. + +The active physical connection is assigned before `Opened`. Both assignment +and send lookup use one mutex. Finalization clears the mutable send target only +when it is still the exact connection being finalized. + +### Protocol versus transport termination + +`decodeRoomFrame` alone creates `Closed`, and only from a `room_closed` frame. +The socket loop creates `TransportTerminated` for normal EOF and failures. +Cancellation is handled before the general failure branch and rethrown, so an +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 +`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. + +Cleartext consent canonicalizes `ws://host[:port]` to +`http://host[:port]` and `wss://` to `https://`. It passes the canonical HTTP +origin to the consent store, allowing an existing exact-origin approval to +authorize the corresponding websocket. Unapproved cleartext fails in the auth +plugin before the OkHttp engine observes any header or query credential. + +## Tests + +Common: + +- `RoomFrameDecoderTest` +- `WatchTogetherRealtimeClientTest` +- `HttpOriginPolicyTest` +- `PrairieAuthPluginPinTest` +- `WatchTogetherRepositoryTest` + +Android/JVM real transport: + +- `WatchTogetherRealtimeWebSocketTest` + +The real test uses the production Ktor/OkHttp client against +`MockWebServer`. It verifies the encoded handshake path, JWT absence from the +query, retained room/profile query contract, auth/profile headers, +open-before-early-snapshot ordering, outbound attach/ping ordering, normal +physical EOF, owner cancellation, and pre-engine cleartext rejection. + +## TDD evidence + +### RED + +Command: + +```text +./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.network.WatchTogetherRealtimeClientTest' \ + --tests 'org.prairieserver.prairie.network.HttpOriginPolicyTest' \ + --tests 'org.prairieserver.prairie.network.PrairieAuthPluginPinTest' +``` + +Observed compile RED: + +```text +Unresolved reference 'Opened' +Unresolved reference 'TransportTerminated' +Unit send results were incompatible with Boolean delivery assertions +WatchTogetherSocketConnector / WatchTogetherSocketConnection were unresolved +No socket-seam constructor existed +BUILD FAILED in 1s +``` + +This established the missing physical/protocol distinction, writable epoch, +Boolean send contract, and testable connection-ownership boundary before +production changes. + +The first real-websocket run then passed every app assertion except +MockWebServer shutdown in the cancellation test. Its server listener observed +the client close but did not answer the close handshake, leaving the dispatcher +queue alive. Adding the correct server-side close response fixed the fixture; +no production behavior changed for that failure. + +### GREEN + +Fresh focused command: + +```text +./gradlew \ + :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.network.RoomFrameDecoderTest' \ + --tests 'org.prairieserver.prairie.network.WatchTogetherRealtimeClientTest' \ + --tests 'org.prairieserver.prairie.network.HttpOriginPolicyTest' \ + --tests 'org.prairieserver.prairie.network.PrairieAuthPluginPinTest' \ + --tests 'org.prairieserver.prairie.repository.WatchTogetherRepositoryTest' \ + :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.network.WatchTogetherRealtimeWebSocketTest' \ + --rerun-tasks +``` + +Observed: + +```text +RoomFrameDecoderTest: 10 tests, 0 failures/errors +WatchTogetherRealtimeClientTest: 7 tests, 0 failures/errors +HttpOriginPolicyTest: 13 tests, 0 failures/errors +PrairieAuthPluginPinTest: 20 tests, 0 failures/errors +WatchTogetherRepositoryTest: 11 tests, 0 failures/errors +WatchTogetherRealtimeWebSocketTest: 3 tests, 0 failures/errors +BUILD SUCCESSFUL in 16s +70 actionable tasks: 70 executed +``` + +Total: 64 tests, 0 failures, 0 errors. + +## Concerns + +- Room/profile credentials remain in the query because the server contract + still requires them. The access JWT is removed, and the residual exposure is + documented. +- `WatchTogetherRepository` only receives the new open/termination event and + Boolean send surface in this task. Attempt counting, stable-window reset, + immutable leases, and reconnect ownership are intentionally deferred to + Slice F Task 2. +- Existing Kotlin/Gradle deprecation and coroutine opt-in warnings remain. +- No push, PR, or merge was performed. + +## Review correction: coherent websocket auth scope + +The websocket handshake now captures `snapshotCurrentScope()` exactly once at +connect start. It validates the access token against that exact snapshot, +derives `profile_id` and `profile_token` from the same snapshot, and passes the +snapshot through the connector boundary. The production Ktor connector applies +`authScope(scope)` to the websocket request, so URL rebasing, bearer lookup, and +profile headers remain pinned even if the globally-active server, profile, or +temporary identity changes before the engine builds the handshake. A missing +snapshot or missing exact-scope access token terminates before the connector +opens. + +### Correction TDD evidence + +RED command: + +```text +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.network.WatchTogetherRealtimeWebSocketTest.handshake remains entirely on captured auth scope when active identity switches' +``` + +Before the correction, the deterministic fixture switched the active identity +from server A to server B immediately before engine/auth-plugin processing. +The test failed because the request did not reach captured server A: + +```text +1 test completed, 1 failed +BUILD FAILED in 2s +``` + +After the correction, that real-WebSocket test proves the request reaches only +server A and carries A's room/profile query fields, bearer, and profile headers; +server B receives no request. A common seam test additionally proves that an +unavailable exact-scope access token fails closed before connector invocation. + +Fresh focused GREEN used the same command listed above with all six test +classes and `--rerun-tasks`. Observed: + +```text +RoomFrameDecoderTest: 10 tests, 0 failures/errors +WatchTogetherRealtimeClientTest: 8 tests, 0 failures/errors +HttpOriginPolicyTest: 13 tests, 0 failures/errors +PrairieAuthPluginPinTest: 20 tests, 0 failures/errors +WatchTogetherRepositoryTest: 11 tests, 0 failures/errors +WatchTogetherRealtimeWebSocketTest: 4 tests, 0 failures/errors +BUILD SUCCESSFUL in 17s +70 actionable tasks: 70 executed +``` + +Total after correction: 66 tests, 0 failures, 0 errors. Existing off-origin and +cleartext-consent gates, including the real pre-engine rejection test, remain +covered and green. No push, PR, or merge was performed. + +## Review correction 2: token loss before engine + +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 +their existing behavior. + +### Correction 2 TDD evidence + +The deterministic real-WebSocket fixture returns `ACCESS_A` for connect +validation and null for the plugin's second exact-scope read. + +RED: + +```text +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.network.WatchTogetherRealtimeWebSocketTest.handshake fails before engine when captured scope token disappears after validation' + +1 test completed, 1 failed +BUILD FAILED in 2s +``` + +The failure was the expected zero-request assertion: before the correction, +the plugin omitted `Authorization` but still sent the query-bearing handshake +to `MockWebServer`. + +Fresh focused GREEN: + +```text +./gradlew \ + :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.network.PrairieAuthPluginPinTest' \ + --tests 'org.prairieserver.prairie.network.WatchTogetherRealtimeClientTest' \ + :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.network.WatchTogetherRealtimeWebSocketTest' \ + --rerun-tasks + +PrairieAuthPluginPinTest: 20 tests, 0 failures/errors +WatchTogetherRealtimeClientTest: 8 tests, 0 failures/errors +WatchTogetherRealtimeWebSocketTest: 5 tests, 0 failures/errors +BUILD SUCCESSFUL in 15s +70 actionable tasks: 70 executed +``` + +The new regression verifies two scoped token reads, typed transport +termination, and zero engine/server requests. Total in this focused correction +run: 33 tests, 0 failures, 0 errors. No push, PR, or merge was performed. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4dc11520d..6e1e797d5 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,6 +14,10 @@ Rebuild and source instructions are in [scripts/README-ffmpeg-aar.md](scripts/RE Prairie uses [`ass-media` 0.4.0](https://github.com/peerless2012/libass-android) under the MIT license to integrate authored ASS/SSA subtitle rendering with AndroidX Media3. Its native package includes [`libass`](https://github.com/libass/libass), distributed under the ISC license, plus libass's font and text-shaping dependencies under their respective upstream licenses. +## libdovi + +The Dolby Vision bridge AAR is built from the `dolby_vision` crate in [`quietvoid/dovi_tool` 2.3.1](https://github.com/quietvoid/dovi_tool/tree/b25558062e4a56973482ec70133bd7b891320e48/dolby_vision), distributed under the MIT license. The upstream source commit, archive and lockfiles, the OSV-clean build lock, toolchains, native outputs, and packaged AAR are pinned in `android-shared/src/native/dovi/provenance.json`. The build lock records remediations for RUSTSEC-2026-0190, RUSTSEC-2026-0105, and RUSTSEC-2026-0204. + ## Gradle Wrapper The Gradle wrapper scripts retain their upstream Apache-2.0 license. diff --git a/android-shared/build.gradle.kts b/android-shared/build.gradle.kts index 57e713639..ee5b590fc 100644 --- a/android-shared/build.gradle.kts +++ b/android-shared/build.gradle.kts @@ -111,6 +111,7 @@ kotlin { // SQLite implementation, neither of which the default unit-test // stubs provide. androidx-test-core supplies ApplicationProvider. implementation(libs.androidx.test.core) + implementation(libs.androidx.room.testing) implementation(libs.robolectric) implementation(libs.media3.test.utils) implementation(libs.media3.test.utils.robolectric) @@ -121,6 +122,7 @@ kotlin { implementation(libs.ktor.client.mock) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.json) + implementation(libs.okhttp.mockwebserver) } } } @@ -128,6 +130,7 @@ kotlin { android { namespace = "org.prairieserver.prairie.common" compileSdk = 36 + sourceSets.getByName("debug").assets.srcDir("$projectDir/schemas") defaultConfig { minSdk = 24 // Gate for preferring FFmpeg audio decoders over platform decoders. @@ -150,6 +153,7 @@ android { } testOptions { unitTests { + isIncludeAndroidResources = true // 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. isReturnDefaultValues = true diff --git a/android-shared/gradle.lockfile b/android-shared/gradle.lockfile new file mode 100644 index 000000000..1c97015f9 --- /dev/null +++ b/android-shared/gradle.lockfile @@ -0,0 +1,515 @@ +# 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-ktx:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.activity:activity:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation-experimental:1.4.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath +androidx.annotation:annotation-experimental:1.5.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +androidx.annotation:annotation-experimental:1.5.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,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,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation:1.9.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.appcompat:appcompat-resources:1.7.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.appcompat:appcompat:1.7.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.arch.core:core-common:2.2.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.autofill:autofill:1.0.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection-ktx:1.5.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection:1.5.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.animation:animation-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.animation:animation-core-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.animation:animation-core:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.animation:animation:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.foundation:foundation-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.foundation:foundation-layout:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.foundation:foundation:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime-saveable-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime-saveable:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-geometry:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-graphics:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-text-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-text:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-unit:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-util-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-util:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.1.0=androidDebugUnitTestCompileClasspath,androidReleaseUnitTestCompileClasspath +androidx.concurrent:concurrent-futures-ktx:1.2.0=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=androidDebugUnitTestCompileClasspath,androidReleaseUnitTestCompileClasspath +androidx.concurrent:concurrent-futures:1.2.0=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.core:core-ktx:1.13.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath +androidx.core:core-ktx:1.15.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.core:core-viewtree:1.0.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.core:core:1.13.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath +androidx.core:core:1.15.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.cursoradapter:cursoradapter:1.0.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.customview:customview-poolingcontainer:1.0.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.customview:customview:1.0.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-android:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core-android:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core-okio-jvm:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core-okio:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-android:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-core-android:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-core:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-external-protobuf:1.2.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-proto:1.2.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.drawerlayout:drawerlayout:1.0.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.emoji2:emoji2-views-helper:1.3.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.emoji2:emoji2:1.3.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.exifinterface:exifinterface:1.3.6=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +androidx.exifinterface:exifinterface:1.3.7=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.fragment:fragment-ktx:1.8.8=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.fragment:fragment:1.8.8=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.graphics:graphics-path:1.0.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.interpolator:interpolator:1.0.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-common-jvm:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.3.1=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-process:2.9.4=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-android:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-service:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.loader:loader:1.0.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-common-ktx:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-common:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-container:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-database:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-datasource-okhttp:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-datasource:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-decoder:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-effect:1.10.1=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-exoplayer-hls:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-exoplayer:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-extractor:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-inspector:1.10.1=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-muxer:1.10.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-session:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-test-utils-robolectric:1.10.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-test-utils:1.10.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-transformer:1.10.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-ui:1.10.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media:media:1.7.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.profileinstaller:profileinstaller:1.4.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.recyclerview:recyclerview:1.3.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.resourceinspection:resourceinspection-annotation:1.0.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-common-jvm:2.8.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-common:2.8.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-compiler-processing:2.8.4=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +androidx.room:room-compiler:2.8.4=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +androidx.room:room-external-antlr:2.8.4=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +androidx.room:room-ktx:2.8.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-migration-jvm:2.8.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-migration:2.8.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-runtime-android:2.8.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-runtime:2.8.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-testing-android:2.8.4=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-testing:2.8.4=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate-android:1.3.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate-compose-android:1.3.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate-compose:1.3.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate-ktx:1.3.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate:1.3.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.security:security-crypto:1.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.sqlite:sqlite-android:2.6.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.sqlite:sqlite-framework-android:2.6.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.sqlite:sqlite-framework:2.6.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.sqlite:sqlite:2.6.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.startup:startup-runtime:1.1.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,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.ext:truth:1.5.0=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test:annotation:1.0.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test:core-ktx:1.6.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test:core:1.6.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test:monitor:1.8.0=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.tracing:tracing-ktx:1.2.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.tracing:tracing:1.1.0=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath +androidx.tracing:tracing:1.2.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.vectordrawable:vectordrawable-animated:1.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.vectordrawable:vectordrawable:1.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.versionedparcelable:versionedparcelable:1.1.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.viewpager:viewpager:1.0.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.work:work-runtime-ktx:2.11.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.work:work-runtime:2.11.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrency-jvm:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrency:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrent-collections-jvm:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrent-collections:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-strict-jvm:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-strict:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.almworks.sqlite4java:sqlite4java:1.0.392=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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 +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.android.tools:desugar_jdk_libs:2.1.5=coreLibraryDesugaring +com.android.tools:desugar_jdk_libs_configuration:2.1.5=coreLibraryDesugaring +com.google.accompanist:accompanist-drawablepainter:0.36.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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.value:auto-value-annotations:1.11.0=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.auto.value:auto-value-annotations:1.6.3=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +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,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +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,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.crypto.tink:tink-android:1.8.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,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.devtools.ksp:symbol-processing-api:2.0.10-1.0.24=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +com.google.devtools.ksp:symbol-processing-api:2.1.20-2.0.1=kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kspPluginClasspath,kspPluginClasspathNonEmbeddable +com.google.devtools.ksp:symbol-processing-cmdline:2.1.20-2.0.1=kspPluginClasspathNonEmbeddable +com.google.devtools.ksp:symbol-processing:2.1.20-2.0.1=kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kspPluginClasspath +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.26.1=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +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,debugAndroidTestRuntimeClasspath,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=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath +com.google.guava:failureaccess:1.0.3=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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.2.1-jre=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +com.google.guava:guava:33.3.1-android=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,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:guava:33.4.8-jre=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,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,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,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 +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.testparameterinjector:test-parameter-injector:1.18=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.truth.extensions:truth-java8-extension:1.4.0=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.truth:truth:1.4.0=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.ibm.icu:icu4j:77.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.intellij:annotations:12.0=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +com.squareup.okhttp3:mockwebserver:4.12.0=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.squareup.okhttp3:okhttp-sse:4.12.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.squareup.okio:okio-jvm:3.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +com.squareup.okio:okio:3.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.squareup.okio:okio:3.6.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +com.squareup:javapoet:1.13.0=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +com.squareup:kotlinpoet-javapoet:2.0.0=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +com.squareup:kotlinpoet-jvm:2.0.0=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +com.squareup:kotlinpoet:2.0.0=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +commons-codec:commons-codec:1.15=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +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,debugAndroidTestRuntimeClasspath,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,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-compose-core-android:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-compose-core:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-compose:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-core-android:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-core:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-network-core-android:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-network-core:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-network-ktor3-android:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-network-ktor3:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.github.peerless2012:ass-kt:0.4.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.github.peerless2012:ass-media:0.4.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.insert-koin:koin-core-jvm:4.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.insert-koin:koin-core-viewmodel-android:4.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.insert-koin:koin-core-viewmodel:4.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.insert-koin:koin-core:4.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-auth-jvm:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-auth:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-content-negotiation-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-content-negotiation:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-core-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-client-core-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-core:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-client-core:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-logging-jvm:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-logging:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-mock-jvm:3.1.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-mock:3.1.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-okhttp-jvm:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-okhttp:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-websockets-jvm:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-websockets:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-events-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-events-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-events:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-events:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-cio-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-cio:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-http-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-http:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-io-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-io-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-io:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-io:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-network-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-network:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-serialization-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-serialization:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-sse-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-sse-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-sse:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-sse:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-utils-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-utils-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-utils:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-utils:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websocket-serialization-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-websocket-serialization-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websocket-serialization:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-websocket-serialization:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websockets-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-websockets-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websockets:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +io.ktor:ktor-websockets:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +junit:junit:4.13.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.11.13=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +net.bytebuddy:byte-buddy:1.11.13=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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.81=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +org.bouncycastle:bcprov-jdk18on:1.84=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.bouncycastle:bctls-jdk18on:1.84=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.bouncycastle:bcutil-jdk18on:1.84=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,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.42.0=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseUnitTestCompileClasspath,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,debugAndroidTestRuntimeClasspath,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=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.0-beta01=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.8.4=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.0-beta01=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.savedstate:savedstate:1.3.0-beta01=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.animation:animation-core:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.animation:animation:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.annotation-internal:annotation:1.8.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.collection-internal:collection:1.7.3=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.foundation:foundation-layout:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.foundation:foundation:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.runtime:runtime-saveable:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.runtime:runtime:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.ui:ui-geometry:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.ui:ui-graphics:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.ui:ui-text:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.ui:ui-unit:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.ui:ui-util:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.ui:ui:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.0=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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-compose-compiler-plugin-embeddable:2.1.20=kotlin-extension,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +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-metadata-jvm:2.2.0=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +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-reflect:2.0.10=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.1.20=kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +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=kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kspPluginClasspath,kspPluginClasspathNonEmbeddable +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 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +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-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 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.21=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +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: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=androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable,metadataCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-junit:2.1.20=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.1.20=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,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,debugAndroidTestRuntimeClasspath,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=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.10.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.5.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.6.0=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.5.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.6.0=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.5.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.6.0=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core:0.5.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-core:0.6.0=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3=kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kspPluginClasspath,kspPluginClasspathNonEmbeddable +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3=kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kspPluginClasspath,kspPluginClasspathNonEmbeddable +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3=kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kspPluginClasspath,kspPluginClasspathNonEmbeddable +org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.8.1=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.8.1=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.3=kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kspPluginClasspath,kspPluginClasspathNonEmbeddable +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3=kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kspPluginClasspath,kspPluginClasspathNonEmbeddable +org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,kspDebugKotlinAndroidProcessorClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable,kspReleaseKotlinAndroidProcessorClasspath +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,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jspecify:jspecify:1.0.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.mockito:mockito-core:3.12.4=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.objenesis:objenesis:3.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.ow2.asm:asm-commons:9.8=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.ow2.asm:asm-tree:9.8=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.ow2.asm:asm:9.8=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:annotations:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:junit:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:nativeruntime-dist-compat:1.0.18=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:nativeruntime:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:pluginapi:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:plugins-maven-dependency-resolver:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:resources:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:robolectric:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:sandbox:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:shadowapi:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:shadows-framework:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:utils-reflector:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:utils:4.16.1=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.slf4j:slf4j-api:2.0.16=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.xerial:sqlite-jdbc:3.41.2.2=kspDebugKotlinAndroidProcessorClasspath,kspReleaseKotlinAndroidProcessorClasspath +org.yaml:snakeyaml:2.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +empty=_agp_internal_javaPreCompileDebugAndroidTest_kspClasspath,_agp_internal_javaPreCompileDebugUnitTest_kspClasspath,_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileReleaseUnitTest_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,androidApis,androidDebugAndroidTestRuntimeClasspath,androidDebugIntransitiveDependenciesMetadata,androidInstrumentedTestDebugIntransitiveDependenciesMetadata,androidInstrumentedTestIntransitiveDependenciesMetadata,androidJdkImage,androidMainIntransitiveDependenciesMetadata,androidReleaseIntransitiveDependenciesMetadata,androidTestUtil,androidUnitTestDebugIntransitiveDependenciesMetadata,androidUnitTestIntransitiveDependenciesMetadata,androidUnitTestReleaseIntransitiveDependenciesMetadata,commonMainIntransitiveDependenciesMetadata,commonTestIntransitiveDependenciesMetadata,debugAndroidTestAnnotationProcessorClasspath,debugAnnotationProcessorClasspath,debugUnitTestAnnotationProcessorClasspath,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kspDebugAndroidTestKotlinAndroidProcessorClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinAndroidProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinAndroidProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseUnitTestAnnotationProcessorClasspath diff --git a/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/8.json b/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/8.json new file mode 100644 index 000000000..0d8fa92b0 --- /dev/null +++ b/android-shared/schemas/org.prairieserver.prairie.common.data.db.PrairieDatabase/8.json @@ -0,0 +1,876 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "7db9e8f8fef4735ce710bf216f145d7d", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7db9e8f8fef4735ce710bf216f145d7d')" + ] + } +} \ No newline at end of file diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/audiobook/AudiobookBookmarksStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/audiobook/AudiobookBookmarksStore.kt index 2a7ea80a7..73c098eea 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/audiobook/AudiobookBookmarksStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/audiobook/AudiobookBookmarksStore.kt @@ -29,11 +29,14 @@ class AudiobookBookmarksStore(baseDir: File) { contentId: String, bookmark: AudiobookBookmark, ): List { - val updated = (list(serverId, profileId, contentId) + bookmark) - .distinctBy { it.id } - .sortedBy { it.positionSeconds } - write(serverId, profileId, contentId, updated) - return updated + val target = store.fileFor(serverId, profileId, contentId) + return store.withTargetLock(target) { + val updated = (store.read>(target).orEmpty() + bookmark) + .distinctBy { it.id } + .sortedBy { it.positionSeconds } + store.write(target, updated) + updated + } } /** Remove a bookmark by id. No-op if missing. */ @@ -43,9 +46,14 @@ class AudiobookBookmarksStore(baseDir: File) { contentId: String, bookmarkId: String, ): List { - val updated = list(serverId, profileId, contentId).filterNot { it.id == bookmarkId } - write(serverId, profileId, contentId, updated) - return updated + val target = store.fileFor(serverId, profileId, contentId) + return store.withTargetLock(target) { + val updated = store.read>(target) + .orEmpty() + .filterNot { it.id == bookmarkId } + store.write(target, updated) + updated + } } /** Replace the bookmark with matching [id]'s note. */ @@ -56,20 +64,14 @@ class AudiobookBookmarksStore(baseDir: File) { bookmarkId: String, note: String?, ): List { - val updated = list(serverId, profileId, contentId).map { - if (it.id == bookmarkId) it.copy(note = note?.takeIf { n -> n.isNotBlank() }) else it + val target = store.fileFor(serverId, profileId, contentId) + return store.withTargetLock(target) { + val updated = store.read>(target).orEmpty().map { + if (it.id == bookmarkId) it.copy(note = note?.takeIf { n -> n.isNotBlank() }) else it + } + store.write(target, updated) + updated } - write(serverId, profileId, contentId, updated) - return updated - } - - private fun write( - serverId: String, - profileId: String, - contentId: String, - bookmarks: List, - ) { - store.write(store.fileFor(serverId, profileId, contentId), bookmarks) } companion object { private const val TAG = "AudiobookBookmarksStore" } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt index e24cda235..d0a0bf32d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabase.kt @@ -11,6 +11,7 @@ import org.prairieserver.prairie.common.data.db.dao.DirtyOperationDao import org.prairieserver.prairie.common.data.db.dao.DownloadDao import org.prairieserver.prairie.common.data.db.dao.DownloadDeletionDao import org.prairieserver.prairie.common.data.db.dao.DownloadSubscriptionDao +import org.prairieserver.prairie.common.data.db.dao.ServerPurgeDao import org.prairieserver.prairie.common.data.db.dao.HomeCacheDao import org.prairieserver.prairie.common.data.db.dao.LegacyImportDao import org.prairieserver.prairie.common.data.db.dao.UserItemStateDao @@ -48,7 +49,7 @@ import org.prairieserver.prairie.common.data.db.entity.UserItemStateEntity DownloadDeletionEntity::class, DownloadSubscriptionEntity::class, ], - version = 7, + version = 8, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -57,6 +58,7 @@ import org.prairieserver.prairie.common.data.db.entity.UserItemStateEntity AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7), + AutoMigration(from = 7, to = 8), ], ) abstract class PrairieDatabase : RoomDatabase() { @@ -69,6 +71,7 @@ abstract class PrairieDatabase : RoomDatabase() { abstract fun homeCacheDao(): HomeCacheDao abstract fun catalogCacheDao(): CatalogCacheDao abstract fun downloadSubscriptionDao(): DownloadSubscriptionDao + abstract fun serverPurgeDao(): ServerPurgeDao companion object { const val NAME = "prairie.db" diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DirtyOperationDao.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DirtyOperationDao.kt index 52c61f126..f629ed20d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DirtyOperationDao.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/DirtyOperationDao.kt @@ -45,6 +45,48 @@ interface DirtyOperationDao { ) suspend fun getLatestByCoalesceKey(coalesceKey: String): DirtyOperationEntity? + /** + * Enqueues [op] and pulls any backed-off sibling for the same item forward. + * + * `dueBatch` orders by `nextAttemptAtMs ASC, id ASC`, and `recordFailure` + * pushes a failed op 30s·2ⁿ into the future — so an op enqueued now drains + * *before* one that was created earlier and failed once. For a single item + * that inverts creation order: mark an episode watched while the server is + * flaky, then resume it, and the progress syncs first while the retried + * watched lands 30s later and clears it. The episode vanishes from Continue + * Watching on every device while the user is ten minutes in, and local and + * server diverge permanently. Deterministic, not a race. + * + * Clamping rather than deleting the sibling is deliberate: deleting a + * pending `SET_WATCHED` would strand `watched = true` locally with no + * terminal op to revert it, and could drop a deliberately-requeued replay. + * `attemptCount` is untouched, so backoff still grows on the next failure. + */ + @Transaction + suspend fun enqueueCoalescingRestoringItemOrder(op: DirtyOperationEntity, nowMs: Long): Long { + clearBackoffForTargetContent( + serverId = op.serverId, + profileId = op.profileId, + contentId = op.targetContentId, + nowMs = nowMs, + ) + return enqueueCoalescing(op) + } + + @Query( + "UPDATE dirty_operations SET nextAttemptAtMs = :nowMs " + + "WHERE serverId = :serverId AND profileId = :profileId " + + "AND targetContentId = :contentId " + + "AND state = '${DirtyOperationEntity.STATE_PENDING}' " + + "AND nextAttemptAtMs > :nowMs", + ) + suspend fun clearBackoffForTargetContent( + serverId: String, + profileId: String, + contentId: String, + nowMs: Long, + ) + @Query( "DELETE FROM dirty_operations WHERE serverId = :serverId AND profileId = :profileId " + "AND targetContentId = :contentId AND opKind = :opKind " + @@ -83,6 +125,32 @@ interface DirtyOperationDao { ) suspend fun dueBatch(serverId: String, profileId: String, nowMs: Long, limit: Int): List + /** + * Due oldest operation for each content item. The anti-join is evaluated + * before LIMIT so an older sibling in backoff cannot fall outside the SQL + * page and let a newer operation overtake it. + */ + @Query( + "SELECT candidate.* FROM dirty_operations candidate " + + "WHERE candidate.state = '${DirtyOperationEntity.STATE_PENDING}' " + + "AND candidate.nextAttemptAtMs <= :nowMs " + + "AND candidate.serverId = :serverId AND candidate.profileId = :profileId " + + "AND NOT EXISTS (" + + "SELECT 1 FROM dirty_operations older " + + "WHERE older.serverId = candidate.serverId " + + "AND older.profileId = candidate.profileId " + + "AND older.targetContentId = candidate.targetContentId " + + "AND older.state IN ('${DirtyOperationEntity.STATE_PENDING}', '${DirtyOperationEntity.STATE_IN_FLIGHT}') " + + "AND older.id < candidate.id" + + ") ORDER BY candidate.nextAttemptAtMs ASC, candidate.id ASC LIMIT :limit", + ) + suspend fun dueTargetHeads( + serverId: String, + profileId: String, + nowMs: Long, + limit: Int, + ): List + /** * Atomically claim a pending op for sending. Returns the number of rows * updated: 1 if this caller won the claim, 0 if it was already claimed or diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/ServerPurgeDao.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/ServerPurgeDao.kt new file mode 100644 index 000000000..cf595eb37 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/dao/ServerPurgeDao.kt @@ -0,0 +1,86 @@ +package org.prairieserver.prairie.common.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction + +/** + * Cross-table purge for a server that is no longer in the registry. + * + * Removing a server wiped its tokens and its registry entry and nothing else, + * so "remove this server to reclaim space" reclaimed none: every Room row + * survived. Worse, `idFor()` is base64 of the normalised URL, so re-adding the + * same server resurrected its Downloads list, resume positions, cached home and + * catalog rows, and any pending outbox ops — under an identity the user thought + * they had deleted. + * + * The queries live together rather than one per DAO because they are only ever + * used as a set, and a table added later that nobody adds here is exactly the + * silent gap this replaces — see [knownServerIds], which fails loudly the same + * way (an unlisted table simply never reports orphans). + */ +@Dao +interface ServerPurgeDao { + + /** + * Every serverId with rows in any scoped table. + * + * Orphans are computed as this minus the registry rather than recorded at + * removal time, which makes the purge idempotent, re-runnable at startup, + * and — the reason it is worth doing this way — able to clean installs that + * removed servers before the purge existed. + */ + @Query( + "SELECT DISTINCT serverId FROM downloads " + + "UNION SELECT DISTINCT serverId FROM download_subscriptions " + + "UNION SELECT DISTINCT serverId FROM download_deletions " + + "UNION SELECT DISTINCT serverId FROM dirty_operations " + + "UNION SELECT DISTINCT serverId FROM content_item_state " + + "UNION SELECT DISTINCT serverId FROM user_item_state " + + "UNION SELECT DISTINCT serverId FROM home_cache " + + "UNION SELECT DISTINCT serverId FROM catalog_cache", + ) + suspend fun knownServerIds(): List + + @Query("SELECT recordId FROM downloads WHERE serverId = :serverId") + suspend fun downloadRecordIdsForServer(serverId: String): List + + @Query("SELECT mediaFileId FROM downloads WHERE serverId = :serverId") + suspend fun downloadFileIdsForServer(serverId: String): List + + @Query("DELETE FROM downloads WHERE serverId = :serverId") + suspend fun deleteDownloads(serverId: String) + + @Query("DELETE FROM download_subscriptions WHERE serverId = :serverId") + suspend fun deleteDownloadSubscriptions(serverId: String) + + @Query("DELETE FROM download_deletions WHERE serverId = :serverId") + suspend fun deleteDownloadDeletions(serverId: String) + + @Query("DELETE FROM dirty_operations WHERE serverId = :serverId") + suspend fun deleteDirtyOperations(serverId: String) + + @Query("DELETE FROM content_item_state WHERE serverId = :serverId") + suspend fun deleteContentItemState(serverId: String) + + @Query("DELETE FROM user_item_state WHERE serverId = :serverId") + suspend fun deleteUserItemState(serverId: String) + + @Query("DELETE FROM home_cache WHERE serverId = :serverId") + suspend fun deleteHomeCache(serverId: String) + + @Query("DELETE FROM catalog_cache WHERE serverId = :serverId") + suspend fun deleteCatalogCache(serverId: String) + + @Transaction + suspend fun deleteAllRowsForServer(serverId: String) { + deleteDownloads(serverId) + deleteDownloadSubscriptions(serverId) + deleteDownloadDeletions(serverId) + deleteDirtyOperations(serverId) + deleteContentItemState(serverId) + deleteUserItemState(serverId) + deleteHomeCache(serverId) + deleteCatalogCache(serverId) + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt index 56f8a85ec..feb29d4e7 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/db/entity/DownloadEntity.kt @@ -72,4 +72,11 @@ data class DownloadEntity( * 206 (source unchanged → safe to append) or 200 (source changed → restart). * Null = no validator captured (resume falls back to a fresh download). */ val resumeValidator: String? = null, + /** Requested download quality wire string (`DownloadQuality.wire`). Persisted + * because the Downloads tab falls back to the stored record when there is no + * live server record — offline, or once the download has completed. */ + val quality: String? = null, + /** Quality the server actually delivered after any compatibility fallback; + * may differ from [quality]. Same offline-fallback reason as above. */ + val effectiveQuality: String? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepository.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepository.kt index be8b0f872..84f226a4c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepository.kt @@ -15,6 +15,8 @@ import org.prairieserver.prairie.repository.port.LocalContentState import org.prairieserver.prairie.repository.port.LocalPlaybackProgress import org.prairieserver.prairie.repository.port.LocalTrackSelection import org.prairieserver.prairie.repository.port.OutboxHandle +import org.prairieserver.prairie.repository.port.PlaybackWriteScope +import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate import org.prairieserver.prairie.repository.port.UserItemStatePort import org.prairieserver.prairie.repository.port.WriteOutcome import kotlinx.serialization.json.JsonPrimitive @@ -82,6 +84,51 @@ class RoomUserItemStateRepository( positionSeconds: Double, durationSeconds: Double?, ) { + val snapshot = snapshotProvider() ?: return + val serverId = snapshot.serverId + val profileId = snapshot.profileId ?: return + recordPositionOwned( + serverId = serverId, + profileId = profileId, + contentId = contentId, + fileId = fileId, + positionSeconds = positionSeconds, + durationSeconds = durationSeconds, + ) + } + + override suspend fun recordPosition( + scope: PlaybackWriteScope, + contentId: String, + fileId: Int, + positionSeconds: Double, + durationSeconds: Double?, + ): Boolean { + val current = snapshotProvider() ?: return false + if (current.serverId != scope.serverId || + current.profileId != scope.profileId || + current.credentialGenerationId != scope.credentialGenerationId || + current.identityGeneration != scope.identityGeneration + ) return false + + return recordPositionOwned( + serverId = scope.serverId, + profileId = scope.profileId, + contentId = contentId, + fileId = fileId, + positionSeconds = positionSeconds, + durationSeconds = durationSeconds, + ) + } + + private suspend fun recordPositionOwned( + serverId: String, + profileId: String, + contentId: String, + fileId: Int, + positionSeconds: Double, + durationSeconds: Double?, + ): Boolean { // Reject values that would enqueue invalid JSON and poison the drain // (a NaN/Infinity/negative would parse-fail after claim and retry forever). // Reject 0 too: a resume row is only readable when positionSeconds > 0, so @@ -91,12 +138,8 @@ class RoomUserItemStateRepository( // 0 overwrites a good resume locally AND syncs SET_POSITION=0 to the server, // so re-entering the detail shows "Play" instead of "Resume" (Jim TV/phone // QA 2026-07-10). Legit resets go through markUnwatched, not this path. - if (contentId.isBlank() || !positionSeconds.isFinite() || positionSeconds <= 0.0) return + if (contentId.isBlank() || !positionSeconds.isFinite() || positionSeconds <= 0.0) return false val safeDuration = durationSeconds?.takeIf { it.isFinite() && it > 0.0 } - - val snapshot = snapshotProvider() ?: return - val serverId = snapshot.serverId - val profileId = snapshot.profileId ?: return val nowMs = now() db.withTransaction { @@ -125,7 +168,7 @@ class RoomUserItemStateRepository( // Content-level outbox op: syncProgress is keyed by content id, so the // coalesce key omits fileId — all pending positions for the item // collapse to the latest. - outboxDao.enqueueCoalescing( + outboxDao.enqueueCoalescingRestoringItemOrder( DirtyOperationEntity( opKind = OutboxOperation.SET_POSITION, serverId = serverId, @@ -138,8 +181,10 @@ class RoomUserItemStateRepository( createdAtMs = nowMs, nextAttemptAtMs = nowMs, ), + nowMs = nowMs, ) } + return true } override suspend fun localPosition(contentId: String, fileId: Int): Double? { @@ -177,7 +222,7 @@ class RoomUserItemStateRepository( fileId: Int, audioFingerprint: String?, ) { - recordTrackSelection( + recordSingleTrackSelection( contentId = contentId, fileId = fileId, update = { it.copy(audioFingerprint = audioFingerprint?.trim()?.takeIf { value -> value.isNotBlank() }) }, @@ -189,13 +234,95 @@ class RoomUserItemStateRepository( fileId: Int, subtitleFingerprint: String?, ) { - recordTrackSelection( + recordSingleTrackSelection( contentId = contentId, fileId = fileId, update = { it.copy(subtitleFingerprint = subtitleFingerprint?.trim()?.takeIf { value -> value.isNotBlank() }) }, ) } + override suspend fun recordTrackSelection( + contentId: String, + fileId: Int, + audioUpdate: TrackSelectionFingerprintUpdate, + subtitleUpdate: TrackSelectionFingerprintUpdate, + ) { + if (contentId.isBlank()) return + val snapshot = snapshotProvider() ?: return + val serverId = snapshot.serverId + val profileId = snapshot.profileId ?: return + recordTrackSelectionOwned( + serverId = serverId, + profileId = profileId, + contentId = contentId, + fileId = fileId, + audioUpdate = audioUpdate, + subtitleUpdate = subtitleUpdate, + ) + } + + override suspend fun recordTrackSelection( + scope: PlaybackWriteScope, + contentId: String, + fileId: Int, + audioUpdate: TrackSelectionFingerprintUpdate, + subtitleUpdate: TrackSelectionFingerprintUpdate, + ): Boolean { + val current = snapshotProvider() ?: return false + if (current.serverId != scope.serverId || + current.profileId != scope.profileId || + current.credentialGenerationId != scope.credentialGenerationId || + current.identityGeneration != scope.identityGeneration + ) return false + + return recordTrackSelectionOwned( + serverId = scope.serverId, + profileId = scope.profileId, + contentId = contentId, + fileId = fileId, + audioUpdate = audioUpdate, + subtitleUpdate = subtitleUpdate, + ) + } + + private suspend fun recordTrackSelectionOwned( + serverId: String, + profileId: String, + contentId: String, + fileId: Int, + audioUpdate: TrackSelectionFingerprintUpdate, + subtitleUpdate: TrackSelectionFingerprintUpdate, + ): Boolean { + if (contentId.isBlank()) return false + val nowMs = now() + + db.withTransaction { + val existing = userStateDao.get(serverId, profileId, contentId, fileId) + val row = existing ?: UserItemStateEntity( + serverId = serverId, + profileId = profileId, + contentId = contentId, + fileId = fileId, + positionSeconds = 0.0, + durationSeconds = null, + audioFingerprint = null, + subtitleFingerprint = null, + cfi = null, + readProgress = null, + clientUpdatedAtMs = nowMs, + serverUpdatedAtMs = null, + ) + userStateDao.upsert( + row.copy( + audioFingerprint = audioUpdate.applyTo(row.audioFingerprint), + subtitleFingerprint = subtitleUpdate.applyTo(row.subtitleFingerprint), + clientUpdatedAtMs = nowMs, + ), + ) + } + return true + } + override suspend fun localTrackSelection(contentId: String, fileId: Int): LocalTrackSelection? { val snapshot = snapshotProvider() ?: return null val profileId = snapshot.profileId ?: return null @@ -395,7 +522,7 @@ class RoomUserItemStateRepository( return OutboxHandle(opId, snapshot) } - private suspend fun recordTrackSelection( + private suspend fun recordSingleTrackSelection( contentId: String, fileId: Int, update: (UserItemStateEntity) -> UserItemStateEntity, @@ -427,6 +554,13 @@ class RoomUserItemStateRepository( } } +private fun TrackSelectionFingerprintUpdate.applyTo(current: String?): String? = + when (this) { + TrackSelectionFingerprintUpdate.Preserve -> current + TrackSelectionFingerprintUpdate.Clear -> null + is TrackSelectionFingerprintUpdate.Set -> fingerprint.trim() + } + // Newest local write wins — NOT the furthest position. Picking the max // position made a deliberate backward seek (or an old row for a different // file version) permanently shadow the real resume point. diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/OutboxSyncStarter.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/OutboxSyncStarter.kt index 1c9a777a6..909858aea 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/OutboxSyncStarter.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/OutboxSyncStarter.kt @@ -6,8 +6,10 @@ import android.net.Network import android.util.Log import org.prairieserver.prairie.network.ServerRegistry import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch /** @@ -30,20 +32,38 @@ class OutboxSyncStarter( private val context: Context, private val registry: ServerRegistry, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + // Seam for tests: every trigger goes through here, so a test can observe + // them without standing up WorkManager. + private val enqueueDrain: () -> Unit = { SyncWorker.enqueue(context) }, ) { fun start() { // Drain leftovers on launch; the worker's CONNECTED constraint defers // the actual run until the network is available. - SyncWorker.enqueue(context) + enqueueDrain() - // Drain when the active server changes (e.g. switched back to a scope - // whose ops were queued earlier and never got a trigger). - scope.launch { - var seenInitial = false - registry.activeServerId.collect { - if (seenInitial) SyncWorker.enqueue(context) else seenInitial = true - } + // Drain when the active SCOPE changes — server or profile. + // + // Profile matters as much as server and had no trigger at all. The + // outbox is scoped by (serverId, profileId), and SyncEngine counts + // what remains for the scope active at the END of a drain; SyncWorker + // turns a non-zero count into Result.retry(), which is the only thing + // keeping the retry chain alive. A profile switch leaves activeServerId + // untouched, so the chain for the scope that still had queued work just + // stopped: watched marks, ratings, favourites and resume positions from + // that profile never synced again. On a TV, which is never relaunched, + // that is permanent and invisible. + val initialScope = registry.activeEntry.value?.let { it.id to it.profileId } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + var previousScope = initialScope + registry.activeEntry + .map { entry -> entry?.let { it.id to it.profileId } } + .collect { currentScope -> + if (currentScope != previousScope) { + previousScope = currentScope + enqueueDrain() + } + } } val cm = context.getSystemService(ConnectivityManager::class.java) @@ -55,7 +75,7 @@ class OutboxSyncStarter( cm.registerDefaultNetworkCallback( object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - SyncWorker.enqueue(context) + enqueueDrain() } }, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/SyncEngine.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/SyncEngine.kt index 0be49c368..92aee322e 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/SyncEngine.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/sync/SyncEngine.kt @@ -29,6 +29,8 @@ import org.prairieserver.prairie.repository.port.toWriteOutcome * - **Reclaim** at drain start — in-flight rows stranded by a crash are dropped * if a newer pending op supersedes them, else returned to pending. * - **Atomic supersede-or-record** on transient failure. + * - **Per-item FIFO** — an op is held back while an older op for the same + * content id is still queued, so backoff can't reorder a watched/position pair. * * Transient failures (no network / 401 / 408 / 429 / 5xx) are kept indefinitely * with capped backoff — offline data is never dropped on a retry cap. Only @@ -81,7 +83,11 @@ class SyncEngine( var batches = 0 while (batches++ < MAX_BATCHES) { - val batch = dao.dueBatch(serverId, profileId, now(), batchLimit) + val nowMs = now() + // The DAO selects only due target heads and applies its older-sibling + // anti-join before LIMIT. This preserves per-item FIFO even when the + // backing-off predecessor lies outside the current SQL page. + val batch = dao.dueTargetHeads(serverId, profileId, nowMs, batchLimit) if (batch.isEmpty()) break for (op in batch) { @@ -124,23 +130,32 @@ class SyncEngine( nextAttemptAtMs = now() + backoffMs(op.attemptCount), error = WriteOutcome.RETRIABLE.name, ) - if (superseded) dropped++ else retriable++ + if (superseded) { + dropped++ + } else { + retriable++ + } } } } } - // Count remaining for whatever scope is active NOW (re-snapshot), not the - // scope we just drained. If the user switched mid-drain, this keeps the - // worker's retry chain alive for the newly-active scope — covering the - // case where an activation enqueue was dropped by ExistingWorkPolicy.KEEP - // while this worker was running. + // Count remaining for the scope we just drained AND, if the user switched + // mid-drain, for whatever scope is active NOW (re-snapshot). Counting only + // the end scope stranded the drained scope's queued work on a switch: the + // worker saw remaining=0, reported success and ended the retry chain. + // OutboxSyncStarter now also triggers on a profile change, so a switch is + // no longer silent, but this stays the in-drain defence — the switch can + // land between the drain and the count. Including the end scope covers an + // activation enqueue dropped by ExistingWorkPolicy.KEEP while this worker + // was running. + var remaining = dao.countForScope(serverId, profileId) val endScope = snapshotProvider() val endProfileId = endScope?.profileId - val remaining = if (endScope != null && endProfileId != null) { - dao.countForScope(endScope.serverId, endProfileId) - } else { - 0 + if (endScope != null && endProfileId != null && + (endScope.serverId != serverId || endProfileId != profileId) + ) { + remaining += dao.countForScope(endScope.serverId, endProfileId) } return DrainResult( @@ -253,5 +268,6 @@ class SyncEngine( // Backstop against a pathological re-due loop; a normal drain terminates // long before this because each op is deleted or pushed into the future. private const val MAX_BATCHES = 1_000 + } } 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 bfd27c4f7..a939f4b71 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 @@ -4,6 +4,7 @@ import org.prairieserver.prairie.common.network.ServerReachabilityMonitor import org.prairieserver.prairie.common.pip.PrairiePictureInPictureCoordinator import org.prairieserver.prairie.common.player.ActivePlayerHolder import org.prairieserver.prairie.common.player.AudiobookSettingsStore +import org.prairieserver.prairie.common.player.FinalPlaybackPositionWriter import org.prairieserver.prairie.common.player.PlaybackSessionLifecycle import org.prairieserver.prairie.common.diagnostics.DiagnosticsPlaybackSessionTracker import org.prairieserver.prairie.common.player.SleepTimerController @@ -46,6 +47,24 @@ val playerInfraModule = module { single { PrairiePictureInPictureCoordinator() } + single { + val userItemState = get() + val syncScheduler = get() + FinalPlaybackPositionWriter( + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + scopeProvider = { get().snapshotCurrentScope() }, + ) { snapshot -> + val written = userItemState.recordPosition( + snapshot.scope, + snapshot.contentId, + snapshot.fileId, + snapshot.positionSeconds, + snapshot.durationSeconds, + ) + if (written) syncScheduler.requestSync() + } + } + // Long-lived application-scope flusher: debounced server writes survive // ViewModel teardown. Uses Dispatchers.IO since flushOne does network work. single { 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 d7ec51282..a34240120 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 @@ -34,10 +34,18 @@ val playerModule = module { // 401 on the refresh call can't loop back through MediaAuthInterceptor. single(named("player-refresh-okhttp")) { buildPlayerRefreshOkHttpClient() } - single { MediaAuthSession(tokenManager = get(), refreshClient = get(named("player-refresh-okhttp"))) } + single { + MediaAuthSession( + tokenManager = get(), + refreshClient = get(named("player-refresh-okhttp")), + cleartextOriginConsent = getOrNull(), + ) + } single { MediaAuthInterceptor(authSession = get()) } - single(PLAYER_TRANSPORT_OKHTTP_QUALIFIER) { buildPlayerOkHttpClient() } + single(PLAYER_TRANSPORT_OKHTTP_QUALIFIER) { + buildPlayerOkHttpClient(cleartextOriginConsent = getOrNull()) + } // Reader/download callers still consume the authenticated OkHttp client. // Media3 itself uses the raw pooled transport below and applies auth in a diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt index 20c9715f2..d1c7e79f5 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadEnqueuer.kt @@ -2,6 +2,7 @@ package org.prairieserver.prairie.common.downloads import android.content.Context import android.util.Log +import androidx.work.WorkManager import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.model.download.DownloadQuality import org.prairieserver.prairie.model.download.DownloadMediaType @@ -16,7 +17,9 @@ import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.DownloadsRepository import org.prairieserver.prairie.repository.ProfileRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext /** * Thin Android-side helper that combines the cross-platform @@ -57,7 +60,7 @@ class DownloadEnqueuer( Log.i(TAG, "start: contentId=$contentId fileId=$fileId title=$displayTitle") if (activeDownloadExists(fileId)) { Log.i(TAG, "start: fileId=$fileId already queued/downloading — skipping duplicate") - return ApiResult.Success(Unit) + return alreadyActive() } val record = when (val r = repository.create( downloadRequest( @@ -95,7 +98,7 @@ class DownloadEnqueuer( Log.i(TAG, "startEpisode: series=$seriesContentId ep=$episodeContentId fileId=$fileId S${seasonNumber}E${episodeNumber}") if (activeDownloadExists(fileId)) { Log.i(TAG, "startEpisode: fileId=$fileId already queued/downloading — skipping duplicate") - return ApiResult.Success(Unit) + return alreadyActive() } val displayTitle = "$seriesTitle S${seasonNumber}E${episodeNumber}" + (episodeTitle?.takeIf { it.isNotBlank() }?.let { " · $it" } ?: "") @@ -250,7 +253,10 @@ class DownloadEnqueuer( // though startEpisode itself honors quality for single episodes. downloadQualityOverride = DownloadQuality.Original, ) - if (result is ApiResult.Success) queued++ + // An episode that is already in flight counts as queued: the season + // is in the state the user asked for, so a re-tap must not report + // the whole season as failed. + if (result is ApiResult.Success || result.isAlreadyActive()) queued++ else if (firstError == null) firstError = result } Log.i(TAG, "startSeason: queued $queued of ${episodes.size}") @@ -286,17 +292,46 @@ class DownloadEnqueuer( * [fileId]. Two workers for one fileId share the same on-disk target, and * [DownloadStorage.prepareWrite] recreates that directory — so a duplicate * enqueue lets the second worker wipe the first one's partial mid-stream. - * The sidecar store is the local source of truth for per-scope status. + * The sidecar store is the local source of truth for per-scope status — + * but only while a worker is actually alive for it: a row can outlive its + * work (cancel, process death mid-stream, WorkManager pruning), and a + * stale Queued/Downloading row would otherwise block every future enqueue + * for that file forever. So a row with no live work is dropped, not honoured. */ private suspend fun activeDownloadExists(fileId: Int): Boolean { val serverId = serverRegistry.activeServerId.value ?: DEFAULT_SERVER_ID val profileId = profileRepository.getActiveProfileId() ?: DEFAULT_PROFILE_ID val existing = runCatching { metadataStore.readSidecar(serverId, profileId, fileId) }.getOrNull() ?: return false - return when (existing.record.statusEnum()) { + val claimsActive = when (existing.record.statusEnum()) { DownloadStatus.Queued, DownloadStatus.Downloading -> true else -> false } + if (!claimsActive) return false + // WorkManager commits an enqueue on its own executor, so a just-written + // row can briefly have no visible work. Trust the row inside that window + // rather than racing a double-tap into a second worker. + if (System.currentTimeMillis() - existing.updatedAtMs < WORK_VISIBILITY_GRACE_MS) return true + if (hasLiveWork(existing.record.id)) return true + Log.i(TAG, "activeDownloadExists: fileId=$fileId sidecar is stale (no live work) — dropping") + runCatching { metadataStore.deleteSidecar(serverId, profileId, fileId) } + .onFailure { Log.w(TAG, "activeDownloadExists: stale deleteSidecar failed for fileId=$fileId", it) } + return false + } + + /** True when WorkManager still holds a non-terminal worker for [downloadId]. + * Unknown (query threw) is reported as live so an unreadable WorkManager + * can't turn the duplicate guard into a partial-wiping double enqueue. */ + private suspend fun hasLiveWork(downloadId: String): Boolean = withContext(Dispatchers.IO) { + runCatching { + WorkManager.getInstance(context) + .getWorkInfosForUniqueWork(DownloadWorker.tagFor(downloadId)) + .get() + .any { !it.state.isFinished } + }.getOrElse { + Log.w(TAG, "hasLiveWork: WorkManager query failed for id=$downloadId", it) + true + } } private suspend fun downloadRequest( @@ -406,15 +441,60 @@ class DownloadEnqueuer( private fun ItemDetail.versionFor(fileId: Int): FileVersion? = versions.firstOrNull { it.fileId == fileId } - /** Cancel a queued / in-flight download by record id (worker-tag). */ - fun cancel(downloadId: String) { + /** Cancel a queued / in-flight download by record id (worker-tag). + * Callers that own the local metadata (Downloads tab) clean the sidecar up + * themselves; everyone else should use the [fileId] overload. */ + suspend fun cancel(downloadId: String) { Log.i(TAG, "cancel: downloadId=$downloadId") - DownloadWorker.cancel(context, downloadId) + DownloadWorker.cancelAndAwait(context, downloadId) + } + + data class CancelScope( + val serverId: String, + val profileId: String, + ) + + /** Capture identity synchronously with the UI record being acted on. */ + fun captureCancelScope(): CancelScope { + val active = serverRegistry.activeEntry.value + return CancelScope( + serverId = active?.id ?: DEFAULT_SERVER_ID, + profileId = active?.profileId ?: DEFAULT_PROFILE_ID, + ) + } + + /** + * Cancel and forget in the explicitly captured identity scope. Leaving the + * row behind made a cancelled download un-restartable; resolving scope + * after a coroutine dispatch could instead delete the next profile's row. + */ + suspend fun cancel( + downloadId: String, + fileId: Int, + scope: CancelScope, + ) { + cancel(downloadId) + runCatching { metadataStore.deleteSidecar(scope.serverId, scope.profileId, fileId) } + .onFailure { Log.w(TAG, "cancel: deleteSidecar failed for fileId=$fileId", it) } } + /** The duplicate-guard skip result. Deliberately not [ApiResult.Success]: + * nothing was enqueued, and callers key their "download started" feedback + * off success. */ + private fun alreadyActive(): ApiResult = + ApiResult.Error(409, ALREADY_ACTIVE_ERROR, "Already downloading") + + private fun ApiResult.isAlreadyActive(): Boolean = + this is ApiResult.Error && error == ALREADY_ACTIVE_ERROR + companion object { private const val TAG = "DownloadEnqueuer" const val DEFAULT_SERVER_ID = "default" const val DEFAULT_PROFILE_ID = "default" + + /** Local-only error code: this file already has a live download worker. */ + const val ALREADY_ACTIVE_ERROR = "download_already_active" + + private const val WORK_VISIBILITY_GRACE_MS = 5_000L } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt index 8c678b75b..fe26f5969 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadSidecarMapping.kt @@ -10,9 +10,9 @@ import kotlinx.serialization.json.Json /** * Maps between the on-the-wire-ish [DownloadSidecar] (the download's full local * picture) and the Room [DownloadEntity] projection. Chapters serialize to a JSON - * column (no TypeConverter). `episodeId`/`batchId` on [DownloadRecord] have no - * entity column and aren't read anywhere in the download/playback paths, so they - * round-trip as null. + * column (no TypeConverter). `episodeId`/`batchId`/`deliveryFormat`/ + * `targetBitrateKbps` on [DownloadRecord] have no entity column and aren't read + * anywhere in the download/playback paths, so they round-trip as null. */ private val mappingJson = Json { ignoreUnknownKeys = true } @@ -49,6 +49,8 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit createdAt = record.createdAt, completedAt = record.completedAt, updatedAtMs = updatedAtMs, + quality = record.quality, + effectiveQuality = record.effectiveQuality, ) fun DownloadEntity.toSidecar(): DownloadSidecar = @@ -65,6 +67,8 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = status = status, createdAt = createdAt, completedAt = completedAt, + quality = quality, + effectiveQuality = effectiveQuality, ), title = title, subtitle = subtitle, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt index 47e5a84b1..1555d7697 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorage.kt @@ -9,10 +9,31 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore +import org.prairieserver.prairie.common.store.containedLegacyChild +import org.prairieserver.prairie.common.store.containedSafeChild +import org.prairieserver.prairie.common.store.safePathSegment import org.prairieserver.prairie.model.download.DownloadMediaType import java.io.File +import java.io.IOException import java.io.OutputStream import java.net.URI +import java.nio.channels.Channels +import java.nio.channels.FileChannel +import java.nio.channels.SeekableByteChannel +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.OpenOption +import java.nio.file.Path +import java.nio.file.SecureDirectoryStream +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean /** * Storage coordinator for downloads. Sidecars live under private [baseDir]; @@ -66,7 +87,6 @@ class DownloadStorage( mediaType: String? = null, ): DownloadTarget { val displayName = localMediaFileName(fileId, fileName, container) - publicStore.delete(serverId, profileId, fileId, uriString = null) return publicStore.create( serverId = serverId, profileId = profileId, @@ -89,9 +109,8 @@ class DownloadStorage( fun delete(serverId: String, profileId: String, fileId: Int): Boolean = publicStore.delete(serverId, profileId, fileId, uriString = null) - fun completeWrite(uriString: String) { + fun completeWrite(uriString: String): String = publicStore.complete(uriString) - } /** Actual on-disk bytes of a partial download at [uriString], or 0 if it's * gone/unreadable. Used to resume an interrupted download via HTTP Range. @@ -185,7 +204,7 @@ interface PublicDownloadStore { fun locate(uriString: String): DownloadLocation? fun locateByFileId(serverId: String, profileId: String, fileId: Int): DownloadLocation? fun delete(serverId: String, profileId: String, fileId: Int, uriString: String?): Boolean - fun complete(uriString: String) = Unit + fun complete(uriString: String): String = uriString /** Open an existing partial for append (resume). Null = unsupported/unavailable. */ fun openAppend(uriString: String): OutputStream? = null @@ -208,9 +227,11 @@ class FileBackedPublicDownloadStore( collectionRoots ?: PublicDownloadCollection.entries.associateWith { collection -> File(requireNotNull(root) { "root or collectionRoots is required" }, collection.directoryName) } - private val roots: List = rootsByCollection.values.distinctBy { file -> - runCatching { file.canonicalPath }.getOrDefault(file.absolutePath) - } + private val roots: List = rootsByCollection.values + .map { file -> runCatching { file.canonicalFile }.getOrDefault(file.absoluteFile) } + .distinctBy { file -> file.path } + private val pendingReplacedDeletes = ConcurrentHashMap>() + private val pendingWrites = ConcurrentHashMap() override fun create( serverId: String, @@ -221,56 +242,149 @@ class FileBackedPublicDownloadStore( mediaType: String?, ): DownloadTarget { val collection = PublicDownloadCollection.forMediaType(mediaType) - val dir = File(collectionRoot(collection), "$serverId/$profileId/$fileId").apply { - deleteRecursively() - mkdirs() + val root = collectionRoot(collection) + val dir = requireNotNull(containedSafeChild(root, serverId, profileId, fileId.toString())) { + "Download path is outside its collection root" + } + check(dir.mkdirs() || dir.isDirectory) { "Could not create download directory" } + val file = requireNotNull(directDisplayNameChild(dir, displayName)) { + "Download display name is outside its file directory" } - val file = File(dir, displayName) + val replaced = PublicDownloadCollection.entries + .flatMap { candidateCollection -> + val candidateRoot = collectionRoot(candidateCollection) + downloadCandidates(candidateRoot, serverId, profileId, fileId).flatMap { candidate -> + if (candidate.canonicalPath == dir.canonicalPath) { + candidate.listFiles() + .orEmpty() + .filter { child -> + child.name != file.name && + !child.name.startsWith(STAGED_FILE_PREFIX) && + isRegularContainedFile(candidateRoot, child) + } + } else { + listOf(candidate) + } + } + } + .filter { candidate -> candidate.exists() } + .distinctBy { candidate -> candidate.canonicalPath } + val staged = createStagedDownload(dir, file, displayName) + val uriString = staged.uriString + abortPendingWithin(dir) + pendingWrites[uriString] = staged + if (replaced.isNotEmpty()) pendingReplacedDeletes[uriString] = replaced return DownloadTarget( - uriString = fileUriString(file), + uriString = uriString, displayName = displayName, - openOutputStream = { file.outputStream() }, - sizeBytes = { file.length() }, + openOutputStream = staged::claimOutputStream, + sizeBytes = { staged.tempFile.length() }, ) } override fun locate(uriString: String): DownloadLocation? { if (!uriString.startsWith("file:", ignoreCase = true)) return null - val file = runCatching { File(URI(uriString)) }.getOrElse { - File(uriString.removePrefix("file://").removePrefix("file:")) - } - if (!file.isFile) return null - return FileDownloadLocation(uriString, file.name, file.length(), file) + val reference = parseFileReference(uriString) ?: return null + val file = reference.file.canonicalFile + if (!file.isFile || roots.none { file.isContainedBy(it) }) return null + return FileDownloadLocation(uriString, reference.displayName ?: file.name, file.length(), file) } override fun locateByFileId(serverId: String, profileId: String, fileId: Int): DownloadLocation? { PublicDownloadCollection.entries.forEach { collection -> - val dir = File(collectionRoot(collection), "$serverId/$profileId/$fileId") - val file = dir.listFiles { f -> f.isFile }?.sortedBy { it.name }?.firstOrNull() - if (file != null) { - return FileDownloadLocation(fileUriString(file), file.name, file.length(), file) + val root = collectionRoot(collection) + downloadCandidates(root, serverId, profileId, fileId).forEach { dir -> + val file = firstContainedFile(root, dir) + if (file != null) { + val staged = pendingWrites.values.firstOrNull { pending -> + pending.tempFile.absolutePath == file.absolutePath + } + return FileDownloadLocation( + staged?.uriString ?: fileUriString(file), + staged?.displayName ?: file.name, + file.length(), + file, + ) + } } } return null } override fun delete(serverId: String, profileId: String, fileId: Int, uriString: String?): Boolean { + uriString?.let { pendingUri -> + pendingWrites.remove(pendingUri)?.let { staged -> + pendingReplacedDeletes.remove(pendingUri) + return staged.abort() + } + } val fromUri = uriString?.let { locate(it) as? FileDownloadLocation }?.file - if (fromUri != null) return fromUri.delete() - return PublicDownloadCollection.entries.any { collection -> - val target = File(collectionRoot(collection), "$serverId/$profileId/$fileId") - target.exists() && target.deleteRecursively() + if (fromUri != null) { + val root = roots.firstOrNull { fromUri.isContainedBy(it) } ?: return false + return deleteContainedRecursively(root, fromUri) + } + var deleted = false + PublicDownloadCollection.entries.forEach { collection -> + val root = collectionRoot(collection) + downloadCandidates(root, serverId, profileId, fileId).forEach { target -> + abortPendingWithin(target) + deleted = deleteContainedRecursively(root, target) || deleted + } } + return deleted } - override fun complete(uriString: String) { - val file = (locate(uriString) as? FileDownloadLocation)?.file ?: return - onFileCompleted(file) + override fun complete(uriString: String): String { + val staged = pendingWrites.remove(uriString) + try { + val reference = parseFileReference(uriString) + ?: throw IOException("Invalid completed-download URI") + val tempFile = staged?.tempFile ?: reference.file + val displayName = staged?.displayName ?: reference.displayName + if (displayName == null) { + val existing = (locate(uriString) as? FileDownloadLocation)?.file ?: return uriString + onFileCompleted(existing) + return fileUriString(existing) + } + staged?.closeBeforePublish() + val root = roots.firstOrNull { tempFile.isContainedBy(it) } + ?: throw IOException("Staged download escaped its collection root") + if (!isRegularContainedFile(root, tempFile)) { + throw IOException("Staged download is missing or is not a regular file") + } + val finalFile = staged?.finalFile + ?: tempFile.parentFile?.let { parent -> directDisplayNameChild(parent, displayName) } + ?: throw IOException("Invalid completed-download display name") + if (staged != null) { + staged.publish() + } else { + moveReplacingWithoutFollowing(tempFile, finalFile) + } + onFileCompleted(finalFile) + pendingReplacedDeletes.remove(uriString).orEmpty().forEach { replaced -> + roots.firstOrNull { replaced.isContainedBy(it) }?.let { replacementRoot -> + deleteContainedRecursively(replacementRoot, replaced) + } + } + return fileUriString(finalFile) + } catch (failure: Throwable) { + staged?.closeForRetry() + throw failure + } } override fun openAppend(uriString: String): OutputStream? { val file = (locate(uriString) as? FileDownloadLocation)?.file ?: return null - return java.io.FileOutputStream(file, /* append = */ true) + return runCatching { + Channels.newOutputStream( + FileChannel.open( + file.toPath(), + StandardOpenOption.WRITE, + StandardOpenOption.APPEND, + LinkOption.NOFOLLOW_LINKS, + ), + ) + }.getOrNull() } override fun partialSize(uriString: String): Long = @@ -279,8 +393,11 @@ class FileBackedPublicDownloadStore( override fun deleteAllForProfile(serverId: String, profileId: String): Boolean { var deleted = false PublicDownloadCollection.entries.forEach { collection -> - val target = File(collectionRoot(collection), "$serverId/$profileId") - deleted = (target.exists() && target.deleteRecursively()) || deleted + val root = collectionRoot(collection) + scopedCandidates(root, serverId, profileId).forEach { target -> + abortPendingWithin(target) + deleted = deleteContainedRecursively(root, target) || deleted + } } return deleted } @@ -288,8 +405,11 @@ class FileBackedPublicDownloadStore( override fun deleteAllForServer(serverId: String): Boolean { var deleted = false PublicDownloadCollection.entries.forEach { collection -> - val target = File(collectionRoot(collection), serverId) - deleted = (target.exists() && target.deleteRecursively()) || deleted + val root = collectionRoot(collection) + scopedCandidates(root, serverId).forEach { target -> + abortPendingWithin(target) + deleted = deleteContainedRecursively(root, target) || deleted + } } return deleted } @@ -297,7 +417,7 @@ class FileBackedPublicDownloadStore( override fun totalBytesUsed(): Long { var total = 0L roots.forEach { root -> - if (root.exists()) root.walkTopDown().forEach { if (it.isFile) total += it.length() } + total += containedBytes(root, root) } return total } @@ -305,21 +425,256 @@ class FileBackedPublicDownloadStore( override fun totalBytesUsed(serverId: String, profileId: String): Long { var total = 0L PublicDownloadCollection.entries.forEach { collection -> - val dir = File(collectionRoot(collection), "$serverId/$profileId") - if (dir.exists()) dir.walkTopDown().forEach { if (it.isFile) total += it.length() } + val root = collectionRoot(collection) + scopedCandidates(root, serverId, profileId).forEach { dir -> + total += containedBytes(root, dir) + } } return total } private fun collectionRoot(collection: PublicDownloadCollection): File = - rootsByCollection[collection] ?: error("No public download root for $collection") + (rootsByCollection[collection] ?: error("No public download root for $collection")).canonicalFile private fun fileUriString(file: File): String = URI("file", "", file.absolutePath, null).toASCIIString() + + private fun downloadCandidates(root: File, serverId: String, profileId: String, fileId: Int): List = + distinctCandidates( + containedSafeChild(root, serverId, profileId, fileId.toString()), + containedLegacyChild(root, serverId, profileId, fileId.toString()), + ) + + private fun scopedCandidates(root: File, vararg segments: String): List = + distinctCandidates( + containedSafeChild(root, *segments), + containedLegacyChild(root, *segments), + ) + + private fun distinctCandidates(vararg candidates: File?): List = + candidates.filterNotNull().distinctBy { it.canonicalPath } + + private fun directDisplayNameChild(directory: File, displayName: String): File? { + if (displayName.isEmpty() || displayName == "." || displayName == "..") return null + if ('/' in displayName || '\\' in displayName) return null + return File(directory, displayName).absoluteFile.takeIf { candidate -> + candidate.parentFile == directory.absoluteFile + } + } + + private fun firstContainedFile(root: File, directory: File): File? { + if (!directory.isContainedBy(root) || !directory.isDirectory) return null + return directory.listFiles() + ?.asSequence() + ?.filter { child -> isRegularContainedFile(root, child) } + ?.map { it.canonicalFile } + ?.sortedBy { it.name } + ?.firstOrNull() + } + + /** + * Walks without `FOLLOW_LINKS`, so a symlink is deleted as an entry and its + * target is never visited. Android does not guarantee + * [java.nio.file.SecureDirectoryStream] for every filesystem provider, so + * parent namespace replacement between the containment check and the walk + * remains a platform limitation; managed roots are app-owned/private where + * applicable, and each write target uses an unguessable no-follow stage. + */ + private fun deleteContainedRecursively(root: File, target: File): Boolean { + val path = target.toPath() + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return false + val canonicalParent = runCatching { target.parentFile?.canonicalFile }.getOrNull() ?: return false + if (!canonicalParent.isContainedBy(root, allowRoot = true)) return false + var deleted = false + Files.walkFileTree(path, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + deleted = Files.deleteIfExists(file) || deleted + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(directory: Path, error: java.io.IOException?): FileVisitResult { + if (error != null) throw error + deleted = Files.deleteIfExists(directory) || deleted + return FileVisitResult.CONTINUE + } + }) + return deleted + } + + private fun containedBytes(root: File, target: File): Long { + if (!target.exists()) return 0L + val canonicalTarget = runCatching { target.canonicalFile }.getOrNull() ?: return 0L + if (target.absoluteFile.path != canonicalTarget.path) return 0L + if (!canonicalTarget.isContainedBy(root, allowRoot = true)) return 0L + if (canonicalTarget.isFile) return canonicalTarget.length() + if (!canonicalTarget.isDirectory) return 0L + return canonicalTarget.listFiles().orEmpty().sumOf { child -> + containedBytes(root, child) + } + } + + private fun File.isContainedBy(root: File, allowRoot: Boolean = false): Boolean { + val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return false + val canonicalTarget = runCatching { canonicalFile }.getOrNull() ?: return false + return (allowRoot && canonicalTarget.path == canonicalRoot.path) || + canonicalTarget.path.startsWith(canonicalRoot.path + File.separator) + } + + private fun createStagedDownload(directory: File, finalFile: File, displayName: String): StagedDownload { + repeat(MAX_TEMP_ATTEMPTS) { + val tempFile = File(directory, "$STAGED_FILE_PREFIX${UUID.randomUUID()}.tmp") + var directoryStream: java.nio.file.DirectoryStream? = null + try { + val uriString = pendingFileUriString(tempFile, displayName) + directoryStream = Files.newDirectoryStream(directory.toPath()) + if (directoryStream is SecureDirectoryStream<*>) { + @Suppress("UNCHECKED_CAST") + val secure = directoryStream as SecureDirectoryStream + val relative = directory.toPath().fileSystem.getPath(tempFile.name) + val channel = secure.newByteChannel(relative, CREATE_NEW_NOFOLLOW) + return StagedDownload( + tempFile, + finalFile, + displayName, + uriString, + channel, + secure, + relative, + ) + } + directoryStream.close() + directoryStream = null + return StagedDownload( + tempFile, + finalFile, + displayName, + uriString, + FileChannel.open(tempFile.toPath(), *CREATE_NEW_NOFOLLOW.toTypedArray()), + secureDirectory = null, + relativePath = null, + ) + } catch (_: java.nio.file.FileAlreadyExistsException) { + directoryStream?.close() + // Retry with a new unguessable name. + } catch (failure: Throwable) { + directoryStream?.close() + throw failure + } + } + error("Could not create staged download") + } + + private fun pendingFileUriString(file: File, displayName: String): String = + URI("file", "", file.absolutePath, null, displayName).toASCIIString() + + private fun parseFileReference(uriString: String): FileReference? = + runCatching { + val uri = URI(uriString) + if (!uri.scheme.equals("file", ignoreCase = true)) return null + val pathOnly = URI(uri.scheme, uri.authority, uri.path, null, null) + FileReference(File(pathOnly), uri.fragment) + }.getOrNull() + + private fun isRegularContainedFile(root: File, file: File): Boolean { + val canonical = runCatching { file.canonicalFile }.getOrNull() ?: return false + return file.absoluteFile.path == canonical.path && + canonical.isContainedBy(root) && + Files.isRegularFile(file.toPath(), LinkOption.NOFOLLOW_LINKS) + } + + private fun abortPendingWithin(directory: File) { + val directoryPath = runCatching { directory.canonicalPath + File.separator }.getOrNull() ?: return + pendingWrites.entries.toList().forEach { (uri, staged) -> + if (staged.tempFile.absolutePath.startsWith(directoryPath) && pendingWrites.remove(uri, staged)) { + pendingReplacedDeletes.remove(uri) + staged.abort() + } + } + } + + /** + * Same-directory atomic replacement changes the directory entry itself, so + * an attacker-created destination symlink is replaced rather than followed. + * The fallback remains no-follow but loses crash atomicity on providers that + * do not implement `ATOMIC_MOVE`. + */ + private fun moveReplacingWithoutFollowing(source: File, target: File) { + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } + + private data class FileReference(val file: File, val displayName: String?) + + private inner class StagedDownload( + val tempFile: File, + val finalFile: File, + val displayName: String, + val uriString: String, + private val channel: SeekableByteChannel, + private val secureDirectory: SecureDirectoryStream?, + private val relativePath: Path?, + ) { + private val claimed = AtomicBoolean(false) + + fun claimOutputStream(): OutputStream { + check(claimed.compareAndSet(false, true)) { "Download output stream already opened" } + return Channels.newOutputStream(channel) + } + + fun closeBeforePublish() { + if (channel.isOpen) { + (channel as? FileChannel)?.force(true) + channel.close() + } + } + + fun closeForRetry() { + runCatching { closeBeforePublish() } + runCatching { secureDirectory?.close() } + } + + fun publish() { + val secure = secureDirectory + val source = relativePath + if (secure != null && source != null) { + try { + secure.move(source, secure, source.fileSystem.getPath(finalFile.name)) + } finally { + secure.close() + } + } else { + moveReplacingWithoutFollowing(tempFile, finalFile) + } + } + + fun abort(): Boolean { + closeForRetry() + return runCatching { Files.deleteIfExists(tempFile.toPath()) }.getOrDefault(false) + } + } + + companion object { + private const val STAGED_FILE_PREFIX = ".prairie-stage-" + private const val MAX_TEMP_ATTEMPTS = 8 + private val CREATE_NEW_NOFOLLOW: Set = setOf( + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS, + ) + } } class MediaStorePublicDownloadStore(context: Context) : PublicDownloadStore { private val resolver: ContentResolver = context.contentResolver + private val pendingReplacedDeletes = ConcurrentHashMap() override fun create( serverId: String, @@ -329,8 +684,8 @@ class MediaStorePublicDownloadStore(context: Context) : PublicDownloadStore { container: String?, mediaType: String?, ): DownloadTarget { - delete(serverId, profileId, fileId, uriString = null) val collection = PublicDownloadCollection.forMediaType(mediaType) + deleteByExactRelativePath(collection, mediaStoreRelativePath(collection, serverId, profileId, fileId)) val values = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) put(MediaStore.MediaColumns.MIME_TYPE, mimeTypeForDownloadName(displayName, container)) @@ -340,6 +695,7 @@ class MediaStorePublicDownloadStore(context: Context) : PublicDownloadStore { } } val uri = resolver.insert(collection.contentUri(), values) ?: error("Could not create MediaStore download") + pendingReplacedDeletes[uri.toString()] = MediaStoreScope(collection, serverId, profileId, fileId) return DownloadTarget( uriString = uri.toString(), displayName = displayName, @@ -369,19 +725,21 @@ class MediaStorePublicDownloadStore(context: Context) : PublicDownloadStore { ) val selection = "${MediaStore.MediaColumns.RELATIVE_PATH} = ?" PublicDownloadCollection.entries.forEach { collection -> - resolver.query( - collection.contentUri(), - projection, - selection, - arrayOf(mediaStoreRelativePath(collection, serverId, profileId, fileId)), - null, - )?.use { cursor -> - if (cursor.moveToFirst()) { - val id = cursor.getLong(0) - val name = cursor.getString(1) ?: fileId.toString() - val size = cursor.getLong(2) - val uri = ContentUris.withAppendedId(collection.contentUri(), id) - return DownloadLocation(uri.toString(), name, size) + mediaStorePathCandidates(collection, serverId, profileId, fileId).forEach { path -> + resolver.query( + collection.contentUri(), + projection, + selection, + arrayOf(path), + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + val id = cursor.getLong(0) + val name = cursor.getString(1) ?: fileId.toString() + val size = cursor.getLong(2) + val uri = ContentUris.withAppendedId(collection.contentUri(), id) + return DownloadLocation(uri.toString(), name, size) + } } } } @@ -413,18 +771,30 @@ class MediaStorePublicDownloadStore(context: Context) : PublicDownloadStore { // containing `_` can't wildcard-match a sibling scope's same-fileId row. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return false return deleteAcrossCollections { collection -> - deleteByExactRelativePath(collection, mediaStoreRelativePath(collection, serverId, profileId, fileId)) + var deleted = false + mediaStorePathCandidates(collection, serverId, profileId, fileId).forEach { path -> + deleted = deleteByExactRelativePath(collection, path) || deleted + } + deleted } } override fun deleteAllForProfile(serverId: String, profileId: String): Boolean = deleteAcrossCollections { collection -> - deleteByRelativePathPrefix(collection, "${collection.relativeRoot}/Prairie/${escapeLike(serverId)}/${escapeLike(profileId)}/%") + var deleted = false + mediaStorePrefixCandidates(collection, serverId, profileId).forEach { pattern -> + deleted = deleteByRelativePathPrefix(collection, pattern) || deleted + } + deleted } override fun deleteAllForServer(serverId: String): Boolean = deleteAcrossCollections { collection -> - deleteByRelativePathPrefix(collection, "${collection.relativeRoot}/Prairie/${escapeLike(serverId)}/%") + var deleted = false + mediaStorePrefixCandidates(collection, serverId).forEach { pattern -> + deleted = deleteByRelativePathPrefix(collection, pattern) || deleted + } + deleted } override fun totalBytesUsed(): Long { @@ -446,23 +816,34 @@ class MediaStorePublicDownloadStore(context: Context) : PublicDownloadStore { val selection = "${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ? ESCAPE '\\'" var total = 0L PublicDownloadCollection.entries.forEach { collection -> - resolver.query( - collection.contentUri(), - projection, - selection, - arrayOf("${collection.relativeRoot}/Prairie/${escapeLike(serverId)}/${escapeLike(profileId)}/%"), - null, - )?.use { cursor -> - while (cursor.moveToNext()) total += cursor.getLong(0) + mediaStorePrefixCandidates(collection, serverId, profileId).forEach { pattern -> + resolver.query( + collection.contentUri(), + projection, + selection, + arrayOf(pattern), + null, + )?.use { cursor -> + while (cursor.moveToNext()) total += cursor.getLong(0) + } } } return total } - override fun complete(uriString: String) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + override fun complete(uriString: String): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return uriString val values = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) } - resolver.update(Uri.parse(uriString), values, null, null) + if (resolver.update(Uri.parse(uriString), values, null, null) <= 0) return uriString + pendingReplacedDeletes.remove(uriString)?.let { scope -> + val newPath = mediaStoreRelativePath(scope.collection, scope.serverId, scope.profileId, scope.fileId) + PublicDownloadCollection.entries.forEach { collection -> + mediaStorePathCandidates(collection, scope.serverId, scope.profileId, scope.fileId) + .filterNot { path -> collection == scope.collection && path == newPath } + .forEach { path -> deleteByExactRelativePath(collection, path) } + } + } + return uriString } /** Exact-path delete (no wildcards). Used for single-file deletes. */ @@ -472,7 +853,7 @@ class MediaStorePublicDownloadStore(context: Context) : PublicDownloadStore { return resolver.delete(collection.contentUri(), selection, arrayOf(path)) > 0 } - /** Prefix delete (`.../%`). Caller must pre-escape dynamic segments via [escapeLike]. */ + /** Prefix delete (`.../%`). Caller must pre-escape dynamic segments via [escapeMediaStoreLike]. */ private fun deleteByRelativePathPrefix(collection: PublicDownloadCollection, pattern: String): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return false val selection = "${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ? ESCAPE '\\'" @@ -485,9 +866,6 @@ class MediaStorePublicDownloadStore(context: Context) : PublicDownloadStore { * otherwise a scoped delete/scan could match a sibling scope. Pair with * `ESCAPE '\'`. */ - private fun escapeLike(value: String): String = - value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - private fun deleteAcrossCollections(block: (PublicDownloadCollection) -> Boolean): Boolean { var deleted = false PublicDownloadCollection.entries.forEach { collection -> @@ -495,6 +873,13 @@ class MediaStorePublicDownloadStore(context: Context) : PublicDownloadStore { } return deleted } + + private data class MediaStoreScope( + val collection: PublicDownloadCollection, + val serverId: String, + val profileId: String, + val fileId: Int, + ) } fun mediaStoreRelativePath( @@ -502,7 +887,82 @@ fun mediaStoreRelativePath( serverId: String, profileId: String, fileId: Int, -): String = "${collection.relativeRoot}/Prairie/$serverId/$profileId/$fileId/" +): String = "${collection.relativeRoot}/Prairie/${safePathSegment(serverId)}/${safePathSegment(profileId)}/${safePathSegment(fileId.toString())}/" + +internal fun mediaStoreRelativePathPrefix( + collection: PublicDownloadCollection, + serverId: String, + profileId: String? = null, +): String = buildString { + append(collection.relativeRoot) + append("/Prairie/") + append(escapeMediaStoreLike(safePathSegment(serverId))) + append('/') + if (profileId != null) { + append(escapeMediaStoreLike(safePathSegment(profileId))) + append('/') + } + append('%') +} + +internal fun escapeMediaStoreLike(value: String): String = + value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + +private fun legacyMediaStoreRelativePath( + collection: PublicDownloadCollection, + serverId: String, + profileId: String, + fileId: Int, +): String? { + if (!isLegacyMediaStoreSegment(serverId) || !isLegacyMediaStoreSegment(profileId)) return null + return "${collection.relativeRoot}/Prairie/$serverId/$profileId/$fileId/" +} + +private fun mediaStorePathCandidates( + collection: PublicDownloadCollection, + serverId: String, + profileId: String, + fileId: Int, +): List = + listOfNotNull( + mediaStoreRelativePath(collection, serverId, profileId, fileId), + legacyMediaStoreRelativePath(collection, serverId, profileId, fileId), + ).distinct() + +private fun mediaStorePrefixCandidates( + collection: PublicDownloadCollection, + serverId: String, + profileId: String? = null, +): List { + val encoded = mediaStoreRelativePathPrefix(collection, serverId, profileId) + val legacy = if ( + isLegacyMediaStoreSegment(serverId) && + (profileId == null || isLegacyMediaStoreSegment(profileId)) + ) { + buildString { + append(collection.relativeRoot) + append("/Prairie/") + append(escapeMediaStoreLike(serverId)) + append('/') + if (profileId != null) { + append(escapeMediaStoreLike(profileId)) + append('/') + } + append('%') + } + } else { + null + } + return listOfNotNull(encoded, legacy).distinct() +} + +private fun isLegacyMediaStoreSegment(value: String): Boolean = + value.isNotEmpty() && + !value.startsWith("~") && + value != "." && + value != ".." && + '/' !in value && + '\\' !in value fun legacyPublicCollectionRoots(): Map = mapOf( @@ -523,7 +983,21 @@ fun legacyPublicCollectionRoots(): Map = enum class PublicDownloadCollection(val directoryName: String, val relativeRoot: String) { Video("Movies", "Movies"), Audio("Music", "Music"), - Downloads("Downloads", "Downloads"), + // MediaProvider validates RELATIVE_PATH against its own allow-list, which + // holds the SINGULAR "Download" (Environment.DIRECTORY_DOWNLOADS). The + // plural spelling is rejected with IllegalArgumentException — not an + // IOException — so DownloadWorker failed the download permanently and every + // retry failed identically; ebooks could never be downloaded at all. + // + // These are written as literals rather than Environment constants because + // this enum is constructed in plain JVM unit tests, where android.os + // statics are unavailable and would fail class initialisation. The values + // are fixed platform API and are asserted against the real constants in + // publicDownloadRelativeRootsMatchPlatformConstants(). + // + // directoryName stays plural: it drives the file-backed layout, not the + // MediaStore insert. + Downloads("Downloads", "Download"), ; fun contentUri(): Uri = when (this) { 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 ec6128b1d..df7059a48 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 @@ -94,7 +94,10 @@ class DownloadWorker( val mediaType = inputData.getString(KEY_MEDIA_TYPE) val displayTitle = inputData.getString(KEY_DISPLAY_TITLE) ?: "Download" if (fileId < 0) return@withContext Result.failure() + val lifetimeLease = DownloadWorkerLifetime.acquire(downloadId) + ?: return@withContext Result.failure() + try { Log.i(TAG, "doWork start id=$downloadId fileId=$fileId title=$displayTitle") DiagnosticsDownloadLogger.event("download started") runCatching { @@ -242,9 +245,9 @@ class DownloadWorker( // Server flips status → completed when its serve handler returns; // a refresh here ensures the cache reflects that before the // worker exits and the UI re-renders. - val finalUri = activeUri ?: error("download target was not created") - val finalBytes = storage.partialSize(finalUri) - storage.completeWrite(finalUri) + val pendingUri = activeUri ?: error("download target was not created") + val finalBytes = storage.partialSize(pendingUri) + val finalUri = storage.completeWrite(pendingUri) Log.i(TAG, "doWork success id=$downloadId bytes=$finalBytes") DiagnosticsDownloadLogger.event("download completed") repository.refresh() @@ -313,6 +316,9 @@ class DownloadWorker( } catch (e: Throwable) { failPermanently(e, downloadId, serverId, profileId, fileId, activeUri) } + } finally { + lifetimeLease.close() + } } /** Permanent failure — clean up local file and let the user retry manually. */ @@ -516,8 +522,27 @@ class DownloadWorker( } fun cancel(context: Context, downloadId: String) { + DownloadWorkerLifetime.beginCancellation(downloadId) WorkManager.getInstance(context).cancelAllWorkByTag(tagFor(downloadId)) } + + /** + * Cancellation barrier for destructive identity cleanup. The normal UI + * API remains fire-and-forget, but a server purge must know the worker + * has stopped before deleting the Room rows it can otherwise recreate. + */ + suspend fun cancelAndAwait(context: Context, downloadId: String) { + DownloadWorkerLifetime.beginCancellation(downloadId) + withContext(Dispatchers.IO) { + WorkManager.getInstance(context) + .cancelUniqueWork(tagFor(downloadId)) + .result + .get(CANCEL_ACK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + DownloadWorkerLifetime.awaitIdle(downloadId) + } + + private const val CANCEL_ACK_TIMEOUT_SECONDS = 30L } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerLifetime.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerLifetime.kt new file mode 100644 index 000000000..3174336c0 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerLifetime.kt @@ -0,0 +1,63 @@ +package org.prairieserver.prairie.common.downloads + +import kotlinx.coroutines.CompletableDeferred + +/** + * Process-local barrier between a DownloadWorker and destructive metadata + * cleanup. WorkManager's cancellation Operation acknowledges the stop request, + * not completion of CoroutineWorker cleanup, so callers must also await this + * lease. + */ +internal object DownloadWorkerLifetime { + private const val MAX_CANCELLED_TOMBSTONES = 1_024 + private val lock = Any() + private val states = LinkedHashMap(16, 0.75f, true) + + private class State { + var active = 0 + var cancelling = false + var idle = CompletableDeferred(Unit) + } + + class Lease internal constructor(private val downloadId: String) { + fun close() = release(downloadId) + } + + fun acquire(downloadId: String): Lease? = synchronized(lock) { + val state = states.getOrPut(downloadId) { State() } + if (state.cancelling) return@synchronized null + if (state.active == 0) state.idle = CompletableDeferred() + state.active += 1 + Lease(downloadId) + } + + fun beginCancellation(downloadId: String) = synchronized(lock) { + states.getOrPut(downloadId) { State() }.cancelling = true + trimCancelledTombstones() + } + + suspend fun awaitIdle(downloadId: String) { + val idle = synchronized(lock) { + states[downloadId]?.idle ?: CompletableDeferred(Unit) + } + idle.await() + } + + private fun release(downloadId: String) = synchronized(lock) { + val state = states[downloadId] ?: return@synchronized + state.active -= 1 + if (state.active == 0) { + state.idle.complete(Unit) + if (!state.cancelling) states.remove(downloadId) + } + } + + private fun trimCancelledTombstones() { + if (states.size <= MAX_CANCELLED_TOMBSTONES) return + val iterator = states.entries.iterator() + while (states.size > MAX_CANCELLED_TOMBSTONES && iterator.hasNext()) { + val state = iterator.next().value + if (state.cancelling && state.active == 0) iterator.remove() + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurger.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurger.kt new file mode 100644 index 000000000..2f5b53ecf --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurger.kt @@ -0,0 +1,192 @@ +package org.prairieserver.prairie.common.downloads + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.prairieserver.prairie.common.data.db.PrairieDatabase +import org.prairieserver.prairie.common.data.db.dao.ServerPurgeDao +import org.prairieserver.prairie.common.player.ActivePlaybackFile +import org.prairieserver.prairie.common.store.ServerScopedJsonStatePurger +import org.prairieserver.prairie.network.ServerRegistry + +/** + * Deletes the on-disk files and Room rows of servers that are no longer in the + * registry. + * + * Removing a server wiped four token keys and the registry entry, and nothing + * else. So "remove this server to reclaim space" reclaimed nothing in-app (the + * bytes stayed until the user found them in the system Files app), and because + * `idFor()` is base64 of the normalised URL, re-adding the same server + * resurrected its Downloads list, resume positions, cached home and catalog + * rows, and any pending outbox ops. + * + * Deliberately an observer rather than a step inside `ServerRegistry.remove`: + * + * - `remove` runs inside `identityTransitions.changing { mutex.withLock { … } }` + * on Main. Putting a multi-gigabyte filesystem walk there would serialise + * every identity mutation behind it. + * - `AndroidServerRegistry` lives in `shared` and has no Room access at all. + * - Orphans are *derived* — rows present with no matching registry entry — + * so this is idempotent, safe to re-run at startup, and cleans installs that + * removed servers before any of this existed. + */ +class OrphanedServerDataPurger( + private val registry: ServerRegistry, + private val purgeDao: ServerPurgeDao, + private val storage: DownloadStorage, + private val cancelDownload: suspend (recordId: String) -> Unit, + private val scope: CoroutineScope, + private val io: CoroutineDispatcher = Dispatchers.IO, + private val activePlaybackFileId: () -> Int? = { ActivePlaybackFile.fileId.value }, + private val purgeRows: suspend (String) -> Unit = purgeDao::deleteAllRowsForServer, + private val deleteDownloadStorage: suspend (String) -> Unit = { + storage.deleteAllForServer(it) + Unit + }, + private val jsonStatePurger: ServerScopedJsonStatePurger? = null, +) { + + private val runLock = Mutex() + + fun start(): Job = scope.launch { + var previous = registry.entries.value.map { it.id }.toSet() + jsonStatePurger?.allowRegistered(previous) + val observer = launch(start = CoroutineStart.UNDISPATCHED) { + // Subscribe before the startup filesystem scan. A removal during + // that scan is then buffered instead of falling between a snapshot + // and a later collector subscription. + registry.entries + .map { entries -> entries.map { it.id }.toSet() } + .distinctUntilChanged() + .collect { ids -> + val lost = previous - ids + previous = ids + jsonStatePurger?.allowRegistered(ids) + if (lost.isNotEmpty()) { + runCatching { purgeOnce() } + .onFailure { Log.w(TAG, "registry-change purge failed", it) } + } + } + } + try { + runCatching { purgeOnce() } + .onFailure { Log.w(TAG, "startup purge failed", it) } + observer.join() + } finally { + observer.cancel() + } + } + + /** + * Purges every orphan once. Returns the ids fully purged. + * + * Serialised against itself: startup and a removal can fire together, and + * two passes deleting the same tree would have one of them see files vanish + * mid-walk. + */ + suspend fun purgeOnce(): Set = runLock.withLock { + withContext(io) { + // Orphan-ness is derived purely from registry absence, so a registry + // that failed to load presents every server as an orphan and this + // becomes a device wipe. Absence is only evidence of removal when + // the registry actually knows what it holds. + if (registry.stateLoadFailed) { + Log.w(TAG, "registry state unreadable; skipping purge") + return@withContext emptySet() + } + val registered = registry.entries.value.map { it.id }.toSet() + val orphans = purgeDao.knownServerIds().toSet() - registered + val purged = mutableSetOf() + orphans.forEach { serverId -> + // Once destructive work starts, complete the filesystem/Room pair + // even if the observing app scope is cancelled. Otherwise a + // teardown between those halves can strand rows for a server whose + // one removal-triggered pass has already been consumed. + if (withContext(NonCancellable) { purgeServer(serverId) }) { + purged += serverId + } + } + runCatching { + jsonStatePurger?.deleteJsonOnlyOrphans( + registeredServerIds = registered, + protectedServerIds = orphans - purged, + ) + }.onFailure { Log.w(TAG, "JSON-only orphan purge failed; retaining directory evidence", it) } + purged + } + } + + private suspend fun purgeServer(serverId: String): Boolean { + // Offline playback holds a file open — PiP survives leaving the screen, + // and the player keeps running after a sign-out. Deleting underneath it + // ends playback mid-scene. Leave the whole server for the next pass; + // startup will catch it even if nothing else does. + val activeFileId = activePlaybackFileId() + if (activeFileId != null && activeFileId in purgeDao.downloadFileIdsForServer(serverId)) { + Log.i(TAG, "deferring purge of $serverId: file $activeFileId is playing") + return false + } + + // Cancel first. A running DownloadWorker writes its row back on every + // progress tick, so deleting rows underneath it resurrects one — with + // no registry entry behind it, permanently un-purgeable by this pass. + try { + purgeDao.downloadRecordIdsForServer(serverId).forEach { recordId -> + cancelDownload(recordId) + } + } catch (failure: Exception) { + Log.w(TAG, "download cancellation failed for $serverId; retaining rows", failure) + return false + } + + try { + deleteDownloadStorage(serverId) + jsonStatePurger?.deleteAllForServer(serverId) + } catch (failure: Exception) { + Log.w(TAG, "local-state purge failed for $serverId; retaining rows", failure) + return false + } + + purgeRows(serverId) + Log.i(TAG, "purged orphaned server data for $serverId") + return true + } + + private companion object { + const val TAG = "OrphanedServerPurge" + } +} + +/** + * Builds and starts the purger. + * + * Lives here rather than in each `Application` because the app modules do not + * have Room on their compile classpath — they can name [PrairieDatabase] but not + * call through it. + */ +fun installOrphanedServerDataPurge( + context: Context, + registry: ServerRegistry, + database: PrairieDatabase, + storage: DownloadStorage, + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +): OrphanedServerDataPurger = OrphanedServerDataPurger( + registry = registry, + purgeDao = database.serverPurgeDao(), + storage = storage, + cancelDownload = { recordId -> DownloadWorker.cancelAndAwait(context, recordId) }, + scope = scope, + jsonStatePurger = ServerScopedJsonStatePurger(context.filesDir), +).also { it.start() } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ebook/EbookLocalStateStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ebook/EbookLocalStateStore.kt index 2e0c7db1e..6474227e4 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ebook/EbookLocalStateStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ebook/EbookLocalStateStore.kt @@ -30,7 +30,7 @@ class EbookLocalStateStore(baseDir: File) { store.fileFor(serverId, profileId, contentId, suffix = ".bookmarks.json") private fun displaySettingsFile(serverId: String, profileId: String): File = - store.resolve("$serverId/$profileId/reader-settings.json") + store.fileFor(serverId, profileId, "reader-settings") fun readProgress(serverId: String, profileId: String, contentId: String): ProgressSnapshot? = store.read(progressFile(serverId, profileId, contentId)) @@ -58,7 +58,7 @@ class EbookLocalStateStore(baseDir: File) { * server. Walks `ebook_state///.progress.json`. */ fun listAllProgress(): List { - val root = store.resolve("") + val root = store.rootDirectory() val result = mutableListOf() val serverDirs = root.listFiles()?.filter { it.isDirectory } ?: return result for (serverDir in serverDirs) { @@ -93,16 +93,19 @@ class EbookLocalStateStore(baseDir: File) { location: String, createdAtMs: Long = System.currentTimeMillis(), ): BookmarkSnapshot { - val bookmark = BookmarkSnapshot( - id = "local-$createdAtMs", - location = location, - createdAtMs = createdAtMs, - ) - val updated = (listBookmarks(serverId, profileId, contentId) + bookmark) - .distinctBy { it.id } - .sortedBy { it.createdAtMs } - store.write(bookmarksFile(serverId, profileId, contentId), updated) - return bookmark + val target = bookmarksFile(serverId, profileId, contentId) + return store.withTargetLock(target) { + val bookmark = BookmarkSnapshot( + id = "local-$createdAtMs", + location = location, + createdAtMs = createdAtMs, + ) + val updated = (store.read>(target).orEmpty() + bookmark) + .distinctBy { it.id } + .sortedBy { it.createdAtMs } + store.write(target, updated) + bookmark + } } fun removeBookmark( @@ -111,10 +114,14 @@ class EbookLocalStateStore(baseDir: File) { contentId: String, bookmarkId: String, ): List { - val updated = listBookmarks(serverId, profileId, contentId) - .filterNot { it.id == bookmarkId } - store.write(bookmarksFile(serverId, profileId, contentId), updated) - return updated + val target = bookmarksFile(serverId, profileId, contentId) + return store.withTargetLock(target) { + val updated = store.read>(target) + .orEmpty() + .filterNot { it.id == bookmarkId } + store.write(target, updated) + updated + } } fun readDisplaySettings(serverId: String, profileId: String): ReaderDisplaySettings? = diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/io/LimitedStreams.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/io/LimitedStreams.kt new file mode 100644 index 000000000..0db6f0801 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/io/LimitedStreams.kt @@ -0,0 +1,49 @@ +package org.prairieserver.prairie.common.io + +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +class ContentLimitExceeded( + val maxBytes: Long, + limitName: String = "content", +) : IOException("$limitName exceeds the allowed limit of $maxBytes") + +fun checkedLimitedByteCount( + currentBytes: Long, + additionalBytes: Long, + maxBytes: Long, + limitName: String = "content", +): Long { + require(currentBytes >= 0) { "currentBytes must not be negative" } + require(additionalBytes >= 0) { "additionalBytes must not be negative" } + require(maxBytes >= 0) { "maxBytes must not be negative" } + if (currentBytes > maxBytes || additionalBytes > maxBytes - currentBytes) { + throw ContentLimitExceeded(maxBytes, limitName) + } + return currentBytes + additionalBytes +} + +fun InputStream.copyToLimited( + out: OutputStream, + maxBytes: Long, + bufferSize: Int = DEFAULT_BUFFER_SIZE, +): Long { + require(maxBytes >= 0) { "maxBytes must not be negative" } + require(bufferSize > 0) { "bufferSize must be positive" } + var total = 0L + val buffer = ByteArray(bufferSize) + while (true) { + val read = read(buffer) + if (read < 0) return total + if (read == 0) { + val byte = read() + if (byte < 0) return total + total = checkedLimitedByteCount(total, 1, maxBytes) + out.write(byte) + continue + } + total = checkedLimitedByteCount(total, read.toLong(), maxBytes) + out.write(buffer, 0, read) + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/CleartextConsentStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/CleartextConsentStore.kt new file mode 100644 index 000000000..0a01d76da --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/CleartextConsentStore.kt @@ -0,0 +1,61 @@ +package org.prairieserver.prairie.common.network + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import java.net.URI +import java.security.MessageDigest +import kotlinx.coroutines.flow.first +import org.prairieserver.prairie.network.CleartextOriginConsent + +interface CleartextConsentStore : CleartextOriginConsent { + suspend fun approve(origin: String) +} + +class DataStoreCleartextConsentStore( + private val dataStore: DataStore, +) : CleartextConsentStore { + constructor(context: Context) : this( + PreferenceDataStoreFactory.create { + context.preferencesDataStoreFile(DATA_STORE_NAME) + }, + ) + + override suspend fun isApproved(origin: String): Boolean { + val normalized = cleartextOrigin(origin) ?: return false + return originDigest(normalized) in dataStore.data.first()[APPROVED_ORIGIN_DIGESTS].orEmpty() + } + + override suspend fun approve(origin: String) { + val normalized = requireNotNull(cleartextOrigin(origin)) { + "Cleartext approval requires a valid HTTP origin" + } + val digest = originDigest(normalized) + dataStore.edit { preferences -> + preferences[APPROVED_ORIGIN_DIGESTS] = + preferences[APPROVED_ORIGIN_DIGESTS].orEmpty() + digest + } + } + + private companion object { + private const val DATA_STORE_NAME = "prairie_cleartext_consent" + private val APPROVED_ORIGIN_DIGESTS = stringSetPreferencesKey("approved_origin_sha256") + + private fun originDigest(origin: String): String = + MessageDigest.getInstance("SHA-256") + .digest(origin.encodeToByteArray()) + .joinToString("") { byte -> "%02x".format(byte) } + } +} + +fun cleartextOrigin(url: String): String? = runCatching { + val uri = URI(url.trim()) + if (!uri.scheme.equals("http", ignoreCase = true)) return null + val host = uri.host?.lowercase()?.takeIf(String::isNotBlank) ?: return null + val port = if (uri.port == 80) -1 else uri.port + URI("http", null, host, port, null, null, null).toASCIIString() +}.getOrNull() 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 a4d7bc5c1..18f8753ae 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 @@ -6,6 +6,9 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.CleartextOriginConsent +import org.prairieserver.prairie.network.CleartextOriginNotApprovedException +import org.prairieserver.prairie.network.requiresApproval /** * Narrow commit seam for the pairing receiver after a candidate server approves device @@ -31,6 +34,7 @@ interface PairingAuthPort { class RegistryPairingAuthPort( private val tokenManager: TokenManager, private val serverRegistry: ServerRegistry, + private val cleartextOriginConsent: CleartextOriginConsent? = null, ) : PairingAuthPort { private val commitMutex = Mutex() @@ -42,6 +46,9 @@ class RegistryPairingAuthPort( expiresIn: Long, ) = withContext(NonCancellable) { commitMutex.withLock { + if (cleartextOriginConsent?.requiresApproval(serverUrl) == true) { + throw CleartextOriginNotApprovedException(serverUrl) + } val previousServerId = serverRegistry.activeServerId.value val serverId = serverRegistry.addOrUpdate(serverUrl, fetchedName = serverName) try { diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pip/PrairiePictureInPictureCoordinator.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pip/PrairiePictureInPictureCoordinator.kt index 2398a96ee..5cae22bae 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pip/PrairiePictureInPictureCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pip/PrairiePictureInPictureCoordinator.kt @@ -5,13 +5,13 @@ import android.app.PendingIntent import android.app.PictureInPictureParams import android.app.RemoteAction import android.content.Context -import android.content.Intent import android.content.pm.PackageManager import android.graphics.Rect import android.graphics.drawable.Icon import android.os.Build import android.util.Rational import org.prairieserver.prairie.common.R +import org.prairieserver.prairie.common.player.PipActionCapability import org.prairieserver.prairie.common.player.PrairiePlaybackService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -207,7 +207,7 @@ class PrairiePictureInPictureCoordinator { PendingIntent.getService( context, action.hashCode(), - Intent(context, PrairiePlaybackService::class.java).setAction(action), + PipActionCapability.intent(context, action), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AuthenticatedDataSourceFactory.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AuthenticatedDataSourceFactory.kt index 5a54d213e..65775beab 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AuthenticatedDataSourceFactory.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AuthenticatedDataSourceFactory.kt @@ -10,10 +10,13 @@ import androidx.media3.datasource.DataSpec import androidx.media3.datasource.FileDataSource import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.TransferListener +import org.prairieserver.prairie.common.io.checkedLimitedByteCount import org.prairieserver.prairie.common.player.subtitle.normalizeSubripPayloadIfNeeded import java.io.ByteArrayOutputStream import java.io.IOException import kotlinx.coroutines.runBlocking +import org.prairieserver.prairie.network.isSameHttpOrigin +import org.prairieserver.prairie.network.CleartextOriginNotApprovedException /** * DataSource.Factory that resolves relative stream URLs against the server @@ -78,6 +81,9 @@ internal class RefreshingHttpDataSource( } override fun open(dataSpec: DataSpec): Long { + if (!runBlocking { authSession.isTransportApproved(dataSpec.uri.toString()) }) { + throw CleartextOriginNotApprovedException(dataSpec.uri.toString()) + } val guardEnabled = isResumableDirectPlayUri(dataSpec.uri) if (guardEnabled) { prepareEntityGuard(dataSpec.uri) @@ -88,7 +94,14 @@ internal class RefreshingHttpDataSource( return try { first.openWithGuards(dataSpec, failedSnapshot, guardEnabled) } catch (error: HttpDataSource.InvalidResponseCodeException) { - if (error.responseCode != 401 || !runBlocking { authSession.refreshIfStale(failedSnapshot) }) { + if ( + !shouldRefreshMediaRequest( + serverUrl = failedSnapshot.serverUrl, + requestUrl = dataSpec.uri.toString(), + responseCode = error.responseCode, + ) || + !runBlocking { authSession.refreshIfStale(failedSnapshot) } + ) { throw error } first.close() @@ -189,7 +202,12 @@ internal class RefreshingHttpDataSource( // headers as authoritative while filling only missing auth/profile // headers from the refreshable Prairie session. .setHttpRequestHeaders( - mergeSessionAuthHeaders(snapshot.asRequestHeaders(), httpRequestHeaders), + authenticatedHeadersFor( + serverUrl = snapshot.serverUrl, + requestUrl = uri.toString(), + sessionHeaders = snapshot.asRequestHeaders(), + explicitHeaders = httpRequestHeaders, + ), ) .build() } @@ -225,6 +243,29 @@ internal fun mergeSessionAuthHeaders( } } +internal fun authenticatedHeadersFor( + serverUrl: String, + requestUrl: String, + sessionHeaders: Map, + explicitHeaders: Map, +): Map { + val resolvedRequestUrl = resolveRoutedDataSourceUrl(serverUrl, requestUrl) + val scopedSessionHeaders = if (isSameHttpOrigin(serverUrl, resolvedRequestUrl)) { + sessionHeaders + } else { + emptyMap() + } + return mergeSessionAuthHeaders(scopedSessionHeaders, explicitHeaders) +} + +internal fun shouldRefreshMediaRequest( + serverUrl: String, + requestUrl: String, + responseCode: Int, +): Boolean = + responseCode == 401 && + isSameHttpOrigin(serverUrl, resolveRoutedDataSourceUrl(serverUrl, requestUrl)) + /** * Picks between a [FileDataSource] (offline media playback) and the shared * [OkHttpDataSource] (every other scheme) based on the DataSpec's URI. Also @@ -310,6 +351,7 @@ internal fun resolveRoutedDataSourceUrl(serverUrl: String, rawUri: String): Stri trimmed.startsWith("https://", ignoreCase = true) || trimmed.startsWith("file://", ignoreCase = true) || trimmed.startsWith("content://", ignoreCase = true) -> trimmed + "://" in trimmed -> trimmed trimmed.startsWith("/") -> resolvePlaybackStreamUrl(serverUrl, trimmed) else -> "${serverUrl.trimEnd('/')}/${trimmed.trimStart('/')}" } @@ -318,6 +360,7 @@ internal fun resolveRoutedDataSourceUrl(serverUrl: String, rawUri: String): Stri @UnstableApi internal class SubripNormalizingDataSource( private val upstream: DataSource, + private val maxBytes: Long = MAX_SUBTITLE_BYTES, ) : DataSource { private var normalizedData: ByteArray? = null private var normalizedPosition: Int = 0 @@ -336,14 +379,28 @@ internal class SubripNormalizingDataSource( return upstream.open(dataSpec) } - upstream.open(dataSpec) + val declaredLength = upstream.open(dataSpec) uri = upstream.uri ?: dataSpec.uri val raw = try { + if (declaredLength >= 0) { + checkedLimitedByteCount( + currentBytes = 0, + additionalBytes = declaredLength, + maxBytes = maxBytes, + limitName = "subtitle", + ) + } readAllFromUpstream() } finally { upstream.close() } val normalized = normalizeSubripDataIfNeeded(raw) + checkedLimitedByteCount( + currentBytes = 0, + additionalBytes = normalized.size.toLong(), + maxBytes = maxBytes, + limitName = "normalized subtitle", + ) normalizedData = normalized return normalized.size.toLong() } @@ -372,10 +429,19 @@ internal class SubripNormalizingDataSource( private fun readAllFromUpstream(): ByteArray { val out = ByteArrayOutputStream() val buffer = ByteArray(DEFAULT_SUBRIP_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) out.write(buffer, 0, read) + if (read > 0) { + total = checkedLimitedByteCount( + currentBytes = total, + additionalBytes = read.toLong(), + maxBytes = maxBytes, + limitName = "subtitle", + ) + out.write(buffer, 0, read) + } } return out.toByteArray() } @@ -390,4 +456,5 @@ internal fun shouldNormalizeSubripPath(path: String?, position: Long): Boolean = internal fun normalizeSubripDataIfNeeded(raw: ByteArray): ByteArray = normalizeSubripPayloadIfNeeded(raw, 0, raw.size) ?: raw +internal const val MAX_SUBTITLE_BYTES = 32L * 1024 * 1024 private const val DEFAULT_SUBRIP_READ_BUFFER_SIZE = 16 * 1024 diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/DolbyVisionColorInfoExtractorsFactory.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/DolbyVisionColorInfoExtractorsFactory.kt index 776945998..d3f4dbcfd 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/DolbyVisionColorInfoExtractorsFactory.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/DolbyVisionColorInfoExtractorsFactory.kt @@ -36,6 +36,7 @@ internal class DolbyVisionColorInfoExtractorsFactory( private val transformMode: DolbyVisionTransformMode = DolbyVisionTransformMode.DISABLED, private val converter: DolbyVisionRpuConverter = NativeDolbyVisionRpuConverter, private val expectedDynamicRange: String? = null, + private val expectedColorRange: String? = null, ) : ExtractorsFactory { override fun createExtractors(): Array = delegate.createExtractors().map(::wrap).toTypedArray() @@ -48,18 +49,31 @@ internal class DolbyVisionColorInfoExtractorsFactory( .toTypedArray() private fun wrap(extractor: Extractor): Extractor = - ColorInfoExtractor(extractor, transformMode, converter, expectedDynamicRange) + ColorInfoExtractor( + extractor, + transformMode, + converter, + expectedDynamicRange, + expectedColorRange, + ) private class ColorInfoExtractor( private val delegate: Extractor, private val transformMode: DolbyVisionTransformMode, private val converter: DolbyVisionRpuConverter, private val expectedDynamicRange: String?, + private val expectedColorRange: String?, ) : Extractor by delegate { private var output: ColorInfoExtractorOutput? = null override fun init(output: ExtractorOutput) { - val wrapped = ColorInfoExtractorOutput(output, transformMode, converter, expectedDynamicRange) + val wrapped = ColorInfoExtractorOutput( + output, + transformMode, + converter, + expectedDynamicRange, + expectedColorRange, + ) this.output = wrapped delegate.init(wrapped) } @@ -81,15 +95,25 @@ internal class DolbyVisionColorInfoExtractorsFactory( private val transformMode: DolbyVisionTransformMode, private val converter: DolbyVisionRpuConverter, private val expectedDynamicRange: String?, + private val expectedColorRange: String?, ) : ExtractorOutput { private val tracks = mutableMapOf() override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { val output = delegate.track(id, type) - if (type == C.TRACK_TYPE_VIDEO && transformMode != DolbyVisionTransformMode.DISABLED) { - DolbyVisionTransformingTrackOutput(output, transformMode, converter) + if (type == C.TRACK_TYPE_VIDEO) { + val colorInfoOutput = ColorInfoTrackOutput( + output, + expectedDynamicRange, + expectedColorRange, + ) + if (transformMode != DolbyVisionTransformMode.DISABLED) { + DolbyVisionTransformingTrackOutput(colorInfoOutput, transformMode, converter) + } else { + colorInfoOutput + } } else { - ColorInfoTrackOutput(output, expectedDynamicRange) + output } } @@ -105,12 +129,14 @@ internal class DolbyVisionColorInfoExtractorsFactory( private class ColorInfoTrackOutput( private val delegate: TrackOutput, private val expectedDynamicRange: String?, + private val expectedColorRange: String?, ) : TrackOutput { override fun durationUs(durationUs: Long) = delegate.durationUs(durationUs) override fun format(format: Format) { delegate.format( format + .withValidatedColorRange(expectedColorRange) .withValidatedDynamicRangeColorInfo(expectedDynamicRange) .withDolbyVisionHdrColorInfo(), ) @@ -139,6 +165,22 @@ internal class DolbyVisionColorInfoExtractorsFactory( } } +@UnstableApi +internal fun Format.withValidatedColorRange(expectedColorRange: String?): Format { + if (!MimeTypes.isVideo(sampleMimeType)) return this + val expected = when (expectedColorRange?.trim()?.lowercase()) { + "tv" -> C.COLOR_RANGE_LIMITED + "pc" -> C.COLOR_RANGE_FULL + else -> return this + } + val current = colorInfo + if (current != null && current.colorRange != -1) return this + val repaired = (current?.buildUpon() ?: androidx.media3.common.ColorInfo.Builder()) + .setColorRange(expected) + .build() + return buildUpon().setColorInfo(repaired).build() +} + @UnstableApi internal fun Format.withValidatedDynamicRangeColorInfo(expectedDynamicRange: String?): Format { if (!expectedDynamicRange.equals("hlg", ignoreCase = true) || !MimeTypes.isVideo(sampleMimeType)) { @@ -147,8 +189,7 @@ internal fun Format.withValidatedDynamicRangeColorInfo(expectedDynamicRange: Str val current = colorInfo if (current != null && ( (current.colorSpace != -1 && current.colorSpace != C.COLOR_SPACE_BT2020) || - (current.colorTransfer != -1 && current.colorTransfer != C.COLOR_TRANSFER_HLG) || - (current.colorRange != -1 && current.colorRange != C.COLOR_RANGE_LIMITED) + (current.colorTransfer != -1 && current.colorTransfer != C.COLOR_TRANSFER_HLG) ) ) { return this @@ -156,7 +197,7 @@ internal fun Format.withValidatedDynamicRangeColorInfo(expectedDynamicRange: Str if (current != null && current.colorSpace == C.COLOR_SPACE_BT2020 && current.colorTransfer == C.COLOR_TRANSFER_HLG && - current.colorRange == C.COLOR_RANGE_LIMITED + current.colorRange != -1 ) { return this } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/DolbyVisionProfile7Transformer.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/DolbyVisionProfile7Transformer.kt index 1736661e8..c29ae2800 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/DolbyVisionProfile7Transformer.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/DolbyVisionProfile7Transformer.kt @@ -24,6 +24,7 @@ internal enum class DolbyVisionTransformMode { internal data class PrairieMediaTransformTag( val dolbyVisionMode: DolbyVisionTransformMode, val expectedDynamicRange: String? = null, + val expectedColorRange: String? = null, ) internal class DolbyVisionTransformException( diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/FinalPlaybackPositionWriter.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/FinalPlaybackPositionWriter.kt new file mode 100644 index 000000000..eacae94f2 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/FinalPlaybackPositionWriter.kt @@ -0,0 +1,126 @@ +package org.prairieserver.prairie.common.player + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.repository.port.PlaybackWriteScope + +data class FinalPlaybackPosition( + val scope: PlaybackWriteScope, + val contentId: String, + val fileId: Int, + val positionSeconds: Double, + val durationSeconds: Double?, +) + +/** + * Application-owned final-position queue. Submission never waits for Room; + * pending updates for the same identity/content/file collapse to the newest snapshot. + */ +class FinalPlaybackPositionWriter( + scope: CoroutineScope, + private val scopeProvider: suspend () -> AuthScopeSnapshot?, + private val write: suspend (FinalPlaybackPosition) -> Unit, +) { + private data class Key( + val scope: PlaybackWriteScope, + val contentId: String, + val fileId: Int, + ) + + private val lock = Any() + private val pending = linkedMapOf() + private val wakeUp = Channel(Channel.CONFLATED) + + init { + scope.launch { + for (ignored in wakeUp) { + while (true) { + val batch = synchronized(lock) { + if (pending.isEmpty()) { + emptyList() + } else { + pending.values.toList().also { pending.clear() } + } + } + if (batch.isEmpty()) break + var requeued = false + batch.forEach { snapshot -> + try { + write(snapshot) + } catch (cancellation: CancellationException) { + // Put it back before unwinding: a cancelled drain must + // not be the reason a position disappears. + requeue(snapshot) + throw cancellation + } catch (_: Throwable) { + // The batch was removed from `pending` before the + // write, so swallowing a failure here dropped the + // user's final position outright — no retry, no + // re-queue, and this is the durable record that + // survives a server-side session reset. + requeue(snapshot) + requeued = true + } + } + if (requeued) { + // Back off rather than spinning the drain loop against a + // failing write. A later submit() also wakes us earlier. + delay(RETRY_DELAY_MS) + wakeUp.trySend(Unit) + break + } + } + } + } + } + + suspend fun captureScope(): PlaybackWriteScope? { + val snapshot = scopeProvider() ?: return null + val profileId = snapshot.profileId ?: return null + return PlaybackWriteScope( + serverId = snapshot.serverId, + profileId = profileId, + credentialGenerationId = snapshot.credentialGenerationId, + identityGeneration = snapshot.identityGeneration, + ) + } + + /** + * Returns a failed write to the queue without displacing a newer intent. + * + * `putIfAbsent`, not `put`: while the write was in flight the user may have + * submitted a later position for the same content, and that one is the + * truth. Losing the older snapshot in that case is correct — losing it + * because the write failed is not. + */ + private fun requeue(snapshot: FinalPlaybackPosition) { + synchronized(lock) { + pending.putIfAbsent( + Key(snapshot.scope, snapshot.contentId, snapshot.fileId), + snapshot, + ) + } + } + + fun submit(snapshot: FinalPlaybackPosition): Boolean { + if (!snapshot.isValid()) return false + synchronized(lock) { + pending[Key(snapshot.scope, snapshot.contentId, snapshot.fileId)] = snapshot + } + wakeUp.trySend(Unit) + return true + } +} + +private const val RETRY_DELAY_MS = 5_000L + +private fun FinalPlaybackPosition.isValid(): Boolean = + contentId.isNotBlank() && + fileId > 0 && + positionSeconds.isFinite() && + positionSeconds >= 0.0 && + (durationSeconds == null || durationSeconds.isFinite() && durationSeconds > 0.0) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/LetterboxDetection.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/LetterboxDetection.kt new file mode 100644 index 000000000..d3dea435f --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/LetterboxDetection.kt @@ -0,0 +1,45 @@ +package org.prairieserver.prairie.common.player + +import kotlin.math.min +import kotlin.math.roundToInt + +data class LetterboxInsets( + val topFraction: Float, + val bottomFraction: Float, +) { + val isDetected: Boolean + get() = topFraction > 0f || bottomFraction > 0f + + fun intersect(other: LetterboxInsets): LetterboxInsets = LetterboxInsets( + topFraction = min(topFraction, other.topFraction), + bottomFraction = min(bottomFraction, other.bottomFraction), + ) + + companion object { + val NONE = LetterboxInsets(0f, 0f) + } +} + +internal fun SubtitleVideoRect.insetByLetterbox(insets: LetterboxInsets): SubtitleVideoRect { + if (!insets.isDetected || height <= 0) return this + val topInset = (height * insets.topFraction).roundToInt() + val bottomInset = (height * insets.bottomFraction).roundToInt() + val remaining = height - topInset - bottomInset + if (remaining <= 0) return this + return copy(top = top + topInset, height = remaining) +} + +internal fun SubtitleVideoRect.insetByTitleSafe(fraction: Float): SubtitleVideoRect { + if (fraction <= 0f || width <= 0 || height <= 0) return this + val horizontal = (width * fraction).roundToInt() + val vertical = (height * fraction).roundToInt() + val remainingWidth = width - horizontal * 2 + val remainingHeight = height - vertical * 2 + if (remainingWidth <= 0 || remainingHeight <= 0) return this + return copy( + left = left + horizontal, + top = top + vertical, + width = remainingWidth, + height = remainingHeight, + ) +} 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 1cfed6d69..44649ed78 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 @@ -7,6 +7,7 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import org.prairieserver.prairie.network.isSameHttpOrigin /** * OkHttp interceptor that mirrors [org.prairieserver.prairie.network.PrairieAuthPlugin] @@ -36,6 +37,9 @@ class MediaAuthInterceptor( override fun intercept(chain: Interceptor.Chain): Response { val original = chain.request() val failedSnapshot = runBlocking { authSession.snapshot() } + if (!isSameHttpOrigin(failedSnapshot.serverUrl, original.url.toString())) { + return chain.proceed(original.withoutSiloCredentials()) + } val authed = original.newBuilder() .applyAuthHeaders(failedSnapshot) @@ -55,9 +59,14 @@ class MediaAuthInterceptor( return chain.proceed(authed) } - val retried = original.newBuilder() - .applyAuthHeaders(runBlocking { authSession.snapshot() }) - .build() + val retrySnapshot = runBlocking { authSession.snapshot() } + val retried = if (isSameHttpOrigin(retrySnapshot.serverUrl, original.url.toString())) { + original.newBuilder() + .applyAuthHeaders(retrySnapshot) + .build() + } else { + original.withoutSiloCredentials() + } return chain.proceed(retried) } @@ -66,3 +75,15 @@ class MediaAuthInterceptor( return this } } + +private fun Request.withoutSiloCredentials(): Request = + newBuilder() + .removeHeader("Authorization") + .removeHeader("X-Profile-Id") + .removeHeader("X-Profile-Token") + .apply { + headers.names() + .filter { name -> name.startsWith("X-Prairie-", ignoreCase = true) } + .forEach(::removeHeader) + } + .build() 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 6483f5fe6..90a08bd71 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 @@ -10,7 +10,11 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.prairieserver.prairie.model.auth.RefreshRequest import org.prairieserver.prairie.model.auth.RefreshResponse +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.CleartextOriginConsent import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.isSameHttpOrigin +import org.prairieserver.prairie.network.requiresApproval import java.io.IOException /** @@ -25,17 +29,87 @@ class MediaAuthSession( private val tokenManager: TokenManager, private val refreshClient: OkHttpClient, private val json: Json = Json { ignoreUnknownKeys = true }, + private val cleartextOriginConsent: CleartextOriginConsent? = null, ) { private val refreshMutex = Mutex() - suspend fun snapshot(): MediaAuthSnapshot = MediaAuthSnapshot( - accessToken = tokenManager.getAccessToken(), - profileId = tokenManager.getProfileId(), - profileToken = tokenManager.getProfileToken(), - serverId = tokenManager.getCurrentServerId(), - ) + suspend fun isTransportApproved(requestUrl: String): Boolean = + cleartextOriginConsent?.requiresApproval(requestUrl) != true + + suspend fun snapshot(): MediaAuthSnapshot { + tokenManager.snapshotCurrentScope()?.let { scope -> + if (cleartextOriginConsent?.requiresApproval(scope.serverUrl) == true) { + return MediaAuthSnapshot(null, null, null, scope.serverId, "", null) + } + val accessToken = tokenManager.getAccessTokenForScope(scope) + val scopeAfter = tokenManager.snapshotCurrentScope() + if (scopeAfter != scope || accessToken.isNullOrBlank()) { + return MediaAuthSnapshot( + accessToken = null, + profileId = null, + profileToken = null, + serverId = scopeAfter?.serverId, + serverUrl = "", + authScope = null, + ) + } + return MediaAuthSnapshot( + accessToken = accessToken, + profileId = scope.profileId, + profileToken = scope.profileToken, + serverId = scope.serverId, + serverUrl = scope.serverUrl, + authScope = scope, + ) + } + + val serverIdBefore = tokenManager.getCurrentServerId() + val serverUrl = tokenManager.getServerUrl() + if (cleartextOriginConsent?.requiresApproval(serverUrl) == true) { + return MediaAuthSnapshot(null, null, null, tokenManager.getCurrentServerId(), "") + } + val accessToken = tokenManager.getAccessToken() + val profileId = tokenManager.getProfileId() + val profileToken = tokenManager.getProfileToken() + val serverIdAfter = tokenManager.getCurrentServerId() + val serverUrlAfter = tokenManager.getServerUrl() + + // TokenManager exposes the active fields separately. If the user + // switches servers while they are read, never combine one server's + // credentials with another server's URL. An empty URL makes every + // transport's origin check fail closed and also suppresses refresh. + if ( + serverIdBefore != serverIdAfter || + !isSameHttpOrigin(serverUrl, serverUrlAfter) + ) { + return MediaAuthSnapshot( + accessToken = null, + profileId = null, + profileToken = null, + serverId = serverIdAfter, + serverUrl = "", + ) + } + return MediaAuthSnapshot( + accessToken = accessToken, + profileId = profileId, + profileToken = profileToken, + serverId = serverIdAfter, + serverUrl = serverUrlAfter, + ) + } suspend fun refreshIfStale(failedSnapshot: MediaAuthSnapshot): Boolean = refreshMutex.withLock { + failedSnapshot.authScope?.let { failedScope -> + val currentScope = tokenManager.snapshotCurrentScope() + if (currentScope != failedScope) return@withLock false + val currentAccessToken = tokenManager.getAccessTokenForScope(failedScope) + if (!currentAccessToken.isNullOrBlank() && currentAccessToken != failedSnapshot.accessToken) { + return@withLock true + } + return@withLock attemptRefresh(failedScope) + } + val current = snapshot() if (current.serverId != failedSnapshot.serverId) { return@withLock false @@ -46,6 +120,42 @@ class MediaAuthSession( attemptRefresh(failedSnapshot.serverId) } + private suspend fun attemptRefresh(scope: AuthScopeSnapshot): Boolean { + val refreshToken = tokenManager.getRefreshTokenForScope(scope) ?: return false + if (refreshToken.isBlank() || scope.serverUrl.isBlank()) return false + if (tokenManager.snapshotCurrentScope() != scope) return false + + val request = refreshRequest(scope.serverUrl, refreshToken) + return try { + refreshClient.newCall(request).execute().use { response -> + if (tokenManager.snapshotCurrentScope() != scope) return@use false + if (tokenManager.getRefreshTokenForScope(scope).isNullOrBlank()) return@use false + if (!response.isSuccessful) { + // A temporary remote-playback overlay is process-only. A + // rejected refresh must not remove it and expose the saved + // owner's credentials to the still-running media request. + if ( + response.code.shouldInvalidateSessionAfterMediaRefreshFailure() && + scope.credentialGenerationId == null + ) { + tokenManager.invalidateSessionForScope(scope) + } + return@use false + } + val tokens = decodeRefresh(response.body?.string().orEmpty()) ?: return@use false + tokenManager.saveTokensForScope( + scope = scope, + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken, + expiresIn = tokens.expiresIn, + ) + tokenManager.getAccessTokenForScope(scope) == tokens.accessToken + } + } catch (_: IOException) { + false + } + } + private suspend fun attemptRefresh(serverIdBeforeRequest: String?): Boolean { val refreshToken = tokenManager.getRefreshToken() ?: return false val serverUrl = tokenManager.getServerUrl() @@ -56,13 +166,7 @@ class MediaAuthSession( // reject it. if (tokenManager.getCurrentServerId() != serverIdBeforeRequest) return false - val request = Request.Builder() - .url(serverUrl.trimEnd('/') + "/api/v1/auth/refresh") - .post( - json.encodeToString(RefreshRequest(refreshToken)) - .toRequestBody("application/json; charset=utf-8".toMediaType()), - ) - .build() + val request = refreshRequest(serverUrl, refreshToken) return try { refreshClient.newCall(request).execute().use { response -> @@ -80,9 +184,7 @@ class MediaAuthSession( } return@use false } - val tokens = runCatching { - json.decodeFromString(response.body?.string().orEmpty()) - }.getOrNull() ?: return@use false + val tokens = decodeRefresh(response.body?.string().orEmpty()) ?: return@use false tokenManager.saveTokens( accessToken = tokens.accessToken, refreshToken = tokens.refreshToken, @@ -94,6 +196,18 @@ class MediaAuthSession( false } } + + private fun refreshRequest(serverUrl: String, refreshToken: String): Request = + Request.Builder() + .url(serverUrl.trimEnd('/') + "/api/v1/auth/refresh") + .post( + json.encodeToString(RefreshRequest(refreshToken)) + .toRequestBody("application/json; charset=utf-8".toMediaType()), + ) + .build() + + private fun decodeRefresh(body: String): RefreshResponse? = + runCatching { json.decodeFromString(body) }.getOrNull() } data class MediaAuthSnapshot( @@ -101,6 +215,8 @@ data class MediaAuthSnapshot( val profileId: String?, val profileToken: String?, val serverId: String?, + val serverUrl: String, + internal val authScope: AuthScopeSnapshot? = null, ) { fun asRequestHeaders(): Map = buildMap { accessToken?.takeIf { it.isNotBlank() }?.let { put("Authorization", "Bearer $it") } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaLoadRetryPolicy.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaLoadRetryPolicy.kt index 088232b26..74c765090 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaLoadRetryPolicy.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaLoadRetryPolicy.kt @@ -34,9 +34,22 @@ import okhttp3.internal.http2.StreamResetException @UnstableApi internal class PrairieMediaLoadErrorHandlingPolicy( private val isResumableProgressiveDirectPlay: (Uri) -> Boolean = { false }, + private val nowMs: () -> Long = { android.os.SystemClock.elapsedRealtime() }, ) : DefaultLoadErrorHandlingPolicy() { private val attemptBytesTracker = MediaLoadAttemptBytesTracker() + /** + * When this load task first failed, so the retry window can be measured + * rather than inferred. + * + * `mediaLoadElapsedBeforeRetryMs` reconstructs elapsed time from the retry + * count alone — it counts the delays *between* attempts and nothing spent + * inside them. With a 30s socket timeout per attempt the real elapsed time + * runs several times the 90s window, so a dead server kept a spinner up far + * longer than intended before surfacing an error. + */ + private val firstErrorAtByLoadTask = ConcurrentHashMap() + override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long { val invalidResponse = loadErrorInfo.exception as? HttpDataSource.InvalidResponseCodeException val cumulativeBytesLoaded = loadErrorInfo.loadEventInfo.bytesLoaded @@ -47,6 +60,9 @@ internal class PrairieMediaLoadErrorHandlingPolicy( val resumableDirectPlay = isResumableProgressiveDirectPlay( loadErrorInfo.loadEventInfo.dataSpec.uri, ) + val firstErrorAt = firstErrorAtByLoadTask.computeIfAbsent( + loadErrorInfo.loadEventInfo.loadTaskId, + ) { nowMs() } val delayMs = prairieMediaLoadRetryDelayMs( responseCode = invalidResponse?.responseCode, retryAfterHeaders = invalidResponse?.retryAfterHeaders().orEmpty(), @@ -54,6 +70,7 @@ internal class PrairieMediaLoadErrorHandlingPolicy( errorCount = loadErrorInfo.errorCount, isResumableProgressiveDirectPlay = resumableDirectPlay, bytesLoaded = bytesLoaded, + observedElapsedMs = (nowMs() - firstErrorAt).coerceAtLeast(0L), ) logProgressiveResumeDecision( loadErrorInfo = loadErrorInfo, @@ -66,6 +83,7 @@ internal class PrairieMediaLoadErrorHandlingPolicy( override fun onLoadTaskConcluded(loadTaskId: Long) { attemptBytesTracker.onLoadTaskConcluded(loadTaskId) + firstErrorAtByLoadTask.remove(loadTaskId) super.onLoadTaskConcluded(loadTaskId) } @@ -141,6 +159,7 @@ internal fun prairieMediaLoadRetryDelayMs( isResumableProgressiveDirectPlay: Boolean = false, bytesLoaded: Long = 0L, retryWindowMs: Long = MEDIA_LOAD_RETRY_WINDOW_MS, + observedElapsedMs: Long = 0L, ): Long { if ( !isRetryableMediaLoadFailure( @@ -155,7 +174,11 @@ internal fun prairieMediaLoadRetryDelayMs( val delayMs = retryAfterHeaders.firstNotNullOfOrNull(::parseRetryAfterDelayMs) ?: mediaLoadBackoffDelayMs(errorCount) - val elapsedBeforeRetry = mediaLoadElapsedBeforeRetryMs(errorCount) + // Whichever is larger: the time we can prove has passed, or the sum of the + // delays we intended between attempts. The measured value includes time + // spent inside failed attempts (socket timeouts dominate here); the derived + // one keeps the bound meaningful for callers that cannot measure. + val elapsedBeforeRetry = maxOf(observedElapsedMs, mediaLoadElapsedBeforeRetryMs(errorCount)) return if (elapsedBeforeRetry + delayMs <= retryWindowMs) delayMs else C.TIME_UNSET } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PipActionCapability.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PipActionCapability.kt new file mode 100644 index 000000000..3e62984fa --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PipActionCapability.kt @@ -0,0 +1,41 @@ +package org.prairieserver.prairie.common.player + +import android.content.Context +import android.content.Intent +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import java.util.concurrent.atomic.AtomicReference + +internal object PipActionCapability { + const val EXTRA_CAPABILITY = + "org.prairieserver.prairie.common.player.extra.PIP_ACTION_CAPABILITY" + + private const val CAPABILITY_BYTES = 32 + private val secureRandom = SecureRandom() + private val currentCapability = AtomicReference(generateCapability()) + + fun intent(context: Context, action: String): Intent = + Intent(context, PrairiePlaybackService::class.java) + .setAction(action) + .putExtra( + EXTRA_CAPABILITY, + Base64.getUrlEncoder().withoutPadding() + .encodeToString(currentCapability.get()), + ) + + fun isAuthorized(intent: Intent): Boolean { + val encoded = intent.getStringExtra(EXTRA_CAPABILITY) ?: return false + val supplied = runCatching { + Base64.getUrlDecoder().decode(encoded) + }.getOrNull() ?: return false + return MessageDigest.isEqual(currentCapability.get(), supplied) + } + + fun rotate() { + currentCapability.set(generateCapability()) + } + + private fun generateCapability(): ByteArray = + ByteArray(CAPABILITY_BYTES).also(secureRandom::nextBytes) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayWhenReadyReconciliation.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayWhenReadyReconciliation.kt new file mode 100644 index 000000000..2324d1cab --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayWhenReadyReconciliation.kt @@ -0,0 +1,92 @@ +package org.prairieserver.prairie.common.player + +import androidx.media3.common.Player + +/** + * Only deliberate user/MediaSession changes may become room transport intent. + * + * Audio-focus loss, noisy-route changes, end-of-item, and playback suppression + * are local player conditions. Broadcasting them would pause the room for + * everyone, while forcibly undoing them could resume playback against the + * platform's safety decision. + */ +fun shouldReconcileExternalPlayWhenReady(reason: Int): Boolean = + reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST || + reason == Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE + +/** + * Suppresses the callback produced by our own room/ViewModel synchronization. + * Media3 reports a programmatic `playWhenReady` assignment as USER_REQUEST, so + * the reason alone cannot distinguish it from a notification/headset command. + */ +class PlayWhenReadyReconciliationGate( + private val nowMs: () -> Long = { android.os.SystemClock.elapsedRealtime() }, +) { + private data class PendingChange(val value: Boolean, val createdAtMs: Long) + + data class CallbackDecision( + val shouldReconcile: Boolean, + val followUpProgrammaticValue: Boolean? = null, + ) + + private var inFlight: PendingChange? = null + private var queuedLatest: Boolean? = null + + val hasPendingChanges: Boolean + get() { + discardExpired() + return inFlight != null || queuedLatest != null + } + + /** + * Register a programmatic target. Returns true only when the caller should + * issue it now. While one command awaits its callback, later targets are + * coalesced and issued by [onPlayWhenReadyChanged] after that callback. + */ + fun requestProgrammaticChange(value: Boolean): Boolean { + discardExpired() + if (inFlight == null) { + inFlight = PendingChange(value, nowMs()) + return true + } + queuedLatest = value + return false + } + + fun onPlayWhenReadyChanged(value: Boolean, reason: Int): CallbackDecision { + discardExpired() + if (inFlight?.value == value) { + inFlight = null + val followUp = queuedLatest + queuedLatest = null + if (followUp != null && followUp != value) { + inFlight = PendingChange(followUp, nowMs()) + return CallbackDecision( + shouldReconcile = false, + followUpProgrammaticValue = followUp, + ) + } + return CallbackDecision(shouldReconcile = false) + } + return CallbackDecision( + shouldReconcile = shouldReconcileExternalPlayWhenReady(reason), + ) + } + + fun reset() { + inFlight = null + queuedLatest = null + } + + private fun discardExpired() { + val cutoff = nowMs() - PENDING_CHANGE_MAX_AGE_MS + if (inFlight?.createdAtMs?.let { it < cutoff } == true) { + inFlight = null + queuedLatest = null + } + } + + private companion object { + const val PENDING_CHANGE_MAX_AGE_MS = 2_000L + } +} 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 0cb6f13db..13b89821d 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 @@ -297,10 +297,11 @@ class PlaybackCapabilityDetector( // Media3's DefaultSubtitleParserFactory decodes the // three embedded bitmap families carried by our // direct-play containers: PGS, VobSub/DVD, and DVB. - // Keep sidecar bitmap disabled because Prairie does not - // mount raw bitmap sidecars into the MediaItem. + // Sidecar bitmap is on too: SubtitleManager mounts the + // server's raw `.sup` extract as a MediaItem sidecar, + // so a bitmap track no longer has to be burned in. embeddedBitmap = true, - sidecarBitmap = false, + sidecarBitmap = true, fontAttachments = libassEmbeddedFonts, ), features = buildList { @@ -352,6 +353,12 @@ class PlaybackCapabilityDetector( embeddedText = true, sidecarText = true, assStyling = libassRendering, + // The transport carries no subtitle track, but the + // server raw-serves the embedded PGS as a `.sup` + // sidecar and Media3 parses it — so bitmap subtitles + // render here without a burn-in transcode. + embeddedBitmap = true, + sidecarBitmap = true, ), features = buildList { addAll(listOf("hls", "track_switching", "buffer_reporting")) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackColorRangeFallback.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackColorRangeFallback.kt new file mode 100644 index 000000000..b07fed5ed --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackColorRangeFallback.kt @@ -0,0 +1,21 @@ +package org.prairieserver.prairie.common.player + +import org.prairieserver.prairie.model.playback.PlaybackDelivery +import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan + +fun PlaybackExecutionPlan?.validatedColorRangeFallback(): String? { + val plan = this ?: return null + return when (plan.delivery) { + PlaybackDelivery.ORIGINAL_HTTP, + PlaybackDelivery.SERVER_REMUX_PROGRESSIVE, + PlaybackDelivery.SERVER_REMUX_HLS, + -> plan.source.colorRange + ?.trim() + ?.lowercase() + ?.takeIf { it == "tv" || it == "pc" } + + PlaybackDelivery.CLIENT_LOCAL_NORMALIZATION, + PlaybackDelivery.SERVER_TRANSCODE_HLS, + -> 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 23dca1258..176e50002 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 @@ -13,7 +13,9 @@ 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 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable @@ -24,10 +26,13 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext /** * Wraps [PlaybackSessionManager] with a unified state machine that handles @@ -86,6 +91,58 @@ class PlaybackSessionLifecycle( private var reporterJob: Job? = null private var recoveryJob: Job? = null private var outageJob: Job? = null + private val pendingStopLock = Any() + private var pendingStopJob: Job? = null + + /** Session [pendingStopJob] is stopping; guarded by `pendingStopLock`. */ + private var pendingStopSessionId: String? = null + + private data class ActiveSessionSnapshot( + val state: SessionState, + val notice: PlayerNotice?, + val lastStartParams: StartParams?, + val lastReportedPosition: Double?, + val lastReportedDuration: Double, + val lastIsPaused: Boolean, + val recoveringFromMissingSession: String?, + val flushProgressOnStop: Boolean, + val stopActiveSessionOnStop: Boolean, + val renewMissingSessionWithLegacyStart: Boolean, + val diagnosticsRecording: DiagnosticsPlaybackSessionRecording, + val reporterWasActive: Boolean, + ) + + private data class PendingActiveSessionPublication( + val replacementSessionId: String, + val predecessor: ActiveSessionSnapshot, + ) + + private var pendingActiveSessionPublication: PendingActiveSessionPublication? = null + + /** + * 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. + */ + @Volatile + private var lastAdoptedSessionId: String? = null + + /** + * 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. + */ + @Volatile + private var stopEpoch: Long = 0L // ---- Public API --------------------------------------------------------- @@ -95,10 +152,14 @@ class PlaybackSessionLifecycle( * 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) @@ -116,9 +177,52 @@ class PlaybackSessionLifecycle( manageProgress: Boolean = true, stopSessionOnStop: Boolean = true, renewMissingSessionWithLegacyStart: Boolean = true, + deferPublication: Boolean = false, ) { + awaitPendingStop() + adoptActiveSessionIfCurrent( + params = params, + session = session, + manageProgress = manageProgress, + stopSessionOnStop = stopSessionOnStop, + renewMissingSessionWithLegacyStart = renewMissingSessionWithLegacyStart, + deferPublication = deferPublication, + isCurrent = { true }, + ) + } + + /** + * Waits for an older screen's queued teardown before granting ownership to + * a new external start, then snapshots the epoch under the lifecycle lock. + */ + suspend fun acquireOwnershipEpoch(): Long { + awaitPendingStop() + return mutex.withLock { stopEpoch } + } + + /** + * Atomically adopts an already-started session only while its caller still + * owns the surrounding transaction. The predicate is evaluated inside the + * lifecycle mutex immediately before any lifecycle state is changed. + */ + suspend fun adoptActiveSessionIfCurrent( + params: StartParams, + session: PlaybackSessionResponse, + manageProgress: Boolean = true, + stopSessionOnStop: Boolean = true, + renewMissingSessionWithLegacyStart: Boolean = true, + deferPublication: Boolean = false, + isCurrent: () -> Boolean, + ): Boolean { val diagnosticsRecording = playbackSessions.recording() - mutex.withLock { + return mutex.withLock { + if (!isCurrent()) return@withLock false + val predecessor = if (deferPublication) { + pendingActiveSessionPublication?.predecessor + ?: captureActiveSessionSnapshot() + } else { + null + } cancelRecoveryJobs() reporterJob?.cancel() reporterJob = null @@ -133,29 +237,209 @@ class PlaybackSessionLifecycle( this.renewMissingSessionWithLegacyStart = renewMissingSessionWithLegacyStart this.diagnosticsRecording = diagnosticsRecording diagnosticsRecording.record(session.sessionId) + lastAdoptedSessionId = session.sessionId _state.value = SessionState.Active(session) if (manageProgress) { startProgressReporter() } + pendingActiveSessionPublication = predecessor?.let { + PendingActiveSessionPublication( + replacementSessionId = session.sessionId, + predecessor = it, + ) + } + true + } + } + + /** + * Adopts an externally-started session only if no teardown has happened + * since [expectedOwnershipEpoch] was captured. Rejected or canceled + * candidates are closed here so the server stream cannot be orphaned. + */ + suspend fun adoptActiveSessionIfCurrent( + params: StartParams, + session: PlaybackSessionResponse, + manageProgress: Boolean = true, + stopSessionOnStop: Boolean = true, + renewMissingSessionWithLegacyStart: Boolean = true, + deferPublication: Boolean = false, + expectedOwnershipEpoch: Long, + ): Boolean = try { + currentCoroutineContext().ensureActive() + val adopted = adoptActiveSessionIfCurrent( + params = params, + session = session, + manageProgress = manageProgress, + stopSessionOnStop = stopSessionOnStop, + renewMissingSessionWithLegacyStart = renewMissingSessionWithLegacyStart, + deferPublication = deferPublication, + isCurrent = { stopEpoch == expectedOwnershipEpoch }, + ) + if (!adopted) { + sessionManager.stopSession(session.sessionId) + } + adopted + } catch (cancellation: CancellationException) { + withContext(NonCancellable) { + sessionManager.stopSession(session.sessionId) + } + throw cancellation + } + + /** + * Settles manager and lifecycle publication as one lifecycle-locked + * transition. The manager callback runs only after exact replacement + * ownership has been verified, and no lifecycle reset/stop/adoption can + * enter between manager settlement and the matching lifecycle transition. + * + * A false callback result leaves the pending lifecycle publication intact + * so the caller can retry or choose the opposite settlement. + */ + suspend fun settlePendingPublicationIfCurrent( + sessionId: String, + confirm: Boolean, + settleManager: suspend () -> Boolean, + ): Boolean = mutex.withLock { + val pending = pendingActiveSessionPublication + ?.takeIf { it.replacementSessionId == sessionId } + ?: return@withLock false + if ((_state.value as? SessionState.Active)?.session?.sessionId != sessionId) { + return@withLock false + } + if (!settleManager()) return@withLock false + + pendingActiveSessionPublication = null + if (!confirm) { + cancelRecoveryJobs() + reporterJob?.cancel() + reporterJob = null + restoreActiveSessionSnapshot(pending.predecessor) + } + true + } + + /** + * Rolls back whichever deferred replacement is currently pending without + * requiring the caller to first observe its session id. Exact ownership is + * resolved under the lifecycle mutex and supplied to [settleManager], so a + * fresh content load cannot race a stale, caller-cached replacement id. + * + * No pending publication is already settled and therefore succeeds. A + * manager failure leaves the replacement and its predecessor snapshot + * intact for an exact retry. + */ + suspend fun rollbackCurrentPendingPublication( + settleManager: suspend (sessionId: String) -> Boolean, + ): Boolean = mutex.withLock { + val pending = pendingActiveSessionPublication ?: return@withLock true + val sessionId = pending.replacementSessionId + if ((_state.value as? SessionState.Active)?.session?.sessionId != sessionId) { + return@withLock false + } + if (!settleManager(sessionId)) return@withLock false + + pendingActiveSessionPublication = null + cancelRecoveryJobs() + reporterJob?.cancel() + reporterJob = null + restoreActiveSessionSnapshot(pending.predecessor) + true + } + + suspend fun confirmActiveSessionPublication(sessionId: String): Boolean = + settlePendingPublicationIfCurrent( + sessionId = sessionId, + confirm = true, + settleManager = { true }, + ) + + suspend fun rollbackUnpublishedActiveSession(sessionId: String): Boolean = + settlePendingPublicationIfCurrent( + sessionId = sessionId, + confirm = false, + settleManager = { true }, + ) + + private fun captureActiveSessionSnapshot(): ActiveSessionSnapshot = + ActiveSessionSnapshot( + state = _state.value, + notice = _notice.value, + lastStartParams = lastStartParams, + lastReportedPosition = lastReportedPosition, + lastReportedDuration = lastReportedDuration, + lastIsPaused = lastIsPaused, + recoveringFromMissingSession = recoveringFromMissingSession, + flushProgressOnStop = flushProgressOnStop, + stopActiveSessionOnStop = stopActiveSessionOnStop, + renewMissingSessionWithLegacyStart = renewMissingSessionWithLegacyStart, + diagnosticsRecording = diagnosticsRecording, + reporterWasActive = reporterJob?.isActive == true, + ) + + private fun restoreActiveSessionSnapshot( + snapshot: ActiveSessionSnapshot, + restartReporter: Boolean = true, + ) { + lastStartParams = snapshot.lastStartParams + lastReportedPosition = snapshot.lastReportedPosition + lastReportedDuration = snapshot.lastReportedDuration + 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 } + _state.value = snapshot.state + if ( + restartReporter && + snapshot.reporterWasActive && + snapshot.state is SessionState.Active + ) { + startProgressReporter() } } private suspend fun startInternal( params: StartParams, diagnosticsRecording: DiagnosticsPlaybackSessionRecording, + alreadyLocked: Boolean = false, ): SessionState { - _notice.value = null - _state.value = SessionState.Loading - lastStartParams = params - flushProgressOnStop = true - stopActiveSessionOnStop = true - renewMissingSessionWithLegacyStart = true + // `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) { - val failed = SessionState.Failed("No active profile selected.") - _state.value = failed - return failed + return publishFailureUnlessStopped("No active profile selected.") } val result = if ( @@ -187,11 +471,35 @@ class PlaybackSessionLifecycle( ) } return when (result) { - is ApiResult.Success -> { + 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 @@ -201,16 +509,14 @@ class PlaybackSessionLifecycle( is ApiResult.Error -> { DiagnosticsPlaybackLogger.sessionEvent("session start failed") Log.w(TAG, "start session error: ${result.code} ${result.error} ${result.message}") - val failed = SessionState.Failed(result.message.ifBlank { "Failed to start playback." }) - _state.value = failed - failed + 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}") - val failed = SessionState.Failed("Network error starting playback.") - _state.value = failed - failed + publishFailureUnlessStopped("Network error starting playback.") } } } @@ -236,37 +542,106 @@ class PlaybackSessionLifecycle( * snapshot to PersonalData so position survives a server-side reset, and * stops the active session. */ - suspend fun stop() { + /** + * True while [sessionId] is still the session this lifecycle is presenting. + * + * A recovery run that resumes after cancellation, or after a newer session + * has been adopted, would otherwise republish stale state — a pre-outage + * Active over a freshly adopted session, or a terminal Failed over content + * that is playing fine. + */ + private fun ownsRecoveredSession(sessionId: String): Boolean = + when (val current = _state.value) { + is SessionState.Active -> current.session.sessionId == sessionId + // Still recovering the same session: no newer one has been adopted. + // + // A pending publication for *this* session is not evidence that + // ownership moved — a subtitle commit defers publication for up to + // MAX_LOCAL_MOUNT_WAIT_MS (30s), which outlasts the 10s progress + // interval, so a progress-report NetworkError inside that window + // enters Reconnecting with a pending publication of our own. Reading + // that as "someone else owns this now" left the outage banner up + // forever: beginOutageRecovery's Reconnecting guard blocks every + // later attempt, and settlePendingPublicationIfCurrent requires + // Active, so nothing could ever clear it again. + else -> lastStartParams != null && + (pendingActiveSessionPublication?.replacementSessionId ?: sessionId) == sessionId + } + + /** + * Stops the session this caller believes is playing. + * + * This lifecycle is a process-scoped singleton, and teardown is deferred + * behind settlement work, so a dying screen's stop can land after the next + * screen has already started and adopted its own session — killing the + * episode the user just started. Passing the id the caller was playing makes + * the stop a no-op once ownership has moved on. + */ + suspend fun stop(expectedSessionId: String? = null) { DiagnosticsPlaybackLogger.sessionEvent("session stop requested") - val current = _state.value - cancelRecoveryJobs() - reporterJob?.cancel() - reporterJob = null + mutex.withLock { + if (expectedSessionId != null) { + // Read the ownership token, not the presented state: a session + // being reconnected or restarted is still owned, and answering + // "no id" there let a stale stop cancel a live recovery. + val activeSessionId = + (_state.value as? SessionState.Active)?.session?.sessionId ?: lastAdoptedSessionId + if (activeSessionId != null && activeSessionId != expectedSessionId) { + DiagnosticsPlaybackLogger.sessionEvent("session stop skipped, ownership moved") + return + } + } + // Past the ownership guard: this stop is going to tear down, so any + // start currently in flight must not publish over it. + stopEpoch++ + cancelRecoveryJobs() + reporterJob?.cancel() + reporterJob = null - val sessionId = (current as? SessionState.Active)?.session?.sessionId - // Fire the final snapshot regardless — even during Reconnecting we - // want to durably record where the user was so a fresh login resumes - // there. - if (flushProgressOnStop) { - flushFinalProgress() - } + val pending = pendingActiveSessionPublication + val pendingSessionId = + (_state.value as? SessionState.Active)?.session?.sessionId + if ( + pending != null && + pendingSessionId != null && + pending.replacementSessionId == pendingSessionId && + sessionManager.rollbackUnpublishedVideoSession(pendingSessionId) + ) { + pendingActiveSessionPublication = null + restoreActiveSessionSnapshot( + snapshot = pending.predecessor, + restartReporter = false, + ) + } - if (sessionId != null && stopActiveSessionOnStop) { - when (val r = sessionManager.stopSession(sessionId)) { - is ApiResult.Error -> Log.w(TAG, "stopSession error: ${r.code} ${r.message}") - is ApiResult.NetworkError -> Log.w(TAG, "stopSession network error: ${r.exception}") - else -> {} + val sessionId = (_state.value as? SessionState.Active)?.session?.sessionId + // Fire the final snapshot regardless — even during Reconnecting we + // want to durably record where the user was so a fresh login resumes + // there. + if (flushProgressOnStop) { + flushFinalProgress() } + + if (sessionId != null && stopActiveSessionOnStop) { + when (val r = sessionManager.stopSession(sessionId)) { + is ApiResult.Error -> Log.w(TAG, "stopSession error: ${r.code} ${r.message}") + is ApiResult.NetworkError -> + Log.w(TAG, "stopSession network error: ${r.exception}") + else -> {} + } + } + lastStartParams = null + lastReportedPosition = null + lastReportedDuration = 0.0 + recoveringFromMissingSession = null + flushProgressOnStop = true + stopActiveSessionOnStop = true + renewMissingSessionWithLegacyStart = true + pendingActiveSessionPublication = null + _notice.value = null + lastAdoptedSessionId = null + _state.value = SessionState.Idle } - lastStartParams = null - lastReportedPosition = null - lastReportedDuration = 0.0 - recoveringFromMissingSession = null - flushProgressOnStop = true - stopActiveSessionOnStop = true - renewMissingSessionWithLegacyStart = true - _notice.value = null - _state.value = SessionState.Idle DiagnosticsPlaybackLogger.sessionEvent("session stopped") } @@ -278,8 +653,39 @@ class PlaybackSessionLifecycle( * lifecycle's own singleton scope outlives any ViewModel, and * [NonCancellable] keeps the stop running even if that scope is torn down. */ - fun stopAsync() { - scope.launch(NonCancellable + Dispatchers.IO) { stop() } + fun stopAsync(expectedSessionId: String? = null) { + val job = synchronized(pendingStopLock) { + // Coalesce onto an in-flight stop only when it targets the same + // session. Across different sessions the older job carries the older + // id and no-ops once ownership has moved, so reusing it would + // silently drop the newer stop and leave that session running. + pendingStopJob + ?.takeUnless { it.isCompleted } + ?.takeIf { pendingStopSessionId == expectedSessionId } + ?: scope.launch( + context = NonCancellable + Dispatchers.IO, + start = CoroutineStart.LAZY, + ) { + stop(expectedSessionId) + }.also { + pendingStopJob = it + pendingStopSessionId = expectedSessionId + } + } + job.invokeOnCompletion { + synchronized(pendingStopLock) { + if (pendingStopJob === job) { + pendingStopJob = null + pendingStopSessionId = null + } + } + } + job.start() + } + + private suspend fun awaitPendingStop() { + val job = synchronized(pendingStopLock) { pendingStopJob } ?: return + job.join() } // ---- Internal: progress reporter ---------------------------------------- @@ -339,7 +745,11 @@ class PlaybackSessionLifecycle( ) // Re-invoke the start flow with the latest position without // cancelling this recovery coroutine out from under itself. - startInternal(params.copy(startPosition = resumePos), diagnosticsRecording) + startInternal( + params.copy(startPosition = resumePos), + diagnosticsRecording, + alreadyLocked = true, + ) recoveryJob = null } } @@ -361,6 +771,11 @@ class PlaybackSessionLifecycle( ) 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 @@ -372,15 +787,24 @@ class PlaybackSessionLifecycle( val step = delayMs.coerceAtMost(OUTAGE_TIMEOUT_MS - elapsed) delay(step) elapsed += step - if (!isActive || elapsed >= OUTAGE_TIMEOUT_MS) break + 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 @@ -389,6 +813,8 @@ class PlaybackSessionLifecycle( 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) 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 f9bf17e3f..a9924d8d6 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 @@ -25,22 +25,41 @@ import org.prairieserver.prairie.model.playback.PlaybackReplanRequestV3 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.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.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.AtomicReference +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.withContext import org.prairieserver.prairie.common.player.audio.PassthroughSuppressionRegistry +data class StagedVideoReplan( + val basePlaybackAttemptId: String, + val baseSessionId: String, + val basePlanAttemptId: String, + val candidate: VideoSessionStartV3.Ready, + val candidateSessionId: String, + val outputRouteGeneration: Long, +) + /** * Manages the playback session lifecycle: creation, progress reporting, * audio track switching, transcoding, and teardown. @@ -51,6 +70,21 @@ open class PlaybackSessionManager( private val playbackRepository: PlaybackRepository, private val tokenManager: TokenManager, private val networkEvidenceProvider: PlaybackNetworkEvidenceProvider = PlaybackNetworkEvidenceProvider.None, + /** + * Owns asynchronous predecessor cleanup after a committed publication. + * Production uses the manager's long-lived IO scope. Tests may inject their + * structured scope so cleanup is observable and cannot outlive the test. + */ + private val committedSessionCleanupScope: CoroutineScope? = null, + /** + * How long a content reset waits for a deferred publication before rolling + * it back itself. Injectable because it is a wall-clock safety net, and + * `runTest` advances virtual time whenever the scheduler idles — a fixed + * value would fire inside tests that are legitimately waiting for a + * settlement, making them order-dependent. Tests asserting the wait pass + * [NEVER_SELF_HEAL]; the test that asserts self-healing passes a real value. + */ + private val pendingPublicationSettleTimeoutMs: Long? = PENDING_PUBLICATION_SETTLE_TIMEOUT_MS, ) { private data class ActiveVideoAttempt( val fileId: Int, @@ -70,14 +104,95 @@ open class PlaybackSessionManager( val attemptCount: Int, val startedAtElapsedRealtimeMs: Long, val firstFrameReported: Boolean, + /** + * The plan the SERVER currently holds for this session. + * + * POST /replan is a commit: once it returns 200 the server has moved on, + * and for an in-place replan (same session id) there is nothing to undo. + * `plan` describes what is actually rendering and must still revert on + * rollback, but the cursor must not — reverting it retires a planId the + * server has already superseded, after which every later replan is + * rejected 409 "The failed plan is no longer current" for the rest of + * the session. Null until this attempt has replanned at least once. + */ + 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 + + /** Identity of the plan the server last acknowledged for a session. */ + private data class ServerPlanCursor( + val planId: String, + val planAttemptId: String, + val planAttemptKey: String, + val attemptedPlanKeys: List, + val attemptCount: Int, + ) + + private data class PreparedStagedVideoReplan( + val nextAttempt: ActiveVideoAttempt, + val fallbackReason: String, + ) + + private data class PendingVideoPublication( + val replacement: ActiveVideoAttempt, + val predecessor: ActiveVideoAttempt?, + val settled: CompletableDeferred = CompletableDeferred(), + ) + + private data class PendingPublicationRollback( + val replacementSessionId: String, + val predecessorSessionId: String?, + val candidateSessionIds: List, + val settled: CompletableDeferred, + ) + + private sealed interface PreparedVideoReplan { + data class Staged(val value: StagedVideoReplan) : PreparedVideoReplan + data class ImmediateOutcome(val value: VideoSessionStartV3) : PreparedVideoReplan + } + // Suspendable plan operations stay serialized, while synchronous Media3 // reporter callbacks use CAS below so they never block the playback thread // or overwrite a newer plan published by one of those operations. private val videoAttemptMutex = Mutex() + private val contentStartMutex = Mutex() + private val immediateVideoReplanMutex = Mutex() private val telemetryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val sessionCleanupScope = committedSessionCleanupScope ?: telemetryScope private val activeVideoAttempt = AtomicReference() + private val stagedVideoReplans = + IdentityHashMap() + private val orphanedSessionIds = mutableSetOf() + private var pendingVideoPublication: PendingVideoPublication? = null + private var contentResetInProgress = false + + private suspend fun withSettledVideoAttempt( + block: suspend () -> T, + ): T { + while (true) { + videoAttemptMutex.lock() + val pending = pendingVideoPublication + if (pending == null) { + try { + return block() + } finally { + videoAttemptMutex.unlock() + } + } + videoAttemptMutex.unlock() + pending.settled.await() + } + } suspend fun startVideoSessionV3( fileId: Int, @@ -89,107 +204,166 @@ open class PlaybackSessionManager( qualityPreference: String?, startPosition: Double?, subtitleFidelityPreference: SubtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, - ): ApiResult { - val playbackAttemptId = UUID.randomUUID().toString() - val network = networkEvidenceProvider.snapshot() - val request = PlaybackStartRequestV3( - fileId = fileId, - profileId = profileId, - playbackAttemptId = playbackAttemptId, - qualityPreference = qualityPreference?.lowercase() ?: "auto", - subtitleFidelityPreference = subtitleFidelityPreference, - startPosition = startPosition, - audioTrackId = audioTrackIndex?.let { stableTrackId(fileId, "audio", it) }, - audioTrackIndex = audioTrackIndex, - subtitleTrackId = subtitleTrackIndex?.takeIf { it >= 0 } - ?.let { stableTrackId(fileId, "subtitle", it) }, - subtitleTrackIndex = subtitleTrackIndex, - outputRouteGeneration = clientPlaybackContext.output.outputRouteGeneration, - metered = network.metered, - bandwidthEstimateKbps = network.bandwidthEstimateKbps, - capabilities = capabilities, - clientPlaybackContext = clientPlaybackContext, - ) - return when (val result = playbackRepository.startPlaybackV3(request)) { - is ApiResult.Success -> when (val validated = result.data.validateForMedia3()) { - is PlaybackV3Validation.Playable -> { - val planAttemptId = UUID.randomUUID().toString() - val active = newActiveAttempt( - request = request, - network = network, - sessionId = validated.sessionId, - plan = validated.plan, - serverFeatures = result.data.serverFeatures.toSet(), - planAttemptId = planAttemptId, - ) - videoAttemptMutex.withLock { activeVideoAttempt.set(active) } - PassthroughSuppressionRegistry.beginAttempt(active.planAttemptKey) - reportActiveVideoEvent("plan_selected", network.asRouteDiagnostics()) - ApiResult.Success( - VideoSessionStartV3.Ready( - session = validated.plan.toSessionResponse(validated.sessionId, profileId, fileId), + deferPublication: Boolean = false, + ): ApiResult = contentStartMutex.withLock { + try { + beginContentReset() + val predecessorForPublication = videoAttemptMutex.withLock { + activeVideoAttempt.get() + } + val playbackAttemptId = UUID.randomUUID().toString() + val network = networkEvidenceProvider.snapshot() + val request = PlaybackStartRequestV3( + fileId = fileId, + profileId = profileId, + playbackAttemptId = playbackAttemptId, + qualityPreference = qualityPreference?.lowercase() ?: "auto", + subtitleFidelityPreference = subtitleFidelityPreference, + startPosition = startPosition, + audioTrackId = audioTrackIndex?.let { stableTrackId(fileId, "audio", it) }, + audioTrackIndex = audioTrackIndex, + subtitleTrackId = subtitleTrackIndex?.takeIf { it >= 0 } + ?.let { stableTrackId(fileId, "subtitle", it) }, + // -1 is the client's "subtitles off" marker, but the server + // validates subtitle_track_index as 0..10_000 and rejects the + // whole start with 400 "subtitle_track_index is invalid" + // (validateTrackPairV3). Omitting the field is how V3 expresses + // off: ResolveSubtitlePolicyV3 defaults the index to -1 when it + // is absent and maps index < 0 to SubtitleOffV3, so the plan is + // 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, + metered = network.metered, + bandwidthEstimateKbps = network.bandwidthEstimateKbps, + capabilities = capabilities, + clientPlaybackContext = clientPlaybackContext, + ) + return@withLock when (val result = playbackRepository.startPlaybackV3(request)) { + is ApiResult.Success -> when (val validated = result.data.validateForMedia3()) { + is PlaybackV3Validation.Playable -> { + val planAttemptId = UUID.randomUUID().toString() + val active = newActiveAttempt( + request = request, + network = network, + sessionId = validated.sessionId, plan = validated.plan, - playbackAttemptId = playbackAttemptId, + serverFeatures = result.data.serverFeatures.toSet(), planAttemptId = planAttemptId, - planAttemptKey = active.planAttemptKey, - ), - ) - } - is PlaybackV3Validation.Terminal -> { - videoAttemptMutex.withLock { activeVideoAttempt.set(null) } - emitRouteEvent( - PlaybackRouteEventV3( - playbackAttemptId = playbackAttemptId, - sessionId = result.data.sessionId, - event = "terminal", - fallbackReason = validated.reason, - outputRouteGeneration = request.outputRouteGeneration, - ), - ) - (result.data.playbackPlan?.sessionId ?: result.data.sessionId) - ?.let { playbackRepository.stopPlayback(it) } - ApiResult.Success( - VideoSessionStartV3.Terminal(validated.reason, validated.message, validated.retryable), - ) - } - is PlaybackV3Validation.Incompatible -> { - videoAttemptMutex.withLock { activeVideoAttempt.set(null) } - validated.allocatedSessionId?.let { playbackRepository.stopPlayback(it) } - 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. - val planAttemptId = UUID.randomUUID().toString() - val active = newActiveAttempt( - request = request, - network = network, - sessionId = validated.sessionId, - plan = validated.plan, - serverFeatures = result.data.serverFeatures.toSet(), - planAttemptId = planAttemptId, - ) - videoAttemptMutex.withLock { activeVideoAttempt.set(active) } - PassthroughSuppressionRegistry.beginAttempt(active.planAttemptKey) - val replanResult = replanActiveVideoSession( - classification = validated.reason, - message = "The server returned a legacy player route.", - positionSeconds = startPosition ?: 0.0, - audioTrackIndex = audioTrackIndex, - subtitleTrackIndex = subtitleTrackIndex, - ) - if (replanResult is ApiResult.Error || replanResult is ApiResult.NetworkError) { - val cleared = videoAttemptMutex.withLock { - activeVideoAttempt.compareAndSet(active, null) + ) + videoAttemptMutex.withLock { + installActiveVideoAttemptLocked( + replacement = active, + predecessor = predecessorForPublication, + deferPublication = deferPublication, + ) } - if (cleared) playbackRepository.stopPlayback(validated.sessionId) + PassthroughSuppressionRegistry.beginAttempt(active.planAttemptKey) + reportActiveVideoEvent("plan_selected", network.asRouteDiagnostics()) + ApiResult.Success( + VideoSessionStartV3.Ready( + session = validated.plan.toSessionResponse(validated.sessionId, profileId, fileId), + plan = validated.plan, + playbackAttemptId = playbackAttemptId, + planAttemptId = planAttemptId, + planAttemptKey = active.planAttemptKey, + ), + ) + } + is PlaybackV3Validation.Terminal -> { + if (!deferPublication) { + videoAttemptMutex.withLock { + activeVideoAttempt.set(null) + } + } + emitRouteEvent( + PlaybackRouteEventV3( + playbackAttemptId = playbackAttemptId, + sessionId = result.data.sessionId, + event = "terminal", + fallbackReason = validated.reason, + outputRouteGeneration = request.outputRouteGeneration, + ), + ) + (result.data.playbackPlan?.sessionId ?: result.data.sessionId) + ?.let { playbackRepository.stopPlayback(it) } + ApiResult.Success( + VideoSessionStartV3.Terminal(validated.reason, validated.message, validated.retryable), + ) + } + is PlaybackV3Validation.Incompatible -> { + if (!deferPublication) { + videoAttemptMutex.withLock { + activeVideoAttempt.set(null) + } + } + validated.allocatedSessionId?.let { playbackRepository.stopPlayback(it) } + 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. + val planAttemptId = UUID.randomUUID().toString() + val active = newActiveAttempt( + request = request, + network = network, + sessionId = validated.sessionId, + plan = validated.plan, + serverFeatures = result.data.serverFeatures.toSet(), + planAttemptId = planAttemptId, + ) + videoAttemptMutex.withLock { activeVideoAttempt.set(active) } + PassthroughSuppressionRegistry.beginAttempt(active.planAttemptKey) + finishContentReset() + val replanResult = replanActiveVideoSession( + classification = validated.reason, + message = "The server returned a legacy player route.", + positionSeconds = startPosition ?: 0.0, + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + ) + if (deferPublication) { + var abandonedSessionId: String? = null + videoAttemptMutex.withLock { + val replacement = activeVideoAttempt.get() + val ready = replanResult is ApiResult.Success && + replanResult.data is VideoSessionStartV3.Ready + if (ready && replacement != null) { + pendingVideoPublication = PendingVideoPublication( + replacement = replacement, + predecessor = predecessorForPublication, + ) + } else { + if (replacement?.sessionId == active.sessionId) { + abandonedSessionId = active.sessionId + } + revertRenderedPlanKeepingCursor(predecessorForPublication) + predecessorForPublication?.let { + PassthroughSuppressionRegistry.beginAttempt(it.planAttemptKey) + } + } + } + abandonedSessionId?.let { playbackRepository.stopPlayback(it) } + } else if ( + replanResult is ApiResult.Error || + replanResult is ApiResult.NetworkError + ) { + val cleared = videoAttemptMutex.withLock { + activeVideoAttempt.compareAndSet(active, null) + } + if (cleared) { + playbackRepository.stopPlayback(validated.sessionId) + } + } + replanResult } - replanResult } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result } - is ApiResult.Error -> result - is ApiResult.NetworkError -> result + } finally { + finishContentReset() } } @@ -223,6 +397,126 @@ open class PlaybackSessionManager( ) } + private fun installActiveVideoAttemptLocked( + replacement: ActiveVideoAttempt, + predecessor: ActiveVideoAttempt?, + deferPublication: Boolean, + ) { + activeVideoAttempt.set(replacement) + pendingVideoPublication = if (deferPublication) { + PendingVideoPublication( + replacement = replacement, + predecessor = predecessor?.takeIf { + it.sessionId != replacement.sessionId + }, + ) + } else { + null + } + } + + private suspend fun beginContentReset() { + val callerContext = currentCoroutineContext() + var resetState: Pair, String?>? = null + while (resetState == null) { + videoAttemptMutex.lock() + val pending = pendingVideoPublication + if (pending != null) { + videoAttemptMutex.unlock() + // Bounded, because settlement is owned by a *different* object. + // The manager's pending publication is created inside + // startVideoSessionV3, while the lifecycle's counterpart is + // installed by the caller afterwards; a cancellation between the + // two leaves this one with nobody to settle it. Waiting forever + // then wedged every future start — an unrecoverable spinner — + // because the lifecycle-side recovery hatch reports success when + // its own pending is absent and never consults this one. + val settled = if (pendingPublicationSettleTimeoutMs == null) { + pending.settled.await() + } else { + withTimeoutOrNull(pendingPublicationSettleTimeoutMs) { + pending.settled.await() + } + } + if (settled == null) { + Log.w( + TAG, + "pending publication ${pending.replacement.sessionId} never settled; rolling it back", + ) + rollbackUnpublishedVideoSession(pending.replacement.sessionId) + // Guarantees progress even if the rollback found nothing to + // do: an unsettled deferred publication must not outlive the + // start that is waiting on it. + pending.settled.complete(Unit) + videoAttemptMutex.withLock { + if (pendingVideoPublication === pending) pendingVideoPublication = null + } + } + continue + } + try { + // Publication settlement was observed while holding the same + // mutex used to fence replans. Marking the reset in progress + // here prevents a new pending publication from appearing + // between the check above and staged-candidate drainage. + contentResetInProgress = true + val activeSessionId = activeVideoAttempt.get()?.sessionId + resetState = ( + drainStagedCandidateSessionsLocked( + protectedSessionIds = setOfNotNull(activeSessionId), + ) + ).distinct().filterNot { it == activeSessionId } to activeSessionId + } finally { + videoAttemptMutex.unlock() + } + } + val (candidateSessionIds, protectedSessionId) = checkNotNull(resetState) + withContext(NonCancellable) { + try { + stopSessionsRetainingFailures(candidateSessionIds) + } finally { + drainOrphanedSessions(protectedSessionIds = setOfNotNull(protectedSessionId)) + } + } + callerContext.ensureActive() + } + + private suspend fun finishContentReset() { + withContext(NonCancellable) { + videoAttemptMutex.withLock { + contentResetInProgress = false + } + } + } + + private fun drainStagedCandidateSessionsLocked( + protectedSessionIds: Set, + ): List = stagedVideoReplans.keys + .map { it.candidateSessionId } + .distinct() + .filter { it !in protectedSessionIds } + .also { stagedVideoReplans.clear() } + + private fun drainStagedCandidateSessionsForBaseLocked( + baseSessionId: String, + protectedSessionIds: Set, + ): List { + val matchingHandles = stagedVideoReplans.keys + .filter { it.baseSessionId == baseSessionId } + matchingHandles.forEach { stagedVideoReplans.remove(it) } + val remainingCandidateSessionIds = stagedVideoReplans.keys + .mapTo(mutableSetOf()) { it.candidateSessionId } + return matchingHandles + .map { it.candidateSessionId } + .distinct() + .filter { it !in protectedSessionIds && it !in remainingCandidateSessionIds } + } + + internal fun activeSessionIdForTest(): String? = activeVideoAttempt.get()?.sessionId + + internal suspend fun orphanedSessionIdsForTest(): Set = + videoAttemptMutex.withLock { orphanedSessionIds.toSet() } + suspend fun replanActiveVideoSession( classification: String, message: String? = null, @@ -234,8 +528,90 @@ open class PlaybackSessionManager( qualityPreference: String? = null, capabilities: ClientCodecCapabilities? = null, clientPlaybackContext: ClientPlaybackContext? = null, - ): ApiResult = videoAttemptMutex.withLock { - val active = activeVideoAttempt.get() ?: return@withLock ApiResult.Error( + ): ApiResult = immediateVideoReplanMutex.withLock { + when ( + val prepared = prepareActiveVideoSessionReplan( + classification = classification, + message = message, + positionSeconds = positionSeconds, + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + decoderName = decoderName, + diagnostics = diagnostics, + qualityPreference = qualityPreference, + capabilities = capabilities, + clientPlaybackContext = clientPlaybackContext, + preserveImmediateOutcomes = true, + ) + ) { + is ApiResult.Success -> when (val value = prepared.data) { + is PreparedVideoReplan.Staged -> commitStagedVideoReplan(value.value) + is PreparedVideoReplan.ImmediateOutcome -> ApiResult.Success(value.value) + } + is ApiResult.Error -> prepared + is ApiResult.NetworkError -> prepared + } + } + + suspend fun stageActiveVideoSessionReplan( + classification: String, + message: String? = null, + positionSeconds: Double, + audioTrackIndex: Int?, + subtitleTrackIndex: Int?, + decoderName: String? = null, + diagnostics: Map = emptyMap(), + qualityPreference: String? = null, + capabilities: ClientCodecCapabilities? = null, + clientPlaybackContext: ClientPlaybackContext? = null, + ): ApiResult = when ( + val prepared = prepareActiveVideoSessionReplan( + classification = classification, + message = message, + positionSeconds = positionSeconds, + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + decoderName = decoderName, + diagnostics = diagnostics, + qualityPreference = qualityPreference, + capabilities = capabilities, + clientPlaybackContext = clientPlaybackContext, + preserveImmediateOutcomes = false, + ) + ) { + is ApiResult.Success -> when (val value = prepared.data) { + is PreparedVideoReplan.Staged -> ApiResult.Success(value.value) + is PreparedVideoReplan.ImmediateOutcome -> ApiResult.Error( + code = 500, + error = "invalid_staged_replan_state", + message = "A staged replan unexpectedly produced an immediate playback outcome.", + ) + } + is ApiResult.Error -> prepared + is ApiResult.NetworkError -> prepared + } + + private suspend fun prepareActiveVideoSessionReplan( + classification: String, + message: String? = null, + positionSeconds: Double, + audioTrackIndex: Int?, + subtitleTrackIndex: Int?, + decoderName: String? = null, + diagnostics: Map = emptyMap(), + qualityPreference: String? = null, + capabilities: ClientCodecCapabilities? = null, + clientPlaybackContext: ClientPlaybackContext? = null, + preserveImmediateOutcomes: Boolean, + ): ApiResult = withSettledVideoAttempt { + if (contentResetInProgress) { + return@withSettledVideoAttempt ApiResult.Error( + code = 409, + error = "content_reset_in_progress", + message = "A replacement playback content session is still being installed.", + ) + } + val active = activeVideoAttempt.get() ?: return@withSettledVideoAttempt ApiResult.Error( code = 409, error = "playback_attempt_not_active", message = "No protocol-v3 playback attempt is active.", @@ -243,7 +619,7 @@ open class PlaybackSessionManager( if (classification == SEEK_REANCHOR_V3_OPERATION || classification == SEEK_FAILURE_RECOVERY_V3_OPERATION ) { - return@withLock ApiResult.Error( + return@withSettledVideoAttempt ApiResult.Error( code = 400, error = "reserved_playback_operation", message = "Seek operations must use the dedicated playback session methods.", @@ -276,11 +652,15 @@ open class PlaybackSessionManager( network.asRouteDiagnostics(), ), ) + // 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( playbackAttemptId = active.playbackAttemptId, replanRequestId = UUID.randomUUID().toString(), - failedPlanId = active.plan.planId, - planAttemptId = active.planAttemptId, + failedPlanId = active.serverPlanId, + planAttemptId = cursor?.planAttemptId ?: active.planAttemptId, planAttemptKey = failedKey, attemptedPlanKeys = attemptedKeys, attemptCount = requestAttemptCount, @@ -298,27 +678,89 @@ open class PlaybackSessionManager( capabilities = currentCapabilities, clientPlaybackContext = currentContext, ) - when (val result = playbackRepository.replanPlaybackV3(active.sessionId, request)) { + val result = playbackRepository.replanPlaybackV3(active.sessionId, request) + 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, + ) + // 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. + activeVideoAttempt.get() + ?.takeIf { it.sessionId == active.sessionId } + ?.let { live -> + activeVideoAttempt.compareAndSet( + live, + 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, + planAttemptKey = committedKey, + attemptedPlanKeys = (attemptedKeys + committedKey).distinct(), + attemptCount = requestAttemptCount, + ), + ), + ) + } + } + } + when (result) { is ApiResult.Success -> when (val validated = result.data.validateForMedia3()) { is PlaybackV3Validation.Playable -> { val nextKey = validated.plan.planAttemptKey(currentContext.output.outputRouteGeneration) if (nextKey in attemptedKeys) { - if (validated.sessionId != active.sessionId) { - playbackRepository.stopPlayback(validated.sessionId) + stopCandidateSessionIfUnowned(active.sessionId, validated.sessionId) + if (preserveImmediateOutcomes) { + activeVideoAttempt.set(null) + return@withSettledVideoAttempt ApiResult.Success( + PreparedVideoReplan.ImmediateOutcome( + VideoSessionStartV3.Terminal( + "replan_loop_detected", + "The server returned a playback plan that already failed on this output route.", + false, + ), + ), + ) } - activeVideoAttempt.set(null) - return@withLock ApiResult.Success( - VideoSessionStartV3.Terminal( - "replan_loop_detected", - "The server returned a playback plan that already failed on this output route.", - false, - ), + return@withSettledVideoAttempt ApiResult.Error( + code = 409, + error = "replan_loop_detected", + message = "The server returned a playback plan that already failed on this output route.", + ) + } + val subtitleMismatch = subtitleCandidateMismatch( + requested = request.selectedTracks.subtitle, + candidate = validated.plan, + ) + if (subtitleMismatch != null) { + stopCandidateSessionIfUnowned(active.sessionId, validated.sessionId) + return@withSettledVideoAttempt ApiResult.Error( + code = 502, + error = "invalid_subtitle_replan_candidate", + message = subtitleMismatch, ) } val nextAttemptId = UUID.randomUUID().toString() val next = active.copy( sessionId = validated.sessionId, plan = validated.plan, + // A committed replan makes the rendered plan the server + // plan, so no cursor is needed — `cursor ?: plan.planId` + // then addresses correctly. Clearing it also matters: + // this copy is taken from the PRE-request snapshot, so + // carrying `active`'s cursor forward would reinstate a + // retired planId, and hoisting the CAS-written one is + // wrong too because its planAttemptId belongs to the + // previous attempt, not to nextAttemptId below. + serverPlanCursor = null, serverFeatures = result.data.serverFeatures.toSet(), planAttemptId = nextAttemptId, planAttemptKey = nextKey, @@ -332,59 +774,111 @@ open class PlaybackSessionManager( startedAtElapsedRealtimeMs = SystemClock.elapsedRealtime(), firstFrameReported = false, ) - activeVideoAttempt.set(next) - PassthroughSuppressionRegistry.beginAttempt(nextKey) - if (validated.sessionId != active.sessionId) playbackRepository.stopPlayback(active.sessionId) - emitRouteEvent( - PlaybackRouteEventV3( - playbackAttemptId = active.playbackAttemptId, - sessionId = validated.sessionId, - planId = validated.plan.planId, - planAttemptId = nextAttemptId, - planAttemptKey = nextKey, - event = "plan_selected", - fallbackReason = classification, - appliedQuirkIds = validated.plan.appliedQuirks.map { it.id }, - quirkRegistryRevision = validated.plan.appliedQuirks.firstOrNull()?.registryRevision, - outputRouteGeneration = currentContext.output.outputRouteGeneration, + val ready = VideoSessionStartV3.Ready( + session = validated.plan.toSessionResponse( + validated.sessionId, + active.profileId, + active.fileId, ), + plan = validated.plan, + playbackAttemptId = active.playbackAttemptId, + planAttemptId = nextAttemptId, + planAttemptKey = nextKey, ) - ApiResult.Success( - VideoSessionStartV3.Ready( - session = validated.plan.toSessionResponse(validated.sessionId, active.profileId, active.fileId), - plan = validated.plan, - playbackAttemptId = active.playbackAttemptId, - planAttemptId = nextAttemptId, - planAttemptKey = nextKey, - ), + val staged = StagedVideoReplan( + basePlaybackAttemptId = active.playbackAttemptId, + baseSessionId = active.sessionId, + basePlanAttemptId = active.planAttemptId, + candidate = ready, + candidateSessionId = validated.sessionId, + outputRouteGeneration = next.context.output.outputRouteGeneration, ) + stagedVideoReplans[staged] = PreparedStagedVideoReplan( + nextAttempt = next, + fallbackReason = classification, + ) + ApiResult.Success(PreparedVideoReplan.Staged(staged)) } is PlaybackV3Validation.Terminal -> { - reportActiveVideoEvent( - event = "terminal", - diagnostics = mapOf("reason" to validated.reason), - ) - activeVideoAttempt.set(null) - listOfNotNull(active.sessionId, result.data.sessionId).distinct() - .forEach { playbackRepository.stopPlayback(it) } - ApiResult.Success(VideoSessionStartV3.Terminal(validated.reason, validated.message, validated.retryable)) + if (preserveImmediateOutcomes) { + reportActiveVideoEvent( + event = "terminal", + diagnostics = mapOf("reason" to validated.reason), + ) + activeVideoAttempt.set(null) + stopImmediateFailureSessions( + activeSessionId = active.sessionId, + candidateSessionIds = listOf(result.data.sessionId), + stopActiveSession = true, + ) + ApiResult.Success( + PreparedVideoReplan.ImmediateOutcome( + VideoSessionStartV3.Terminal( + validated.reason, + validated.message, + validated.retryable, + ), + ), + ) + } else { + stopCandidateSessionsIfUnowned( + active.sessionId, + result.data.sessionId, + result.data.playbackPlan?.sessionId, + ) + ApiResult.Error( + code = 409, + error = validated.reason, + message = validated.message, + ) + } } is PlaybackV3Validation.Incompatible -> { - activeVideoAttempt.set(null) - validated.allocatedSessionId?.let { playbackRepository.stopPlayback(it) } - ApiResult.Success(VideoSessionStartV3.ServerUpgradeRequired) + if (preserveImmediateOutcomes) { + activeVideoAttempt.set(null) + stopCandidateSessionIfUnowned( + activeSessionId = null, + candidateSessionId = validated.allocatedSessionId, + ) + ApiResult.Success( + PreparedVideoReplan.ImmediateOutcome( + VideoSessionStartV3.ServerUpgradeRequired, + ), + ) + } else { + stopCandidateSessionIfUnowned(active.sessionId, validated.allocatedSessionId) + ApiResult.Error( + code = 502, + error = "playback_server_upgrade_required", + message = "The server returned an incompatible playback replan.", + ) + } } is PlaybackV3Validation.ReplanRequired -> { - activeVideoAttempt.set(null) - listOf(active.sessionId, validated.sessionId).distinct() - .forEach { playbackRepository.stopPlayback(it) } - ApiResult.Success( - VideoSessionStartV3.Terminal( - "unsupported_legacy_engine", - "The server could not provide a Media3 playback route.", - false, - ), - ) + if (preserveImmediateOutcomes) { + activeVideoAttempt.set(null) + stopImmediateFailureSessions( + activeSessionId = active.sessionId, + candidateSessionIds = listOf(validated.sessionId), + stopActiveSession = true, + ) + ApiResult.Success( + PreparedVideoReplan.ImmediateOutcome( + VideoSessionStartV3.Terminal( + "unsupported_legacy_engine", + "The server could not provide a Media3 playback route.", + false, + ), + ), + ) + } else { + stopCandidateSessionIfUnowned(active.sessionId, validated.sessionId) + ApiResult.Error( + code = 502, + error = "unsupported_legacy_engine", + message = "The server could not provide a Media3 playback route.", + ) + } } } is ApiResult.Error -> result @@ -392,31 +886,451 @@ open class PlaybackSessionManager( } } + suspend fun commitStagedVideoReplan( + staged: StagedVideoReplan, + deferPublication: Boolean = false, + ): ApiResult = videoAttemptMutex.withLock { + val prepared = stagedVideoReplans.remove(staged) + ?: return@withLock stagedVideoReplanUnavailable() + val active = activeVideoAttempt.get() + if (active == null || + active.playbackAttemptId != staged.basePlaybackAttemptId || + active.sessionId != staged.baseSessionId || + active.planAttemptId != staged.basePlanAttemptId + ) { + stopCandidateSessionIfUnowned(active?.sessionId, staged.candidateSessionId) + return@withLock stagedVideoReplanUnavailable() + } + + val next = prepared.nextAttempt + PassthroughSuppressionRegistry.beginAttempt(next.planAttemptKey) + val routeEvent = PlaybackRouteEventV3( + playbackAttemptId = next.playbackAttemptId, + sessionId = next.sessionId, + planId = next.plan.planId, + planAttemptId = next.planAttemptId, + planAttemptKey = next.planAttemptKey, + event = "plan_selected", + fallbackReason = prepared.fallbackReason, + appliedQuirkIds = next.plan.appliedQuirks.map { it.id }, + quirkRegistryRevision = next.plan.appliedQuirks.firstOrNull()?.registryRevision, + outputRouteGeneration = next.context.output.outputRouteGeneration, + ) + + // 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]. + activeVideoAttempt.set(next) + if (deferPublication) { + pendingVideoPublication = PendingVideoPublication( + replacement = next, + predecessor = active, + ) + } + runCatching { emitRouteEvent(routeEvent) } + if (!deferPublication) { + scheduleCommittedSessionCleanup( + oldSessionId = active.sessionId, + activeSessionId = next.sessionId, + ) + } + ApiResult.Success(staged.candidate) + } + + suspend fun confirmVideoSessionPublication(sessionId: String): Boolean { + val confirmation = videoAttemptMutex.withLock { + val pending = pendingVideoPublication + ?.takeIf { it.replacement.sessionId == sessionId } + ?: return@withLock null + if (activeVideoAttempt.get()?.sessionId != sessionId) return@withLock null + pendingVideoPublication = null + val predecessorSessionId = pending.predecessor?.sessionId + ?.takeIf { it != sessionId } + predecessorSessionId?.let { orphanedSessionIds += it } + pending.settled.complete(Unit) + true to predecessorSessionId + } ?: return false + + val predecessorSessionId = confirmation.second + if (predecessorSessionId != null) { + scheduleRegisteredCommittedSessionCleanup( + oldSessionId = predecessorSessionId, + activeSessionId = sessionId, + ) + } + return true + } + + /** + * Rolls back whatever deferred publication this manager still holds. + * + * The lifecycle's `rollbackCurrentPendingPublication` can only settle a + * publication the *lifecycle* knows about, and reports success when it has + * none — but the manager's is created first, so a cancellation between the + * two leaves this side pending with no owner. Callers about to start fresh + * content should clear both. + * + * Returns true when nothing is pending or the rollback succeeded. + */ + suspend fun rollbackCurrentPendingVideoPublication(): Boolean { + val pendingSessionId = videoAttemptMutex.withLock { + pendingVideoPublication?.replacement?.sessionId + } ?: return true + return rollbackUnpublishedVideoSession(pendingSessionId) + } + + /** + * Drops manager ownership of [sessionId] and stops it. + * + * For a non-deferred commit there is no publication to roll back: ownership + * has already moved to the new attempt and the predecessor's session is + * being cleaned up, so a caller that must abandon the result cannot revert + * to anything. Stopping the session while leaving it installed as the active + * attempt left every later replan and progress report aimed at a session the + * server had already torn down. + */ + suspend fun abandonActiveVideoSession(sessionId: String): Boolean { + val disowned = videoAttemptMutex.withLock { + val active = activeVideoAttempt.get() + if (active?.sessionId != sessionId) false + else activeVideoAttempt.compareAndSet(active, null) + } + stopSession(sessionId) + return disowned + } + + suspend fun rollbackUnpublishedVideoSession(sessionId: String): Boolean { + val rollback = videoAttemptMutex.withLock { + rollbackPendingPublicationLocked(sessionId) + } ?: return false + return try { + try { + if (rollback.replacementSessionId == rollback.predecessorSessionId) { + // In-place replan: the server reused the session id, so the + // "replacement" we would stop is the session still playing. + // Ownership already reverted to it; only candidates go. + stopSessionAfterOwnershipCleared( + sessionId = null, + candidateSessionIds = rollback.candidateSessionIds, + ) + } else { + stopSessionAfterOwnershipCleared( + sessionId = rollback.replacementSessionId, + candidateSessionIds = rollback.candidateSessionIds, + ) + } + } catch (_: Throwable) { + // Ownership already converged to the predecessor under + // videoAttemptMutex. The replacement is orphan-tracked before + // its best-effort stop, so cleanup failure or caller + // cancellation must not veto the lifecycle half of rollback. + // A cancelled caller remains cancelled and will observe that + // at its next cancellation check after joint convergence. + } + true + } finally { + rollback.settled.complete(Unit) + } + } + + + /** + * Reverts what is rendering while KEEPING the server-side plan cursor. + * + * A replan the server has acknowledged cannot be un-acknowledged — for an + * in-place replan there is not even a second session to discard. Restoring + * the predecessor wholesale also restores its plan identity, which the + * server has already retired, so every subsequent replan is rejected 409 + * "The failed plan is no longer current" until playback restarts. + */ + private fun revertRenderedPlanKeepingCursor(predecessor: ActiveVideoAttempt?) { + // The predecessor's own cursor wins: it describes the plan the server + // holds for ITS session. Only borrow the live attempt's cursor when the + // predecessor has none AND the two are the same session — otherwise a + // failed start of a different item would graft its cursor onto the item + // still playing, permanently retiring a plan the server never issued + // for it and disabling every later replan on that session. + val live = activeVideoAttempt.get() + val carried = live + ?.takeIf { it.sessionId == predecessor?.sessionId } + ?.serverPlanCursor + activeVideoAttempt.set( + predecessor?.copy(serverPlanCursor = predecessor.serverPlanCursor ?: carried), + ) + } + + private fun rollbackPendingPublicationLocked( + sessionId: String, + ): PendingPublicationRollback? { + val pending = pendingVideoPublication + ?.takeIf { it.replacement.sessionId == sessionId } + ?: return null + if (activeVideoAttempt.get()?.sessionId != sessionId) return null + + pendingVideoPublication = null + revertRenderedPlanKeepingCursor(pending.predecessor) + pending.predecessor?.let { + PassthroughSuppressionRegistry.beginAttempt(it.planAttemptKey) + } + val protectedSessionIds = setOfNotNull( + sessionId, + pending.predecessor?.sessionId, + ) + val candidates = drainStagedCandidateSessionsForBaseLocked( + baseSessionId = sessionId, + protectedSessionIds = protectedSessionIds, + ) + return PendingPublicationRollback( + replacementSessionId = sessionId, + predecessorSessionId = pending.predecessor?.sessionId, + candidateSessionIds = candidates, + settled = pending.settled, + ) + } + + private fun scheduleCommittedSessionCleanup( + oldSessionId: String, + activeSessionId: String, + ) { + if (oldSessionId == activeSessionId) return + orphanedSessionIds += oldSessionId + scheduleRegisteredCommittedSessionCleanup( + oldSessionId = oldSessionId, + activeSessionId = activeSessionId, + ) + } + + private fun scheduleRegisteredCommittedSessionCleanup( + oldSessionId: String, + activeSessionId: String, + ) { + if (oldSessionId == activeSessionId) return + runCatching { + sessionCleanupScope.launch(start = CoroutineStart.UNDISPATCHED) { + var stopped = false + for (attempt in 0 until COMMITTED_SESSION_CLEANUP_ATTEMPTS) { + val result = try { + playbackRepository.stopPlayback(oldSessionId) + } catch (_: CancellationException) { + return@launch + } catch (_: Throwable) { + null + } + if (result is ApiResult.Success) { + stopped = true + break + } + } + if (stopped) { + videoAttemptMutex.withLock { + orphanedSessionIds -= oldSessionId + } + } + } + } + } + + private suspend fun drainOrphanedSessions(protectedSessionIds: Set) { + val orphanIds = videoAttemptMutex.withLock { + val live = setOfNotNull(activeVideoAttempt.get()?.sessionId) + orphanedSessionIds.filterNot { it in protectedSessionIds || it in live } + } + 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 + } + } + } + } + + private suspend fun stopSessionsRetainingFailures(sessionIds: Collection) { + val uniqueSessionIds = sessionIds.distinct() + if (uniqueSessionIds.isEmpty()) return + videoAttemptMutex.withLock { + orphanedSessionIds += uniqueSessionIds + } + uniqueSessionIds.forEach { sessionId -> + val result = try { + playbackRepository.stopPlayback(sessionId) + } catch (_: Throwable) { + null + } + if (result is ApiResult.Success) { + videoAttemptMutex.withLock { + orphanedSessionIds -= sessionId + } + } + } + } + + suspend fun discardStagedVideoReplan(staged: StagedVideoReplan) { + val candidateSessionId = videoAttemptMutex.withLock { + if (stagedVideoReplans.remove(staged) == null) return + staged.candidateSessionId?.takeIf { candidateSessionId -> + // The server may replan in place and hand back the SAME session + // id it was given. Such a candidate is not a disposable session: + // stopping it kills the playback the user is still watching. + candidateSessionId != staged.baseSessionId && + candidateSessionId != activeVideoAttempt.get()?.sessionId && + stagedVideoReplans.keys.none { + it.candidateSessionId == candidateSessionId + } + }?.also { orphanedSessionIds += it } + } + if (candidateSessionId == null) return + + withContext(NonCancellable) { + val result = try { + playbackRepository.stopPlayback(candidateSessionId) + } catch (_: Throwable) { + null + } + if (result is ApiResult.Success) { + videoAttemptMutex.withLock { + orphanedSessionIds -= candidateSessionId + } + } + } + } + + private fun stagedVideoReplanUnavailable(): ApiResult.Error = ApiResult.Error( + code = 409, + error = "staged_video_replan_unavailable", + message = "The staged playback replan was already consumed or no longer matches the active content.", + ) + + private suspend fun stopCandidateSessionIfUnowned( + activeSessionId: String?, + candidateSessionId: String?, + ) { + if (candidateSessionId == null || + candidateSessionId == activeSessionId || + candidateSessionId == activeVideoAttempt.get()?.sessionId || + stagedVideoReplans.keys.any { it.candidateSessionId == candidateSessionId } + ) { + return + } + playbackRepository.stopPlayback(candidateSessionId) + } + + private suspend fun stopCandidateSessionsIfUnowned( + activeSessionId: String?, + vararg candidateSessionIds: String?, + ) { + candidateSessionIds.filterNotNull().distinct() + .forEach { stopCandidateSessionIfUnowned(activeSessionId, it) } + } + + private suspend fun stopImmediateFailureSessions( + activeSessionId: String, + candidateSessionIds: List, + stopActiveSession: Boolean, + ) { + if (stopActiveSession) { + playbackRepository.stopPlayback(activeSessionId) + } + candidateSessionIds.filterNotNull().distinct() + .filter { it != activeSessionId } + .forEach { stopCandidateSessionIfUnowned(activeSessionId = null, it) } + } + + private fun subtitleCandidateMismatch( + requested: PlaybackTrackIdentityV3?, + candidate: PlaybackPlanV3, + ): String? { + val selected = candidate.selectedTracks.subtitle + val subtitle = candidate.subtitle + if (requested == null) { + return if (selected == null && + subtitle.mode == PlaybackSubtitleModeV3.OFF && + subtitle.trackId == null && + subtitle.artifact == null + ) { + null + } else { + "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." + } + return when (subtitle.mode) { + PlaybackSubtitleModeV3.BURN_IN -> null + PlaybackSubtitleModeV3.CONVERT, + PlaybackSubtitleModeV3.RENDER, + -> { + val artifact = subtitle.artifact + if (artifact == null || + artifact.url.isBlank() || + artifact.mimeType.isBlank() || + artifact.format.isBlank() + ) { + "The candidate omitted the exact requested subtitle artifact." + } else { + null + } + } + PlaybackSubtitleModeV3.OFF -> + "The candidate disabled the requested subtitle track." + } + } + /** Reopens the active V3 transport at a new source-time origin. */ suspend fun reanchorActiveVideoSession( positionSeconds: Double, diagnostics: Map = emptyMap(), - ): ApiResult = videoAttemptMutex.withLock { - val active = activeVideoAttempt.get() ?: return@withLock ApiResult.Error( + ): ApiResult = withSettledVideoAttempt { + if (contentResetInProgress) { + return@withSettledVideoAttempt ApiResult.Error( + code = 409, + error = "content_reset_in_progress", + message = "A replacement playback content session is still being installed.", + ) + } + val active = activeVideoAttempt.get() ?: return@withSettledVideoAttempt ApiResult.Error( code = 409, error = "playback_attempt_not_active", message = "No protocol-v3 playback attempt is active.", ) if (SEEK_REANCHOR_V3_FEATURE !in active.serverFeatures) { - return@withLock ApiResult.Error( + return@withSettledVideoAttempt ApiResult.Error( code = 409, error = "seek_reanchor_not_supported", message = "The active playback server did not negotiate seek re-anchoring.", ) } if (!positionSeconds.isFinite() || positionSeconds < 0.0) { - return@withLock ApiResult.Error( + return@withSettledVideoAttempt ApiResult.Error( code = 400, error = "invalid_seek_position", message = "Seek position must be a finite, non-negative source timestamp.", ) } + // Re-anchor deliberately addresses the RENDERED plan below, because + // seekReanchorMismatch requires the response to come back on it. Once a + // rollback has left the rendered plan behind the server's, that request + // can only 409 — decline so the caller falls through to seek recovery, + // which addresses the server's plan. + if (active.serverPlanCursor?.planId?.let { it != active.plan.planId } == true) { + return@withSettledVideoAttempt ApiResult.Error( + code = 409, + error = "seek_reanchor_plan_superseded", + message = "The rendered plan is behind the server's; recover instead.", + ) + } + val network = networkEvidenceProvider.snapshot() val request = PlaybackReplanRequestV3( operation = SEEK_REANCHOR_V3_OPERATION, @@ -459,7 +1373,7 @@ open class PlaybackSessionManager( when (val result = playbackRepository.replanPlaybackV3(active.sessionId, request)) { is ApiResult.Success -> { if (SEEK_REANCHOR_V3_FEATURE !in result.data.serverFeatures) { - return@withLock invalidSeekReanchorResponse( + return@withSettledVideoAttempt invalidSeekReanchorResponse( "The server omitted the negotiated seek re-anchor feature from its response.", ) } @@ -472,7 +1386,7 @@ open class PlaybackSessionManager( candidate = validated.plan, ) if (mismatch != null) { - return@withLock invalidSeekReanchorResponse(mismatch) + return@withSettledVideoAttempt invalidSeekReanchorResponse(mismatch) } // Synchronous local recovery mutations do not acquire the // suspend operation mutex. Re-read the active record at @@ -484,7 +1398,7 @@ open class PlaybackSessionManager( serverFeatures = result.data.serverFeatures, ) if (next == null) { - return@withLock ApiResult.Error( + return@withSettledVideoAttempt ApiResult.Error( code = 409, error = "playback_attempt_changed", message = "The active playback attempt changed while seek re-anchoring.", @@ -618,6 +1532,10 @@ open class PlaybackSessionManager( } val next = current.copy( plan = plan, + // The server accepted and now holds this plan, so rendered and + // server plans are back in step and the cursor is spent. Leaving a + // stale one here re-opens the 409-forever bug via the seek path. + serverPlanCursor = null, serverFeatures = serverFeatures.toSet(), startedAtElapsedRealtimeMs = SystemClock.elapsedRealtime(), firstFrameReported = false, @@ -635,28 +1553,35 @@ open class PlaybackSessionManager( message: String? = null, decoderName: String? = null, diagnostics: Map = emptyMap(), - ): ApiResult = videoAttemptMutex.withLock { - val active = activeVideoAttempt.get() ?: return@withLock ApiResult.Error( + ): ApiResult = withSettledVideoAttempt { + if (contentResetInProgress) { + return@withSettledVideoAttempt ApiResult.Error( + code = 409, + error = "content_reset_in_progress", + message = "A replacement playback content session is still being installed.", + ) + } + val active = activeVideoAttempt.get() ?: return@withSettledVideoAttempt ApiResult.Error( code = 409, error = "playback_attempt_not_active", message = "No protocol-v3 playback attempt is active.", ) if (SEEK_REANCHOR_V3_FEATURE !in active.serverFeatures) { - return@withLock ApiResult.Error( + return@withSettledVideoAttempt ApiResult.Error( code = 409, error = "seek_reanchor_not_supported", message = "The active playback server did not negotiate seek recovery.", ) } if (!positionSeconds.isFinite() || positionSeconds < 0.0) { - return@withLock ApiResult.Error( + return@withSettledVideoAttempt ApiResult.Error( code = 400, error = "invalid_seek_position", message = "Seek position must be a finite, non-negative source timestamp.", ) } if (classification.isBlank()) { - return@withLock ApiResult.Error( + return@withSettledVideoAttempt ApiResult.Error( code = 400, error = "invalid_failure_classification", message = "Seek recovery requires a failure classification.", @@ -669,7 +1594,7 @@ open class PlaybackSessionManager( operation = SEEK_FAILURE_RECOVERY_V3_OPERATION, playbackAttemptId = active.playbackAttemptId, replanRequestId = UUID.randomUUID().toString(), - failedPlanId = active.plan.planId, + failedPlanId = active.serverPlanId, planAttemptId = active.planAttemptId, planAttemptKey = active.planAttemptKey, attemptedPlanKeys = attemptedKeys, @@ -712,7 +1637,7 @@ open class PlaybackSessionManager( when (val result = playbackRepository.replanPlaybackV3(active.sessionId, request)) { is ApiResult.Success -> { if (SEEK_REANCHOR_V3_FEATURE !in result.data.serverFeatures) { - return@withLock invalidSeekRecoveryResponse( + return@withSettledVideoAttempt invalidSeekRecoveryResponse( "The server omitted the negotiated seek recovery feature from its response.", ) } @@ -725,13 +1650,13 @@ open class PlaybackSessionManager( candidate = validated.plan, ) if (mismatch != null) { - return@withLock invalidSeekRecoveryResponse(mismatch) + return@withSettledVideoAttempt invalidSeekRecoveryResponse(mismatch) } val nextKey = validated.plan.planAttemptKey( active.context.output.outputRouteGeneration, ) if (nextKey in attemptedKeys) { - return@withLock ApiResult.Success( + return@withSettledVideoAttempt ApiResult.Success( VideoSessionStartV3.Terminal( reason = "replan_loop_detected", message = "The server returned a seek-recovery route that already failed.", @@ -749,7 +1674,7 @@ open class PlaybackSessionManager( attemptedPlanKeys = attemptedKeys, ) if (next == null) { - return@withLock ApiResult.Error( + return@withSettledVideoAttempt ApiResult.Error( code = 409, error = "playback_attempt_changed", message = "The active playback attempt changed during seek recovery.", @@ -850,6 +1775,10 @@ open class PlaybackSessionManager( } val next = current.copy( plan = plan, + // The server accepted and now holds this plan, so rendered and + // server plans are back in step and the cursor is spent. Leaving a + // stale one here re-opens the 409-forever bug via the seek path. + serverPlanCursor = null, serverFeatures = serverFeatures.toSet(), planAttemptId = planAttemptId, planAttemptKey = planAttemptKey, @@ -1085,6 +2014,24 @@ open class PlaybackSessionManager( companion object { private const val TAG = "PlaybackSessionMgr" + private const val COMMITTED_SESSION_CLEANUP_ATTEMPTS = 2 + + /** + * How long a content reset waits for a deferred publication to settle + * before rolling it back itself. Comfortably above the 30s local-mount + * wait a legitimate subtitle commit can take, so this only fires for a + * publication whose owner is gone. + */ + internal const val PENDING_PUBLICATION_SETTLE_TIMEOUT_MS = 45_000L + + /** + * Pass `null` as the timeout to wait forever. + * + * NOT `Long.MAX_VALUE`: `runTest`'s virtual scheduler fires such a + * timeout immediately, which silently turned the self-heal on inside + * every test that meant to assert the wait. + */ + internal val NEVER_SELF_HEAL: Long? = null /** * Replan classifications that mean a user-initiated track/quality/route @@ -1115,16 +2062,113 @@ open class PlaybackSessionManager( * Stops an active playback session. * Must be called when exiting the player or when playback completes. */ - open suspend fun stopSession(sessionId: String): ApiResult = videoAttemptMutex.withLock { - while (true) { - val active = activeVideoAttempt.get() - if (active?.sessionId != sessionId) break - if (activeVideoAttempt.compareAndSet(active, null)) { - emitActiveVideoEvent(active, "stopped") - break + open suspend fun stopSession(sessionId: String): ApiResult { + val pendingPublicationStop = videoAttemptMutex.withLock { + val pending = pendingVideoPublication + when { + pending?.replacement?.sessionId == sessionId -> { + rollbackPendingPublicationLocked(sessionId)?.let { + Triple( + it.replacementSessionId, + it.candidateSessionIds, + it.settled, + ) + } + } + pending?.predecessor?.sessionId == sessionId -> { + pendingVideoPublication = null + activeVideoAttempt.set(null) + val candidates = ( + drainStagedCandidateSessionsLocked( + protectedSessionIds = setOf(sessionId), + ) + pending.replacement.sessionId + ).distinct().filterNot { it == sessionId } + Triple(sessionId, candidates, pending.settled) + } + else -> null + } + } + if (pendingPublicationStop != null) { + return try { + stopSessionAfterOwnershipCleared( + sessionId = pendingPublicationStop.first, + candidateSessionIds = pendingPublicationStop.second, + ) + } finally { + pendingPublicationStop.third.complete(Unit) + } + } + + val candidateSessionIds = videoAttemptMutex.withLock { + var stoppedActiveSession = false + while (true) { + val active = activeVideoAttempt.get() + if (active?.sessionId != sessionId) break + if (activeVideoAttempt.compareAndSet(active, null)) { + emitActiveVideoEvent(active, "stopped") + stoppedActiveSession = true + break + } + } + if (stoppedActiveSession) { + drainStagedCandidateSessionsLocked(protectedSessionIds = setOf(sessionId)) + } else { + drainStagedCandidateSessionsForBaseLocked( + baseSessionId = sessionId, + protectedSessionIds = setOfNotNull( + sessionId, + activeVideoAttempt.get()?.sessionId, + ), + ) + } + } + return stopSessionAfterOwnershipCleared( + sessionId = sessionId, + candidateSessionIds = candidateSessionIds, + ) + } + + /** + * Stops [sessionId] once ownership no longer points at it. A null + * [sessionId] cleans up only the candidates — used when the server replanned + * in place and the "replacement" is the session still playing. + */ + private suspend fun stopSessionAfterOwnershipCleared( + sessionId: String?, + candidateSessionIds: List, + ): ApiResult { + val callerContext = currentCoroutineContext() + var result: ApiResult? = null + var requestedFailure: Throwable? = null + withContext(NonCancellable) { + try { + stopSessionsRetainingFailures(candidateSessionIds) + if (sessionId != null) { + videoAttemptMutex.withLock { + orphanedSessionIds += sessionId + } + try { + result = playbackRepository.stopPlayback(sessionId) + if (result is ApiResult.Success) { + videoAttemptMutex.withLock { + orphanedSessionIds -= sessionId + } + } + } catch (failure: Throwable) { + requestedFailure = failure + } + } else { + result = ApiResult.Success(Unit) + } + } finally { + drainOrphanedSessions( + protectedSessionIds = setOfNotNull(activeVideoAttempt.get()?.sessionId), + ) } } - playbackRepository.stopPlayback(sessionId) + callerContext.ensureActive() + requestedFailure?.let { throw it } + return requireNotNull(result) } /** diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTrackSelectionWriteCoordinator.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTrackSelectionWriteCoordinator.kt new file mode 100644 index 000000000..4f078e1eb --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTrackSelectionWriteCoordinator.kt @@ -0,0 +1,124 @@ +package org.prairieserver.prairie.common.player + +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.prairieserver.prairie.repository.port.PlaybackWriteScope + +/** + * Process-global latest-write-wins ordering for final playback track choices. + * + * Adapter-local queues preserve FIFO while an adapter is alive. Tickets issued + * here extend that ordering across a retired adapter and its replacement. + */ +class PlaybackTrackSelectionWriteCoordinator { + class Ticket internal constructor( + internal val key: Key, + internal val sequence: Long, + internal val state: State, + ) { + internal var resolved: Boolean = false + internal var resolvedSuccess: Boolean = false + } + + internal class State { + val mutex = Mutex() + var latestStartedSequence = 0L + var latestDurableSequence = 0L + var outstandingTickets = 0 + var activeWrites = 0 + } + + internal val activeKeyCount: Int + get() = synchronized(states) { + states.size + } + + internal data class Key( + val scope: PlaybackWriteScope, + val contentId: String, + val fileId: Int, + ) + + private val sequence = AtomicLong(0L) + private val states = mutableMapOf() + + fun capture( + scope: PlaybackWriteScope, + contentId: String, + fileId: Int, + ): Ticket { + val key = Key(scope, contentId, fileId) + val state = synchronized(states) { + states.getOrPut(key, ::State).also { + it.outstandingTickets += 1 + } + } + return Ticket( + key = key, + sequence = sequence.incrementAndGet(), + state = state, + ) + } + + suspend fun write( + ticket: Ticket, + persist: suspend () -> Boolean, + ): Boolean { + synchronized(ticket) { + if (ticket.resolved) return ticket.resolvedSuccess + synchronized(states) { + ticket.state.activeWrites += 1 + } + } + val state = ticket.state + try { + return state.mutex.withLock { + synchronized(ticket) { + if (ticket.resolved) return@withLock ticket.resolvedSuccess + } + val success = if (ticket.sequence < state.latestStartedSequence) { + state.latestDurableSequence >= ticket.sequence + } else { + state.latestStartedSequence = ticket.sequence + persist().also { durable -> + if (durable) state.latestDurableSequence = ticket.sequence + } + } + if (success) resolve(ticket, success = true) + success + } + } finally { + synchronized(states) { + state.activeWrites -= 1 + removeIfUnused(ticket.key, state) + } + } + } + + fun abandon(ticket: Ticket) { + resolve(ticket, success = false) + } + + private fun resolve(ticket: Ticket, success: Boolean) { + synchronized(ticket) { + if (ticket.resolved) return + ticket.resolved = true + ticket.resolvedSuccess = success + } + synchronized(states) { + ticket.state.outstandingTickets -= 1 + removeIfUnused(ticket.key, ticket.state) + } + } + + private fun removeIfUnused(key: Key, state: State) { + if (state.outstandingTickets == 0 && state.activeWrites == 0) { + states.remove(key, state) + } + } + + companion object { + val Process = PlaybackTrackSelectionWriteCoordinator() + } +} 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 918e04d76..a3f34b667 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 @@ -112,7 +112,10 @@ internal fun PlaybackPlanV3.toSessionResponse( audioCodec = effectiveRecipe.audioCodec, resolution = effectiveRecipe.height?.let { "${it}p" }, hdrFormat = effectiveRecipe.dynamicRange, + colorRange = source.colorRange, subtitleCodec = subtitle.artifact?.format, + letterboxTopFraction = source.letterboxTopFraction, + letterboxBottomFraction = source.letterboxBottomFraction, ), claims = claims, transformations = transformations, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerOkHttpClient.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerOkHttpClient.kt index 2c94ff730..1cb1e8f2d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerOkHttpClient.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerOkHttpClient.kt @@ -4,6 +4,14 @@ import okhttp3.ConnectionPool import okhttp3.Dispatcher import okhttp3.OkHttpClient import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Interceptor +import okhttp3.Response +import kotlinx.coroutines.runBlocking +import org.prairieserver.prairie.network.CleartextOriginConsent +import org.prairieserver.prairie.network.CleartextOriginNotApprovedException +import org.prairieserver.prairie.network.isSameHttpOrigin +import org.prairieserver.prairie.network.requiresApproval import java.util.concurrent.TimeUnit /** @@ -15,18 +23,59 @@ import java.util.concurrent.TimeUnit * chain on the Ktor client must not see media traffic. Media3 auth lives above * this transport in [RefreshingHttpDataSource]. */ -internal fun buildPlayerOkHttpClient(): OkHttpClient = +internal fun buildPlayerOkHttpClient( + cleartextOriginConsent: CleartextOriginConsent? = null, +): OkHttpClient = OkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .followRedirects(true) .followSslRedirects(true) + // Network interceptors run once for the initial request and again for + // every redirect follow-up. This is the last boundary before bytes + // leave the process, so arbitrary plan headers and query credentials + // cannot ride an HTTPS→HTTP or approved→unapproved redirect. + .addNetworkInterceptor(CleartextConsentNetworkInterceptor(cleartextOriginConsent)) + .addNetworkInterceptor { chain -> + val initialTarget = chain.call().request().url.toString() + val networkRequest = chain.request() + val safeRequest = if (isSameHttpOrigin(initialTarget, networkRequest.url.toString())) { + networkRequest + } else { + networkRequest.withoutCrossOriginCredentials() + } + chain.proceed(safeRequest) + } .protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1)) .connectionPool(ConnectionPool(maxIdleConnections = 5, keepAliveDuration = 5, timeUnit = TimeUnit.MINUTES)) .dispatcher(Dispatcher()) .build() +internal class CleartextConsentNetworkInterceptor( + private val consent: CleartextOriginConsent?, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val url = chain.request().url.toString() + if (runBlocking { consent?.requiresApproval(url) == true }) { + throw CleartextOriginNotApprovedException(url) + } + return chain.proceed(chain.request()) + } +} + +private fun Request.withoutCrossOriginCredentials(): Request = + newBuilder() + .removeHeader("Authorization") + .removeHeader("X-Profile-Id") + .removeHeader("X-Profile-Token") + .apply { + headers.names() + .filter { name -> name.startsWith("X-Prairie-", ignoreCase = true) } + .forEach(::removeHeader) + } + .build() + /** * Bounded bootstrap client for the token-refresh RPC. It intentionally has no * auth interceptor so a rejected refresh cannot recurse through the media 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 611afd943..a63309431 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 @@ -2,6 +2,7 @@ package org.prairieserver.prairie.common.player import android.content.Intent 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 @@ -17,6 +18,7 @@ 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 @@ -60,6 +62,31 @@ class PrairiePlaybackService : MediaSessionService() { ACTION_PIP_SKIP_BACK, ACTION_PIP_SKIP_FORWARD, ) + + internal fun dispatchPictureInPictureAction(intent: Intent?, player: Player?): Boolean { + val action = intent?.action + if (intent == null || action !in PIP_ACTIONS) return false + if (!PipActionCapability.isAuthorized(intent)) return true + if (player == null) return true + + when (action) { + ACTION_PIP_PLAY -> { + player.playWhenReady = true + player.play() + } + ACTION_PIP_PAUSE -> player.pause() + ACTION_PIP_SKIP_BACK -> player.seekTo( + (player.currentPosition - PIP_SKIP_BACK_MS).coerceAtLeast(0L), + ) + ACTION_PIP_SKIP_FORWARD -> { + val duration = player.duration.takeIf { it > 0 } ?: Long.MAX_VALUE + player.seekTo( + (player.currentPosition + PIP_SKIP_FORWARD_MS).coerceAtMost(duration), + ) + } + } + return true + } } private val playerFactory: PrairiePlayerFactory by inject() @@ -78,6 +105,13 @@ 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) /** @@ -98,6 +132,8 @@ 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( @@ -147,8 +183,10 @@ class PrairiePlaybackService : MediaSessionService() { // 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 { - playerSettingsStore.subtitleSyncMsFlow + activeContentId + .flatMapLatest(playerSettingsStore::subtitleSyncMsFor) .distinctUntilChanged() .collect { offsetMs -> val previous = subtitleOffsetHolder.getOffsetMs() @@ -191,32 +229,12 @@ class PrairiePlaybackService : MediaSessionService() { mediaSession override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (handlePictureInPictureAction(intent?.action)) { + if (dispatchPictureInPictureAction(intent, activePlayer ?: mediaSession?.player)) { return START_STICKY } return super.onStartCommand(intent, flags, startId) } - private fun handlePictureInPictureAction(action: String?): Boolean { - val player = activePlayer ?: mediaSession?.player ?: return action in PIP_ACTIONS - when (action) { - ACTION_PIP_PLAY -> { - player.playWhenReady = true - player.play() - } - ACTION_PIP_PAUSE -> player.pause() - ACTION_PIP_SKIP_BACK -> player.seekTo( - (player.currentPosition - PIP_SKIP_BACK_MS).coerceAtLeast(0L), - ) - ACTION_PIP_SKIP_FORWARD -> { - val duration = player.duration.takeIf { it > 0 } ?: Long.MAX_VALUE - player.seekTo((player.currentPosition + PIP_SKIP_FORWARD_MS).coerceAtMost(duration)) - } - else -> return false - } - return true - } - override fun onTaskRemoved(rootIntent: Intent?) { // Prairie is a video player — when the user swipes the app away // we want playback (and audio) to end, not continue in the @@ -237,7 +255,7 @@ class PrairiePlaybackService : MediaSessionService() { subtitleSyncJob?.cancel() scope.cancel() mediaSession?.run { - player.release() + playerFactory.releasePlayer(player) release() } mediaSession = null 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 4857bf58d..279e6b54d 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 @@ -34,6 +34,7 @@ import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.text.DefaultSubtitleParserFactory import androidx.media3.extractor.text.SubtitleExtractor import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory @@ -42,6 +43,7 @@ import org.prairieserver.prairie.common.BuildConfig 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.SubtitleOffsetHolder import org.prairieserver.prairie.common.player.video.PrairieMediaCodecVideoRenderer import org.prairieserver.prairie.common.player.video.PlaybackRuntimeCorrectionState @@ -107,9 +109,32 @@ class PrairiePlayerFactory( private val sidecarSubtitleParserFactory = OffsetSubtitleParserFactory( offsetUsProvider = subtitleOffsetHolder::getOffsetUs, - delegate = libassBridge.parserFactory, + delegate = BitmapAwareSubtitleParserFactory(libassBridge.parserFactory), ) + private class BitmapAwareSubtitleParserFactory( + private val delegate: SubtitleParser.Factory, + private val media3: SubtitleParser.Factory = DefaultSubtitleParserFactory(), + ) : SubtitleParser.Factory { + private fun factoryFor(format: androidx.media3.common.Format): SubtitleParser.Factory = + when (format.sampleMimeType) { + MimeTypes.APPLICATION_PGS, + MimeTypes.APPLICATION_VOBSUB, + MimeTypes.APPLICATION_DVBSUBS, + -> media3 + else -> delegate + } + + override fun supportsFormat(format: androidx.media3.common.Format): Boolean = + factoryFor(format).supportsFormat(format) + + override fun getCueReplacementBehavior(format: androidx.media3.common.Format): Int = + factoryFor(format).getCueReplacementBehavior(format) + + override fun create(format: androidx.media3.common.Format): SubtitleParser = + factoryFor(format).create(format) + } + private fun configuredExtractorsFactory() = libassBridge.wrapExtractors( DefaultExtractorsFactory() // Media3 1.10 expects parsed cue samples by default. Forcing raw @@ -238,7 +263,7 @@ class PrairiePlayerFactory( } val renderersFactory = libassBridge.wrapRenderers( media3RenderersFactory, - subtitleOffsetHolder::getUserOffsetUs, + subtitleOffsetHolder::getOffsetUs, ) val trackSelector = DefaultTrackSelector(context).apply { @@ -255,9 +280,10 @@ class PrairiePlayerFactory( val mediaLoadErrorHandlingPolicy = PrairieMediaLoadErrorHandlingPolicy( isResumableProgressiveDirectPlay = ::isResumableDirectPlayUri, ) - fun defaultMediaSourceFactory( + fun correctedMediaSourceFactory( mode: DolbyVisionTransformMode, expectedDynamicRange: String? = null, + expectedColorRange: String? = null, ) = DefaultMediaSourceFactory( context, @@ -265,6 +291,7 @@ class PrairiePlayerFactory( configuredExtractorsFactory(), mode, expectedDynamicRange = expectedDynamicRange, + expectedColorRange = expectedColorRange, ), ) .setDataSourceFactory(dataSourceFactory) @@ -275,16 +302,12 @@ class PrairiePlayerFactory( .setSubtitleParserFactory(embeddedSubtitleParserFactory) .setLoadErrorHandlingPolicy(mediaLoadErrorHandlingPolicy) val mediaSourceFactory = PrairieMediaSourceFactory( - defaultFactory = defaultMediaSourceFactory(DolbyVisionTransformMode.DISABLED), - hlgFactory = defaultMediaSourceFactory( - DolbyVisionTransformMode.DISABLED, - expectedDynamicRange = "hlg", - ), - dv81Factory = defaultMediaSourceFactory(DolbyVisionTransformMode.PROFILE7_TO_PROFILE81), - hdr10Factory = defaultMediaSourceFactory(DolbyVisionTransformMode.PROFILE7_TO_HDR10), + defaultFactory = correctedMediaSourceFactory(DolbyVisionTransformMode.DISABLED), + correctedFactory = ::correctedMediaSourceFactory, hlsFactory = hlsMediaSourceFactory, dataSourceFactory = dataSourceFactory, subtitleParserFactory = sidecarSubtitleParserFactory, + subtitleOffsetProvider = subtitleOffsetHolder::getOffsetUs, loadErrorHandlingPolicy = mediaLoadErrorHandlingPolicy, ) @@ -338,6 +361,12 @@ class PrairiePlayerFactory( return builder.build().also(libassBridge::initialize) } + /** Releases the player and the libass handler/font graph adopted for it. */ + fun releasePlayer(player: Player) { + (player as? ExoPlayer)?.let(libassBridge::releasePlayer) + player.release() + } + private fun playbackBufferDeviceProfile(): PlaybackBufferDeviceProfile { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager return PlaybackBufferDeviceProfile( @@ -393,6 +422,7 @@ class PrairiePlayerFactory( * selected media source factory wires them through a merging source. */ fun buildMediaItem( + contentId: String? = null, streamUrl: String, playMethod: PlayMethod, delivery: PlaybackDelivery? = null, @@ -406,6 +436,7 @@ class PrairiePlayerFactory( timelineOffsetSeconds: Double = 0.0, requestHeaders: Map = emptyMap(), expectedDynamicRange: String? = null, + expectedColorRange: String? = null, transformations: List = emptyList(), runtimeCorrections: List = emptyList(), ): MediaItem { @@ -426,6 +457,7 @@ class PrairiePlayerFactory( val builder = MediaItem.Builder() .setUri(absoluteUrl) + .apply { contentId?.takeIf(String::isNotBlank)?.let(::setMediaId) } .setSubtitleConfigurations(subtitleConfigurations) .setTag( PrairieMediaTransformTag( @@ -437,6 +469,7 @@ class PrairiePlayerFactory( else -> DolbyVisionTransformMode.DISABLED }, expectedDynamicRange = expectedDynamicRange, + expectedColorRange = expectedColorRange, ), ) @@ -495,21 +528,24 @@ class PrairiePlayerFactory( private class PrairieMediaSourceFactory( private val defaultFactory: MediaSource.Factory, - private val hlgFactory: MediaSource.Factory, - private val dv81Factory: MediaSource.Factory, - private val hdr10Factory: MediaSource.Factory, + private val correctedFactory: ( + DolbyVisionTransformMode, + String?, + String?, + ) -> MediaSource.Factory, private val hlsFactory: MediaSource.Factory, private val dataSourceFactory: DataSource.Factory, private val subtitleParserFactory: SubtitleParser.Factory, + private val subtitleOffsetProvider: () -> Long, private var loadErrorHandlingPolicy: LoadErrorHandlingPolicy, ) : MediaSource.Factory { + private var drmSessionManagerProvider: DrmSessionManagerProvider? = null + override fun setDrmSessionManagerProvider( drmSessionManagerProvider: DrmSessionManagerProvider, ): MediaSource.Factory { + this.drmSessionManagerProvider = drmSessionManagerProvider defaultFactory.setDrmSessionManagerProvider(drmSessionManagerProvider) - hlgFactory.setDrmSessionManagerProvider(drmSessionManagerProvider) - dv81Factory.setDrmSessionManagerProvider(drmSessionManagerProvider) - hdr10Factory.setDrmSessionManagerProvider(drmSessionManagerProvider) hlsFactory.setDrmSessionManagerProvider(drmSessionManagerProvider) return this } @@ -519,9 +555,6 @@ class PrairiePlayerFactory( ): MediaSource.Factory { this.loadErrorHandlingPolicy = loadErrorHandlingPolicy defaultFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) - hlgFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) - dv81Factory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) - hdr10Factory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) hlsFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) return this } @@ -547,15 +580,7 @@ class PrairiePlayerFactory( hlsFactory.createMediaSource(contentItem) } else { val tag = localConfiguration.tag as? PrairieMediaTransformTag - when (tag?.dolbyVisionMode) { - DolbyVisionTransformMode.PROFILE7_TO_PROFILE81 -> dv81Factory.createMediaSource(contentItem) - DolbyVisionTransformMode.PROFILE7_TO_HDR10 -> hdr10Factory.createMediaSource(contentItem) - else -> if (tag?.expectedDynamicRange.equals("hlg", ignoreCase = true)) { - hlgFactory.createMediaSource(contentItem) - } else { - defaultFactory.createMediaSource(contentItem) - } - } + mediaSourceFactory(tag).createMediaSource(contentItem) } if (subtitleConfigurations.isEmpty()) return contentSource @@ -567,6 +592,16 @@ class PrairiePlayerFactory( return MergingMediaSource(*sources.toTypedArray()) } + private fun mediaSourceFactory(tag: PrairieMediaTransformTag?): MediaSource.Factory = + correctedFactory( + tag?.dolbyVisionMode ?: DolbyVisionTransformMode.DISABLED, + tag?.expectedDynamicRange, + tag?.expectedColorRange, + ).also { factory -> + drmSessionManagerProvider?.let(factory::setDrmSessionManagerProvider) + factory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) + } + private fun createSubtitleMediaSource( configuration: MediaItem.SubtitleConfiguration, ): MediaSource { @@ -588,13 +623,25 @@ class PrairiePlayerFactory( subtitleParserFactory.getCueReplacementBehavior(baseFormat), ) .build() - val extractorsFactory = ExtractorsFactory { - arrayOf( - SubtitleExtractor( - subtitleParserFactory.create(outputFormat), - outputFormat, - ), - ) + val extractorsFactory = if (configuration.mimeType == MimeTypes.APPLICATION_PGS) { + ExtractorsFactory { + arrayOf( + PgsSupExtractor( + subtitleParserFactory, + subtitleOffsetProvider, + outputFormat, + ), + ) + } + } else { + ExtractorsFactory { + arrayOf( + SubtitleExtractor( + subtitleParserFactory.create(outputFormat), + outputFormat, + ), + ) + } } return ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) .setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleDiagnostics.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleDiagnostics.kt new file mode 100644 index 000000000..d61a161bc --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleDiagnostics.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.common.player + +import android.util.Log + +/** + * Tracing for the subtitle transaction flow — the mount/replan chain. + * + * These are the only observability into that chain, and the defects it hides + * are diagnosable from nothing else, so the call sites stay. What must not ship + * is the output: several lines interpolate subtitle identities and session ids, + * and this is a public client. + * + * Off unless someone asks for it. To enable on a device: + * + * adb shell setprop log.tag.SUBDIAG DEBUG + * + * The gate is here rather than at the call sites so it cannot be half-applied: + * all thirty of them are covered by this one check, and each remains a single + * line that can be deleted mechanically if the tracing is ever retired. + */ +object SubDiag { + private const val TAG = "SUBDIAG" + + /** + * Read once. `isLoggable` walks the property table on every call, and these + * fire inside mount and track-selection paths that run per frame-group. + */ + private val enabled: Boolean by lazy { + runCatching { Log.isLoggable(TAG, Log.DEBUG) }.getOrDefault(false) + } + + fun log(message: String) { + if (enabled) Log.d(TAG, message) + } + + fun trace(message: String) { + if (enabled) Log.d(TAG, message, Throwable("caller")) + } +} 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 b3ec49228..cceb1f1fb 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 @@ -24,6 +24,7 @@ import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.PlayerView import org.prairieserver.prairie.libass.LibassBridge import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset @@ -45,6 +46,20 @@ class SubtitleManager( private val videoRectSyncs = WeakHashMap() + var letterbox: LetterboxInsets = LetterboxInsets.NONE + set(value) { + if (field == value) return + field = value + videoRectSyncs.values.forEach { it.letterbox = value } + } + + var titleSafeFraction: Float = 0f + set(value) { + if (field == value) return + field = value + videoRectSyncs.values.forEach { it.titleSafeFraction = value } + } + /** * Builds MediaItem.SubtitleConfiguration entries for external subtitle tracks. * @@ -67,14 +82,31 @@ class SubtitleManager( } val absoluteUrl = resolveSubtitleUrl(serverUrl, subtitle.url) val mimeType = subtitleMimeType(subtitle.codec, absoluteUrl) - if (!isMedia3TextSidecarMimeType(mimeType)) { + if (!isMountableSidecarMimeType(mimeType)) { return@mapNotNull null } - MediaItem.SubtitleConfiguration.Builder(Uri.parse(absoluteUrl)) + val builder = MediaItem.SubtitleConfiguration.Builder(Uri.parse(absoluteUrl)) + val stableTrackId = if (subtitle.isDownloadedSubtitleArtifact()) { + subtitle.downloadId?.let(::downloadedSubtitleArtifactTrackId) + } else { + subtitleArtifactTrackId(subtitle.index) + } + if (stableTrackId != null) { + builder.setId(stableTrackId) + } + builder .setMimeType(mimeType) .setLanguage(subtitle.language) - .setLabel(subtitle.label ?: subtitle.language ?: "Track ${subtitle.index}") + // Preserve the catalog label when a server-side artifact has a + // generic runtime label. The mounted label also carries SDH/CC + // semantics used by exact identity reconciliation. + .setLabel( + subtitle.catalogLabel + ?: subtitle.label + ?: subtitle.language + ?: "Track ${subtitle.index}", + ) .setSelectionFlags( if (subtitle.forced == true) C.SELECTION_FLAG_FORCED else 0 ) @@ -135,7 +167,6 @@ class SubtitleManager( } val selection = resolveSubtitleSelection(player.currentTracks, subtitle) - ?: resolveSubtitleSelection(player.currentTracks, subtitleIndex) if (selection == null) { Log.w( TAG, @@ -149,6 +180,31 @@ class SubtitleManager( return true } + /** + * Selects a track already present in the Media3 snapshot by its complete + * domain identity. This path never converts the identity back to an app + * list ordinal, so exact artifact and Format ids survive list reordering. + */ + fun selectSubtitle(player: Player, identity: SubtitleIdentity): Boolean { + if (identity == SubtitleIdentity.Off || identity is SubtitleIdentity.ServerBurnIn) { + disableSubtitles(player) + return true + } + + val selection = resolveSubtitleSelection(player.currentTracks, identity) + if (selection == null) { + Log.w( + TAG, + "selectSubtitle failed: identity=$identity " + + "tracks=${player.currentTracks.describeTextTracks()}", + ) + return false + } + + applySubtitleSelection(player, selection) + return true + } + /** * Resolves the backend track id (Format.id) for the [subtitleIndex]-th * subtitle track (`secondary-sid`) without going through Media3's @@ -160,9 +216,7 @@ class SubtitleManager( subtitleIndex: Int, ): String? { val subtitle = subtitles.getOrNull(subtitleIndex) ?: return null - val selection = resolveSubtitleSelection(player.currentTracks, subtitle) - ?: resolveSubtitleSelection(player.currentTracks, subtitleIndex) - ?: return null + val selection = resolveSubtitleSelection(player.currentTracks, subtitle) ?: return null return selection.mediaTrackGroup.getFormat(selection.trackIndex).id } @@ -217,7 +271,11 @@ class SubtitleManager( playerView.subtitleView?.let { libassBridge?.attachTo(it) } val existing = videoRectSyncs[playerView] val sync = if (existing?.isDisposed == true || existing == null) { - SubtitleVideoRectSync(playerView).also { videoRectSyncs[playerView] = it } + SubtitleVideoRectSync(playerView).also { + it.letterbox = letterbox + it.titleSafeFraction = titleSafeFraction + videoRectSyncs[playerView] = it + } } else { existing } @@ -269,20 +327,21 @@ class SubtitleManager( private fun fractionalSizeFor(preset: SubtitleFontSizePreset): Float { return when (preset) { - SubtitleFontSizePreset.Small -> 0.032f - SubtitleFontSizePreset.Medium -> 0.040f - SubtitleFontSizePreset.Large -> 0.050f - SubtitleFontSizePreset.XLarge -> 0.060f - SubtitleFontSizePreset.XXLarge -> 0.072f + 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 { - return when (position) { + val base = when (position) { SubtitlePositionPreset.Bottom -> 0.09f SubtitlePositionPreset.LowerThird -> 0.18f SubtitlePositionPreset.Top -> 0.74f } + return (base - titleSafeFraction).coerceAtLeast(0.02f) } private fun parseHexColor(hex: String, alpha: Int = 255): Int { @@ -318,12 +377,22 @@ class SubtitleManager( } } - private fun isMedia3TextSidecarMimeType(mimeType: String): Boolean = + /** + * Sidecar formats Media3 can parse for us. Bitmap families are included: + * the server raw-serves an embedded PGS track as `.sup`, and Media3's + * DefaultSubtitleParserFactory decodes PGS, VobSub and DVB. Mounting those + * is what lets a bitmap subtitle render client-side instead of forcing the + * server to burn it into the picture, which costs a full transcode. + */ + private fun isMountableSidecarMimeType(mimeType: String): Boolean = when (mimeType) { MimeTypes.TEXT_VTT, MimeTypes.TEXT_SSA, MimeTypes.APPLICATION_SUBRIP, MimeTypes.APPLICATION_TTML, + MimeTypes.APPLICATION_PGS, + MimeTypes.APPLICATION_VOBSUB, + MimeTypes.APPLICATION_DVBSUBS, -> true else -> false } @@ -498,6 +567,20 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : ) private var observedPlayer: Player? = null + var letterbox: LetterboxInsets = LetterboxInsets.NONE + set(value) { + if (field == value) return + field = value + update() + } + + var titleSafeFraction: Float = 0f + set(value) { + if (field == value) return + field = value + update() + } + var isDisposed: Boolean = false private set @@ -573,7 +656,7 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : private fun applyRect(playerView: PlayerView) { val subtitleView = playerView.subtitleView ?: return val videoSize = playerView.player?.videoSize ?: VideoSize.UNKNOWN - val rect = playerView.contentFrameSubtitleRect() + val rect = (playerView.contentFrameSubtitleRect() ?: displayedSubtitleVideoRect( viewWidth = playerView.width, viewHeight = playerView.height, @@ -581,7 +664,7 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : videoHeight = videoSize.height, videoPixelWidthHeightRatio = videoSize.pixelWidthHeightRatio, resizeMode = playerView.resizeMode, - ) + )).insetByLetterbox(letterbox).insetByTitleSafe(titleSafeFraction) val current = subtitleView.layoutParams as? FrameLayout.LayoutParams val params = current ?: FrameLayout.LayoutParams(rect.width, rect.height) val gravity = Gravity.TOP or Gravity.START @@ -658,67 +741,55 @@ internal fun resolveSubtitleSelection( tracks: Tracks, subtitle: PlayerSubtitleInfo, ): SubtitleSelection? { - val label = subtitle.label?.trim()?.takeIf { it.isNotBlank() } - val language = subtitle.language?.trim()?.lowercase()?.takeIf { it.isNotBlank() } - val family = subtitleCodecFamily(subtitle.codec ?: subtitle.url) - if (label == null && language == null && family == null) return null + val candidates = textTrackCandidates(tracks) + val mounted = candidates.map(TextTrackCandidate::track) + val match = resolveMountedSubtitle(subtitle, mounted) ?: return null + return candidates.firstOrNull { it.track.index == match.track.index }?.selection +} +internal fun resolveSubtitleSelection( + tracks: Tracks, + identity: SubtitleIdentity, +): SubtitleSelection? { val candidates = textTrackCandidates(tracks) - if (label != null) { - candidates.firstOrNull { it.label?.trim() == label }?.let { return it.selection } - } - if (label != null) { - val normalizedLabel = label.lowercase() - candidates.firstOrNull { candidate -> - candidate.label?.trim()?.lowercase() == normalizedLabel - }?.let { return it.selection } - } - if (language != null) { - val languageMatches = candidates.filter { - it.language?.trim()?.lowercase() == language - } - family?.let { targetFamily -> - languageMatches.firstOrNull { it.codecFamily == targetFamily } - ?.let { return it.selection } - } - val targetIsBitmap = isBitmapSubtitleCodecOrMime(subtitle.codec) - languageMatches.firstOrNull { it.isBitmap == targetIsBitmap } - ?.let { return it.selection } - languageMatches.firstOrNull()?.let { return it.selection } - } - family?.let { targetFamily -> - candidates.firstOrNull { it.codecFamily == targetFamily } - ?.let { return it.selection } - } - return null + val match = resolveMountedSubtitle(identity, candidates.map(TextTrackCandidate::track)) ?: return null + return candidates.firstOrNull { it.track.index == match.track.index }?.selection } private data class TextTrackCandidate( val selection: SubtitleSelection, - val label: String?, - val language: String?, - val isBitmap: Boolean, - val codecFamily: String?, + val track: MountedSubtitleTrack, ) private fun textTrackCandidates(tracks: Tracks): List { val candidates = mutableListOf() + var flatIndex = 0 for (group in tracks.groups) { if (group.type != C.TRACK_TYPE_TEXT) continue for (trackIndex in 0 until group.length) { val format = group.getTrackFormat(trackIndex) candidates += TextTrackCandidate( selection = SubtitleSelection(group.mediaTrackGroup, trackIndex), - label = format.label, - language = format.language, - isBitmap = isBitmapSubtitleCodecOrMime(format.subtitleCodecOrMime()), - codecFamily = subtitleCodecFamily(format.subtitleCodecOrMime()), + track = MountedSubtitleTrack( + index = flatIndex, + trackId = format.id, + label = format.label, + language = format.language, + codec = format.subtitleCodecOrMime(), + forced = format.selectionFlags and C.SELECTION_FLAG_FORCED != 0, + hearingImpaired = format.isHearingImpairedSubtitle(), + ), ) + flatIndex++ } } return candidates } +private fun Format.isHearingImpairedSubtitle(): Boolean = + roleFlags and (C.ROLE_FLAG_CAPTION or C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND) != 0 || + subtitleLabelIndicatesHearingImpaired(label) + /** * Bitmap (image-based) subtitle detection over codec names and mimes. * Normalization strips ALL non-alphanumerics so ffprobe names @@ -739,27 +810,6 @@ fun isBitmapSubtitleCodecOrMime(codecOrMime: String?): Boolean { normalized.contains("vobsub") } -private fun subtitleCodecFamily(codecOrMime: String?): String? { - val normalized = codecOrMime - ?.filter { it.isLetterOrDigit() } - ?.lowercase() - ?.takeIf { it.isNotEmpty() } - ?: return null - return when { - normalized.contains("pgs") -> "pgs" - normalized.contains("vobsub") || normalized.contains("dvdsubtitle") -> "vobsub" - normalized.contains("dvbsub") -> "dvbsub" - normalized.contains("subrip") || normalized.endsWith("srt") -> "subrip" - normalized.contains("webvtt") || normalized.endsWith("vtt") -> "webvtt" - normalized.contains("tx3g") || normalized.contains("movtext") -> "tx3g" - normalized.contains("ssa") || normalized.contains("ass") -> "ssa" - normalized.contains("ttml") -> "ttml" - normalized.contains("cea608") || normalized.contains("eia608") -> "cea608" - normalized.contains("cea708") -> "cea708" - else -> null - } -} - private fun Format.subtitleCodecOrMime(): String? = if (sampleMimeType == MEDIA3_CUES_MIME_TYPE) { codecs ?: sampleMimeType 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 new file mode 100644 index 000000000..372d5c640 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt @@ -0,0 +1,271 @@ +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.playback.canonicalSubtitleCodecFamily +import org.prairieserver.prairie.playback.canonicalSubtitleLanguage +import org.prairieserver.prairie.playback.isTextSubtitleCodecFamily + +private const val SUBTITLE_ARTIFACT_TRACK_ID_PREFIX = "prairie-subtitle:" +private const val DOWNLOADED_SUBTITLE_ARTIFACT_TRACK_ID_PREFIX = + "prairie-downloaded-subtitle:" + +/** + * Stable Media3 identity for a server-authored subtitle artifact. + * + * The index is the server's combined subtitle index, not a Media3 ordinal. + */ +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 + * `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. + * + * Only a purely numeric prefix is accepted, so this can never collide with an + * authored id that happens to contain a colon. + */ +fun trackIdDenotes(actual: String?, expected: String): Boolean { + if (actual == null) return false + if (actual == expected) return true + val separator = actual.indexOf(':') + if (separator <= 0) return false + if (!actual.substring(0, separator).all(Char::isDigit)) return false + return actual.substring(separator + 1) == expected +} + +/** + * Complete Media3 subtitle metadata retained by both phone and TV adapters. + */ +data class MountedSubtitleTrack( + val index: Int, + val trackId: String?, + val label: String?, + val language: String?, + val codec: String?, + val forced: Boolean?, + val hearingImpaired: Boolean?, +) + +data class MountedSubtitleMatch( + val track: MountedSubtitleTrack, +) + +/** + * Resolves a committed typed identity against a mounted Media3 track snapshot. + * + * Exact IDs are checked across the complete snapshot before any metadata + * fallback. Server sidecars are exact-ID only. Other identity types may use a + * complete typed metadata fallback, but may never fall through to a + * server-authored sidecar ID. + */ +fun resolveMountedSubtitle( + identity: SubtitleIdentity, + tracks: List, +): MountedSubtitleMatch? { + val expectedTrackId = identity.expectedMediaTrackId() + if (expectedTrackId != null) { + tracks.firstOrNull { trackIdDenotes(it.trackId, expectedTrackId) } + ?.let { return MountedSubtitleMatch(it) } + } + + val media = identity.fallbackMediaIdentity() ?: return null + if (!media.hasTypedFallback()) return null + + val typedMatches = tracks + .asSequence() + .filterNot { it.hasReservedArtifactId() } + .filter { it.matchesTypedMetadata(media) } + .toList() + if (typedMatches.isEmpty()) return null + + val targetLabel = normalizedLabel(media.label) + val labelMatches = if (targetLabel == null) { + emptyList() + } else { + typedMatches.filter { normalizedLabel(it.label) == targetLabel } + } + return when { + labelMatches.size == 1 -> MountedSubtitleMatch(labelMatches.single()) + typedMatches.size == 1 -> MountedSubtitleMatch(typedMatches.single()) + else -> null + } +} + +/** + * Bridges current catalog rows to typed identity. Both phone and TV use this + * while the coordinator adapters still receive [PlayerSubtitleInfo]. + */ +fun resolveMountedSubtitle( + subtitle: PlayerSubtitleInfo, + tracks: List, +): MountedSubtitleMatch? { + val explicitSource = subtitle.effectiveSubtitleSource() + val isEmbedded = when { + subtitle.url.isNotBlank() -> false + explicitSource.equals("embedded", ignoreCase = true) -> true + explicitSource != null -> false + else -> subtitle.url.isBlank() + } + val isDownloadedArtifact = subtitle.isDownloadedSubtitleArtifact() + if (!isEmbedded) { + if (isDownloadedArtifact) { + // Modern downloaded rows are domain-ID exact only. Legacy rows + // without that optional field continue below to non-reserved typed + // metadata matching; no downloaded identity is synthesized. + subtitle.downloadId?.let { downloadId -> + return resolveMountedSubtitle( + SubtitleIdentity.Downloaded( + downloadId = downloadId, + media = SubtitleMediaIdentity(), + ), + tracks, + ) + } + } else { + resolveMountedSubtitle(SubtitleIdentity.ServerSidecar(subtitle.index), tracks) + ?.let { return it } + } + if (subtitle.url.isBlank()) return null + if (!isDownloadedArtifact && tracks.any(MountedSubtitleTrack::hasReservedArtifactId)) { + return null + } + } + + val codecFamilies = listOfNotNull( + subtitle.url.substringBefore('?').substringBefore('#').substringAfterLast('.', "") + .takeIf(String::isNotBlank), + subtitle.codec, + ).distinct() + val familiesToTry: List = + if (codecFamilies.isEmpty()) listOf(null) else codecFamilies + for (codec in familiesToTry) { + val media = SubtitleMediaIdentity( + label = subtitle.label, + language = subtitle.language, + codecFamily = codec, + forced = subtitle.forced, + hearingImpaired = subtitle.label + ?.takeIf(::subtitleLabelIndicatesHearingImpaired) + ?.let { true }, + ) + val identity = if (isEmbedded) { + SubtitleIdentity.Embedded(subtitle.index, media) + } else { + SubtitleIdentity.LocalMedia3(media) + } + resolveMountedSubtitle(identity, tracks)?.let { return it } + } + return null +} + +private fun SubtitleIdentity.expectedMediaTrackId(): String? = when (this) { + is SubtitleIdentity.ServerSidecar -> subtitleArtifactTrackId(serverIndex) + is SubtitleIdentity.Embedded -> media.trackId.normalizedNonServerTrackId() + is SubtitleIdentity.Downloaded -> downloadedSubtitleArtifactTrackId(downloadId) + is SubtitleIdentity.LocalMedia3 -> media.trackId.normalizedNonServerTrackId() + SubtitleIdentity.Off, + is SubtitleIdentity.ServerBurnIn, + -> null +} + +private fun SubtitleIdentity.fallbackMediaIdentity(): SubtitleMediaIdentity? = when (this) { + is SubtitleIdentity.Embedded -> media.takeUnless { it.trackId.isReservedArtifactTrackId() } + is SubtitleIdentity.LocalMedia3 -> media.takeUnless { it.trackId.isReservedArtifactTrackId() } + SubtitleIdentity.Off, + is SubtitleIdentity.ServerSidecar, + is SubtitleIdentity.ServerBurnIn, + is SubtitleIdentity.Downloaded, + -> null +} + +private fun SubtitleMediaIdentity.hasTypedFallback(): Boolean = + canonicalSubtitleLanguage(language) != null || + normalizedSubtitleCodecFamily(codecFamily) != null || + forced != null || + hearingImpaired != null + +private fun MountedSubtitleTrack.matchesTypedMetadata(identity: SubtitleMediaIdentity): Boolean { + val targetLanguage = canonicalSubtitleLanguage(identity.language) + val targetCodec = normalizedSubtitleCodecFamily(identity.codecFamily) + + if (targetLanguage != null && canonicalSubtitleLanguage(language) != targetLanguage) return false + if (targetCodec != null && normalizedSubtitleCodecFamily(codec) != targetCodec) { + // A catalog row names its SOURCE format (subrip, ssa, …) but the server + // serves the artifact it materialises for that row as WebVTT. Requiring + // an exact family match meant a picked embedded text track could never + // resolve to the sidecar the server had just produced for it: the mount + // deadline fired and the selection rolled back to Off. Bitmap families + // are never converted, so they stay strict. + val mountedCodec = normalizedSubtitleCodecFamily(codec) + if (!isTextSubtitleCodecFamily(targetCodec) || !isTextSubtitleCodecFamily(mountedCodec)) { + return false + } + } + if (identity.forced != null && forced != identity.forced) return false + // A server artifact's hearing-impaired flag is only ever inferred from its + // label, and the server labels artifacts generically. When the mount carries + // no positive SDH signal it is unknown, not "not SDH" — treating it as the + // latter meant an SDH pick never matched the sidecar the server had just + // produced for it. The reserved artifact id already pins WHICH row this is, + // so nothing is loosened for genuinely distinct local tracks. + val hearingImpairedIsInferred = hasReservedArtifactId() && hearingImpaired != true + if ( + identity.hearingImpaired != null && + !hearingImpairedIsInferred && + hearingImpaired != identity.hearingImpaired + ) { + return false + } + return true +} + +private fun MountedSubtitleTrack.hasReservedArtifactId(): Boolean = + trackId.isReservedArtifactTrackId() + +private fun String?.normalizedValue(): String? = + this?.trim()?.takeIf(String::isNotEmpty) + +private fun String?.normalizedNonServerTrackId(): String? = + normalizedValue()?.takeUnless(String::isReservedArtifactTrackId) + +private fun String?.isReservedArtifactTrackId(): Boolean = + this?.startsWith(SUBTITLE_ARTIFACT_TRACK_ID_PREFIX) == true || + this?.startsWith(DOWNLOADED_SUBTITLE_ARTIFACT_TRACK_ID_PREFIX) == true + +internal fun PlayerSubtitleInfo.isDownloadedSubtitleArtifact(): Boolean = + source.normalizedValue().equals("downloaded", ignoreCase = true) || + catalogSource.normalizedValue().equals("downloaded", ignoreCase = true) + +private fun PlayerSubtitleInfo.effectiveSubtitleSource(): String? = + source.normalizedValue() ?: catalogSource.normalizedValue() + +private fun normalizedLabel(label: String?): String? = + label.normalizedValue()?.lowercase() + +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/VideoPlayerMediaMounter.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaMounter.kt index 7d72fad42..e9bb6dcf5 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaMounter.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaMounter.kt @@ -13,6 +13,7 @@ fun mountVideoMedia( playWhenReady: Boolean = true, ) { val mediaItem = playerFactory.buildMediaItem( + contentId = spec.contentId, streamUrl = spec.streamUrl, playMethod = spec.playMethod, delivery = spec.delivery, @@ -26,6 +27,7 @@ fun mountVideoMedia( timelineOffsetSeconds = spec.timelineOffsetSeconds, requestHeaders = spec.requestHeaders, expectedDynamicRange = spec.expectedDynamicRange, + expectedColorRange = spec.expectedColorRange, transformations = spec.transformations, runtimeCorrections = spec.runtimeCorrections, ) @@ -43,6 +45,7 @@ fun refreshMountedVideoMedia( val resumePositionMs = player.currentPosition.coerceAtLeast(0L) val wasPlaying = player.playWhenReady val mediaItem = playerFactory.buildMediaItem( + contentId = spec.contentId, streamUrl = spec.streamUrl, playMethod = spec.playMethod, delivery = spec.delivery, @@ -56,6 +59,7 @@ fun refreshMountedVideoMedia( timelineOffsetSeconds = spec.timelineOffsetSeconds, requestHeaders = spec.requestHeaders, expectedDynamicRange = spec.expectedDynamicRange, + expectedColorRange = spec.expectedColorRange, transformations = spec.transformations, runtimeCorrections = spec.runtimeCorrections, ) 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 3d35304f2..1f4452543 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 @@ -5,6 +5,12 @@ import org.prairieserver.prairie.model.playback.PlaybackDelivery import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo 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. + */ + val contentId: String? = null, val streamUrl: String, val playMethod: PlayMethod, val delivery: PlaybackDelivery? = null, @@ -20,6 +26,7 @@ data class VideoPlayerMediaSpec( val audioPassthroughCodecs: List = emptyList(), val requestHeaders: Map = emptyMap(), val expectedDynamicRange: String? = null, + val expectedColorRange: String? = null, val transformations: List = emptyList(), val runtimeCorrections: List = emptyList(), ) { 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 019216937..66ef11df8 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 @@ -12,6 +12,7 @@ import org.prairieserver.prairie.common.player.video.VideoTrackSelectionCoordina import org.prairieserver.prairie.model.playback.AudioPassthroughCapabilities import org.prairieserver.prairie.model.playback.HdrCapabilities import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity @UnstableApi class Media3VideoPlaybackBackend( @@ -57,6 +58,13 @@ class Media3VideoPlaybackBackend( selectedTrack = track, ) + override fun selectMountedSubtitle( + identity: SubtitleIdentity, + ): Boolean = trackSelectionCoordinator.selectMountedSubtitle( + player = player, + identity = identity, + ) + override fun selectMountedSubtitle( subtitles: List, selectedIndex: Int, @@ -92,7 +100,7 @@ class Media3VideoPlaybackBackend( } override fun release() { - player.release() + playerFactory.releasePlayer(player) } private fun requireMediaSpecForExternalSubtitle(track: VideoPlayerTrackEntry?): VideoPlayerMediaSpec { 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 5a88c7678..5683d935d 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 @@ -7,6 +7,7 @@ import org.prairieserver.prairie.common.player.video.VideoPlayerTrackEntry import org.prairieserver.prairie.model.playback.AudioPassthroughCapabilities import org.prairieserver.prairie.model.playback.HdrCapabilities import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity @UnstableApi interface VideoPlaybackBackend { @@ -24,6 +25,11 @@ interface VideoPlaybackBackend { fun selectSubtitle(track: VideoPlayerTrackEntry?): Boolean + fun selectMountedSubtitle( + identity: SubtitleIdentity, + ): Boolean + + /** Compatibility bridge until every platform adapter publishes typed identity. */ fun selectMountedSubtitle( subtitles: List, selectedIndex: Int, 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 new file mode 100644 index 000000000..97f539120 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractor.kt @@ -0,0 +1,286 @@ +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.PositionHolder +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput +import androidx.media3.extractor.text.CueEncoder +import androidx.media3.extractor.text.SubtitleParser +import java.io.EOFException + +/** + * Extractor for a raw PGS elementary stream (`.sup`), which Media3 has no + * extractor for. + * + * Media3 parses PGS but only as one *display set* at a time, timestamped by + * whatever container carried it — inside Matroska the block supplies the time + * and the payload is bare `[type][len][payload]` segments. A `.sup` file is the + * same segments with a 10-byte `PG`+PTS+DTS prefix on each and no container at + * all, so handing the whole file to the parser (which is what SubtitleExtractor + * does) yields nothing: it sees one enormous sample with no timing. That is why + * a mounted `.sup` sidecar selected cleanly and drew nothing. + * + * So: frame the stream into display sets, strip each segment's prefix back to + * the container-shaped form the parser expects, and emit one parsed cue sample + * per set at the PTS the prefix carried. + * + * The parser is injected rather than constructed here so the caller's offset + * wrapper still applies — subtitle sync and the re-anchor delta have to reach + * these cues like any other. + */ +@UnstableApi +class PgsSupExtractor( + private val parserFactory: SubtitleParser.Factory, + /** + * Subtitle sync + the server re-anchor delta, in microseconds. Applied to + * the sample timestamp here because PGS carries no cue-relative time for + * the parser's own offset wrapper to shift — it reports TIME_UNSET, and + * adding that produced timestamps ~9.2e15 ms into the future. + */ + private val offsetUsProvider: () -> Long, + /** + * The sidecar's own format. Emitting a freshly built one instead drops the + * stable track id, language and label the mount resolver matches on — the + * track then appears as `mounted=[1:]` and the pick dies on its deadline. + */ + private val sourceFormat: Format, +) : Extractor { + + private val cueEncoder = CueEncoder() + private val headerScratch = ByteArray(SEGMENT_HEADER_SIZE) + + private var trackOutput: TrackOutput? = null + private var parser: SubtitleParser? = null + + /** Segments of the display set being accumulated, already prefix-stripped. */ + private var displaySet = ByteArrayBuilder() + private var displaySetTimeUs = C.TIME_UNSET + private var displaySetSegmentCount = 0 + private var failedClosed = false + private var emittedSets = 0 + private var emittedCues = 0 + + override fun sniff(input: ExtractorInput): Boolean { + val probe = ByteArray(2) + return try { + input.peekFully(probe, 0, probe.size) + probe[0] == MAGIC_P && probe[1] == MAGIC_G + } catch (_: EOFException) { + false + } + } + + override fun init(output: ExtractorOutput) { + val track = output.track(0, C.TRACK_TYPE_TEXT) + parser = parserFactory.create(sourceFormat) + // Everything except the sample mime carries over: id, language, label, + // selection and role flags are what identify this track downstream. + track.format( + sourceFormat.buildUpon() + .setSampleMimeType(MimeTypes.APPLICATION_MEDIA3_CUES) + .setCodecs(sourceFormat.sampleMimeType) + .setCueReplacementBehavior( + parserFactory.getCueReplacementBehavior(sourceFormat), + ) + .build(), + ) + trackOutput = track + 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)) + } + + 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 + try { + input.readFully(headerScratch, 0, SEGMENT_HEADER_SIZE) + } catch (_: EOFException) { + discardPendingDisplaySet() + return Extractor.RESULT_END_OF_INPUT + } + val header = ParsableByteArray(headerScratch) + if (header.readUnsignedByte() != MAGIC_P_INT || header.readUnsignedByte() != MAGIC_G_INT) { + // Resynchronising mid-stream would mean guessing where the next + // segment starts; a truncated or mislabelled artifact is better + // reported as end-of-input than turned into fabricated cues. + discardPendingDisplaySet() + return Extractor.RESULT_END_OF_INPUT + } + val pts90kHz = header.readUnsignedInt() + header.skipBytes(4) // DTS: unused, presentation time is what cues need. + val segmentType = header.readUnsignedByte() + val segmentLength = header.readUnsignedShort() + + if (displaySetSegmentCount >= MAX_DISPLAY_SET_SEGMENTS || + displaySet.size + CONTAINER_SEGMENT_HEADER_SIZE + segmentLength > MAX_DISPLAY_SET_BYTES + ) { + failClosed() + return Extractor.RESULT_END_OF_INPUT + } + + val payload = ByteArray(segmentLength) + if (segmentLength > 0) { + try { + input.readFully(payload, 0, segmentLength) + } catch (_: EOFException) { + discardPendingDisplaySet() + return Extractor.RESULT_END_OF_INPUT + } + } + + if (segmentType == SEGMENT_TYPE_END) { + // The END section stays in the buffer: Media3's PgsParser builds the + // cue when it reads one. Stripping it as pure framing produced a set + // the parser accepted and returned zero cues for — a mounted, + // selected, correctly timed track that drew nothing. + appendSegment(segmentType, segmentLength, payload) + flushDisplaySet(output) + 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 + } + appendSegment(segmentType, segmentLength, payload) + return Extractor.RESULT_CONTINUE + } + + /** Back to the container-shaped form the parser reads: [type][length][payload]. */ + private fun appendSegment(segmentType: Int, segmentLength: Int, payload: ByteArray) { + displaySet.append(segmentType.toByte()) + displaySet.append((segmentLength shr 8 and 0xFF).toByte()) + displaySet.append((segmentLength and 0xFF).toByte()) + displaySet.append(payload) + displaySetSegmentCount += 1 + } + + /** + * A set is only complete once its END segment arrives, so a stream that + * stops mid-set has nothing renderable — parsing what arrived would risk a + * half-built caption from a composition with no bitmap yet. + */ + private fun discardPendingDisplaySet() { + displaySet = ByteArrayBuilder() + displaySetTimeUs = C.TIME_UNSET + displaySetSegmentCount = 0 + } + + private fun failClosed() { + discardPendingDisplaySet() + failedClosed = true + } + + private fun flushDisplaySet(output: TrackOutput) { + if (displaySet.isEmpty()) return + val bytes = displaySet.toByteArray() + val timeUs = displaySetTimeUs + displaySet = ByteArrayBuilder() + displaySetTimeUs = C.TIME_UNSET + displaySetSegmentCount = 0 + val activeParser = parser ?: return + if (timeUs == C.TIME_UNSET) return + + emittedSets++ + if (emittedSets <= 3 || emittedSets % 200 == 0) { + org.prairieserver.prairie.common.player.SubDiag.log( + "SUP set=$emittedSets t=${timeUs / 1000}ms bytes=${bytes.size}", + ) + } + activeParser.parse( + bytes, + 0, + bytes.size, + SubtitleParser.OutputOptions.allCues(), + ) { cues -> + emittedCues += cues.cues.size + if (emittedCues <= 3) { + org.prairieserver.prairie.common.player.SubDiag.log( + "SUP cue n=${cues.cues.size} at=${(timeUs + offsetUsProvider()) / 1000}ms", + ) + } + // 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) + output.sampleMetadata( + (timeUs + offsetUsProvider()).coerceAtLeast(0L), + C.BUFFER_FLAG_KEY_FRAME, + encoded.size, + 0, + null, + ) + } + } + + override fun seek(position: Long, timeUs: Long) { + displaySet = ByteArrayBuilder() + displaySetTimeUs = C.TIME_UNSET + displaySetSegmentCount = 0 + failedClosed = false + parser?.reset() + } + + override fun release() { + parser = null + } + + /** Grow-on-append byte buffer; a display set is a handful of small segments. */ + private class ByteArrayBuilder { + private var buffer = ByteArray(INITIAL_CAPACITY) + var size = 0 + private set + + fun isEmpty(): Boolean = size == 0 + + fun append(value: Byte) { + ensure(1) + buffer[size++] = value + } + + fun append(values: ByteArray) { + if (values.isEmpty()) return + ensure(values.size) + values.copyInto(buffer, size) + size += values.size + } + + fun toByteArray(): ByteArray = buffer.copyOf(size) + + private fun ensure(extra: Int) { + if (size + extra <= buffer.size) return + var capacity = buffer.size + while (capacity < size + extra) capacity *= 2 + buffer = buffer.copyOf(capacity) + } + + private companion object { + const val INITIAL_CAPACITY = 4096 + } + } + + companion object { + /** `PG` magic, 4-byte PTS, 4-byte DTS, type, 2-byte length. */ + const val SEGMENT_HEADER_SIZE = 13 + const val SEGMENT_TYPE_END = 0x80 + 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 + private const val PTS_CLOCK_HZ = 90_000L + private const val MAGIC_P_INT = 0x50 + private const val MAGIC_G_INT = 0x47 + private const val MAGIC_P = MAGIC_P_INT.toByte() + private const val MAGIC_G = MAGIC_G_INT.toByte() + } +} 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 b10368631..00b79536f 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 @@ -9,6 +9,7 @@ import org.prairieserver.prairie.common.player.SubtitleManager import org.prairieserver.prairie.common.player.VideoPlayerMediaSpec import org.prairieserver.prairie.common.player.refreshMountedVideoMedia import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity data class VideoPlayerTrackEntry( val index: Int, @@ -45,6 +46,12 @@ class VideoTrackSelectionCoordinator( return subtitleManager.selectSubtitle(player, selectedTrack.index) } + fun selectMountedSubtitle( + player: Player, + identity: SubtitleIdentity, + ): Boolean = subtitleManager.selectSubtitle(player, identity) + + /** Compatibility bridge for adapters not yet migrated to typed identity. */ fun selectMountedSubtitle( player: Player, subtitles: List, 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 2863b0b8b..dbb2db377 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 @@ -186,6 +186,18 @@ 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) } @@ -299,6 +311,28 @@ 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)) @@ -579,3 +613,33 @@ 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/PlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt index 60b412209..982d00814 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 @@ -28,6 +28,14 @@ interface PlayerSettingsStore { // Ints val audioSyncMsFlow: Flow 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. */ @@ -78,6 +86,13 @@ 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). */ diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/SafePathSegment.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/SafePathSegment.kt new file mode 100644 index 000000000..630998e15 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/SafePathSegment.kt @@ -0,0 +1,96 @@ +package org.prairieserver.prairie.common.store + +import java.io.File +import java.security.MessageDigest + +private val SAFE_PATH_SEGMENT = Regex("[A-Za-z0-9_-][A-Za-z0-9._-]{0,119}") +private const val MAX_SAFE_SEGMENT_LENGTH = 120 +private const val ENCODED_PREFIX = "~" +private const val BASE64_URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + +/** + * Converts an untrusted identity value into one filesystem path segment. + * + * Already-safe identifiers remain readable on disk. Other values use a + * reserved `~` prefix plus unpadded base64url; exceptionally long encodings + * use a fixed-size SHA-256 digest. + */ +internal fun safePathSegment(value: String): String { + if (value != "." && value != ".." && SAFE_PATH_SEGMENT.matches(value)) return value + + val encoded = ENCODED_PREFIX + value.toByteArray(Charsets.UTF_8).toBase64Url() + if (encoded.length <= MAX_SAFE_SEGMENT_LENGTH) return encoded + + val digest = MessageDigest.getInstance("SHA-256").digest(encoded.toByteArray(Charsets.UTF_8)) + return ENCODED_PREFIX + digest.joinToString(separator = "") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') + } +} + +/** + * Resolves encoded identity [segments] under [root]. + * + * Explicit traversal components are rejected instead of silently normalised. + * All other unsafe values are encoded before canonical containment is checked. + */ +internal fun containedChild(root: File, vararg segments: String): File? { + if (segments.any(::containsTraversalComponent)) return null + return containedSafeChild(root, *segments) +} + +/** Encodes identity values and contains them even when the raw value resembles traversal. */ +internal fun containedSafeChild(root: File, vararg segments: String): File? { + return runCatching { + val canonicalRoot = root.canonicalFile + val child = segments.fold(canonicalRoot) { parent, value -> + File(parent, safePathSegment(value)) + }.canonicalFile + child.takeIf { it.isContainedBy(canonicalRoot, allowRoot = segments.isEmpty()) } + }.getOrNull() +} + +/** + * Resolves an old, unencoded path only when its canonical target is a strict + * descendant of [root]. Values beginning with the reserved encoded namespace + * marker (`~`) are deliberately never probed as raw legacy names, preventing a + * raw identity from aliasing a different identity's encoded path. This is for + * read/delete compatibility only. + */ +internal fun containedLegacyChild(root: File, vararg segments: String): File? = + if (segments.any { it.startsWith(ENCODED_PREFIX) }) { + null + } else { + runCatching { + val canonicalRoot = root.canonicalFile + val child = segments.fold(canonicalRoot) { parent, value -> File(parent, value) }.canonicalFile + child.takeIf { it.isContainedBy(canonicalRoot, allowRoot = false) } + }.getOrNull() + } + +private fun containsTraversalComponent(value: String): Boolean = + value.replace('\\', '/').split('/').any { component -> component == "." || component == ".." } + +private fun File.isContainedBy(root: File, allowRoot: Boolean): Boolean = + (allowRoot && path == root.path) || path.startsWith(root.path + File.separator) + +private fun ByteArray.toBase64Url(): String { + if (isEmpty()) return "" + val result = StringBuilder((size * 4 + 2) / 3) + var index = 0 + while (index < size) { + val first = this[index].toInt() and 0xff + val second = if (index + 1 < size) this[index + 1].toInt() and 0xff else -1 + val third = if (index + 2 < size) this[index + 2].toInt() and 0xff else -1 + + result.append(BASE64_URL_ALPHABET[first ushr 2]) + result.append(BASE64_URL_ALPHABET[((first and 0x03) shl 4) or if (second >= 0) second ushr 4 else 0]) + if (second >= 0) { + result.append(BASE64_URL_ALPHABET[((second and 0x0f) shl 2) or if (third >= 0) third ushr 6 else 0]) + } + if (third >= 0) { + result.append(BASE64_URL_ALPHABET[third and 0x3f]) + } + index += 3 + } + return result.toString() +} 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 11ea395a5..a342eabb8 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 @@ -4,7 +4,19 @@ import android.util.Log import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File -import java.io.FileOutputStream +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.channels.SeekableByteChannel +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.OpenOption +import java.nio.file.Path +import java.nio.file.SecureDirectoryStream +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap /** * Shared plumbing for the per-(serverId, profileId, contentId) JSON @@ -18,20 +30,40 @@ internal class ScopedJsonFileStore( private val root: File, internal val tag: String, ) { + private val canonicalRoot: File = root.canonicalFile + private val legacyByTarget = ConcurrentHashMap() + private val targetLockStripes = Array(TARGET_LOCK_STRIPES) { Any() } - internal fun resolve(relativePath: String): File = File(root, relativePath) + internal fun rootDirectory(): File = canonicalRoot + + internal fun withTargetLock(target: File, block: () -> T): T { + val hash = target.canonicalPath.hashCode() and Int.MAX_VALUE + val lock = targetLockStripes[hash % TARGET_LOCK_STRIPES] + return synchronized(lock, block) + } internal fun fileFor( serverId: String, profileId: String, contentId: String, suffix: String = ".json", - ): File = resolve("$serverId/$profileId/$contentId$suffix") + ): File { + val directory = requireNotNull(containedSafeChild(canonicalRoot, serverId, profileId)) { + "Scoped JSON directory is outside its root" + } + val target = requireNotNull(containedFile(directory, safePathSegment(contentId) + suffix)) { + "Scoped JSON filename is outside its directory" + } + containedLegacyChild(canonicalRoot, serverId, profileId, contentId + suffix) + ?.takeIf { it.canonicalPath != target.canonicalPath } + ?.let { legacy -> legacyByTarget[target.canonicalPath] = legacy } + return target + } internal inline fun read(file: File): T? { - if (!file.isFile) return null - return runCatching { json.decodeFromString(file.readText()) } - .onFailure { Log.w(tag, "read failed for ${file.path}", it) } + val source = containedReadCandidate(file) ?: return null + return runCatching { json.decodeFromString(source.readText()) } + .onFailure { Log.w(tag, "read failed for ${source.path}", it) } .getOrNull() } @@ -40,18 +72,157 @@ internal class ScopedJsonFileStore( } internal fun writeAtomic(target: File, text: String) { + val serverStorageKey = runCatching { + canonicalRoot.toPath().relativize(target.canonicalFile.toPath()).first().toString() + }.getOrNull() ?: run { + Log.w(tag, "write rejected without a server scope") + return + } + val written = ServerScopedJsonAccess.withWriter(canonicalRoot, serverStorageKey) { + writeAtomicUnlocked(target, text) + } + if (written == null) Log.w(tag, "write rejected for removed server scope") + } + + private fun writeAtomicUnlocked(target: File, text: String) { + if (!isContained(target)) { + Log.w(tag, "write rejected outside store root") + return + } target.parentFile?.mkdirs() - val tmp = File(target.parentFile, "${target.name}.tmp") - FileOutputStream(tmp).use { stream -> - stream.write(text.toByteArray(Charsets.UTF_8)) - stream.fd.sync() + val parent = target.parentFile?.canonicalFile + if (parent == null || !isContained(parent)) { + Log.w(tag, "write rejected after parent changed") + return } - if (!tmp.renameTo(target)) { + val staged = createExclusiveTemp(parent) ?: run { + Log.w(tag, "could not create atomic temp file") + return + } + val tmp = staged.file + val bytes = text.toByteArray(Charsets.UTF_8) + val published = runCatching { + staged.channel.use { channel -> + var buffer = ByteBuffer.wrap(bytes) + while (buffer.hasRemaining()) channel.write(buffer) + (channel as? FileChannel)?.force(true) + } + staged.publishTo(target) + true + }.getOrElse { Log.w(tag, "atomic rename failed for ${target.path}") + false + } + if (!published) { + Files.deleteIfExists(tmp.toPath()) + return + } + legacyByTarget.remove(target.canonicalPath) + ?.takeIf(::isContained) + ?.let { legacy -> Files.deleteIfExists(legacy.toPath()) } + } + + internal fun containedReadCandidate(file: File): File? { + if (!isContained(file)) return null + if (file.isFile) return file + return legacyByTarget[file.canonicalPath] + ?.takeIf(::isContained) + ?.takeIf { it.isFile } + } + + private fun isContained(file: File): Boolean = + runCatching { + file.canonicalPath.startsWith(canonicalRoot.path + File.separator) + }.getOrDefault(false) + + private fun containedFile(directory: File, fileName: String): File? = + runCatching { + File(directory, fileName).canonicalFile.takeIf { target -> + target.parentFile?.canonicalFile == directory.canonicalFile && + isContained(target) + } + }.getOrNull() + + private fun createExclusiveTemp(parent: File): ExclusiveTemp? { + repeat(MAX_TEMP_ATTEMPTS) { + val candidate = File(parent, ".silo-${UUID.randomUUID()}.tmp") + var directoryStream: java.nio.file.DirectoryStream? = null + try { + directoryStream = Files.newDirectoryStream(parent.toPath()) + if (directoryStream is SecureDirectoryStream<*>) { + @Suppress("UNCHECKED_CAST") + val secure = directoryStream as SecureDirectoryStream + val relative = parent.toPath().fileSystem.getPath(candidate.name) + val channel = secure.newByteChannel(relative, CREATE_NEW_NOFOLLOW) + return ExclusiveTemp(candidate, channel, secure, relative) + } + directoryStream.close() + directoryStream = null + return ExclusiveTemp( + candidate, + FileChannel.open(candidate.toPath(), *CREATE_NEW_NOFOLLOW.toTypedArray()), + secureDirectory = null, + relativePath = null, + ) + } catch (_: java.nio.file.FileAlreadyExistsException) { + directoryStream?.close() + // Retry with a new unguessable name. + } catch (failure: Throwable) { + directoryStream?.close() + throw failure + } + } + return null + } + + private inner class ExclusiveTemp( + val file: File, + val channel: SeekableByteChannel, + val secureDirectory: SecureDirectoryStream?, + val relativePath: Path?, + ) { + fun publishTo(target: File) { + val secure = secureDirectory + val source = relativePath + if (secure != null && source != null) { + try { + secure.move(source, secure, source.fileSystem.getPath(target.name)) + } finally { + secure.close() + } + } else { + moveReplacingWithoutFollowing(file, target) + } + } + } + + /** + * `Files.move` replaces a destination symlink as a directory entry; it does + * not follow the link. `ATOMIC_MOVE` is used on the same-directory fast path. + * Providers without atomic-move support retain no-follow replacement but + * cannot provide crash-atomic publication. + */ + private fun moveReplacingWithoutFollowing(source: File, target: File) { + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) } } companion object { val json = Json { ignoreUnknownKeys = true; prettyPrint = false } + private const val MAX_TEMP_ATTEMPTS = 8 + private const val TARGET_LOCK_STRIPES = 64 + private val CREATE_NEW_NOFOLLOW: Set = setOf( + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS, + ) } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ServerScopedJsonAccess.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ServerScopedJsonAccess.kt new file mode 100644 index 000000000..8107c4db0 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ServerScopedJsonAccess.kt @@ -0,0 +1,44 @@ +package org.prairieserver.prairie.common.store + +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Process-wide, bounded-lock coordination between scoped JSON writers and + * identity purge. Invalidating a server key prevents a stale coroutine from + * recreating its directory after deletion; observing the server registered + * again explicitly enables new writes. + */ +internal object ServerScopedJsonAccess { + private const val STRIPE_COUNT = 64 + private val stripes = Array(STRIPE_COUNT) { Any() } + private val invalidated = ConcurrentHashMap.newKeySet() + + fun withWriter(root: File, serverStorageKey: String, block: () -> T): T? { + val identityKey = identityKey(root, serverStorageKey) + return synchronized(stripe(identityKey)) { + if (identityKey in invalidated) null else block() + } + } + + fun invalidateAndRun(root: File, serverStorageKey: String, block: () -> Unit) { + val identityKey = identityKey(root, serverStorageKey) + synchronized(stripe(identityKey)) { + invalidated += identityKey + block() + } + } + + fun allow(root: File, serverStorageKey: String) { + val identityKey = identityKey(root, serverStorageKey) + synchronized(stripe(identityKey)) { + invalidated -= identityKey + } + } + + private fun identityKey(root: File, serverStorageKey: String): String = + root.canonicalPath + '\u0000' + serverStorageKey + + private fun stripe(identityKey: String): Any = + stripes[(identityKey.hashCode() and Int.MAX_VALUE) % STRIPE_COUNT] +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ServerScopedJsonStatePurger.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ServerScopedJsonStatePurger.kt new file mode 100644 index 000000000..054da7d64 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ServerScopedJsonStatePurger.kt @@ -0,0 +1,125 @@ +package org.prairieserver.prairie.common.store + +import java.io.File +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +/** + * Cleans local JSON state whose only identity index is its directory name. + * + * Directory names are compared in their encoded storage form because long + * identity values intentionally use a one-way digest and cannot be decoded. + * Tree deletion never follows symlinks. + */ +class ServerScopedJsonStatePurger(baseDir: File) { + private val canonicalBase = baseDir.canonicalFile + private val roots = listOf( + File(canonicalBase, "ebook_state"), + File(canonicalBase, "audiobook_bookmarks"), + ) + + fun allowRegistered(serverIds: Set) { + roots.mapNotNull(::validatedRoot).forEach { root -> + serverIds.forEach { serverId -> + ServerScopedJsonAccess.allow(root, safePathSegment(serverId)) + } + } + } + + fun deleteAllForServer(serverId: String) { + roots.mapNotNull(::validatedRoot).forEach { root -> + val safeKey = safePathSegment(serverId) + ServerScopedJsonAccess.invalidateAndRun(root, safeKey) { + // Keep the leaf lexical: canonicalising it would follow an + // in-root symlink to a registered sibling and delete that + // sibling's real directory. + deleteTree(File(root, safeKey)) + } + legacyDirectChild(root, serverId)?.let { legacy -> + if (legacy.name != safeKey) { + ServerScopedJsonAccess.invalidateAndRun(root, legacy.name) { + deleteTree(legacy) + } + } + } + } + } + + private fun legacyDirectChild(root: File, serverId: String): File? = + serverId.takeIf { + it != "." && it != ".." && !it.startsWith("~") && + '/' !in it && '\\' !in it + }?.let { File(root, it) } + + fun deleteJsonOnlyOrphans( + registeredServerIds: Set, + protectedServerIds: Set, + ) { + val retainedIds = registeredServerIds + protectedServerIds + roots.mapNotNull(::validatedRoot).forEach { root -> + registeredServerIds.forEach { serverId -> + ServerScopedJsonAccess.allow(root, safePathSegment(serverId)) + } + val retainedTopLevelNames = retainedIds.flatMapTo(mutableSetOf()) { serverId -> + buildList { + add(safePathSegment(serverId)) + containedLegacyChild(root, serverId)?.let { legacy -> + val relative = root.toPath().relativize(legacy.toPath()) + relative.firstOrNull()?.toString()?.let(::add) + } + } + } + root.listFiles().orEmpty() + .filterNot { it.name in retainedTopLevelNames } + .forEach { orphan -> + ServerScopedJsonAccess.invalidateAndRun(root, orphan.name) { + deleteTree(orphan) + } + } + } + } + + private fun validatedRoot(root: File): File? = runCatching { + val path = root.toPath() + if (Files.isSymbolicLink(path)) return@runCatching null + if (Files.exists(path, java.nio.file.LinkOption.NOFOLLOW_LINKS) && + !Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS) + ) { + return@runCatching null + } + root.canonicalFile.takeIf { canonical -> + canonical.parentFile == canonicalBase && + canonical.path.startsWith(canonicalBase.path + File.separator) + } + }.getOrNull() + + private fun deleteTree(candidate: File) { + val path = candidate.toPath() + if (!Files.exists(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return + Files.walkFileTree( + path, + object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.deleteIfExists(file) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: java.io.IOException): FileVisitResult { + // A dangling or unreadable symlink is still safe to remove as + // a directory entry; Files.delete does not follow it. + Files.deleteIfExists(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: java.io.IOException?): FileVisitResult { + if (exc != null) throw exc + Files.deleteIfExists(dir) + return FileVisitResult.CONTINUE + } + }, + ) + } +} 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/SiloDatabaseMigrationTest.kt new file mode 100644 index 000000000..fbdf85b47 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/SiloDatabaseMigrationTest.kt @@ -0,0 +1,71 @@ +package org.prairieserver.prairie.common.data.db + +import androidx.room.testing.MigrationTestHelper +import androidx.test.platform.app.InstrumentationRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PrairieDatabaseMigrationTest { + @get:Rule + val migrationHelper = + MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + PrairieDatabase::class.java, + ) + + @Test + fun migration7To8PreservesDownloadsAndAddsNullableQualityColumns() { + migrationHelper.createDatabase(DATABASE_NAME, 7).use { database -> + database.execSQL( + """ + INSERT INTO downloads ( + serverId, profileId, mediaFileId, recordId, contentId, title, + mediaType, status, kind, fileSize, bytesSent, createdAt, updatedAtMs + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + arrayOf( + "server-a", + "profile-a", + 42L, + "record-a", + "content-a", + "Existing download", + "video", + "complete", + "movie", + 1024L, + 1024L, + "2026-07-27T00:00:00Z", + 1234L, + ), + ) + } + + migrationHelper.runMigrationsAndValidate(DATABASE_NAME, 8, true).use { database -> + database.query( + """ + SELECT serverId, profileId, mediaFileId, recordId, quality, effectiveQuality + FROM downloads + """.trimIndent(), + ).use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("server-a", cursor.getString(0)) + assertEquals("profile-a", cursor.getString(1)) + assertEquals(42L, cursor.getLong(2)) + assertEquals("record-a", cursor.getString(3)) + assertNull(cursor.getString(4)) + assertNull(cursor.getString(5)) + assertEquals(false, cursor.moveToNext()) + } + } + } + + private companion object { + const val DATABASE_NAME = "migration-7-to-8" + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepositoryTest.kt index 74350c9cb..881051326 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepositoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomUserItemStateRepositoryTest.kt @@ -7,15 +7,22 @@ import org.prairieserver.prairie.common.data.sync.OutboxOperation import org.prairieserver.prairie.common.data.sync.OutboxSyncScheduler import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.repository.port.OutboxHandle +import org.prairieserver.prairie.repository.port.PlaybackWriteScope +import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate import org.prairieserver.prairie.repository.port.WriteOutcome import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.util.concurrent.CancellationException import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFails +import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertNotNull +import kotlin.test.assertSame import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) @@ -27,9 +34,11 @@ class RoomUserItemStateRepositoryTest { ).allowMainThreadQueries().build() private var nextId = 0 + private var currentSnapshot: AuthScopeSnapshot? = + AuthScopeSnapshot("s1", "p1", "https://s1.example", "pt") private val repo = RoomUserItemStateRepository( db = db, - snapshotProvider = { AuthScopeSnapshot("s1", "p1", "https://s1.example", "pt") }, + snapshotProvider = { currentSnapshot }, now = { 1000L }, idGenerator = { "id-${nextId++}" }, ) @@ -50,6 +59,35 @@ class RoomUserItemStateRepositoryTest { assertEquals("s1|p1|c1|${OutboxOperation.SET_WATCHED}", op.coalesceKey) } + @Test + fun aRetriedWatchedStillDrainsBeforeAPositionRecordedAfterIt() = runTest { + // Mark watched while the server is flaky, then resume the episode. + // dueBatch orders by nextAttemptAtMs, and a failed op is pushed 30s+ into + // the future — so the position drains first and the retried watched lands + // afterwards and clears it. The episode disappears from Continue Watching + // on every device while the user is ten minutes in, and local and server + // diverge permanently. Deterministic, not a race. + val watched = repo.recordWatched("c1", watched = true) + db.dirtyOperationDao().recordFailure( + id = watched.opId, + nowMs = 1000L, + nextAttemptAtMs = 31_000L, + error = "offline", + ) + + repo.recordPosition("c1", fileId = 7, positionSeconds = 456.0, durationSeconds = 3600.0) + + val drained = db.dirtyOperationDao().dueBatch("s1", "p1", nowMs = 40_000L, limit = 10) + assertEquals( + listOf(OutboxOperation.SET_WATCHED, OutboxOperation.SET_POSITION), + drained.map { it.opKind }, + "creation order must survive backoff, or the retry undoes the newer write", + ) + // Clamped, not deleted: dropping the pending SET_WATCHED would strand + // watched = true locally with no terminal op to revert it. + assertEquals(1, drained.first().attemptCount, "backoff history must be preserved") + } + @Test fun watchedChangesClearResumeButPreserveFilePreferencesAndReadingState() = runTest { val location = "epubcfi(/6/4!/4)" @@ -238,6 +276,28 @@ class RoomUserItemStateRepositoryTest { assertEquals(7, op.targetFileId) } + @Test + fun scopedPositionRejectsProfileThatIsNoLongerCurrent() = runTest { + val captured = PlaybackWriteScope("server", "profile-a", null, 4L) + currentSnapshot = authScope("server", "profile-b", generation = 5L) + + assertFalse(repo.recordPosition(captured, "movie", 7, 42.0, 100.0)) + assertNull(db.userItemStateDao().get("server", "profile-a", "movie", 7)) + assertNull(db.userItemStateDao().get("server", "profile-b", "movie", 7)) + } + + @Test + fun scopedPositionWritesOnlyToMatchingCapturedIdentity() = runTest { + val captured = PlaybackWriteScope("server", "profile-a", null, 4L) + currentSnapshot = authScope("server", "profile-a", generation = 4L) + + assertTrue(repo.recordPosition(captured, "movie", 7, 42.0, 100.0)) + assertEquals( + 42.0, + db.userItemStateDao().get("server", "profile-a", "movie", 7)?.positionSeconds, + ) + } + @Test fun recordPositionCoalescesToLatestPerContent() = runTest { repo.recordPosition("c1", fileId = 7, positionSeconds = 10.0, durationSeconds = 3600.0) @@ -291,6 +351,158 @@ class RoomUserItemStateRepositoryTest { assertEquals(1, db.dirtyOperationDao().count(), "track selections are local-only; position op is unchanged") } + @Test + fun recordTrackSelectionWritesOneLogicalSnapshotWithoutClobberingOtherFileState() = runTest { + repo.recordPosition("c1", fileId = 7, positionSeconds = 456.0, durationSeconds = 3600.0) + repo.recordAudioTrackSelection("c1", fileId = 7, audioFingerprint = "old-audio") + repo.recordSubtitleTrackSelection("c1", fileId = 7, subtitleFingerprint = "old-subtitle") + + repo.recordTrackSelection( + contentId = "c1", + fileId = 7, + audioFingerprint = " new-audio ", + subtitleFingerprint = " new-subtitle ", + ) + + val row = db.userItemStateDao().get("s1", "p1", "c1", 7) + assertEquals(456.0, row?.positionSeconds) + assertEquals(3600.0, row?.durationSeconds) + assertEquals("new-audio", row?.audioFingerprint) + assertEquals("new-subtitle", row?.subtitleFingerprint) + assertEquals(1, db.dirtyOperationDao().count()) + } + + @Test + fun recordTrackSelectionCanPreserveAudioWhileReplacingSubtitle() = runTest { + repo.recordAudioTrackSelection("c1", fileId = 7, audioFingerprint = "old-audio") + repo.recordSubtitleTrackSelection("c1", fileId = 7, subtitleFingerprint = "old-subtitle") + + repo.recordTrackSelection( + contentId = "c1", + fileId = 7, + audioUpdate = TrackSelectionFingerprintUpdate.Preserve, + subtitleUpdate = TrackSelectionFingerprintUpdate.Set("new-subtitle"), + ) + + val row = db.userItemStateDao().get("s1", "p1", "c1", 7) + assertEquals("old-audio", row?.audioFingerprint) + assertEquals("new-subtitle", row?.subtitleFingerprint) + } + + @Test + fun recordTrackSelectionCanClearAudioWhilePreservingSubtitle() = runTest { + repo.recordAudioTrackSelection("c1", fileId = 7, audioFingerprint = "old-audio") + repo.recordSubtitleTrackSelection("c1", fileId = 7, subtitleFingerprint = "old-subtitle") + + repo.recordTrackSelection( + contentId = "c1", + fileId = 7, + audioUpdate = TrackSelectionFingerprintUpdate.Clear, + subtitleUpdate = TrackSelectionFingerprintUpdate.Preserve, + ) + + val row = db.userItemStateDao().get("s1", "p1", "c1", 7) + assertNull(row?.audioFingerprint) + assertEquals("old-subtitle", row?.subtitleFingerprint) + } + + @Test + fun delayedTrackSelectionIsRejectedAfterProfileSwitch() = runTest { + val playbackScope = PlaybackWriteScope( + serverId = "s1", + profileId = "p1", + credentialGenerationId = null, + identityGeneration = 0L, + ) + currentSnapshot = AuthScopeSnapshot("s1", "p2", "https://s1.example", "p2-token") + + val accepted = repo.recordTrackSelection( + scope = playbackScope, + contentId = "c1", + fileId = 7, + audioUpdate = TrackSelectionFingerprintUpdate.Set("old-audio"), + subtitleUpdate = TrackSelectionFingerprintUpdate.Set("old-subtitle"), + ) + + assertFalse(accepted) + assertNull(db.userItemStateDao().get("s1", "p1", "c1", 7)) + assertNull(db.userItemStateDao().get("s1", "p2", "c1", 7)) + } + + @Test + fun delayedTrackSelectionIsRejectedAfterServerSwitch() = runTest { + val playbackScope = PlaybackWriteScope( + serverId = "s1", + profileId = "p1", + credentialGenerationId = null, + identityGeneration = 0L, + ) + currentSnapshot = AuthScopeSnapshot("s2", "p1", "https://s2.example", "p1-token") + + val accepted = repo.recordTrackSelection( + scope = playbackScope, + contentId = "c1", + fileId = 7, + audioUpdate = TrackSelectionFingerprintUpdate.Set("old-audio"), + subtitleUpdate = TrackSelectionFingerprintUpdate.Set("old-subtitle"), + ) + + assertFalse(accepted) + assertNull(db.userItemStateDao().get("s1", "p1", "c1", 7)) + assertNull(db.userItemStateDao().get("s2", "p1", "c1", 7)) + } + + @Test + fun recordTrackSelectionRollsBackBothFingerprintsWhenCombinedUpsertFails() = runTest { + repo.recordAudioTrackSelection("c1", fileId = 7, audioFingerprint = "old-audio") + repo.recordSubtitleTrackSelection("c1", fileId = 7, subtitleFingerprint = "old-subtitle") + db.openHelper.writableDatabase.execSQL( + """ + CREATE TRIGGER reject_new_subtitle + BEFORE INSERT ON user_item_state + WHEN NEW.subtitleFingerprint = 'new-subtitle' + BEGIN + SELECT RAISE(ABORT, 'forced track selection failure'); + END + """.trimIndent(), + ) + + assertFails { + repo.recordTrackSelection( + contentId = "c1", + fileId = 7, + audioFingerprint = "new-audio", + subtitleFingerprint = "new-subtitle", + ) + } + + val row = db.userItemStateDao().get("s1", "p1", "c1", 7) + assertEquals("old-audio", row?.audioFingerprint) + assertEquals("old-subtitle", row?.subtitleFingerprint) + } + + @Test + fun recordTrackSelectionPropagatesCancellationWithoutWriting() = runTest { + val cancellation = CancellationException("cancel track persistence") + val cancelledRepo = RoomUserItemStateRepository( + db = db, + snapshotProvider = { throw cancellation }, + now = { 1000L }, + ) + + val thrown = assertFailsWith { + cancelledRepo.recordTrackSelection( + contentId = "c1", + fileId = 7, + audioFingerprint = "new-audio", + subtitleFingerprint = "new-subtitle", + ) + } + + assertSame(cancellation, thrown) + assertNull(db.userItemStateDao().get("s1", "p1", "c1", 7)) + } + @Test fun recordEbookProgressWritesProjectionOpAndReadsBack() = runTest { repo.recordEbookProgress("c1", fileId = 7, location = "epubcfi(/6/4!/4)", progress = 0.42) @@ -333,4 +545,16 @@ class RoomUserItemStateRepositoryTest { assertEquals("p1", handle.scope?.profileId) assertEquals("https://s1.example", handle.scope?.serverUrl) } + + private fun authScope( + serverId: String, + profileId: String?, + generation: Long, + ) = AuthScopeSnapshot( + serverId = serverId, + profileId = profileId, + serverUrl = "https://$serverId.example", + profileToken = "pt", + identityGeneration = generation, + ) } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/OutboxSyncStarterScopeTriggerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/OutboxSyncStarterScopeTriggerTest.kt new file mode 100644 index 000000000..d81ba7722 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/OutboxSyncStarterScopeTriggerTest.kt @@ -0,0 +1,137 @@ +package org.prairieserver.prairie.common.data.sync + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.prairieserver.prairie.network.AndroidServerRegistry +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The outbox is scoped by `(serverId, profileId)`, and the retry chain that + * keeps a backed-off scope alive is `SyncWorker`'s `Result.retry()`, driven by + * what `SyncEngine` counts as remaining for the scope active at the *end* of a + * drain. Nothing re-triggers a drain for a scope that stops being active, so a + * scope change is the only thing standing between queued work and silence. + * + * Server changes had a trigger. Profile changes did not — and a profile switch + * leaves `activeServerId` untouched, so watched marks, ratings, favourites and + * resume positions from the outgoing profile simply stopped syncing. On a TV, + * which is never relaunched, permanently. + * + * These run on real dispatchers rather than `runTest`'s scheduler: the starter's + * collector lives on its own scope and `advanceUntilIdle` does not drive it, so + * a virtual-time test observes nothing at all and passes for the wrong reason. + */ +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class OutboxSyncStarterScopeTriggerTest { + + private fun registry(name: String): Pair { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences(name, Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val registry = AndroidServerRegistry(prefs) + val serverId = runBlocking { + val id = registry.addOrUpdate("https://prairie.example", fetchedName = "Living Room") + registry.switchTo(id) + registry.setProfileId(id, "p1") + id + } + return registry to serverId + } + + /** Waits for [drains] to reach [target], or gives up so the assertion reports. */ + private suspend fun awaitDrains(drains: AtomicInteger, target: Int) { + withTimeoutOrNull(5_000) { + while (drains.get() < target) delay(5) + } + } + + @Test + fun `a profile switch on the same server triggers a drain`() = runBlocking { + val (registry, serverId) = registry("outbox-starter-profile") + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val drains = AtomicInteger() + OutboxSyncStarter( + context = ApplicationProvider.getApplicationContext(), + registry = registry, + scope = scope, + enqueueDrain = { drains.incrementAndGet() }, + ).start() + awaitDrains(drains, 1) + val afterLaunch = drains.get() + + registry.setProfileId(serverId, "p2") + awaitDrains(drains, afterLaunch + 1) + + assertEquals( + afterLaunch + 1, + drains.get(), + "the household switched profile and the outgoing profile's queued work went quiet", + ) + scope.cancel() + } + + @Test + fun `a change that is not a scope change does not trigger`() = runBlocking { + val (registry, serverId) = registry("outbox-starter-idempotent") + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val drains = AtomicInteger() + OutboxSyncStarter( + context = ApplicationProvider.getApplicationContext(), + registry = registry, + scope = scope, + enqueueDrain = { drains.incrementAndGet() }, + ).start() + awaitDrains(drains, 1) + val afterLaunch = drains.get() + + registry.setProfileId(serverId, "p1") + registry.rename(serverId, "Den") + // Nothing to await — give any spurious emission room to land. + delay(200) + + assertEquals( + afterLaunch, + drains.get(), + "only the scope pair may trigger — a rename is not a scope change", + ) + scope.cancel() + } + + @Test + fun `profile switch before collector starts is not swallowed as initial state`() = runTest { + val (registry, serverId) = registry("outbox-starter-immediate-profile") + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = CoroutineScope(SupervisorJob() + dispatcher) + val drains = AtomicInteger() + OutboxSyncStarter( + context = ApplicationProvider.getApplicationContext(), + registry = registry, + scope = scope, + enqueueDrain = { drains.incrementAndGet() }, + ).start() + + // The collector is queued on [dispatcher] but has not run. Reproduce the + // real startup race by switching before its first StateFlow read. + registry.setProfileId(serverId, "p2") + advanceUntilIdle() + + assertEquals(2, drains.get(), "launch plus the captured p1-to-p2 scope change") + scope.cancel() + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/SyncEngineTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/SyncEngineTest.kt index 784ee155c..a736cc9fd 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/SyncEngineTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/sync/SyncEngineTest.kt @@ -64,6 +64,7 @@ class SyncEngineTest { private fun op( serverId: String = "s1", + targetContentId: String = "c1", coalesceKey: String = "s1|p1|c1|${OutboxOperation.SET_WATCHED}", idempotencyKey: String, payload: String = "true", @@ -73,7 +74,7 @@ class SyncEngineTest { opKind = opKind, serverId = serverId, profileId = "p1", - targetContentId = "c1", + targetContentId = targetContentId, targetFileId = null, coalesceKey = coalesceKey, idempotencyKey = idempotencyKey, @@ -119,6 +120,75 @@ class SyncEngineTest { assertTrue(row.nextAttemptAtMs > clock, "backoff should push nextAttemptAtMs into the future") } + @Test + fun newerOpForTheSameItemWaitsWhileAnOlderOpIsBackingOff() = runTest { + // The state after an older op fails a SECOND time: recordFailure pushes it + // into the future again, so the enqueue-time clamp + // (enqueueCoalescingRestoringItemOrder) has already been undone and cannot + // help. dueBatch only returns due rows, so without per-item FIFO the newer + // SET_POSITION drains now and the retried SET_WATCHED lands after it and + // clears the resume position the user just created. + val dao = db.dirtyOperationDao() + dao.insert( + op( + idempotencyKey = "watched-retrying", + opKind = OutboxOperation.SET_WATCHED, + coalesceKey = "s1|p1|c1|${OutboxOperation.SET_WATCHED}", + nextAttemptAtMs = clock + 30_000L, + ), + ) + dao.insert( + op( + idempotencyKey = "position-newer", + opKind = OutboxOperation.SET_POSITION, + coalesceKey = "s1|p1|c1|${OutboxOperation.SET_POSITION}", + payload = """{"positionTicks":1,"playedPercentage":1.0}""", + nextAttemptAtMs = 0L, + ), + ) + status = HttpStatusCode.OK + + val result = engine().drainOnce() + + assertEquals(0, result.synced, "newer op must not overtake an older op for the same item") + assertEquals(2, dao.count(), "both ops stay queued until the older one clears") + } + + @Test + fun olderBackoffOutsideSqlPageStillBlocksNewerSibling() = runTest { + val dao = db.dirtyOperationDao() + dao.insert( + op( + targetContentId = "blocked", + idempotencyKey = "blocked-older", + coalesceKey = "s1|p1|blocked|${OutboxOperation.SET_WATCHED}", + nextAttemptAtMs = clock + 30_000L, + ), + ) + dao.insert( + op( + targetContentId = "blocked", + idempotencyKey = "blocked-newer", + opKind = OutboxOperation.SET_POSITION, + coalesceKey = "s1|p1|blocked|${OutboxOperation.SET_POSITION}", + payload = """{"positionTicks":1,"playedPercentage":1.0}""", + ), + ) + dao.insert( + op( + targetContentId = "independent", + idempotencyKey = "independent", + coalesceKey = "s1|p1|independent|${OutboxOperation.SET_WATCHED}", + ), + ) + status = HttpStatusCode.OK + + val result = engine(batchLimit = 2).drainOnce() + + assertEquals(1, result.synced, "only the independent target may pass") + assertEquals(2, dao.count(), "both blocked siblings remain queued in creation order") + } + @Test fun terminalDropsOp() = runTest { db.dirtyOperationDao().insert(op(idempotencyKey = "i1")) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorageTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorageTest.kt index 32da770b2..57d8c774f 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorageTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadStorageTest.kt @@ -4,9 +4,13 @@ import org.prairieserver.prairie.model.download.DownloadMediaType import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import java.io.File +import java.net.URI +import java.nio.file.Files import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertTrue class DownloadStorageTest { @@ -124,7 +128,9 @@ class DownloadStorageTest { @Test fun `locateLocalFile finds original-format download only`() { val storage = newStorage() - storage.prepareWrite("srv1", "profA", 42, fileName = "The Book.epub").writeTargetBytes(ByteArray(10)) + val target = storage.prepareWrite("srv1", "profA", 42, fileName = "The Book.epub") + target.writeTargetBytes(ByteArray(10)) + storage.completeWrite(target.uriString) assertEquals("The Book.epub", storage.locateLocalFile("srv1", "profA", 42)?.name) } @@ -147,6 +153,27 @@ class DownloadStorageTest { assertEquals("The Book.epub", completedFile?.name) } + @Test + fun `staged download completion survives file backed store recreation`() { + val publicRoot = tmp.newFolder("public") + val firstStorage = DownloadStorage( + baseDir = tmp.newFolder("first-files"), + publicStore = FileBackedPublicDownloadStore(publicRoot), + ) + val target = firstStorage.prepareWrite("srv1", "profA", 42, fileName = "The Book.epub") + target.writeTargetBytes(ByteArray(10)) + + val recreatedStorage = DownloadStorage( + baseDir = tmp.newFolder("second-files"), + publicStore = FileBackedPublicDownloadStore(publicRoot), + ) + val completedUri = recreatedStorage.completeWrite(target.uriString) + + assertEquals(null, URI(completedUri).fragment) + assertEquals("The Book.epub", File(URI(completedUri)).name) + assertEquals(10L, File(URI(completedUri)).length()) + } + @Test fun `prepareWrite refuses to invent a non-original download extension`() { val storage = newStorage() @@ -240,6 +267,176 @@ class DownloadStorageTest { assertTrue(storage.exists("srv2", "profA", 3)) } + @Test + fun `identity segments are encoded and remain under the collection root`() { + val downloadsRoot = tmp.newFolder("public-downloads") + val storage = DownloadStorage( + baseDir = tmp.newFolder("filesDir"), + publicStore = FileBackedPublicDownloadStore(downloadsRoot), + ) + + val target = storage.prepareWrite("../server", "profile/name", 9, fileName = "Book.epub") + target.writeTargetBytes(ByteArray(10)) + val file = storage.locateLocalFile("../server", "profile/name", 9) + + assertTrue(file != null) + assertTrue(file.canonicalPath.startsWith(downloadsRoot.canonicalPath + File.separator)) + assertFalse(file.path.contains("../server")) + assertFalse(file.path.contains("profile/name")) + } + + @Test + fun `recursive scoped deletion cannot touch a sentinel outside the collection root`() { + val parent = tmp.newFolder("parent") + val downloadsRoot = File(parent, "public").apply { mkdirs() } + val sentinel = File(parent, "sentinel.txt").apply { writeText("keep") } + val storage = DownloadStorage( + baseDir = tmp.newFolder("filesDir"), + publicStore = FileBackedPublicDownloadStore(downloadsRoot), + ) + + assertFalse(storage.deleteAllForServer("..")) + assertFalse(storage.deleteAllForProfile("..", ".")) + assertTrue(sentinel.isFile) + assertEquals("keep", sentinel.readText()) + } + + @Test + fun `recursive deletion does not follow a symlink outside the collection root`() { + val parent = tmp.newFolder("parent") + val downloadsRoot = File(parent, "public") + val serverDir = File(downloadsRoot, "Downloads/srv1/profile/1").apply { mkdirs() } + val outside = File(parent, "outside").apply { mkdirs() } + val sentinel = File(outside, "sentinel.txt").apply { writeText("keep") } + Files.createSymbolicLink(File(serverDir, "linked-outside").toPath(), outside.toPath()) + val storage = DownloadStorage( + baseDir = tmp.newFolder("filesDir"), + publicStore = FileBackedPublicDownloadStore(downloadsRoot), + ) + + assertTrue(storage.deleteAllForServer("srv1")) + assertTrue(sentinel.isFile) + assertEquals("keep", sentinel.readText()) + } + + @Test + fun `file uri deletion cannot remove a file outside managed roots`() { + val parent = tmp.newFolder("parent") + val downloadsRoot = File(parent, "public").apply { mkdirs() } + val sentinel = File(parent, "sentinel.txt").apply { writeText("keep") } + val storage = DownloadStorage( + baseDir = tmp.newFolder("filesDir"), + publicStore = FileBackedPublicDownloadStore(downloadsRoot), + ) + + assertFalse(storage.deleteUri(sentinel.toURI().toASCIIString())) + assertTrue(sentinel.isFile) + } + + @Test + fun `contained legacy download remains readable and migrates after completed update`() { + val downloadsRoot = tmp.newFolder("public-downloads") + val legacyDir = File(downloadsRoot, "Downloads/server id/profile%id/42").apply { mkdirs() } + val legacyFile = File(legacyDir, "Legacy.epub").apply { writeBytes(ByteArray(7)) } + val storage = DownloadStorage( + baseDir = tmp.newFolder("filesDir"), + publicStore = FileBackedPublicDownloadStore(downloadsRoot), + ) + + assertEquals(legacyFile.canonicalFile, storage.locateLocalFile("server id", "profile%id", 42)?.canonicalFile) + + val target = storage.prepareWrite("server id", "profile%id", 42, fileName = "Updated.epub") + target.writeTargetBytes(ByteArray(11)) + assertTrue(legacyFile.isFile) + + storage.completeWrite(target.uriString) + + assertFalse(legacyDir.exists()) + assertEquals("Updated.epub", storage.locateLocalFile("server id", "profile%id", 42)?.name) + assertEquals(11L, storage.locateLocalFile("server id", "profile%id", 42)?.length()) + } + + @Test + fun `replacement keeps old collection bytes until the new write completes`() { + val storage = newStorage() + val old = storage.prepareWrite( + "srv1", + "profA", + 42, + fileName = "Old.mkv", + mediaType = DownloadMediaType.Movie.wire, + ) + old.writeTargetBytes(ByteArray(7)) + storage.completeWrite(old.uriString) + val oldFile = checkNotNull(storage.locateLocalFile("srv1", "profA", 42)) + + val replacement = storage.prepareWrite( + "srv1", + "profA", + 42, + fileName = "New.epub", + mediaType = DownloadMediaType.Ebook.wire, + ) + replacement.writeTargetBytes(ByteArray(11)) + assertTrue(oldFile.isFile) + + storage.completeWrite(replacement.uriString) + + assertFalse(oldFile.exists()) + assertEquals("New.epub", storage.locateLocalFile("srv1", "profA", 42)?.name) + } + + @Test + fun `delete removes both encoded and contained legacy candidates`() { + val downloadsRoot = tmp.newFolder("public-downloads") + val legacyDir = File(downloadsRoot, "Downloads/server id/profile%id/42").apply { mkdirs() } + File(legacyDir, "Legacy.epub").writeBytes(ByteArray(7)) + val storage = DownloadStorage( + baseDir = tmp.newFolder("filesDir"), + publicStore = FileBackedPublicDownloadStore(downloadsRoot), + ) + storage.prepareWrite("server id", "profile%id", 42, fileName = "Updated.epub") + .writeTargetBytes(ByteArray(11)) + + assertTrue(storage.delete("server id", "profile%id", 42)) + assertFalse(legacyDir.exists()) + assertFalse(storage.exists("server id", "profile%id", 42)) + } + + @Test + fun `raw reserved namespace identity cannot read size or delete encoded sibling`() { + val storage = newStorage() + storage.prepareWrite("?", "prof", 42, fileName = "Question.epub") + .writeTargetBytes(ByteArray(13)) + + assertFalse(storage.exists("~Pw", "prof", 42)) + assertEquals(0L, storage.totalBytesUsed("~Pw", "prof")) + assertFalse(storage.delete("~Pw", "prof", 42)) + assertTrue(storage.exists("?", "prof", 42)) + assertEquals(13L, storage.totalBytesUsed("?", "prof")) + } + + @Test + fun `prepared output stream cannot be swapped to an outside symlink before open`() { + val parent = tmp.newFolder("parent") + val publicRoot = File(parent, "public") + val storage = DownloadStorage( + baseDir = tmp.newFolder("filesDir"), + publicStore = FileBackedPublicDownloadStore(publicRoot), + ) + val target = storage.prepareWrite("server", "profile", 42, fileName = "Book.epub") + val intendedFinal = File(publicRoot, "Downloads/server/profile/42/Book.epub") + val sentinel = File(parent, "sentinel.txt").apply { writeText("keep") } + Files.createSymbolicLink(intendedFinal.toPath(), sentinel.toPath()) + + target.writeTargetBytes(ByteArray(9)) + storage.completeWrite(target.uriString) + + assertEquals("keep", sentinel.readText()) + assertTrue(intendedFinal.isFile) + assertEquals(9L, intendedFinal.length()) + } + @Test fun `totalBytesUsed sums every downloaded file under the root`() { val storage = newStorage() @@ -267,12 +464,56 @@ class DownloadStorageTest { @Test fun `media store relative path includes the file id directory`() { + // Singular "Download" — MediaStore's collection root, per + // Environment.DIRECTORY_DOWNLOADS. This assertion previously encoded the + // plural spelling, which MediaProvider rejects outright, so ebook + // downloads failed permanently on API 29+ while the test stayed green. assertEquals( - "Downloads/Prairie/srv1/profA/42/", + "Download/Prairie/srv1/profA/42/", mediaStoreRelativePath(PublicDownloadCollection.Downloads, "srv1", "profA", 42), ) } + @Test + fun `media store relative path encodes unsafe identity segments`() { + val path = mediaStoreRelativePath( + PublicDownloadCollection.Downloads, + "../server", + "profile/name", + 42, + ) + + assertFalse(path.contains("../server")) + assertFalse(path.contains("profile/name")) + assertTrue(path.startsWith("Download/Prairie/")) + assertTrue(path.endsWith("/42/")) + } + + @Test + fun `media store prefix pattern escapes percent underscore and escape character`() { + assertEquals("a\\%b\\_c\\\\d", escapeMediaStoreLike("a%b_c\\d")) + + val pattern = mediaStoreRelativePathPrefix( + PublicDownloadCollection.Downloads, + serverId = "_", + profileId = "profile_name", + ) + assertEquals("Download/Prairie/\\_/profile\\_name/%", pattern) + } + + @Test + fun `unsafe media store prefix contains encoded not raw identity`() { + val pattern = mediaStoreRelativePathPrefix( + PublicDownloadCollection.Downloads, + serverId = "server%", + profileId = "profile\\name", + ) + + assertFalse(pattern.contains("server%")) + assertFalse(pattern.contains("profile\\name")) + assertNotEquals("Download/Prairie/server\\%/profile\\\\name/%", pattern) + } + private fun DownloadTarget.writeTargetBytes(bytes: ByteArray) { openOutputStream().use { it.write(bytes) } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerLifetimeTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerLifetimeTest.kt new file mode 100644 index 000000000..3ff586f90 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerLifetimeTest.kt @@ -0,0 +1,27 @@ +package org.prairieserver.prairie.common.downloads + +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DownloadWorkerLifetimeTest { + + @Test + fun `cancellation waits for worker cleanup and blocks a late worker start`() = runTest { + val id = "lifetime-${System.nanoTime()}" + val lease = requireNotNull(DownloadWorkerLifetime.acquire(id)) + DownloadWorkerLifetime.beginCancellation(id) + + assertNull(DownloadWorkerLifetime.acquire(id)) + val idle = backgroundScope.async { DownloadWorkerLifetime.awaitIdle(id) } + assertFalse(idle.isCompleted) + + lease.close() + idle.await() + assertTrue(idle.isCompleted) + assertNull(DownloadWorkerLifetime.acquire(id)) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/MediaStorePublicDownloadStoreCollisionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/MediaStorePublicDownloadStoreCollisionTest.kt new file mode 100644 index 000000000..80681ea99 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/MediaStorePublicDownloadStoreCollisionTest.kt @@ -0,0 +1,121 @@ +package org.prairieserver.prairie.common.downloads + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MediaStorePublicDownloadStoreCollisionTest { + + private lateinit var provider: RelativePathProvider + private lateinit var store: MediaStorePublicDownloadStore + + @Before + fun setUp() { + provider = Robolectric.setupContentProvider(RelativePathProvider::class.java, "media") + provider.put(ENCODED_QUESTION_PATH, displayName = "Question.epub", size = 13L) + store = MediaStorePublicDownloadStore(ApplicationProvider.getApplicationContext()) + } + + @Test + fun `raw reserved namespace cannot read encoded sibling`() { + assertNull(store.locateByFileId("~Pw", "prof", 42)) + assertNotNull(store.locateByFileId("?", "prof", 42)) + } + + @Test + fun `raw reserved namespace cannot count encoded sibling`() { + assertEquals(0L, store.totalBytesUsed("~Pw", "prof")) + assertEquals(13L, store.totalBytesUsed("?", "prof")) + } + + @Test + fun `raw reserved namespace cannot delete encoded sibling`() { + assertFalse(store.delete("~Pw", "prof", 42, uriString = null)) + assertNotNull(store.locateByFileId("?", "prof", 42)) + } + + class RelativePathProvider : ContentProvider() { + private val rows = linkedMapOf() + + fun put(path: String, displayName: String, size: Long) { + rows[path] = Row(displayName, size) + } + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + val columns: Array = projection?.map { it }?.toTypedArray() ?: emptyArray() + val cursor = MatrixCursor(columns) + matchingPaths(selection, selectionArgs).forEachIndexed { index, path -> + val row = checkNotNull(rows[path]) + cursor.addRow(columns.map { column -> + when (column) { + MediaStore.MediaColumns._ID -> (index + 1).toLong() + MediaStore.MediaColumns.DISPLAY_NAME -> row.displayName + MediaStore.MediaColumns.SIZE -> row.size + else -> null + } + }) + } + return cursor + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + val matches = matchingPaths(selection, selectionArgs) + matches.forEach(rows::remove) + return matches.size + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + override fun getType(uri: Uri): String? = null + + private fun matchingPaths(selection: String?, selectionArgs: Array?): List { + val argument = selectionArgs?.singleOrNull() ?: return emptyList() + return if (selection?.contains(" LIKE ") == true) { + val prefix = argument.removeSuffix("%") + .replace("\\_", "_") + .replace("\\%", "%") + .replace("\\\\", "\\") + rows.keys.filter { path -> path.startsWith(prefix) } + } else { + rows.keys.filter { path -> path == argument } + } + } + + private data class Row(val displayName: String, val size: Long) + } + + companion object { + private const val ENCODED_QUESTION_PATH = "Download/Prairie/~Pw/prof/42/" + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineMediaResolverTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineMediaResolverTest.kt index e55205e36..53af682a7 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineMediaResolverTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OfflineMediaResolverTest.kt @@ -46,7 +46,7 @@ class OfflineMediaResolverTest { @Test fun `findLocalMedia returns completed original-named download`() = runTest { val storage = newStorage() - storage.prepareWrite("srv1", "profA", 42, fileName = "Novel.epub").writeTargetBytes(ByteArray(10)) + storage.writeCompleted("srv1", "profA", 42, "Novel.epub") metadata.writeSidecar("srv1", "profA", sidecar(42, contentId = "book-1", fileName = "Novel.epub")) val media = resolver(storage).findLocalMedia("srv1", "profA", "book-1", requestedFileId = 42) @@ -74,7 +74,7 @@ class OfflineMediaResolverTest { @Test fun `findLocalMedia honors requested file id when fallback is disabled`() = runTest { val storage = newStorage() - storage.prepareWrite("srv1", "profA", 1, fileName = "A.epub").writeTargetBytes(ByteArray(10)) + storage.writeCompleted("srv1", "profA", 1, "A.epub") metadata.writeSidecar("srv1", "profA", sidecar(1, contentId = "book-1", fileName = "A.epub")) assertNull( @@ -91,9 +91,9 @@ class OfflineMediaResolverTest { @Test fun `findLocalMedia stays inside the requested server profile scope`() = runTest { val storage = newStorage() - storage.prepareWrite("srv1", "profA", 42, fileName = "Wrong.epub").writeTargetBytes(ByteArray(10)) + storage.writeCompleted("srv1", "profA", 42, "Wrong.epub") metadata.writeSidecar("srv1", "profA", sidecar(42, contentId = "book-1", fileName = "Wrong.epub")) - storage.prepareWrite("srv1", "profB", 42, fileName = "Right.epub").writeTargetBytes(ByteArray(10)) + storage.writeCompleted("srv1", "profB", 42, "Right.epub") metadata.writeSidecar("srv1", "profB", sidecar(42, contentId = "book-1", fileName = "Right.epub")) val media = resolver(storage).findLocalMedia( @@ -110,11 +110,11 @@ class OfflineMediaResolverTest { @Test fun `listLocalMedia returns every completed local candidate for content`() = runTest { val storage = newStorage() - storage.prepareWrite("srv1", "profA", 7, fileName = "Book.mobi").writeTargetBytes(ByteArray(10)) + storage.writeCompleted("srv1", "profA", 7, "Book.mobi") metadata.writeSidecar("srv1", "profA", sidecar(7, contentId = "book-1", fileName = "Book.mobi")) - storage.prepareWrite("srv1", "profA", 8, fileName = "Book.epub").writeTargetBytes(ByteArray(10)) + storage.writeCompleted("srv1", "profA", 8, "Book.epub") metadata.writeSidecar("srv1", "profA", sidecar(8, contentId = "book-1", fileName = "Book.epub")) - storage.prepareWrite("srv1", "profA", 9, fileName = "Other.epub").writeTargetBytes(ByteArray(10)) + storage.writeCompleted("srv1", "profA", 9, "Other.epub") metadata.writeSidecar("srv1", "profA", sidecar(9, contentId = "book-2", fileName = "Other.epub")) val media = resolver(storage).listLocalMedia("srv1", "profA", "book-1") @@ -151,4 +151,15 @@ class OfflineMediaResolverTest { private fun DownloadTarget.writeTargetBytes(bytes: ByteArray) { openOutputStream().use { it.write(bytes) } } + + private fun DownloadStorage.writeCompleted( + serverId: String, + profileId: String, + fileId: Int, + fileName: String, + ) { + val target = prepareWrite(serverId, profileId, fileId, fileName = fileName) + target.writeTargetBytes(ByteArray(10)) + completeWrite(target.uriString) + } } 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 new file mode 100644 index 000000000..af784e41a --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt @@ -0,0 +1,439 @@ +package org.prairieserver.prairie.common.downloads + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.withTimeout +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.prairieserver.prairie.common.data.db.PrairieDatabase +import org.prairieserver.prairie.common.data.db.entity.DownloadEntity +import org.prairieserver.prairie.common.data.sync.OutboxOperation +import org.prairieserver.prairie.common.data.db.entity.DirtyOperationEntity +import org.prairieserver.prairie.common.audiobook.AudiobookBookmarksStore +import org.prairieserver.prairie.common.ebook.EbookLocalStateStore +import org.prairieserver.prairie.common.store.ServerScopedJsonStatePurger +import org.prairieserver.prairie.model.audiobook.AudiobookBookmark +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.AndroidServerRegistry +import org.prairieserver.prairie.network.ServerRegistry +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import java.nio.file.Files + +/** + * Removing a server wiped four token keys and its registry entry and nothing + * else. Every Room row survived, so reclaiming space reclaimed none — and + * because `idFor()` is base64 of the normalised URL, re-adding the same server + * resurrected its Downloads list, resume positions, cached rows and pending + * outbox ops under an identity the user believed they had deleted. + */ +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class OrphanedServerDataPurgerTest { + + private val context = ApplicationProvider.getApplicationContext() + private val db = Room.inMemoryDatabaseBuilder(context, PrairieDatabase::class.java) + .allowMainThreadQueries() + .build() + + @AfterTest + fun tearDown() = db.close() + + private fun registry(name: String): AndroidServerRegistry { + val prefs = context.getSharedPreferences(name, Context.MODE_PRIVATE) + prefs.edit().clear().commit() + return AndroidServerRegistry(prefs) + } + + private suspend fun seed(serverId: String, mediaFileId: Int, recordId: String) { + db.downloadDao().upsert( + DownloadEntity( + serverId = serverId, + profileId = "p1", + mediaFileId = mediaFileId, + recordId = recordId, + contentId = "c$mediaFileId", + title = "Episode $mediaFileId", + subtitle = null, + posterUrl = null, + posterThumbhash = null, + year = null, + seriesTitle = null, + seasonNumber = null, + episodeNumber = null, + fileName = "file.mkv", + container = "mkv", + localUri = null, + mediaType = "movie", + overview = null, + author = null, + narrator = null, + durationSeconds = null, + chaptersJson = null, + status = "complete", + kind = "queued", + fileSize = 0L, + bytesSent = 0L, + createdAt = "2026-06-16T00:00:00Z", + completedAt = null, + updatedAtMs = 1L, + ), + ) + db.dirtyOperationDao().insert( + DirtyOperationEntity( + opKind = OutboxOperation.SET_WATCHED, + serverId = serverId, + profileId = "p1", + targetContentId = "c$mediaFileId", + targetFileId = null, + coalesceKey = "$serverId|p1|c$mediaFileId|${OutboxOperation.SET_WATCHED}", + idempotencyKey = "idem-$mediaFileId", + payloadJson = "{}", + createdAtMs = 0L, + nextAttemptAtMs = 0L, + ), + ) + } + + private fun purger( + registry: AndroidServerRegistry, + cancelled: MutableList = mutableListOf(), + activeFileId: Int? = null, + ) = OrphanedServerDataPurger( + registry = registry, + purgeDao = db.serverPurgeDao(), + storage = DownloadStorage(context), + cancelDownload = { cancelled += it }, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + io = UnconfinedTestDispatcher(), + activePlaybackFileId = { activeFileId }, + ) + + @Test + fun `rows for a server that is no longer registered are purged`() = runTest { + val registry = registry("purge-basic") + val keptId = registry.addOrUpdate("https://keep.example") + seed(keptId, mediaFileId = 1, recordId = "rec-keep") + seed("orphan-server", mediaFileId = 2, recordId = "rec-orphan") + + val cancelled = mutableListOf() + val purged = purger(registry, cancelled).purgeOnce() + + assertEquals(setOf("orphan-server"), purged) + assertEquals( + listOf("rec-orphan"), + cancelled, + "a live DownloadWorker rewrites its row on every progress tick", + ) + assertNull(db.downloadDao().get("orphan-server", "p1", 2)) + assertTrue(db.dirtyOperationDao().dueBatch("orphan-server", "p1", 1L, 10).isEmpty()) + + // The registered server is untouched. + assertEquals("rec-keep", db.downloadDao().get(keptId, "p1", 1)?.recordId) + assertEquals(1, db.dirtyOperationDao().dueBatch(keptId, "p1", 1L, 10).size) + } + + @Test + fun `an unreadable registry blob purges nothing`() = runTest { + // The registry decodes its whole state from one JSON blob and treats a + // decode failure as "no servers". Purging on that reading would turn a + // corrupt preference — recoverable by signing in again, because ids are + // derived from the URL — into permanent deletion of every download, + // resume position and queued outbox op on the device. + val prefs = context.getSharedPreferences("purge-corrupt", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + prefs.edit() + .putString(AndroidServerRegistry.KEY_REGISTRY_STATE, "{ not valid json") + .commit() + val registry = AndroidServerRegistry(prefs) + assertTrue(registry.entries.value.isEmpty(), "corrupt state presents as an empty registry") + assertTrue(registry.stateLoadFailed, "and must be flagged as untrustworthy") + seed("server-with-data", mediaFileId = 7, recordId = "rec-precious") + + val cancelled = mutableListOf() + val purged = purger(registry, cancelled).purgeOnce() + + assertEquals(emptySet(), purged) + assertEquals(emptyList(), cancelled) + assertEquals( + "rec-precious", + db.downloadDao().get("server-with-data", "p1", 7)?.recordId, + "downloads must survive a registry that could not be read", + ) + assertEquals(1, db.dirtyOperationDao().dueBatch("server-with-data", "p1", 1L, 10).size) + } + + @Test + fun `a file that is currently playing defers its whole server`() = runTest { + val registry = registry("purge-active") + seed("orphan-server", mediaFileId = 42, recordId = "rec-playing") + + // Offline PiP survives leaving the screen and outlives a sign-out. + val purged = purger(registry, activeFileId = 42).purgeOnce() + + assertEquals(emptySet(), purged) + assertEquals("rec-playing", db.downloadDao().get("orphan-server", "p1", 42)?.recordId) + + // Once playback ends the next pass collects it — nothing is stranded. + assertEquals(setOf("orphan-server"), purger(registry).purgeOnce()) + assertNull(db.downloadDao().get("orphan-server", "p1", 42)) + } + + @Test + fun `purging is idempotent and re-runnable`() = runTest { + val registry = registry("purge-idempotent") + seed("orphan-server", mediaFileId = 7, recordId = "rec-7") + + assertEquals(setOf("orphan-server"), purger(registry).purgeOnce()) + assertEquals( + emptySet(), + purger(registry).purgeOnce(), + "a second pass must find nothing — this runs on every cold start", + ) + } + + @Test + fun `room rows remain retry evidence until worker cancellation is acknowledged`() = runTest { + val registry = registry("purge-await-cancel") + seed("orphan-server", mediaFileId = 8, recordId = "rec-8") + val cancellationStarted = CompletableDeferred() + val cancellationAcknowledged = CompletableDeferred() + val purger = OrphanedServerDataPurger( + registry = registry, + purgeDao = db.serverPurgeDao(), + storage = DownloadStorage(context), + cancelDownload = { + cancellationStarted.complete(Unit) + cancellationAcknowledged.await() + }, + scope = backgroundScope, + io = UnconfinedTestDispatcher(testScheduler), + activePlaybackFileId = { null }, + ) + + val purge = backgroundScope.async { purger.purgeOnce() } + cancellationStarted.await() + assertNotNull(db.downloadDao().get("orphan-server", "p1", 8)) + + cancellationAcknowledged.complete(Unit) + assertEquals(setOf("orphan-server"), purge.await()) + assertNull(db.downloadDao().get("orphan-server", "p1", 8)) + } + + @Test + fun `failed worker cancellation retains rows and a later pass retries`() = runTest { + val registry = registry("purge-cancel-retry") + seed("orphan-server", mediaFileId = 12, recordId = "rec-12") + var cancellationFails = true + val purger = OrphanedServerDataPurger( + registry = registry, + purgeDao = db.serverPurgeDao(), + storage = DownloadStorage(context), + cancelDownload = { + if (cancellationFails) error("work manager unavailable") + }, + scope = backgroundScope, + io = UnconfinedTestDispatcher(testScheduler), + activePlaybackFileId = { null }, + ) + + assertEquals(emptySet(), purger.purgeOnce()) + assertNotNull(db.downloadDao().get("orphan-server", "p1", 12)) + + cancellationFails = false + assertEquals(setOf("orphan-server"), purger.purgeOnce()) + assertNull(db.downloadDao().get("orphan-server", "p1", 12)) + } + + @Test + fun `failed file deletion retains rows and a later pass retries`() = runTest { + val registry = registry("purge-storage-retry") + seed("orphan-server", mediaFileId = 13, recordId = "rec-13") + var deletionFails = true + val purger = OrphanedServerDataPurger( + registry = registry, + purgeDao = db.serverPurgeDao(), + storage = DownloadStorage(context), + cancelDownload = {}, + scope = backgroundScope, + io = UnconfinedTestDispatcher(testScheduler), + activePlaybackFileId = { null }, + deleteDownloadStorage = { + if (deletionFails) error("filesystem unavailable") + }, + ) + + assertEquals(emptySet(), purger.purgeOnce()) + assertNotNull(db.downloadDao().get("orphan-server", "p1", 13)) + + deletionFails = false + assertEquals(setOf("orphan-server"), purger.purgeOnce()) + assertNull(db.downloadDao().get("orphan-server", "p1", 13)) + } + + @Test + fun `json-only orphan state is discovered while registered state is retained`() = runTest { + val baseDir = Files.createTempDirectory("prairie-json-orphans").toFile() + val registry = registry("purge-json-only") + val registeredId = registry.addOrUpdate("https://registered-json.example") + val orphanId = "https://orphan.example/" + "long-segment-".repeat(20) + val ebook = EbookLocalStateStore(baseDir) + val audiobook = AudiobookBookmarksStore(baseDir) + val snapshot = EbookLocalStateStore.ProgressSnapshot( + fileId = 91, + location = "chapter-1", + progress = 0.25, + updatedAtMs = 1L, + ) + ebook.writeProgress(orphanId, "p1", "book", snapshot) + ebook.writeProgress(registeredId, "p1", "book", snapshot) + audiobook.add( + orphanId, + "p1", + "audio", + AudiobookBookmark( + id = "bookmark-1", + positionSeconds = 12.0, + createdAtMs = 1L, + ), + ) + + val purger = OrphanedServerDataPurger( + registry = registry, + purgeDao = db.serverPurgeDao(), + storage = DownloadStorage(context), + cancelDownload = {}, + scope = backgroundScope, + io = UnconfinedTestDispatcher(testScheduler), + activePlaybackFileId = { null }, + jsonStatePurger = ServerScopedJsonStatePurger(baseDir), + ) + + assertEquals(emptySet(), purger.purgeOnce(), "JSON-only state has no Room server id") + assertNull(ebook.readProgress(orphanId, "p1", "book")) + assertTrue(audiobook.list(orphanId, "p1", "audio").isEmpty()) + assertEquals(snapshot, ebook.readProgress(registeredId, "p1", "book")) + } + + @Test + fun `cancelling an in-flight purge cannot strand partially deleted server data`() = runTest { + val registry = registry("purge-cancel") + seed("orphan-server", mediaFileId = 9, recordId = "rec-9") + val rowDeletionStarted = CompletableDeferred() + val allowRowDeletion = CompletableDeferred() + val purger = OrphanedServerDataPurger( + registry = registry, + purgeDao = db.serverPurgeDao(), + storage = DownloadStorage(context), + cancelDownload = {}, + scope = backgroundScope, + io = UnconfinedTestDispatcher(testScheduler), + activePlaybackFileId = { null }, + purgeRows = { serverId -> + rowDeletionStarted.complete(Unit) + allowRowDeletion.await() + db.serverPurgeDao().deleteAllRowsForServer(serverId) + }, + ) + + val purge = backgroundScope.async { purger.purgeOnce() } + rowDeletionStarted.await() + purge.cancel() + allowRowDeletion.complete(Unit) + purge.join() + + assertTrue(purge.isCancelled) + assertNull( + db.downloadDao().get("orphan-server", "p1", 9), + "once file deletion has begun, cancellation must not leave the Room half behind", + ) + } + + @Test + fun `removal during startup scan triggers a second purge`() = runTest { + val serverId = "removed-during-start" + val registry = MutableTestServerRegistry(serverId) + seed(serverId, mediaFileId = 10, recordId = "rec-10") + seed("preexisting-orphan", mediaFileId = 11, recordId = "rec-11") + val initialPurgeStarted = CompletableDeferred() + val finishInitialPurge = CompletableDeferred() + val initialRowsPurged = CompletableDeferred() + val rowsPurged = CompletableDeferred() + val purger = OrphanedServerDataPurger( + registry = registry, + purgeDao = db.serverPurgeDao(), + storage = DownloadStorage(context), + cancelDownload = {}, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + io = Dispatchers.Default, + activePlaybackFileId = { null }, + purgeRows = { orphanId -> + if (orphanId == "preexisting-orphan") { + initialPurgeStarted.complete(Unit) + finishInitialPurge.await() + } + db.serverPurgeDao().deleteAllRowsForServer(orphanId) + if (orphanId == "preexisting-orphan") { + initialRowsPurged.complete(Unit) + } + if (orphanId == serverId) { + rowsPurged.complete(Unit) + } + }, + ) + + 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() + } +} + +private class MutableTestServerRegistry(serverId: String) : ServerRegistry { + private val mutableEntries = MutableStateFlow( + listOf(ServerEntry(id = serverId, url = "https://removed-during-start.example")), + ) + override val entries: StateFlow> = mutableEntries + override val activeServerId: StateFlow = MutableStateFlow(serverId) + override val activeEntry: StateFlow = MutableStateFlow(mutableEntries.value.single()) + + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = error("not used") + 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) { + mutableEntries.value = mutableEntries.value.filterNot { it.id == serverId } + } + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/PublicDownloadRelativeRootTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/PublicDownloadRelativeRootTest.kt new file mode 100644 index 000000000..9afbb1455 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/PublicDownloadRelativeRootTest.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.common.downloads + +import android.os.Environment +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * `PublicDownloadCollection.relativeRoot` is written as literals so the enum can + * be constructed in plain JVM unit tests, where `android.os` statics are + * unavailable and would fail class initialisation. + * + * MediaProvider validates `RELATIVE_PATH` against its own allow-list, so a value + * that drifts from the platform constant is rejected with + * `IllegalArgumentException` — which `DownloadWorker` treats as permanent + * failure. That is exactly how the plural "Downloads" made every ebook download + * fail on API 29+. Pin the literals to the real constants here, under + * Robolectric, so the two can never diverge again. + */ +@RunWith(RobolectricTestRunner::class) +class PublicDownloadRelativeRootTest { + + @Test + fun `relative roots match the platform directory constants`() { + assertEquals(Environment.DIRECTORY_MOVIES, PublicDownloadCollection.Video.relativeRoot) + assertEquals(Environment.DIRECTORY_MUSIC, PublicDownloadCollection.Audio.relativeRoot) + assertEquals( + Environment.DIRECTORY_DOWNLOADS, + PublicDownloadCollection.Downloads.relativeRoot, + "MediaStore rejects any RELATIVE_PATH root outside its allow-list", + ) + } + + @Test + fun `the downloads root is singular, unlike its on-disk directory name`() { + // The two genuinely differ: "Download" is MediaStore's collection root, + // "Downloads" is the file-backed layout used on legacy storage. + assertEquals("Download", PublicDownloadCollection.Downloads.relativeRoot) + assertEquals("Downloads", PublicDownloadCollection.Downloads.directoryName) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/io/LimitedStreamsTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/io/LimitedStreamsTest.kt new file mode 100644 index 000000000..e80cd4165 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/io/LimitedStreamsTest.kt @@ -0,0 +1,52 @@ +package org.prairieserver.prairie.common.io + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class LimitedStreamsTest { + @Test + fun `copy accepts exactly the byte limit`() { + val output = ByteArrayOutputStream() + + val copied = ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)) + .copyToLimited(output, maxBytes = 4) + + assertEquals(4, copied) + assertContentEquals(byteArrayOf(1, 2, 3, 4), output.toByteArray()) + } + + @Test + fun `copy rejects limit plus one without writing the overflowing chunk`() { + val output = ByteArrayOutputStream() + + val error = assertFailsWith { + ByteArrayInputStream(byteArrayOf(1, 2, 3, 4, 5)) + .copyToLimited(output, maxBytes = 4, bufferSize = 2) + } + + assertEquals(4, error.maxBytes) + assertContentEquals(byteArrayOf(1, 2, 3, 4), output.toByteArray()) + } + + @Test + fun `checked byte count rejects overflow instead of wrapping`() { + assertFailsWith { + checkedLimitedByteCount( + currentBytes = Long.MAX_VALUE - 1, + additionalBytes = 2, + maxBytes = Long.MAX_VALUE, + ) + } + } + + @Test + fun `negative limits are rejected`() { + assertFailsWith { + ByteArrayInputStream(byteArrayOf()).copyToLimited(ByteArrayOutputStream(), -1) + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/CleartextConsentStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/CleartextConsentStoreTest.kt new file mode 100644 index 000000000..4c74fde5f --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/CleartextConsentStoreTest.kt @@ -0,0 +1,59 @@ +package org.prairieserver.prairie.common.network + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import java.nio.file.Files +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CleartextConsentStoreTest { + + @Test + fun approvalPersistsOnlyNormalizedOriginDigest() = runTest { + val file = Files.createTempDirectory("cleartext-consent").resolve("preferences.preferences_pb").toFile() + val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val store = DataStoreCleartextConsentStore(dataStore) + val supplied = "HTTP://User:secret@PRAIRIE.LAN:80/private?token=credential#fragment" + + store.approve(supplied) + + assertTrue(store.isApproved("http://prairie.lan/another-path")) + val persisted = dataStore.data.first().asMap() + assertEquals(1, persisted.size) + val digests = persisted.values.single() as Set<*> + assertEquals(1, digests.size) + assertTrue((digests.single() as String).matches(Regex("[0-9a-f]{64}"))) + val serialized = persisted.toString() + assertFalse(serialized.contains("prairie.lan", ignoreCase = true)) + assertFalse(serialized.contains("secret", ignoreCase = true)) + assertFalse(serialized.contains("token", ignoreCase = true)) + assertFalse(serialized.contains("credential", ignoreCase = true)) + } + + @Test + fun approvalsAreSpecificToNormalizedSchemeHostAndPort() = runTest { + val file = Files.createTempDirectory("cleartext-origin").resolve("preferences.preferences_pb").toFile() + val store = DataStoreCleartextConsentStore( + PreferenceDataStoreFactory.create(scope = backgroundScope) { file }, + ) + + store.approve("http://PRAIRIE.LAN:8090/path") + + assertTrue(store.isApproved("http://prairie.lan:8090/other")) + assertFalse(store.isApproved("http://prairie.lan:8091")) + assertFalse(store.isApproved("http://other.lan:8090")) + assertFalse(store.isApproved("https://prairie.lan:8090")) + } + + @Test + fun normalizedOriginDropsCredentialsPathQueryFragmentAndDefaultPort() { + assertEquals( + "http://prairie.lan", + cleartextOrigin("HTTP://user:password@PRAIRIE.LAN:80/path?q=token#fragment"), + ) + assertEquals("http://prairie.lan:8090", cleartextOrigin("http://PRAIRIE.LAN:8090/path")) + } +} 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 new file mode 100644 index 000000000..87a0edad9 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/WatchTogetherRealtimeWebSocketTest.kt @@ -0,0 +1,366 @@ +package org.prairieserver.prairie.common.network + +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.CleartextOriginConsent +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.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.canonicalHttpOrigin +import org.prairieserver.prairie.network.createPrairieClient +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchTogetherRealtimeWebSocketTest { + + @Test + fun `handshake fails before engine when captured scope token disappears after validation`() = runBlocking { + val server = MockWebServer() + server.enqueue(MockResponse().setResponseCode(401)) + server.start() + val tokens = VanishingScopeTokenManager(server.url("/").toString()) + val httpClient = createPrairieClient( + tokenManager = tokens, + cleartextOriginConsent = approvedConsent(server), + ) + try { + val events = mutableListOf() + DefaultWatchTogetherRealtimeClient(httpClient, tokens) + .connect("room-a", "ROOM_A") + .collect(events::add) + + assertIs(events.single()) + assertEquals(0, server.requestCount) + assertEquals(2, tokens.scopedAccessReads) + } finally { + httpClient.close() + server.shutdown() + } + } + + @Test + fun `handshake remains entirely on captured auth scope when active identity switches`() = runBlocking { + val serverA = MockWebServer() + val serverB = MockWebServer() + val socketListener = object : WebSocketListener() { + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + } + serverA.enqueue(MockResponse().withWebSocketUpgrade(socketListener)) + serverB.enqueue(MockResponse().withWebSocketUpgrade(socketListener)) + serverA.start() + serverB.start() + + val tokens = SwitchingScopeTokenManager( + serverAUrl = serverA.url("/").toString(), + serverBUrl = serverB.url("/").toString(), + ) + val approvedOrigins = setOf( + canonicalHttpOrigin(serverA.url("/").toString()), + canonicalHttpOrigin(serverB.url("/").toString()), + ) + val httpClient = createPrairieClient( + tokenManager = tokens, + cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = origin in approvedOrigins + }, + ) + var collection: Job? = null + try { + val realtime = DefaultWatchTogetherRealtimeClient(httpClient, tokens) + val events = Channel(Channel.UNLIMITED) + collection = launch { + realtime.connect("room-a", "ROOM_A").collect(events::send) + } + + assertIs(withTimeout(5_000) { events.receive() }) + val requestA = assertNotNull(serverA.takeRequest(1, TimeUnit.SECONDS)) + assertEquals(0, serverB.requestCount) + assertEquals("ROOM_A", requestA.requestUrl?.queryParameter("room_token")) + assertEquals("profile-a", requestA.requestUrl?.queryParameter("profile_id")) + assertEquals("PROFILE_A", requestA.requestUrl?.queryParameter("profile_token")) + assertEquals("Bearer ACCESS_A", requestA.getHeader("Authorization")) + assertEquals("profile-a", requestA.getHeader("X-Profile-Id")) + assertEquals("PROFILE_A", requestA.getHeader("X-Profile-Token")) + } finally { + collection?.cancelAndJoin() + httpClient.close() + serverA.shutdown() + serverB.shutdown() + } + } + + @Test + fun `real handshake protects credentials orders open before snapshot and delivers outbound frames`() = runBlocking { + val server = MockWebServer() + val outbound = Channel(Channel.UNLIMITED) + val serverSocket = CompletableDeferred() + server.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + serverSocket.complete(webSocket) + webSocket.send( + """{"type":"snapshot","room":{"room_id":"room/segment ?#","code":"ABCD1234"}}""", + ) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + outbound.trySend(text) + } + }, + ), + ) + server.start() + val tokens = configuredTokens(server) + val httpClient = createPrairieClient( + tokenManager = tokens, + cleartextOriginConsent = approvedConsent(server), + ) + try { + val realtime = DefaultWatchTogetherRealtimeClient(httpClient, tokens) + val events = Channel(Channel.UNLIMITED) + val collection = launch { + realtime.connect("room/segment ?#", "ROOM_SECRET").collect(events::send) + } + + assertIs(withTimeout(5_000) { events.receive() }) + val snapshot = assertIs( + withTimeout(5_000) { events.receive() }, + ) + assertEquals("room/segment ?#", snapshot.room.roomId) + + assertTrue(realtime.attachSession("playback-1")) + assertTrue(realtime.ping("2026-07-27T10:00:00Z")) + assertEquals( + listOf("attach_session", "ping"), + listOf( + withTimeout(5_000) { outbound.receive() }, + withTimeout(5_000) { outbound.receive() }, + ).map { PrairieJson.parseToJsonElement(it).jsonObject.getValue("type").jsonPrimitive.content }, + ) + + val request = assertNotNull(server.takeRequest(5, TimeUnit.SECONDS)) + assertEquals( + "/api/v1/watch-together/rooms/room%2Fsegment%20%3F%23/ws", + request.requestUrl?.encodedPath, + ) + assertNull(request.requestUrl?.queryParameter("token")) + assertEquals("ROOM_SECRET", request.requestUrl?.queryParameter("room_token")) + assertEquals("profile-1", request.requestUrl?.queryParameter("profile_id")) + assertEquals("PROFILE_SECRET", request.requestUrl?.queryParameter("profile_token")) + assertEquals("Bearer ACCESS_SECRET", request.getHeader("Authorization")) + assertEquals("profile-1", request.getHeader("X-Profile-Id")) + assertEquals("PROFILE_SECRET", request.getHeader("X-Profile-Token")) + + assertTrue(serverSocket.await().close(1000, "physical EOF")) + val terminated = assertIs( + withTimeout(5_000) { events.receive() }, + ) + assertNull(terminated.cause) + assertTrue(collection.join().let { collection.isCompleted }) + } finally { + httpClient.close() + server.shutdown() + } + } + + @Test + fun `real socket cancellation is silent`() = runBlocking { + val server = MockWebServer() + val serverClosed = CompletableDeferred() + server.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + serverClosed.complete(Unit) + } + }, + ), + ) + server.start() + val tokens = configuredTokens(server) + val httpClient = createPrairieClient( + tokenManager = tokens, + cleartextOriginConsent = approvedConsent(server), + ) + try { + val realtime = DefaultWatchTogetherRealtimeClient(httpClient, tokens) + val events = Channel(Channel.UNLIMITED) + val collection = launch { + realtime.connect("room-1", "ROOM_SECRET").collect(events::send) + } + assertIs(withTimeout(5_000) { events.receive() }) + + collection.cancelAndJoin() + withTimeout(5_000) { serverClosed.await() } + + assertNull(events.tryReceive().getOrNull()) + assertFalse(realtime.ping("after-cancel")) + } finally { + httpClient.close() + server.shutdown() + } + } + + @Test + fun `unapproved ws origin fails before credentials reach OkHttp`() = runBlocking { + val server = MockWebServer() + server.start() + val tokens = configuredTokens(server) + val httpClient = createPrairieClient( + tokenManager = tokens, + cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + }, + ) + try { + val events = mutableListOf() + DefaultWatchTogetherRealtimeClient(httpClient, tokens) + .connect("room-1", "ROOM_SECRET") + .collect(events::add) + + val terminated = assertIs(events.single()) + assertIs(terminated.cause) + assertEquals(0, server.requestCount) + } finally { + httpClient.close() + server.shutdown() + } + } + + private suspend fun configuredTokens(server: MockWebServer): TokenManager { + val serverUrl = server.url("/").toString() + val delegate = TokenManagerImpl().apply { + setServerUrl(serverUrl) + saveTokens("ACCESS_SECRET", "refresh", 3_600) + setProfileId("profile-1") + setProfileToken("PROFILE_SECRET") + } + return SnapshotTokenManager( + delegate = delegate, + scope = AuthScopeSnapshot( + serverId = "server-1", + profileId = "profile-1", + serverUrl = serverUrl, + profileToken = "PROFILE_SECRET", + ), + accessToken = "ACCESS_SECRET", + ) + } + + private fun approvedConsent(server: MockWebServer): CleartextOriginConsent { + val approvedOrigin = canonicalHttpOrigin(server.url("/").toString()) + return object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = + origin == approvedOrigin + } + } + + /** + * Returns scope A once, then makes generic active-scope reads report B. + * + * The old client switches on [getProfileToken], immediately before the + * engine/auth plugin runs. The corrected client switches after its first + * exact-scope access-token validation. Either path deterministically puts + * the active identity on B before the handshake is built. + */ + private class SwitchingScopeTokenManager( + serverAUrl: String, + private val serverBUrl: String, + private val delegate: TokenManager = TokenManagerImpl(), + ) : TokenManager by delegate { + private val scopeA = AuthScopeSnapshot( + serverId = "server-a", + profileId = "profile-a", + serverUrl = serverAUrl, + profileToken = "PROFILE_A", + identityGeneration = 1, + credentialEpoch = 1, + ) + private var activeB = false + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = scopeA + + override suspend fun getAccessToken(): String = if (activeB) "ACCESS_B" else "ACCESS_A" + + override suspend fun getProfileId(): String = if (activeB) "profile-b" else "profile-a" + + override suspend fun getProfileToken(): String { + val token = if (activeB) "PROFILE_B" else "PROFILE_A" + activeB = true + return token + } + + override suspend fun getServerUrl(): String = if (activeB) serverBUrl else scopeA.serverUrl + + override suspend fun getCurrentServerId(): String = if (activeB) "server-b" else "server-a" + + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? { + activeB = true + return if (scope == scopeA) "ACCESS_A" else null + } + } + + private class SnapshotTokenManager( + private val delegate: TokenManager, + private val scope: AuthScopeSnapshot, + private val accessToken: String, + ) : TokenManager by delegate { + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = scope + + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = + accessToken.takeIf { scope == this.scope } + } + + private class VanishingScopeTokenManager( + serverUrl: String, + private val delegate: TokenManager = TokenManagerImpl(), + ) : TokenManager by delegate { + private val scope = AuthScopeSnapshot( + serverId = "server-a", + profileId = "profile-a", + serverUrl = serverUrl, + profileToken = "PROFILE_A", + identityGeneration = 1, + credentialEpoch = 1, + ) + var scopedAccessReads = 0 + private set + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = scope + + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? { + scopedAccessReads += 1 + return "ACCESS_A".takeIf { scope == this.scope && scopedAccessReads == 1 } + } + } +} 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 32cd31752..66ac07de9 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 @@ -10,9 +10,37 @@ import org.prairieserver.prairie.network.EncryptedTokenManagerImpl import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertFailsWith +import org.prairieserver.prairie.network.CleartextOriginConsent +import org.prairieserver.prairie.network.CleartextOriginNotApprovedException @RunWith(RobolectricTestRunner::class) class RegistryPairingAuthPortTest { + @Test + fun unapprovedCleartextPairingCannotPersistCredentials() = runTest { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("pairing-cleartext-test", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val registry = AndroidServerRegistry(prefs) + val tokens = EncryptedTokenManagerImpl(prefs, registry) + val consent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + } + + assertFailsWith { + RegistryPairingAuthPort(tokens, registry, consent).persistApprovedSession( + serverUrl = "http://silo.lan", + serverName = "Unsafe", + accessToken = "access", + refreshToken = "refresh", + expiresIn = 3600, + ) + } + + assertNull(registry.activeEntry.value) + assertNull(tokens.getAccessToken()) + } + @Test fun approvedSameUrlSessionClearsOldProfileAndReplacesTokens() = runTest { val context = ApplicationProvider.getApplicationContext() diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AuthenticatedDataSourceFactoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AuthenticatedDataSourceFactoryTest.kt index e5f14bac1..d8e0de686 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AuthenticatedDataSourceFactoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AuthenticatedDataSourceFactoryTest.kt @@ -19,6 +19,10 @@ import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.CleartextOriginConsent +import org.prairieserver.prairie.network.CleartextOriginNotApprovedException +import org.prairieserver.prairie.common.io.ContentLimitExceeded +import androidx.media3.datasource.DataSource @RunWith(RobolectricTestRunner::class) class AuthenticatedDataSourceFactoryTest { @@ -56,9 +60,43 @@ class AuthenticatedDataSourceFactoryTest { ) } + @Test + fun `unapproved cleartext stream is rejected before explicit plan headers reach transport`() { + val transport = FakeHttpDataSource() + val client = OkHttpClient().also { closeables += it } + val tokens = TokenManagerImpl() + runBlocking { tokens.setServerUrl("https://prairie.example") } + val source = RefreshingHttpDataSource( + factory = FakeHttpDataSourceFactory(ArrayDeque(listOf(transport))), + authSession = MediaAuthSession( + tokenManager = tokens, + refreshClient = client, + cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + }, + ), + ) + val plan = DataSpec.Builder() + .setUri(Uri.parse("http://cdn.example/video")) + .setHttpRequestHeaders( + mapOf( + "Authorization" to "Bearer stream-plan", + "X-Stream-Signature" to "secret", + ), + ) + .build() + + assertFailsWith { + source.open(plan) + } + assertEquals(emptyList(), transport.openedDataSpecs) + } + @Test fun explicitPlanHeadersOverrideSessionAuthCaseInsensitively() { - val merged = mergeSessionAuthHeaders( + val merged = authenticatedHeadersFor( + serverUrl = "https://prairie.example", + requestUrl = "https://prairie.example/video", sessionHeaders = mapOf( "Authorization" to "Bearer prairie-session", "X-Profile-Id" to "profile-1", @@ -75,6 +113,101 @@ class AuthenticatedDataSourceFactoryTest { assertEquals("route-7", merged["X-Stream-Scope"]) } + @Test + fun foreignOriginReceivesOnlyExplicitTargetHeaders() { + assertEquals( + mapOf("X-Stream-Scope" to "route-7"), + authenticatedHeadersFor( + serverUrl = "https://prairie.example", + requestUrl = "https://cdn.example/video", + sessionHeaders = mapOf( + "Authorization" to "Bearer prairie-session", + "X-Profile-Id" to "profile-1", + "X-Profile-Token" to "profile-token", + ), + explicitHeaders = mapOf("X-Stream-Scope" to "route-7"), + ), + ) + } + + @Test + fun explicitAuthorizationIsPreservedOnlyForItsIssuedTarget() { + assertEquals( + mapOf("Authorization" to "Signed cdn-route-7"), + authenticatedHeadersFor( + serverUrl = "https://prairie.example", + requestUrl = "https://cdn.example/video", + sessionHeaders = mapOf("Authorization" to "Bearer prairie-session"), + explicitHeaders = mapOf("Authorization" to "Signed cdn-route-7"), + ), + ) + } + + @Test + fun sessionHeadersRequireExactSchemeHostAndPort() { + val sessionHeaders = mapOf( + "Authorization" to "Bearer prairie-session", + "X-Profile-Id" to "profile-1", + "X-Profile-Token" to "profile-token", + ) + listOf( + "https://cdn.prairie.example/video", + "https://prairie.example:444/video", + "http://prairie.example/video", + "file:///tmp/video", + "://malformed", + ).forEach { requestUrl -> + assertEquals( + emptyMap(), + authenticatedHeadersFor( + serverUrl = "https://prairie.example", + requestUrl = requestUrl, + sessionHeaders = sessionHeaders, + explicitHeaders = emptyMap(), + ), + "Session credentials leaked to $requestUrl", + ) + } + } + + @Test + fun relativeMediaAndSubtitleUrlsResolveBeforeOriginPolicy() { + val sessionHeaders = mapOf("Authorization" to "Bearer prairie-session") + + listOf( + "/api/v1/stream/session-1", + "api/v1/stream/session-1/subtitles/4.srt", + ).forEach { requestUrl -> + assertEquals( + sessionHeaders, + authenticatedHeadersFor( + serverUrl = "https://prairie.example", + requestUrl = requestUrl, + sessionHeaders = sessionHeaders, + explicitHeaders = emptyMap(), + ), + ) + } + } + + @Test + fun foreignUnauthorizedMediaResponseDoesNotRefresh() { + assertFalse( + shouldRefreshMediaRequest( + serverUrl = "https://prairie.example", + requestUrl = "https://cdn.example/video", + responseCode = 401, + ), + ) + assertTrue( + shouldRefreshMediaRequest( + serverUrl = "https://prairie.example", + requestUrl = "https://prairie.example/video", + responseCode = 401, + ), + ) + } + @Test fun srtSubtitlePayloadBytesAreNormalizedBeforeMedia3ParsesThem() { val loose = """ @@ -352,4 +485,73 @@ class AuthenticatedDataSourceFactoryTest { closed = true } } + + @Test + fun subripNormalizationAcceptsExactlyTheStreamLimit() { + val upstream = ByteArrayDataSource(byteArrayOf(1, 2, 3, 4)) + val source = SubripNormalizingDataSource(upstream, maxBytes = 4) + + assertEquals(4, source.open(subripDataSpec())) + assertTrue(upstream.closed) + } + + @Test + fun subripNormalizationRejectsDeclaredLimitPlusOneBeforeReading() { + val upstream = ByteArrayDataSource( + bytes = byteArrayOf(1), + declaredLength = 5, + ) + val source = SubripNormalizingDataSource(upstream, maxBytes = 4) + + assertFailsWith { + source.open(subripDataSpec()) + } + assertEquals(0, upstream.readCalls) + assertTrue(upstream.closed) + } + + @Test + fun subripNormalizationRejectsStreamedLimitPlusOneAndClosesUpstream() { + val upstream = ByteArrayDataSource( + bytes = byteArrayOf(1, 2, 3, 4, 5), + declaredLength = C.LENGTH_UNSET.toLong(), + ) + val source = SubripNormalizingDataSource(upstream, maxBytes = 4) + + assertFailsWith { + source.open(subripDataSpec()) + } + assertTrue(upstream.closed) + } + + private fun subripDataSpec(): DataSpec = + DataSpec(Uri.parse("https://prairie.example/subtitles/1.srt")) + + private class ByteArrayDataSource( + private val bytes: ByteArray, + private val declaredLength: Long = bytes.size.toLong(), + ) : DataSource { + private var position = 0 + var readCalls = 0 + var closed = false + + override fun addTransferListener(transferListener: TransferListener) = Unit + + override fun open(dataSpec: DataSpec): Long = declaredLength + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + readCalls += 1 + if (position == bytes.size) return C.RESULT_END_OF_INPUT + val count = minOf(length, bytes.size - position) + bytes.copyInto(buffer, offset, position, position + count) + position += count + return count + } + + override fun getUri(): Uri = Uri.parse("https://prairie.example/subtitles/1.srt") + + override fun close() { + closed = true + } + } } 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 ba87fefb9..b3c2f11fd 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 @@ -2,14 +2,132 @@ package org.prairieserver.prairie.common.player import androidx.media3.common.C import androidx.media3.common.ColorInfo +import androidx.media3.common.DataReader import androidx.media3.common.Format import androidx.media3.common.MimeTypes +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertSame class DolbyVisionColorInfoExtractorsFactoryTest { + @Test + fun transformTagCarriesExpectedColorRange() { + val tag = SiloMediaTransformTag( + dolbyVisionMode = DolbyVisionTransformMode.DISABLED, + expectedDynamicRange = "hlg", + expectedColorRange = "pc", + ) + + assertEquals("pc", tag.expectedColorRange) + } + + @Test + fun extractorAppliesValidatedRangeFallbackToVideoOutput() { + val repaired = extractFormat( + transformMode = DolbyVisionTransformMode.DISABLED, + expectedColorRange = "pc", + source = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .build(), + ) + + assertEquals(C.COLOR_RANGE_FULL, repaired.colorInfo?.colorRange) + } + + @Test + fun extractorPreservesFullRangeWhileRepairingMissingHlgColorInfo() { + val repaired = extractFormat( + transformMode = DolbyVisionTransformMode.DISABLED, + expectedDynamicRange = "hlg", + expectedColorRange = "pc", + source = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .build(), + ) + + assertEquals(C.COLOR_SPACE_BT2020, repaired.colorInfo?.colorSpace) + assertEquals(C.COLOR_TRANSFER_HLG, repaired.colorInfo?.colorTransfer) + assertEquals(C.COLOR_RANGE_FULL, repaired.colorInfo?.colorRange) + } + + @Test + fun transformedDolbyVisionOutputRangeOverridesConflictingSourceFallback() { + val repaired = extractFormat( + transformMode = DolbyVisionTransformMode.PROFILE7_TO_PROFILE81, + expectedColorRange = "pc", + source = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) + .setCodecs("dvhe.07.06") + .build(), + ) + + assertEquals(C.COLOR_RANGE_LIMITED, repaired.colorInfo?.colorRange) + } + + @Test + fun suppliesLimitedRangeFromValidatedTvFallback() { + val source = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .build() + + val repaired = source.withValidatedColorRange("tv") + + assertEquals(C.COLOR_RANGE_LIMITED, repaired.colorInfo?.colorRange) + } + + @Test + fun suppliesFullRangeFromValidatedPcFallback() { + val source = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .build() + + val repaired = source.withValidatedColorRange("pc") + + assertEquals(C.COLOR_RANGE_FULL, repaired.colorInfo?.colorRange) + } + + @Test + fun ignoresUnknownColorRangeFallback() { + val source = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .build() + + assertSame(source, source.withValidatedColorRange(null)) + assertSame(source, source.withValidatedColorRange("unknown")) + } + + @Test + fun preservesExplicitContainerColorRange() { + val source = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .setColorInfo( + ColorInfo.Builder() + .setColorRange(C.COLOR_RANGE_FULL) + .build(), + ) + .build() + + assertSame(source, source.withValidatedColorRange("tv")) + } + + @Test + fun doesNotApplyColorRangeFallbackToNonVideoFormats() { + val source = Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_AAC) + .build() + + assertSame(source, source.withValidatedColorRange("pc")) + } + @Test fun suppliesBt2020PqColorInfoBeforeDolbyVisionDecoderInitialization() { val source = Format.Builder() @@ -83,6 +201,24 @@ class DolbyVisionColorInfoExtractorsFactoryTest { assertNull(repaired.colorInfo?.hdrStaticInfo) } + @Test + fun suppliesMissingHlgFieldsWithoutOverwritingExplicitFullRange() { + val source = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .setColorInfo( + ColorInfo.Builder() + .setColorRange(C.COLOR_RANGE_FULL) + .build(), + ) + .build() + + val repaired = source.withValidatedDynamicRangeColorInfo("hlg") + + assertEquals(C.COLOR_SPACE_BT2020, repaired.colorInfo?.colorSpace) + assertEquals(C.COLOR_TRANSFER_HLG, repaired.colorInfo?.colorTransfer) + assertEquals(C.COLOR_RANGE_FULL, repaired.colorInfo?.colorRange) + } + @Test fun doesNotOverrideConflictingContainerColorInfoWithHlgRecipe() { val source = Format.Builder() @@ -106,4 +242,99 @@ class DolbyVisionColorInfoExtractorsFactoryTest { assertSame(source, source.withValidatedDynamicRangeColorInfo(null)) assertSame(source, source.withValidatedDynamicRangeColorInfo("hdr10")) } + + @Test + fun keepsDolbyVisionOutputRangeAuthoritativeOverSourceFallback() { + val source = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) + .build() + + val repaired = source + .withDolbyVisionHdrColorInfo() + .withValidatedColorRange("pc") + + assertEquals(C.COLOR_RANGE_LIMITED, repaired.colorInfo?.colorRange) + } + + private fun extractFormat( + transformMode: DolbyVisionTransformMode, + expectedDynamicRange: String? = null, + expectedColorRange: String, + source: Format, + ): Format { + val sourceExtractor = RecordingExtractor() + val extractor = DolbyVisionColorInfoExtractorsFactory( + delegate = ExtractorsFactory { arrayOf(sourceExtractor) }, + transformMode = transformMode, + converter = DolbyVisionRpuConverter { it }, + expectedDynamicRange = expectedDynamicRange, + expectedColorRange = expectedColorRange, + ).createExtractors().single() + val output = RecordingExtractorOutput() + extractor.init(output) + + sourceExtractor.emitVideoFormat(source) + + return checkNotNull(output.trackOutput.format) + } + + private class RecordingExtractor : Extractor { + private lateinit var output: ExtractorOutput + + override fun sniff(input: ExtractorInput): Boolean = true + + override fun init(output: ExtractorOutput) { + this.output = output + } + + override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = + Extractor.RESULT_END_OF_INPUT + + override fun seek(position: Long, timeUs: Long) = Unit + + override fun release() = Unit + + fun emitVideoFormat(format: Format) { + output.track(0, C.TRACK_TYPE_VIDEO).format(format) + } + } + + private class RecordingExtractorOutput : ExtractorOutput { + val trackOutput = RecordingTrackOutput() + + override fun track(id: Int, type: Int): TrackOutput = trackOutput + + override fun endTracks() = Unit + + override fun seekMap(seekMap: SeekMap) = Unit + } + + private class RecordingTrackOutput : TrackOutput { + var format: Format? = null + + override fun format(format: Format) { + this.format = format + } + + override fun sampleData( + input: DataReader, + length: Int, + allowEndOfInput: Boolean, + sampleDataPart: Int, + ): Int = error("Unexpected sample data") + + override fun sampleData( + data: ParsableByteArray, + length: Int, + sampleDataPart: Int, + ) = error("Unexpected sample data") + + override fun sampleMetadata( + timeUs: Long, + flags: Int, + size: Int, + offset: Int, + cryptoData: TrackOutput.CryptoData?, + ) = error("Unexpected sample metadata") + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/FinalPlaybackPositionWriterTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/FinalPlaybackPositionWriterTest.kt new file mode 100644 index 000000000..2bb08f767 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/FinalPlaybackPositionWriterTest.kt @@ -0,0 +1,197 @@ +package org.prairieserver.prairie.common.player + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.repository.port.PlaybackWriteScope +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FinalPlaybackPositionWriterTest { + private val scopeA = PlaybackWriteScope("server", "profile-a", null, 4L) + private val scopeB = PlaybackWriteScope("server", "profile-b", null, 5L) + + @Test + fun submitReturnsBeforeSuspendedWriteCompletes() = runTest { + val started = CompletableDeferred() + val release = CompletableDeferred() + val writer = FinalPlaybackPositionWriter( + scope = backgroundScope, + scopeProvider = { null }, + write = { + started.complete(Unit) + release.await() + }, + ) + + assertTrue(writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, 42.0, 100.0))) + runCurrent() + assertTrue(started.isCompleted) + assertFalse(release.isCompleted) + release.complete(Unit) + } + + @Test + fun aFailedWriteIsRetriedInsteadOfDiscarded() = runTest { + // The drain removes the batch from `pending` before writing, so a + // swallowed failure lost the user's final position outright — and this + // queue is the durable record that survives a server-side session reset. + val written = mutableListOf() + var failuresLeft = 1 + val writer = FinalPlaybackPositionWriter( + scope = backgroundScope, + scopeProvider = { null }, + write = { + if (failuresLeft > 0) { + failuresLeft-- + error("room write failed") + } + written += it + }, + ) + + assertTrue(writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, 42.0, 100.0))) + runCurrent() + assertEquals(emptyList(), written, "first attempt fails") + + advanceTimeBy(6_000) + runCurrent() + assertEquals(1, written.size, "the position must be rewritten, not dropped") + assertEquals(42.0, written.single().positionSeconds) + } + + @Test + fun aNewerSubmissionWinsOverARequeuedFailure() = runTest { + val written = mutableListOf() + var failuresLeft = 1 + val writer = FinalPlaybackPositionWriter( + scope = backgroundScope, + scopeProvider = { null }, + write = { + if (failuresLeft > 0) { + failuresLeft-- + error("room write failed") + } + written += it + }, + ) + + assertTrue(writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, 42.0, 100.0))) + runCurrent() + // The user kept watching while that write was failing. + assertTrue(writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, 90.0, 100.0))) + advanceTimeBy(6_000) + runCurrent() + + assertEquals( + listOf(90.0), + written.map { it.positionSeconds }, + "the retry must not resurrect a position older than the newest intent", + ) + } + + @Test + fun invalidSnapshotsAreRejected() = runTest { + val writer = FinalPlaybackPositionWriter( + scope = backgroundScope, + scopeProvider = { null }, + write = { error("must not write") }, + ) + + assertFalse(writer.submit(FinalPlaybackPosition(scopeA, "", 7, 1.0, 2.0))) + assertFalse(writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, -1.0, 2.0))) + assertFalse(writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, Double.NaN, 2.0))) + assertFalse(writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, 1.0, Double.NaN))) + } + + @Test + fun pendingSnapshotsForSameFileCoalesceToNewestValue() = runTest { + val written = mutableListOf() + val writer = FinalPlaybackPositionWriter( + scope = backgroundScope, + scopeProvider = { null }, + write = { written += it }, + ) + + writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, 10.0, 100.0)) + writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, 20.0, 100.0)) + writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, 30.0, 100.0)) + runCurrent() + + assertEquals(listOf(FinalPlaybackPosition(scopeA, "movie", 7, 30.0, 100.0)), written) + } + + @Test + fun pendingSnapshotsFromDifferentProfilesNeverCoalesce() = runTest { + val written = mutableListOf() + val writer = FinalPlaybackPositionWriter( + scope = backgroundScope, + scopeProvider = { null }, + write = { written += it }, + ) + + writer.submit(FinalPlaybackPosition(scopeA, "movie", 7, 10.0, 100.0)) + writer.submit(FinalPlaybackPosition(scopeB, "movie", 7, 20.0, 100.0)) + runCurrent() + + assertEquals(listOf(scopeA, scopeB), written.map { it.scope }) + } + + @Test + fun playbackScopeContainsIdentifiersOnly() { + assertEquals( + setOf("serverId", "profileId", "credentialGenerationId", "identityGeneration"), + PlaybackWriteScope::class.java.declaredFields + .filterNot { it.isSynthetic } + .map { it.name } + .toSet(), + ) + } + + @Test + fun captureScopeCopiesOnlyIdentityFields() = runTest { + val writer = FinalPlaybackPositionWriter( + scope = backgroundScope, + scopeProvider = { + AuthScopeSnapshot( + serverId = "server", + profileId = "profile-a", + serverUrl = "https://server.example", + profileToken = "secret-profile-token", + credentialGenerationId = "credential", + identityGeneration = 4L, + ) + }, + write = { error("must not write") }, + ) + + assertEquals( + PlaybackWriteScope("server", "profile-a", "credential", 4L), + writer.captureScope(), + ) + } + + @Test + fun captureScopeRejectsMissingProfile() = runTest { + val writer = FinalPlaybackPositionWriter( + scope = backgroundScope, + scopeProvider = { + AuthScopeSnapshot( + serverId = "server", + profileId = null, + serverUrl = "https://server.example", + profileToken = null, + identityGeneration = 4L, + ) + }, + write = { error("must not write") }, + ) + + assertNull(writer.captureScope()) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/LetterboxInsetTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/LetterboxInsetTest.kt new file mode 100644 index 000000000..8cf88f691 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/LetterboxInsetTest.kt @@ -0,0 +1,27 @@ +package org.prairieserver.prairie.common.player + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LetterboxInsetTest { + private val fullFrame = SubtitleVideoRect(left = 0, top = 0, width = 1920, height = 1080) + + @Test + fun scopeContentInsetsToThePicture() { + val inset = fullFrame.insetByLetterbox(LetterboxInsets(0.1278f, 0.1278f)) + assertEquals(138, inset.top) + assertEquals(804, inset.height) + assertEquals(0, inset.left) + assertEquals(1920, inset.width) + } + + @Test + fun noMeasurementLeavesTheRectAlone() { + assertEquals(fullFrame, fullFrame.insetByLetterbox(LetterboxInsets.NONE)) + } + + @Test + fun barsLargerThanTheFrameAreRefused() { + assertEquals(fullFrame, fullFrame.insetByLetterbox(LetterboxInsets(0.6f, 0.6f))) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/LibassPlayerReleaseContractTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/LibassPlayerReleaseContractTest.kt new file mode 100644 index 000000000..4437d2785 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/LibassPlayerReleaseContractTest.kt @@ -0,0 +1,40 @@ +package org.prairieserver.prairie.common.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class LibassPlayerReleaseContractTest { + private val factorySource = source( + "src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt", + ) + private val serviceSource = source( + "src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt", + ) + private val backendSource = source( + "src/androidMain/kotlin/org/prairieserver/prairie/common/player/backend/Media3VideoPlaybackBackend.kt", + ) + private val bridgeSource = File( + requireNotNull(System.getProperty("user.dir")).replace("android-shared", "libass-bridge"), + "src/main/java/org/prairieserver/prairie/libass/LibassBridge.java", + ).readText() + + @Test + fun playerTeardownIsOwnedByFactoryAndBridge() { + assertTrue(factorySource.contains("fun releasePlayer(player: Player)")) + assertTrue(factorySource.contains("libassBridge::releasePlayer")) + assertTrue(serviceSource.contains("playerFactory.releasePlayer(player)")) + assertTrue(backendSource.contains("playerFactory.releasePlayer(player)")) + } + + @Test + fun bridgeRecyclesHandlerAndRemovesRetiredOverlay() { + assertTrue(bridgeSource.contains("private volatile AssHandler handler")) + assertTrue(bridgeSource.contains("public void releasePlayer(ExoPlayer player)")) + assertTrue(bridgeSource.contains("retireOverlay();")) + assertTrue(bridgeSource.contains("newHandler();")) + } + + private fun source(relativePath: String): String = + File(requireNotNull(System.getProperty("user.dir")), relativePath).readText() +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaAuthInterceptorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaAuthInterceptorTest.kt index 7060e6046..7da9e8d8b 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaAuthInterceptorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaAuthInterceptorTest.kt @@ -2,6 +2,9 @@ package org.prairieserver.prairie.common.player import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.CleartextOriginConsent +import org.prairieserver.prairie.network.CleartextOriginNotApprovedException +import org.prairieserver.prairie.network.canonicalHttpOrigin import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -16,19 +19,57 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.test.assertFailsWith class MediaAuthInterceptorTest { + @Test + fun `redirect from approved origin to unapproved cleartext is blocked before downstream`() { + val origin = MockWebServer() + val downstream = MockWebServer() + origin.start() + downstream.start() + try { + origin.enqueue( + MockResponse() + .setResponseCode(302) + .setHeader("Location", downstream.url("/asset")), + ) + val approvedOrigin = canonicalHttpOrigin(origin.url("/").toString()) + val consent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = + canonicalHttpOrigin(origin) == approvedOrigin + } + val client = buildPlayerOkHttpClient(consent) + val request = Request.Builder() + .url(origin.url("/redirect")) + .header("X-Stream-Signature", "secret-plan-header") + .build() + + assertFailsWith { + client.newCall(request).execute().close() + } + assertEquals(1, origin.requestCount) + assertEquals(0, downstream.requestCount) + } finally { + origin.shutdown() + downstream.shutdown() + } + } + @Test fun `adds auth and active profile headers to media and reader requests`() { val tokenManager = TokenManagerImpl() runBlocking { + tokenManager.setServerUrl("https://lib.strm.cafe") tokenManager.saveTokens("access-token", "refresh-token", expiresIn = 3600) tokenManager.setProfileId("profile-1") tokenManager.setProfileToken("profile-token") @@ -53,6 +94,194 @@ class MediaAuthInterceptorTest { } } + @Test + fun `foreign origins receive no auth or profile headers`() { + val tokenManager = TokenManagerImpl() + runBlocking { + tokenManager.setServerUrl("https://lib.strm.cafe") + tokenManager.saveTokens("access-token", "refresh-token", expiresIn = 3600) + tokenManager.setProfileId("profile-1") + tokenManager.setProfileToken("profile-token") + } + + listOf( + "https://cdn.lib.strm.cafe/video", + "https://lib.strm.cafe:444/video", + "http://lib.strm.cafe/video", + ).forEach { url -> + val chain = CapturingChain( + Request.Builder() + .url(url) + .header("Authorization", "Signed explicit-credential") + .header("X-Profile-Id", "explicit-profile") + .header("X-Profile-Token", "explicit-profile-token") + .build(), + ) + + MediaAuthInterceptor(tokenManager, refreshClient = OkHttpClient()).intercept(chain) + + val request = chain.capturedRequest ?: error("request was not captured") + assertNull(request.header("Authorization"), "Authorization leaked to $url") + assertNull(request.header("X-Profile-Id"), "X-Profile-Id leaked to $url") + assertNull(request.header("X-Profile-Token"), "X-Profile-Token leaked to $url") + } + } + + @Test + fun `foreign unauthorized response causes no refresh attempt`() { + val tokenManager = FakeTokenManager( + accessToken = "expired-access", + refreshToken = "refresh-token", + serverUrl = "https://lib.strm.cafe", + serverId = "server-a", + ) + var refreshRequests = 0 + val refreshClient = OkHttpClient.Builder() + .addInterceptor { chain -> + refreshRequests += 1 + responseFor(chain.request(), code = 500) + } + .build() + val chain = SequenceChain( + Request.Builder() + .url("https://cdn.example/video") + .build(), + responseCodes = listOf(401, 401), + ) + + MediaAuthInterceptor(tokenManager, refreshClient = refreshClient).intercept(chain).close() + + assertEquals(0, refreshRequests) + assertEquals(1, chain.proceedCalls) + assertFalse(tokenManager.invalidatedSession) + } + + @Test + fun `server switch while snapshot is read cannot pair old credentials with new origin`() { + val tokenManager = FakeTokenManager( + accessToken = "server-a-access", + refreshToken = "server-a-refresh", + serverUrl = "https://server-a.example", + serverId = "server-a", + ).apply { + runBlocking { + setProfileId("server-a-profile") + setProfileToken("server-a-profile-token") + } + onGetServerUrl = { + serverId = "server-b" + serverUrl = "https://server-b.example" + } + } + val chain = CapturingChain( + Request.Builder() + .url("https://server-b.example/video") + .build(), + ) + + MediaAuthInterceptor(tokenManager, refreshClient = OkHttpClient()).intercept(chain) + + val request = chain.capturedRequest ?: error("request was not captured") + assertNull(request.header("Authorization")) + assertNull(request.header("X-Profile-Id")) + assertNull(request.header("X-Profile-Token")) + } + + @Test + fun `cross origin redirect strips prairie and explicit authorization headers`() { + val origin = MockWebServer() + val downstream = MockWebServer() + origin.start() + downstream.start() + try { + origin.enqueue( + MockResponse() + .setResponseCode(302) + .setHeader("Location", downstream.url("/asset")), + ) + downstream.enqueue(MockResponse().setResponseCode(200).setBody("ok")) + val tokenManager = FakeTokenManager( + accessToken = null, + refreshToken = "refresh-token", + serverUrl = origin.url("/").toString(), + serverId = "server-a", + ).apply { + runBlocking { + setProfileId("profile-1") + setProfileToken("profile-token") + } + } + val client = buildPlayerOkHttpClient() + .newBuilder() + .addInterceptor(MediaAuthInterceptor(tokenManager, refreshClient = OkHttpClient())) + .build() + val request = Request.Builder() + .url(origin.url("/redirect")) + .header("Authorization", "Signed explicit-target-credential") + .header("X-Prairie-Device-Id", "device-1") + .build() + + client.newCall(request).execute().use { response -> + assertEquals(200, response.code) + } + + val received = downstream.takeRequest() + assertNull(received.getHeader("Authorization")) + assertNull(received.getHeader("X-Profile-Id")) + assertNull(received.getHeader("X-Profile-Token")) + assertNull(received.getHeader("X-Prairie-Device-Id")) + } finally { + origin.shutdown() + downstream.shutdown() + } + } + + @Test + fun `retry never applies credentials from a newly active foreign server`() { + val tokenManager = FakeTokenManager( + accessToken = "server-a-expired", + refreshToken = "server-a-refresh", + serverUrl = "https://server-a.example", + serverId = "server-a", + ) + val refreshClient = OkHttpClient.Builder() + .addInterceptor { chain -> + responseFor( + chain.request(), + code = 200, + body = """ + { + "access_token": "server-a-fresh", + "refresh_token": "server-a-fresh-refresh", + "expires_in": 3600 + } + """.trimIndent(), + ) + } + .build() + tokenManager.onSaveTokens = { + tokenManager.switchScope( + accessToken = "server-b-access", + refreshToken = "server-b-refresh", + serverUrl = "https://server-b.example", + serverId = "server-b", + ) + } + val chain = SequenceChain( + Request.Builder() + .url("https://server-a.example/video") + .build(), + responseCodes = listOf(401, 401), + ) + + MediaAuthInterceptor(tokenManager, refreshClient = refreshClient).intercept(chain).close() + + val retry = chain.capturedRequests.last() + assertNull(retry.header("Authorization")) + assertNull(retry.header("X-Profile-Id")) + assertNull(retry.header("X-Profile-Token")) + } + @Test fun `failed refresh invalidates session`() { val tokenManager = FakeTokenManager( @@ -276,10 +505,13 @@ class MediaAuthInterceptorTest { private val responseCodes: List, ) : Interceptor.Chain { private var calls = 0 + val proceedCalls: Int get() = calls + val capturedRequests = mutableListOf() override fun request(): Request = request override fun proceed(request: Request): Response { + capturedRequests += request val code = responseCodes.getOrElse(calls) { responseCodes.last() } calls += 1 return mediaAuthTestResponseFor(request, code) @@ -302,6 +534,7 @@ class MediaAuthInterceptorTest { var serverId: String?, ) : TokenManager { var onGetServerUrl: (() -> Unit)? = null + var onSaveTokens: (() -> Unit)? = null var invalidatedSession = false private set var savedTokens = false @@ -320,6 +553,7 @@ class MediaAuthInterceptorTest { savedTokens = true this.accessToken = accessToken this.refreshToken = refreshToken + onSaveTokens?.invoke() } override suspend fun clearTokens() { @@ -364,6 +598,18 @@ class MediaAuthInterceptorTest { override suspend fun signOutCurrentServer() { clearTokens() } + + fun switchScope( + accessToken: String?, + refreshToken: String?, + serverUrl: String, + serverId: String?, + ) { + this.accessToken = accessToken + this.refreshToken = refreshToken + this.serverUrl = serverUrl + this.serverId = serverId + } } private fun responseFor( diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaAuthSessionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaAuthSessionTest.kt index 02b0c87c4..316b8ed12 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaAuthSessionTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaAuthSessionTest.kt @@ -1,13 +1,19 @@ package org.prairieserver.prairie.common.player import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.CleartextOriginConsent +import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.TokenManagerImpl import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class MediaAuthSessionTest { @@ -44,4 +50,156 @@ class MediaAuthSessionTest { assertEquals("profile-1", fresh["X-Profile-Id"]) } } + + @Test + fun temporaryPlaybackRefreshRejectionNeverFallsThroughToSavedOwner() { + val owner = AuthScopeSnapshot("server-a", "owner", "https://prairie.example", null, credentialEpoch = 1) + val guest = AuthScopeSnapshot( + "server-a", + "guest", + "https://prairie.example", + null, + credentialGenerationId = "guest-generation", + ) + val tokens = ScopedTokenManager(owner, "owner-access", "owner-refresh") + tokens.install(guest, "guest-access", "guest-refresh") + tokens.current = guest + val refreshClient = respondingClient(401) + val session = MediaAuthSession(tokens, refreshClient) + + runBlocking { + val failed = session.snapshot() + assertFalse(session.refreshIfStale(failed)) + } + + assertEquals(guest, tokens.current) + assertEquals("guest-access", tokens.access(guest)) + assertEquals("owner-access", tokens.access(owner)) + assertEquals(emptyList(), tokens.invalidatedScopes) + } + + @Test + fun sameServerReloginCannotRefreshUsingStalePlaybackSnapshot() { + val oldLogin = AuthScopeSnapshot("server-a", "profile", "https://prairie.example", null, credentialEpoch = 1) + val newLogin = oldLogin.copy(credentialEpoch = 3) + val tokens = ScopedTokenManager(oldLogin, "old-access", "old-refresh") + val refreshCalls = mutableListOf() + val session = MediaAuthSession( + tokens, + OkHttpClient.Builder().addInterceptor { chain -> + refreshCalls += chain.request().url.toString() + respondingClientResponse(chain.request(), 200) + }.build(), + ) + val failed = runBlocking { session.snapshot() } + tokens.install(newLogin, "new-access", "new-refresh") + tokens.current = newLogin + + assertFalse(runBlocking { session.refreshIfStale(failed) }) + assertEquals(emptyList(), refreshCalls) + assertEquals("new-access", tokens.access(newLogin)) + } + + @Test + fun unapprovedCleartextMediaSnapshotContainsNoCredentialsOrTrustedOrigin() { + val scope = AuthScopeSnapshot("server-a", "profile", "http://prairie.lan", "profile-token", credentialEpoch = 1) + val tokens = ScopedTokenManager(scope, "access", "refresh") + val session = MediaAuthSession( + tokens, + respondingClient(200), + cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + }, + ) + + val snapshot = runBlocking { session.snapshot() } + + assertEquals(emptyMap(), snapshot.asRequestHeaders()) + assertEquals("", snapshot.serverUrl) + } + + private fun respondingClient(code: Int): OkHttpClient = + OkHttpClient.Builder().addInterceptor { chain -> + respondingClientResponse(chain.request(), code) + }.build() + + private fun respondingClientResponse(request: okhttp3.Request, code: Int): Response = + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message(if (code in 200..299) "OK" else "Rejected") + .body( + if (code in 200..299) { + """{"access_token":"fresh-access","refresh_token":"fresh-refresh","expires_in":3600}""" + .toResponseBody(null) + } else { + "".toResponseBody(null) + }, + ) + .build() + + private class ScopedTokenManager( + currentScope: AuthScopeSnapshot, + accessToken: String, + refreshToken: String, + ) : TokenManager { + private data class Tokens(var access: String, var refresh: String) + private val tokens = mutableMapOf() + var current: AuthScopeSnapshot = currentScope + val invalidatedScopes = mutableListOf() + private val expired = MutableSharedFlow() + override val sessionExpired: SharedFlow = expired + + init { + install(currentScope, accessToken, refreshToken) + } + + fun install(scope: AuthScopeSnapshot, access: String, refresh: String) { + tokens[scope] = Tokens(access, refresh) + } + + fun access(scope: AuthScopeSnapshot): String? = tokens[scope]?.access + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = current + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = tokens[scope]?.access + override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot): String? = tokens[scope]?.refresh + override suspend fun saveTokensForScope( + scope: AuthScopeSnapshot, + accessToken: String, + refreshToken: String, + expiresIn: Long, + ) { + tokens[scope] = Tokens(accessToken, refreshToken) + } + override suspend fun invalidateSessionForScope(scope: AuthScopeSnapshot): Boolean { + invalidatedScopes += scope + if (scope.credentialGenerationId == null) tokens.remove(scope) + return true + } + override suspend fun getAccessToken(): String? = tokens[current]?.access + override suspend fun getRefreshToken(): String? = tokens[current]?.refresh + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) { + tokens[current] = Tokens(accessToken, refreshToken) + } + override suspend fun clearTokens() { + tokens.remove(current) + } + override suspend fun invalidateSession() { + tokens.remove(current) + } + override suspend fun getProfileId(): String? = current.profileId + override suspend fun setProfileId(profileId: String?) = Unit + override suspend fun getProfileToken(): String? = current.profileToken + override suspend fun setProfileToken(token: String?) = Unit + override suspend fun getServerUrl(): String = current.serverUrl + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String = current.serverId + override suspend fun switchActiveServer(serverId: String?) = Unit + override suspend fun signOutCurrentServer() = Unit + override suspend fun getAccessTokenForScope(serverId: String): String? = + tokens.entries.firstOrNull { it.key.serverId == serverId }?.value?.access + override suspend fun getRefreshTokenForScope(serverId: String): String? = + tokens.entries.firstOrNull { it.key.serverId == serverId }?.value?.refresh + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaLoadRetryPolicyTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaLoadRetryPolicyTest.kt index 252cde83f..4e57a2b4e 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaLoadRetryPolicyTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/MediaLoadRetryPolicyTest.kt @@ -10,6 +10,7 @@ import java.net.SocketException import java.net.SocketTimeoutException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue import okhttp3.internal.http2.ErrorCode import okhttp3.internal.http2.StreamResetException @@ -243,4 +244,22 @@ class MediaLoadRetryPolicyTest { fun `retry window eventually expires`() { assertEquals(C.TIME_UNSET, prairieMediaLoadRetryDelayMs(responseCode = 503, errorCount = 20)) } + + @Test + fun `time spent inside failed attempts counts against the window`() { + // The count-derived estimate only sums the delays BETWEEN attempts, so + // on the second error it believes ~1s has passed. A 30s-socket-timeout + // server burns far more than that per attempt, and the window silently + // stretched to minutes of spinner before an error surfaced. + assertEquals( + C.TIME_UNSET, + siloMediaLoadRetryDelayMs( + responseCode = 503, + errorCount = 2, + observedElapsedMs = MEDIA_LOAD_RETRY_WINDOW_MS, + ), + ) + // Unmeasured callers keep the previous derived behaviour. + assertTrue(siloMediaLoadRetryDelayMs(responseCode = 503, errorCount = 2) > 0L) + } } 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 new file mode 100644 index 000000000..8cda0f604 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PipActionCapabilityTest.kt @@ -0,0 +1,174 @@ +package org.prairieserver.prairie.common.player + +import android.content.Context +import android.content.Intent +import androidx.media3.common.Player +import androidx.test.core.app.ApplicationProvider +import java.io.File +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Method +import java.lang.reflect.Proxy +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PipActionCapabilityTest { + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun missingCapabilityCannotDispatchCustomAction() { + val customActions = listOf( + PrairiePlaybackService.ACTION_PIP_PLAY, + PrairiePlaybackService.ACTION_PIP_PAUSE, + PrairiePlaybackService.ACTION_PIP_SKIP_BACK, + PrairiePlaybackService.ACTION_PIP_SKIP_FORWARD, + ) + + customActions.forEach { action -> + val player = RecordingPlayer() + val intent = Intent(context, PrairiePlaybackService::class.java).setAction(action) + + assertFalse(PipActionCapability.isAuthorized(intent)) + assertTrue(PrairiePlaybackService.dispatchPictureInPictureAction(intent, player.proxy)) + assertTrue( + player.calls.isEmpty(), + "unauthenticated custom action $action must not touch the player", + ) + } + } + + @Test + fun forgedCapabilityCannotDispatchCustomAction() { + val player = RecordingPlayer() + val issuedToken = requireNotNull( + PipActionCapability.intent(context, PrairiePlaybackService.ACTION_PIP_PLAY) + .getStringExtra(PipActionCapability.EXTRA_CAPABILITY), + ) + val forgedBytes = Base64.getUrlDecoder().decode(issuedToken) + forgedBytes[0] = (forgedBytes[0].toInt() xor 1).toByte() + val forgedToken = + Base64.getUrlEncoder().withoutPadding().encodeToString(forgedBytes) + val intent = Intent(context, PrairiePlaybackService::class.java) + .setAction(PrairiePlaybackService.ACTION_PIP_PLAY) + .putExtra(PipActionCapability.EXTRA_CAPABILITY, forgedToken) + + assertFalse(PipActionCapability.isAuthorized(intent)) + assertTrue(PrairiePlaybackService.dispatchPictureInPictureAction(intent, player.proxy)) + assertTrue(player.calls.isEmpty(), "a forged custom action must not touch the player") + } + + @Test + fun malformedCapabilityCannotDispatchCustomAction() { + val player = RecordingPlayer() + val intent = Intent(context, PrairiePlaybackService::class.java) + .setAction(PrairiePlaybackService.ACTION_PIP_PLAY) + .putExtra(PipActionCapability.EXTRA_CAPABILITY, "not+base64url") + + assertFalse(PipActionCapability.isAuthorized(intent)) + assertTrue(PrairiePlaybackService.dispatchPictureInPictureAction(intent, player.proxy)) + assertTrue(player.calls.isEmpty(), "a malformed custom action must not touch the player") + } + + @Test + fun capabilityIntentDispatchesCustomAction() { + val player = RecordingPlayer() + val intent = PipActionCapability.intent(context, PrairiePlaybackService.ACTION_PIP_PLAY) + + assertTrue(PipActionCapability.isAuthorized(intent)) + assertTrue(PrairiePlaybackService.dispatchPictureInPictureAction(intent, player.proxy)) + assertEquals(listOf("setPlayWhenReady", "play"), player.calls) + assertEquals(PrairiePlaybackService::class.java.name, intent.component?.className) + } + + @Test + fun rotationInvalidatesPreviouslyIssuedIntentAndAuthorizesNewIntent() { + val staleIntent = + PipActionCapability.intent(context, PrairiePlaybackService.ACTION_PIP_PAUSE) + + PipActionCapability.rotate() + + val stalePlayer = RecordingPlayer() + assertFalse(PipActionCapability.isAuthorized(staleIntent)) + assertTrue( + PrairiePlaybackService.dispatchPictureInPictureAction(staleIntent, stalePlayer.proxy), + ) + assertTrue(stalePlayer.calls.isEmpty(), "a stale capability must not touch the player") + + val currentPlayer = RecordingPlayer() + val currentIntent = + PipActionCapability.intent(context, PrairiePlaybackService.ACTION_PIP_PAUSE) + assertTrue(PipActionCapability.isAuthorized(currentIntent)) + assertTrue( + PrairiePlaybackService.dispatchPictureInPictureAction(currentIntent, currentPlayer.proxy), + ) + assertEquals(listOf("pause"), currentPlayer.calls) + } + + @Test + fun capabilityExtraIsBase64UrlEncodingOfThirtyTwoBytes() { + val intent = PipActionCapability.intent(context, PrairiePlaybackService.ACTION_PIP_PLAY) + val encoded = requireNotNull( + intent.getStringExtra(PipActionCapability.EXTRA_CAPABILITY), + ) + + assertFalse(encoded.contains('=')) + assertEquals(32, Base64.getUrlDecoder().decode(encoded).size) + } + + @Test + fun nonCustomServiceActionFallsThroughWithoutCapability() { + val player = RecordingPlayer() + val intent = Intent(context, PrairiePlaybackService::class.java) + .setAction("androidx.media3.session.CONNECT") + + assertFalse(PrairiePlaybackService.dispatchPictureInPictureAction(intent, player.proxy)) + assertTrue(player.calls.isEmpty()) + } + + @Test + fun mediaSessionAccessRemainsAvailableToSystemControllers() { + val source = File( + "src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt", + ).readText() + + assertTrue( + source.contains( + "override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =\n" + + " mediaSession", + ), + "capability checks must not gate trusted MediaSession controllers", + ) + assertTrue( + source.contains("return super.onStartCommand(intent, flags, startId)"), + "non-PiP service intents must retain MediaSessionService handling", + ) + } + + private class RecordingPlayer : InvocationHandler { + val calls = mutableListOf() + val proxy: Player = Proxy.newProxyInstance( + Player::class.java.classLoader, + arrayOf(Player::class.java), + this, + ) as Player + + override fun invoke(proxy: Any, method: Method, args: Array?): Any? { + when (method.name) { + "setPlayWhenReady", "play", "pause", "seekTo" -> calls += method.name + } + return when (method.returnType) { + Boolean::class.javaPrimitiveType -> false + Int::class.javaPrimitiveType -> 0 + Long::class.javaPrimitiveType -> 60_000L + Float::class.javaPrimitiveType -> 0f + Double::class.javaPrimitiveType -> 0.0 + else -> null + } + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlayWhenReadyReconciliationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlayWhenReadyReconciliationTest.kt new file mode 100644 index 000000000..91759388b --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlayWhenReadyReconciliationTest.kt @@ -0,0 +1,119 @@ +package org.prairieserver.prairie.common.player + +import androidx.media3.common.Player +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PlayWhenReadyReconciliationTest { + @Test + fun `deliberate user and media session changes are reconciled`() { + assertTrue(shouldReconcileExternalPlayWhenReady(Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)) + assertTrue(shouldReconcileExternalPlayWhenReady(Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)) + } + + @Test + fun `local safety and lifecycle changes never become room intent`() { + assertFalse(shouldReconcileExternalPlayWhenReady(Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS)) + assertFalse(shouldReconcileExternalPlayWhenReady(Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY)) + assertFalse(shouldReconcileExternalPlayWhenReady(Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM)) + assertFalse(shouldReconcileExternalPlayWhenReady(Player.PLAY_WHEN_READY_CHANGE_REASON_SUPPRESSED_TOO_LONG)) + } + + @Test + fun `programmatic room synchronization is consumed instead of rebroadcast`() { + val gate = PlayWhenReadyReconciliationGate() + assertTrue(gate.requestProgrammaticChange(false)) + + assertFalse( + gate.onPlayWhenReadyChanged( + false, + Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + ).shouldReconcile, + ) + assertTrue( + gate.onPlayWhenReadyChanged( + true, + Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + ).shouldReconcile, + ) + } + + @Test + fun `unrelated safety callback does not consume a pending programmatic value`() { + val gate = PlayWhenReadyReconciliationGate() + gate.requestProgrammaticChange(false) + + assertFalse( + gate.onPlayWhenReadyChanged( + true, + Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS, + ).shouldReconcile, + ) + assertFalse( + gate.onPlayWhenReadyChanged( + false, + Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + ).shouldReconcile, + ) + } + + @Test + fun `rapid programmatic pause resume preserves ordered callback provenance`() { + val gate = PlayWhenReadyReconciliationGate() + assertTrue(gate.requestProgrammaticChange(false)) + assertFalse(gate.requestProgrammaticChange(true)) + + val pause = gate.onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + assertFalse(pause.shouldReconcile) + assertEquals(true, pause.followUpProgrammaticValue) + val play = gate.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + assertFalse(play.shouldReconcile) + assertNull(play.followUpProgrammaticValue) + assertFalse(gate.hasPendingChanges) + } + + @Test + fun `alternating queued targets coalesce before a second command is issued`() { + val gate = PlayWhenReadyReconciliationGate() + assertTrue(gate.requestProgrammaticChange(false)) + assertFalse(gate.requestProgrammaticChange(true)) + assertFalse(gate.requestProgrammaticChange(false)) + + val pause = gate.onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + assertFalse(pause.shouldReconcile) + assertNull(pause.followUpProgrammaticValue) + assertFalse(gate.hasPendingChanges) + } + + @Test + fun `dropped callback expires instead of swallowing a later user command`() { + var nowMs = 0L + val gate = PlayWhenReadyReconciliationGate(nowMs = { nowMs }) + gate.requestProgrammaticChange(false) + nowMs = 2_001L + + assertTrue( + gate.onPlayWhenReadyChanged( + false, + Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + ).shouldReconcile, + ) + } + + @Test + fun `reset clears provenance on controller or room replacement`() { + val gate = PlayWhenReadyReconciliationGate() + gate.requestProgrammaticChange(false) + gate.reset() + + assertTrue( + gate.onPlayWhenReadyChanged( + false, + Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + ).shouldReconcile, + ) + } +} 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 new file mode 100644 index 000000000..aa0ae01c4 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackColorRangeFallbackTest.kt @@ -0,0 +1,54 @@ +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 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PlaybackColorRangeFallbackTest { + @Test + fun preservesValidatedRangeForOriginalDelivery() { + assertEquals("tv", plan(PlaybackDelivery.ORIGINAL_HTTP, "tv").validatedColorRangeFallback()) + } + + @Test + fun preservesValidatedRangeForProgressiveRemuxDelivery() { + assertEquals( + "pc", + plan(PlaybackDelivery.SERVER_REMUX_PROGRESSIVE, "pc").validatedColorRangeFallback(), + ) + } + + @Test + fun rejectsSourceRangeForTranscodeDelivery() { + assertNull(plan(PlaybackDelivery.SERVER_TRANSCODE_HLS, "tv").validatedColorRangeFallback()) + } + + @Test + fun rejectsSourceRangeForClientLocalNormalization() { + assertNull( + plan( + PlaybackDelivery.CLIENT_LOCAL_NORMALIZATION, + "pc", + ).validatedColorRangeFallback(), + ) + } + + @Test + fun rejectsUnknownSourceRange() { + assertNull(plan(PlaybackDelivery.ORIGINAL_HTTP, "unknown").validatedColorRangeFallback()) + } + + private fun plan(delivery: PlaybackDelivery, colorRange: String): PlaybackExecutionPlan = + 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/PlaybackPublicationSettlementIntegrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPublicationSettlementIntegrationTest.kt new file mode 100644 index 000000000..5f19a6059 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPublicationSettlementIntegrationTest.kt @@ -0,0 +1,664 @@ +package org.prairieserver.prairie.common.player + +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.HttpMethod +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.AtomicInteger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlinx.serialization.encodeToString +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.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 +import org.prairieserver.prairie.model.playback.PlaybackStreamV3 +import org.prairieserver.prairie.model.playback.PlaybackTimelineV3 +import org.prairieserver.prairie.model.playback.PlaybackTrackIdentityV3 +import org.prairieserver.prairie.model.playback.SelectedPlaybackTracksV3 +import org.prairieserver.prairie.model.playback.SubtitleFidelityPreference +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.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 +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class PlaybackPublicationSettlementIntegrationTest { + @Test + fun `joint rollback converges before failed B cleanup and next C drains orphan`() = + runTest { + val harness = SettlementHarness( + scope = backgroundScope, + starts = listOf( + response(plan("session-a", 42)), + response(plan("session-b", 84)), + response(plan("session-c", 126)), + ), + stopThrowableBehavior = { sessionId, attempt -> + if (sessionId == "session-b" && attempt <= 2) { + AssertionError("raw stop failure $attempt") + } else { + null + } + }, + ) + harness.startAndAdopt(fileId = 42, deferPublication = false) + harness.startAndAdopt(fileId = 84, deferPublication = true) + + assertTrue( + harness.lifecycle.rollbackCurrentPendingPublication { sessionId -> + harness.manager.rollbackUnpublishedVideoSession(sessionId) + }, + ) + + assertEquals("session-a", harness.manager.activeSessionIdForTest()) + assertEquals("session-a", harness.lifecycle.activeSessionId()) + assertEquals(setOf("session-b"), harness.manager.orphanedSessionIdsForTest()) + assertEquals(2, harness.stopAttempts("session-b")) + + harness.startAndAdopt(fileId = 126, deferPublication = true) + + assertEquals("session-c", harness.manager.activeSessionIdForTest()) + assertEquals("session-c", harness.lifecycle.activeSessionId()) + assertEquals(emptySet(), harness.manager.orphanedSessionIdsForTest()) + assertEquals(3, harness.stopAttempts("session-b")) + } + + @Test + fun `joint rollback cancellation converges lifecycle before cancellation surfaces`() = + runTest { + val stopEntered = CompletableDeferred() + val releaseStop = CompletableDeferred() + val harness = SettlementHarness( + scope = backgroundScope, + starts = listOf( + response(plan("session-a", 42)), + response(plan("session-b", 84)), + response(plan("session-c", 126)), + ), + stopBehavior = { sessionId, attempt -> + if (sessionId == "session-b" && attempt == 1) { + stopEntered.complete(Unit) + releaseStop.await() + } + HttpStatusCode.OK + }, + ) + harness.startAndAdopt(fileId = 42, deferPublication = false) + harness.startAndAdopt(fileId = 84, deferPublication = true) + + val rollback = async { + harness.lifecycle.rollbackCurrentPendingPublication { sessionId -> + harness.manager.rollbackUnpublishedVideoSession(sessionId) + } + } + stopEntered.await() + rollback.cancel(CancellationException("cancel joint rollback")) + releaseStop.complete(Unit) + rollback.join() + + assertEquals("session-a", harness.manager.activeSessionIdForTest()) + assertEquals("session-a", harness.lifecycle.activeSessionId()) + assertEquals(emptySet(), harness.manager.orphanedSessionIdsForTest()) + assertFailsWith { rollback.await() } + + harness.startAndAdopt(fileId = 126, deferPublication = true) + assertEquals("session-c", harness.manager.activeSessionIdForTest()) + assertEquals("session-c", harness.lifecycle.activeSessionId()) + } + + @Test + fun `post Ready failure restores A stops B and leaves the next load unblocked`() = + runTest { + val harness = SettlementHarness( + scope = backgroundScope, + starts = listOf( + response(plan("session-a", 42)), + response(plan("session-b", 84)), + response(plan("session-c", 126)), + ), + ) + harness.startAndAdopt(fileId = 42, deferPublication = false) + harness.startAndAdopt(fileId = 84, deferPublication = true) + + assertTrue( + harness.lifecycle.rollbackCurrentPendingPublication { sessionId -> + harness.manager.rollbackUnpublishedVideoSession(sessionId) + }, + ) + assertEquals("session-a", harness.manager.activeSessionIdForTest()) + assertEquals("session-a", harness.lifecycle.activeSessionId()) + assertEquals(mapOf("session-b" to 1), harness.stopCounts()) + + harness.startAndAdopt(fileId = 126, deferPublication = true) + assertEquals("session-c", harness.manager.activeSessionIdForTest()) + assertEquals("session-c", harness.lifecycle.activeSessionId()) + + assertTrue( + harness.lifecycle.rollbackCurrentPendingPublication { sessionId -> + harness.manager.rollbackUnpublishedVideoSession(sessionId) + }, + ) + assertEquals("session-a", harness.manager.activeSessionIdForTest()) + assertEquals("session-a", harness.lifecycle.activeSessionId()) + assertEquals( + mapOf("session-b" to 1, "session-c" to 1), + harness.stopCounts(), + ) + } + + @Test + fun `seek waits for joint B rollback and never splits lifecycle from manager UI`() = + runTest { + val planA = plan("session-a", 42) + val harness = SettlementHarness( + scope = backgroundScope, + starts = listOf( + response( + planA, + features = listOf( + PLAYBACK_PLAN_V3_FEATURE, + SEEK_REANCHOR_V3_FEATURE, + ), + ), + response(plan("session-b", 84)), + ), + replanResponse = response( + planA.copy( + timeline = PlaybackTimelineV3( + sourceStartSeconds = 90.0, + streamOriginSeconds = 90.0, + playerStartSeconds = 0.0, + timelineOffsetSeconds = 90.0, + canSeekAnywhere = false, + seekRestoration = "source_position", + ), + ), + features = listOf( + PLAYBACK_PLAN_V3_FEATURE, + SEEK_REANCHOR_V3_FEATURE, + ), + ), + ) + harness.startAndAdopt(fileId = 42, deferPublication = false) + harness.startAndAdopt(fileId = 84, deferPublication = true) + + val seek = async { + harness.manager.reanchorActiveVideoSession(positionSeconds = 90.0) + } + repeat(3) { yield() } + + assertFalse(seek.isCompleted) + assertEquals(0, harness.replanCalls) + assertEquals("session-b", harness.manager.activeSessionIdForTest()) + assertEquals("session-b", harness.lifecycle.activeSessionId()) + + assertTrue( + harness.lifecycle.rollbackCurrentPendingPublication { sessionId -> + harness.manager.rollbackUnpublishedVideoSession(sessionId) + }, + ) + assertIs>(seek.await()) + + assertEquals(1, harness.replanCalls) + assertEquals("session-a", harness.manager.activeSessionIdForTest()) + assertEquals("session-a", harness.lifecycle.activeSessionId()) + } + + @Test + fun `fresh load preflight rolls pending B back before failed C and exit stops A`() = + runTest { + val harness = SettlementHarness( + scope = backgroundScope, + starts = listOf( + response(plan("session-a", 42)), + response(plan("session-b", 84)), + ), + ) + harness.startAndAdopt(fileId = 42, deferPublication = false) + harness.startAndAdopt(fileId = 84, deferPublication = true) + + assertTrue( + harness.lifecycle.rollbackCurrentPendingPublication { sessionId -> + harness.manager.rollbackUnpublishedVideoSession(sessionId) + }, + ) + + // C fails before allocating a replacement. Both authoritative + // owners and the lifecycle-backed UI must still expose A. + assertEquals("session-a", harness.manager.activeSessionIdForTest()) + assertEquals("session-a", harness.lifecycle.activeSessionId()) + + // A stale B cleanup arriving after the preflight is harmless. + assertFalse(harness.manager.rollbackUnpublishedVideoSession("session-b")) + assertFalse(harness.lifecycle.rollbackUnpublishedActiveSession("session-b")) + assertEquals("session-a", harness.manager.activeSessionIdForTest()) + assertEquals("session-a", harness.lifecycle.activeSessionId()) + + harness.lifecycle.stop() + + assertEquals(null, harness.manager.activeSessionIdForTest()) + assertEquals(null, harness.lifecycle.activeSessionId()) + assertEquals( + mapOf("session-a" to 1, "session-b" to 1), + harness.stopCounts(), + ) + } + + @Test + fun `joint rollback restores manager and lifecycle predecessor`() = runTest { + val harness = SettlementHarness( + scope = backgroundScope, + starts = listOf( + response(plan("session-a", 42)), + response(plan("session-b", 84)), + ), + ) + harness.startAndAdopt(fileId = 42, deferPublication = false) + harness.startAndAdopt(fileId = 84, deferPublication = true) + + assertTrue( + harness.lifecycle.settlePendingPublicationIfCurrent( + sessionId = "session-b", + confirm = false, + settleManager = { + harness.manager.rollbackUnpublishedVideoSession("session-b") + }, + ), + ) + + assertEquals("session-a", harness.manager.activeSessionIdForTest()) + assertEquals("session-a", harness.lifecycle.activeSessionId()) + assertEquals(mapOf("session-b" to 1), harness.stopCounts()) + } + + @Test + fun `joint confirm retains replacement in manager and lifecycle and stops predecessor`() = + runTest { + val harness = SettlementHarness( + scope = backgroundScope, + starts = listOf( + response(plan("session-a", 42)), + response(plan("session-b", 84)), + ), + ) + harness.startAndAdopt(fileId = 42, deferPublication = false) + harness.startAndAdopt(fileId = 84, deferPublication = true) + + assertTrue( + harness.lifecycle.settlePendingPublicationIfCurrent( + sessionId = "session-b", + confirm = true, + settleManager = { + harness.manager.confirmVideoSessionPublication("session-b") + }, + ), + ) + harness.awaitStopped("session-a") + + assertEquals("session-b", harness.manager.activeSessionIdForTest()) + assertEquals("session-b", harness.lifecycle.activeSessionId()) + assertEquals(mapOf("session-a" to 1), harness.stopCounts()) + } + + @Test + fun `reset cannot enter lifecycle between manager and lifecycle confirmation`() = runTest { + val harness = SettlementHarness( + scope = backgroundScope, + starts = listOf( + response(plan("session-a", 42)), + response(plan("session-b", 84)), + response(plan("session-c", 126)), + ), + ) + harness.startAndAdopt(fileId = 42, deferPublication = false) + harness.startAndAdopt(fileId = 84, deferPublication = true) + val managerConfirmed = CompletableDeferred() + val releaseLifecycleConfirmation = CompletableDeferred() + + val settleB = async { + harness.lifecycle.settlePendingPublicationIfCurrent( + sessionId = "session-b", + confirm = true, + settleManager = { + val confirmed = + harness.manager.confirmVideoSessionPublication("session-b") + managerConfirmed.complete(Unit) + releaseLifecycleConfirmation.await() + confirmed + }, + ) + } + managerConfirmed.await() + + val readyC = harness.startManager(fileId = 126, deferPublication = true) + val adoptC = async { + harness.adopt( + ready = readyC, + fileId = 126, + deferPublication = true, + ) + } + yield() + + assertFalse(adoptC.isCompleted) + assertEquals("session-c", harness.manager.activeSessionIdForTest()) + assertEquals("session-b", harness.lifecycle.activeSessionId()) + + releaseLifecycleConfirmation.complete(Unit) + assertTrue(settleB.await()) + assertTrue(adoptC.await()) + + assertEquals("session-c", harness.manager.activeSessionIdForTest()) + assertEquals("session-c", harness.lifecycle.activeSessionId()) + assertTrue( + harness.lifecycle.settlePendingPublicationIfCurrent( + sessionId = "session-c", + confirm = false, + settleManager = { + harness.manager.rollbackUnpublishedVideoSession("session-c") + }, + ), + ) + assertEquals("session-b", harness.manager.activeSessionIdForTest()) + assertEquals("session-b", harness.lifecycle.activeSessionId()) + } + + @Test + fun `concurrent exit waits for rollback settlement then stops restored owner`() = runTest { + val harness = SettlementHarness( + scope = backgroundScope, + starts = listOf( + response(plan("session-a", 42)), + response(plan("session-b", 84)), + ), + ) + harness.startAndAdopt(fileId = 42, deferPublication = false) + harness.startAndAdopt(fileId = 84, deferPublication = true) + val settlementEntered = CompletableDeferred() + val releaseSettlement = CompletableDeferred() + + val settlement = async { + harness.lifecycle.settlePendingPublicationIfCurrent( + sessionId = "session-b", + confirm = false, + settleManager = { + settlementEntered.complete(Unit) + releaseSettlement.await() + harness.manager.rollbackUnpublishedVideoSession("session-b") + }, + ) + } + settlementEntered.await() + val exit = async { harness.lifecycle.stop() } + yield() + + assertFalse(exit.isCompleted) + + releaseSettlement.complete(Unit) + assertTrue(settlement.await()) + exit.await() + + assertEquals(null, harness.manager.activeSessionIdForTest()) + assertEquals(null, harness.lifecycle.activeSessionId()) + assertEquals( + mapOf("session-a" to 1, "session-b" to 1), + harness.stopCounts(), + ) + } + + private class SettlementHarness( + scope: kotlinx.coroutines.CoroutineScope, + private val starts: List, + private val replanResponse: PlaybackDecisionResponseV3? = null, + private val stopBehavior: suspend (sessionId: String, attempt: Int) -> HttpStatusCode = + { _, _ -> HttpStatusCode.OK }, + private val stopThrowableBehavior: (sessionId: String, attempt: Int) -> Throwable? = + { _, _ -> null }, + ) { + private val startIndex = AtomicInteger() + private val stoppedEvents = Channel(Channel.UNLIMITED) + private val stoppedSessions: MutableList = + Collections.synchronizedList(mutableListOf()) + private val stopAttemptCounts: MutableMap = + Collections.synchronizedMap(mutableMapOf()) + var replanCalls: Int = 0 + private set + private val client = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + var responseStatus = HttpStatusCode.OK + val body = when { + path == "/api/v1/playback/start" -> + PrairieJson.encodeToString(starts[startIndex.getAndIncrement()]) + path.endsWith("/replan") -> { + replanCalls += 1 + PrairieJson.encodeToString(requireNotNull(replanResponse)) + } + request.method == HttpMethod.Delete && + path.startsWith("/api/v1/playback/") -> { + val sessionId = path.substringAfterLast('/') + val attempt = synchronized(stopAttemptCounts) { + val next = (stopAttemptCounts[sessionId] ?: 0) + 1 + stopAttemptCounts[sessionId] = next + next + } + stoppedSessions += sessionId + stoppedEvents.send(sessionId) + stopThrowableBehavior(sessionId, attempt)?.let { throw it } + responseStatus = stopBehavior(sessionId, attempt) + "{}" + } + else -> "{}" + } + respond( + content = body, + status = responseStatus, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val manager = PlaybackSessionManager( + playbackRepository = PlaybackRepository(PlaybackApi(client)), + tokenManager = SettlementTokenManager, + ) + val lifecycle = PlaybackSessionLifecycle( + sessionManager = manager, + profileRepository = SettlementProfileRepository(), + healthApi = SettlementHealthApi(), + personalDataRepository = SettlementPersonalDataRepository(), + scope = scope, + ) + + suspend fun startAndAdopt( + fileId: Int, + deferPublication: Boolean, + ): VideoSessionStartV3.Ready { + val ready = startManager(fileId, deferPublication) + assertTrue(adopt(ready, fileId, deferPublication)) + return ready + } + + suspend fun startManager( + fileId: Int, + deferPublication: Boolean, + ): VideoSessionStartV3.Ready = assertIs( + assertIs>( + manager.startVideoSessionV3( + fileId = fileId, + profileId = "profile-1", + capabilities = capabilities, + clientPlaybackContext = playbackContext, + audioTrackIndex = 0, + subtitleTrackIndex = null, + qualityPreference = "original", + startPosition = 0.0, + subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, + deferPublication = deferPublication, + ), + ).data, + ) + + suspend fun adopt( + ready: VideoSessionStartV3.Ready, + fileId: Int, + deferPublication: Boolean, + ): Boolean = lifecycle.adoptActiveSessionIfCurrent( + params = StartParams( + contentId = "content-$fileId", + fileId = fileId, + capabilities = capabilities, + audioTrackIndex = 0, + subtitleTrackIndex = null, + qualityPreference = "original", + startPosition = 0.0, + clientPlaybackContext = playbackContext, + ), + session = ready.session, + manageProgress = false, + renewMissingSessionWithLegacyStart = false, + deferPublication = deferPublication, + isCurrent = { true }, + ) + + suspend fun awaitStopped(sessionId: String) { + if (sessionId in stoppedSessions) return + while (stoppedEvents.receive() != sessionId) { + // Drain unrelated manager-owned cleanup completions. + } + } + + fun stopCounts(): Map = + stoppedSessions.groupingBy { it }.eachCount() + + fun stopAttempts(sessionId: String): Int = + stopAttemptCounts[sessionId] ?: 0 + + private companion object { + val capabilities = ClientCodecCapabilities( + codecsVideo = listOf("hevc"), + codecsAudio = listOf("eac3"), + containers = listOf("mkv"), + ) + val playbackContext = ClientPlaybackContext( + formFactor = "tv", + appVersion = "test", + output = PlaybackOutputContext(outputRouteGeneration = 7), + ) + } + } + + private companion object { + fun response( + plan: PlaybackPlanV3, + features: List = listOf(PLAYBACK_PLAN_V3_FEATURE), + ): PlaybackDecisionResponseV3 = + PlaybackDecisionResponseV3( + protocolVersion = 3, + serverFeatures = features, + outcome = PlaybackDecisionOutcome.PLAYABLE, + sessionId = plan.sessionId, + playbackPlan = plan, + ) + + fun plan(sessionId: String, fileId: Int): PlaybackPlanV3 = PlaybackPlanV3( + planId = "plan-$sessionId", + sessionId = sessionId, + delivery = PlaybackDelivery.SERVER_REMUX_HLS, + engine = PlaybackEngineKind.MEDIA3_HLS, + stream = PlaybackStreamV3( + url = "/stream/$sessionId/master.m3u8", + protocol = PlaybackStreamProtocol.HLS, + container = "mpegts", + mimeType = "application/x-mpegURL", + ), + selectedTracks = SelectedPlaybackTracksV3( + audio = PlaybackTrackIdentityV3("file:$fileId:audio:0", 0), + ), + effectiveRecipe = PlaybackEffectiveRecipeV3( + videoCodec = "hevc", + audioCodec = "eac3", + ), + decisionReason = "test", + requestedMediaFileId = fileId, + effectiveMediaFileId = fileId, + ) + } +} + +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")) +} + +private class SettlementPersonalDataRepository : PersonalDataRepository( + personalDataApi = PersonalDataApi(HttpClient()), +) { + override suspend fun syncProgress(items: List): ApiResult = + ApiResult.Success(Unit) +} + +private object SettlementTokenManager : 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/PlaybackSessionLifecycleLoggingTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleLoggingTest.kt index 9198a22dc..6c94cd62a 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 @@ -2,6 +2,7 @@ package org.prairieserver.prairie.common.player import kotlin.test.Test import kotlin.test.assertFalse +import kotlin.test.assertTrue class PlaybackSessionLifecycleLoggingTest { private val source = java.io.File( @@ -17,4 +18,22 @@ class PlaybackSessionLifecycleLoggingTest { "Playback lifecycle logs must not include raw playback session identifiers", ) } + + @Test + fun aNewStartWaitsForAnAsynchronousStopToFinish() { + 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(") + .contains("awaitPendingStop()"), + ) + assertTrue( + text.substringAfter("suspend fun adoptActiveSession(") + .substringBefore("private suspend fun startInternal(") + .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 9d60717e6..6553438aa 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 @@ -16,17 +16,22 @@ 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 import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.yield import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -97,6 +102,93 @@ class PlaybackSessionLifecycleTest { lifecycle.stop() } + @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>() + 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() + } + } + 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 lifecycle = newLifecycle( @@ -184,6 +276,118 @@ class PlaybackSessionLifecycleTest { assertTrue(lifecycle.state.value is SessionState.Idle) } + @Test + fun `adoption captured before stop is rejected and server session is closed`() = runTest { + val sessionMgr = FakeSessionManager() + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + val ownershipEpoch = lifecycle.acquireOwnershipEpoch() + + lifecycle.stop() + val adopted = lifecycle.adoptActiveSessionIfCurrent( + params = defaultStartParams(), + session = makeSession("sess-late-adopt"), + expectedOwnershipEpoch = ownershipEpoch, + ) + + assertFalse(adopted) + assertEquals(SessionState.Idle, lifecycle.state.value) + assertEquals("sess-late-adopt", sessionMgr.lastStoppedSessionId) + } + + @Test + fun `epoch adoption can defer publication for the first session`() = runTest { + val sessionMgr = FakeSessionManager() + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + val ownershipEpoch = lifecycle.acquireOwnershipEpoch() + + assertTrue( + lifecycle.adoptActiveSessionIfCurrent( + params = defaultStartParams(), + session = makeSession("sess-first"), + deferPublication = true, + expectedOwnershipEpoch = ownershipEpoch, + ), + ) + + assertTrue(lifecycle.confirmActiveSessionPublication("sess-first")) + assertEquals( + "sess-first", + (lifecycle.state.value as SessionState.Active).session.sessionId, + ) + } + + @Test + fun `ownership acquisition waits for queued stop before accepting a new start`() = runTest { + val oldStopEntered = CompletableDeferred() + val releaseOldStop = CompletableDeferred() + val stopped = mutableListOf() + val sessionMgr = object : FakeSessionManager() { + override suspend fun stopSession(sessionId: String): ApiResult { + stopped += sessionId + if (sessionId == "sess-old") { + oldStopEntered.complete(Unit) + releaseOldStop.await() + } + return ApiResult.Success(Unit) + } + } + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-old")) + + lifecycle.stopAsync() + oldStopEntered.await() + val epoch = async { lifecycle.acquireOwnershipEpoch() } + assertFalse(epoch.isCompleted) + + releaseOldStop.complete(Unit) + val acquired = epoch.await() + assertTrue( + lifecycle.adoptActiveSessionIfCurrent( + params = defaultStartParams(), + session = makeSession("sess-new"), + expectedOwnershipEpoch = acquired, + ), + ) + assertEquals("sess-new", (lifecycle.state.value as SessionState.Active).session.sessionId) + assertEquals(listOf("sess-old"), stopped) + } + + @Test + fun `cancelled adoption closes the allocated server session`() = runTest { + val oldStopEntered = CompletableDeferred() + val releaseOldStop = CompletableDeferred() + val candidateStopped = CompletableDeferred() + val sessionMgr = object : FakeSessionManager() { + override suspend fun stopSession(sessionId: String): ApiResult { + when (sessionId) { + "sess-old" -> { + oldStopEntered.complete(Unit) + releaseOldStop.await() + } + "sess-candidate" -> candidateStopped.complete(Unit) + } + return ApiResult.Success(Unit) + } + } + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-old")) + lifecycle.stopAsync() + oldStopEntered.await() + + val adoption = async { + lifecycle.adoptActiveSessionIfCurrent( + params = defaultStartParams(), + session = makeSession("sess-candidate"), + expectedOwnershipEpoch = 0L, + ) + } + yield() + adoption.cancelAndJoin() + + assertTrue(candidateStopped.isCompleted) + releaseOldStop.complete(Unit) + } + @Test fun `reportPosition with 404 triggers session-missing recovery and re-starts`() = runTest { val sessionMgr = FakeSessionManager().apply { @@ -621,6 +825,7 @@ private open class FakeSessionManager : PlaybackSessionManager( 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 @@ -655,6 +860,7 @@ private open class FakeSessionManager : PlaybackSessionManager( override suspend fun stopSession(sessionId: String): ApiResult { stopCallCount++ + lastStoppedSessionId = sessionId return stopResult } } 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 60ea42a0a..125bf7778 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 @@ -37,6 +37,9 @@ 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.PlaybackSubtitleModeV3 import org.prairieserver.prairie.model.playback.PlaybackTimelineV3 import org.prairieserver.prairie.model.playback.PlaybackTrackIdentityV3 import org.prairieserver.prairie.model.playback.SEEK_FAILURE_RECOVERY_V3_OPERATION @@ -401,6 +404,15 @@ class PlaybackSessionManagerSeekReanchorTest { audio = PlaybackTrackIdentityV3("file:84:audio:2", 2), subtitle = PlaybackTrackIdentityV3("file:84:subtitle:3", 3), ), + subtitle = PlaybackSubtitleDecisionV3( + mode = PlaybackSubtitleModeV3.RENDER, + trackId = "file:84:subtitle:3", + artifact = PlaybackSubtitleArtifactV3( + url = "/stream/session-1/subtitles/3.vtt", + mimeType = "text/vtt", + format = "webvtt", + ), + ), ) val harness = Harness(response(initial)) { _, _ -> success(response(replanned)) } harness.manager.start() 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 new file mode 100644 index 000000000..1bd1e4783 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt @@ -0,0 +1,1592 @@ +package org.prairieserver.prairie.common.player + +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 java.io.File +import java.util.Collections +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +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.jsonObject +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.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.PlaybackSubtitleModeV3 +import org.prairieserver.prairie.model.playback.PlaybackTerminalV3 +import org.prairieserver.prairie.model.playback.PlaybackTrackIdentityV3 +import org.prairieserver.prairie.model.playback.SelectedPlaybackTracksV3 +import org.prairieserver.prairie.model.playback.SubtitleFidelityPreference +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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class PlaybackSessionManagerStagedReplanTest { + @Test + fun `deferred confirmation registers predecessor orphan before releasing reset waiter`() { + val source = File( + "src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt", + ).readText() + val confirmation = source + .substringAfter("suspend fun confirmVideoSessionPublication(") + .substringBefore("suspend fun rollbackUnpublishedVideoSession(") + + val orphanRegistration = confirmation.indexOf("orphanedSessionIds +=") + val waiterRelease = confirmation.indexOf("pending.settled.complete(Unit)") + val registeredCleanup = confirmation.indexOf( + "scheduleRegisteredCommittedSessionCleanup(", + ) + + assertTrue(orphanRegistration >= 0) + assertTrue(orphanRegistration < waiterRelease) + assertTrue(registeredCleanup > waiterRelease) + } + + @Test + fun `deferred confirm cleanup concurrent with orphan drain loses no ledger entry`() = + runTest { + val firstCleanupEntered = CompletableDeferred() + val releaseFirstCleanup = CompletableDeferred() + val oldAttempts = AtomicInteger() + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + stopBehavior = { sessionId -> + if (sessionId == "s1" && oldAttempts.incrementAndGet() == 1) { + firstCleanupEntered.complete(Unit) + releaseFirstCleanup.await() + } + }, + ) + harness.start() + val staged = harness.stageSidecar() + harness.manager.commitStagedVideoReplan( + staged = staged, + deferPublication = true, + ) + + assertTrue(harness.manager.confirmVideoSessionPublication("s2")) + firstCleanupEntered.await() + assertEquals(setOf("s1"), harness.manager.orphanedSessionIdsForTest()) + + // stopSession drains the same orphan ledger while confirmation's + // asynchronous cleanup still owns its first network attempt. + harness.manager.stopSession("s2") + releaseFirstCleanup.complete(Unit) + withTimeout(5_000) { + while (harness.manager.orphanedSessionIdsForTest().isNotEmpty()) { + yield() + } + } + + assertTrue(oldAttempts.get() >= 2) + assertEquals(emptySet(), harness.manager.orphanedSessionIdsForTest()) + assertTrue("s1" in harness.stoppedSessions) + assertTrue("s2" in harness.stoppedSessions) + } + + @Test + fun `staging replacement does not swap attempt or stop old session`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start() + + val staged = harness.manager.stageActiveVideoSessionReplan( + classification = "subtitle_track_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + + val candidate = assertIs>(staged).data + assertEquals("s2", candidate.candidateSessionId) + assertEquals("s1", harness.manager.activeSessionIdForTest()) + assertEquals(emptyList(), harness.stoppedSessions) + } + + @Test + fun `staged replacement exposes manager derived output route generation`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start() + + val staged = assertIs>( + harness.manager.stageActiveVideoSessionReplan( + classification = "output_route_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + clientPlaybackContext = ClientPlaybackContext( + formFactor = "tv", + appVersion = "test", + output = PlaybackOutputContext(outputRouteGeneration = 11), + ), + ), + ).data + + assertEquals(11, staged.outputRouteGeneration) + } + + @Test + fun `commit swaps once then stops old session`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start() + val staged = assertIs>( + harness.manager.stageActiveVideoSessionReplan( + classification = "subtitle_track_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ), + ).data + + val committed = harness.manager.commitStagedVideoReplan(staged) + + assertEquals( + "s2", + assertIs>(committed).data.session.sessionId, + ) + assertEquals("s2", harness.manager.activeSessionIdForTest()) + harness.awaitStopped("s1") + assertEquals(listOf("s1"), harness.stoppedSessions) + + val consumed = harness.manager.commitStagedVideoReplan(staged) + assertEquals(409, assertIs(consumed).code) + assertEquals("s2", harness.manager.activeSessionIdForTest()) + assertEquals(listOf("s1"), harness.stoppedSessions) + } + + @Test + fun `deferred staged commit rollback restores base and unblocks replan from base`() = runTest { + val harness = Harness( + replanResponse = { index, _ -> + response(sidecarPlan(sessionId = if (index == 0) "s2" else "s3")) + }, + ) + harness.start() + val replacement = harness.stageSidecar() + + assertIs>( + harness.manager.commitStagedVideoReplan( + staged = replacement, + deferPublication = true, + ), + ) + assertEquals("s2", harness.manager.activeSessionIdForTest()) + assertEquals(emptyList(), harness.stoppedSessions) + + val reverseMutation = async { + harness.manager.stageActiveVideoSessionReplan( + classification = "output_route_changed", + positionSeconds = 43.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + } + yield() + assertFalse(reverseMutation.isCompleted) + assertEquals(listOf("s1"), harness.replanBaseSessions) + + harness.manager.rollbackUnpublishedVideoSession("s2") + val stagedFromBase = + assertIs>(reverseMutation.await()).data + + assertEquals("s3", stagedFromBase.candidateSessionId) + assertEquals(listOf("s1", "s1"), harness.replanBaseSessions) + assertEquals("s1", harness.manager.activeSessionIdForTest()) + assertEquals(mapOf("s2" to 1), harness.stoppedSessions.groupingBy { it }.eachCount()) + } + + @Test + fun `deferred staged commit confirmation retains replacement and stops base once`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start() + val replacement = harness.stageSidecar() + + assertIs>( + harness.manager.commitStagedVideoReplan( + staged = replacement, + deferPublication = true, + ), + ) + assertEquals("s2", harness.manager.activeSessionIdForTest()) + assertEquals(emptyList(), harness.stoppedSessions) + + harness.manager.confirmVideoSessionPublication("s2") + harness.manager.confirmVideoSessionPublication("s2") + + harness.awaitStopped("s1") + assertEquals("s2", harness.manager.activeSessionIdForTest()) + assertEquals(mapOf("s1" to 1), harness.stoppedSessions.groupingBy { it }.eachCount()) + } + + @Test + fun `committed predecessor cleanup can be owned and awaited by the caller scope`() = runTest { + val cleanupEntered = CompletableDeferred() + val releaseCleanup = CompletableDeferred() + val harness = Harness( + committedSessionCleanupScope = backgroundScope, + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + stopBehavior = { + cleanupEntered.complete(Unit) + releaseCleanup.await() + }, + ) + harness.start() + val replacement = harness.stageSidecar() + assertIs>( + harness.manager.commitStagedVideoReplan( + staged = replacement, + deferPublication = true, + ), + ) + + assertTrue(harness.manager.confirmVideoSessionPublication("s2")) + assertEquals(emptyList(), harness.stoppedSessions) + + cleanupEntered.await() + + assertEquals(emptyList(), harness.stoppedSessions) + releaseCleanup.complete(Unit) + harness.awaitStopped("s1") + + assertEquals(listOf("s1"), harness.stoppedSessions) + assertEquals("s2", harness.manager.activeSessionIdForTest()) + } + + @Test + fun `commit returns after active swap without waiting for cancellable old session cleanup`() = runTest { + val cleanupEntered = CompletableDeferred() + val releaseCleanup = CompletableDeferred() + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + stopBehavior = { + cleanupEntered.complete(Unit) + releaseCleanup.await() + }, + ) + harness.start() + val staged = harness.stageSidecar() + + val commit = async { harness.manager.commitStagedVideoReplan(staged) } + cleanupEntered.await() + + assertTrue( + commit.isCompleted, + "Once the active attempt swaps, old-session cleanup must not keep commit cancellable.", + ) + assertEquals( + "s2", + assertIs>(commit.await()) + .data.session.sessionId, + ) + assertEquals("s2", harness.manager.activeSessionIdForTest()) + + releaseCleanup.cancel(CancellationException("cleanup cancelled")) + } + + @Test + fun `throwing old session cleanup cannot escape after active swap`() = runTest { + val cleanupEntered = CompletableDeferred() + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + stopBehavior = { + cleanupEntered.complete(Unit) + throw AssertionError("old-session cleanup exploded") + }, + ) + harness.start() + val staged = harness.stageSidecar() + + val committed = harness.manager.commitStagedVideoReplan(staged) + + assertEquals( + "s2", + assertIs>(committed) + .data.session.sessionId, + ) + assertEquals("s2", harness.manager.activeSessionIdForTest()) + cleanupEntered.await() + } + + @Test + fun `failed bounded cleanup remains orphaned until later stop drains it`() = runTest { + val oldAttempts = AtomicInteger() + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + stopBehavior = { sessionId -> + if (sessionId == "s1" && oldAttempts.incrementAndGet() <= 2) { + throw IllegalStateException("transient delete failure") + } + }, + ) + harness.start() + val staged = harness.stageSidecar() + + assertIs>( + harness.manager.commitStagedVideoReplan(staged), + ) + harness.awaitStopAttempts("s1", count = 2) + + harness.manager.stopSession("s2") + + assertEquals(3, oldAttempts.get()) + assertTrue("s1" in harness.stoppedSessions) + assertTrue("s2" in harness.stoppedSessions) + } + + @Test + fun `cancelled cleanup is eventually drained by content reset`() = runTest { + val oldAttempts = AtomicInteger() + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + response(basePlan(sessionId = "s3", fileId = 84)), + ), + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + stopBehavior = { sessionId -> + if (sessionId == "s1" && oldAttempts.incrementAndGet() == 1) { + throw CancellationException("cleanup cancelled") + } + }, + ) + harness.start(fileId = 42) + assertIs>( + harness.manager.commitStagedVideoReplan(harness.stageSidecar()), + ) + harness.awaitStopAttempts("s1", count = 1) + + harness.start(fileId = 84) + + assertTrue(oldAttempts.get() >= 2) + assertTrue("s1" in harness.stoppedSessions) + assertEquals("s3", harness.manager.activeSessionIdForTest()) + } + + @Test + fun `candidate stop exception cannot skip requested stop and is retained for drain`() = runTest { + val candidateAttempts = AtomicInteger() + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + stopBehavior = { sessionId -> + if (sessionId == "s2" && candidateAttempts.incrementAndGet() == 1) { + throw IllegalStateException("candidate stop failed") + } + }, + ) + harness.start() + harness.stageSidecar() + + assertIs>(harness.manager.stopSession("s1")) + + assertTrue("s1" in harness.stoppedSessions) + assertTrue("s2" in harness.stoppedSessions) + assertEquals(2, candidateAttempts.get()) + } + + @Test + fun `requested stop failure still drains prior orphan`() = runTest { + val oldAttempts = AtomicInteger() + val requestedAttempts = AtomicInteger() + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + stopBehavior = { sessionId -> + when { + sessionId == "s1" && oldAttempts.incrementAndGet() <= 2 -> + throw IllegalStateException("old cleanup failed") + sessionId == "s2" && requestedAttempts.incrementAndGet() == 1 -> + throw CancellationException("requested stop cancelled locally") + } + }, + ) + harness.start() + assertIs>( + harness.manager.commitStagedVideoReplan(harness.stageSidecar()), + ) + harness.awaitStopAttempts("s1", count = 2) + + assertIs(harness.manager.stopSession("s2")) + + assertEquals(3, oldAttempts.get()) + assertTrue("s1" in harness.stoppedSessions) + assertTrue("s2" in harness.stoppedSessions) + } + + @Test + fun `caller cancellation waits for contained cleanup then rethrows cancellation`() = runTest { + val candidateEntered = CompletableDeferred() + val releaseCandidate = CompletableDeferred() + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + stopBehavior = { sessionId -> + if (sessionId == "s2") { + candidateEntered.complete(Unit) + releaseCandidate.await() + } + }, + ) + harness.start() + harness.stageSidecar() + + val stopJob = launch { + harness.manager.stopSession("s1") + } + candidateEntered.await() + stopJob.cancel(CancellationException("caller stopped")) + releaseCandidate.complete(Unit) + stopJob.join() + + assertTrue(stopJob.isCancelled) + assertTrue("s1" in harness.stoppedSessions) + assertTrue("s2" in harness.stoppedSessions) + } + + @Test + fun `discard stops only candidate and consumes handle`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start() + val staged = assertIs>( + harness.manager.stageActiveVideoSessionReplan( + classification = "subtitle_track_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ), + ).data + + harness.manager.discardStagedVideoReplan(staged) + harness.manager.discardStagedVideoReplan(staged) + + assertEquals("s1", harness.manager.activeSessionIdForTest()) + assertEquals(listOf("s2"), harness.stoppedSessions) + assertEquals( + 409, + assertIs(harness.manager.commitStagedVideoReplan(staged)).code, + ) + assertEquals(listOf("s2"), harness.stoppedSessions) + } + + @Test + fun `suspended discard cleanup does not hold staged ownership mutex`() = runTest { + val firstStopStarted = CompletableDeferred() + val secondStopStarted = CompletableDeferred() + val releaseFirstStop = CompletableDeferred() + val harness = Harness( + replanResponse = { index, _ -> + response(sidecarPlan(sessionId = if (index == 0) "s2" else "s3")) + }, + stopBehavior = { sessionId -> + when (sessionId) { + "s2" -> { + firstStopStarted.complete(Unit) + releaseFirstStop.await() + } + "s3" -> secondStopStarted.complete(Unit) + } + }, + ) + harness.start() + val first = harness.stageSidecar() + val second = harness.stageSidecar() + + val firstDiscard = launch { harness.manager.discardStagedVideoReplan(first) } + firstStopStarted.await() + val secondDiscard = launch { harness.manager.discardStagedVideoReplan(second) } + try { + withContext(Dispatchers.Default) { + withTimeout(5_000) { secondStopStarted.await() } + } + assertFalse(firstDiscard.isCompleted) + assertTrue("s3" in harness.stopAttempts) + } finally { + releaseFirstStop.complete(Unit) + } + firstDiscard.join() + secondDiscard.join() + + assertEquals(setOf("s2", "s3"), harness.stoppedSessions.toSet()) + } + + @Test + fun `stale handle cannot replace newer committed candidate`() = runTest { + val harness = Harness( + replanResponse = { index, _ -> + response(sidecarPlan(sessionId = if (index == 0) "s2" else "s3")) + }, + ) + harness.start() + val first = harness.stageSidecar() + val second = harness.stageSidecar() + + assertIs>( + harness.manager.commitStagedVideoReplan(second), + ) + val stale = harness.manager.commitStagedVideoReplan(first) + + harness.awaitStopped("s1") + harness.awaitStopped("s2") + assertEquals(409, assertIs(stale).code) + assertEquals("s3", harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s1" to 1, "s2" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `content reset invalidates staged handle`() = runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + response(basePlan(sessionId = "s3", fileId = 84)), + ), + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start(fileId = 42) + val staged = harness.stageSidecar() + + harness.start(fileId = 84) + val stale = harness.manager.commitStagedVideoReplan(staged) + + assertEquals(409, assertIs(stale).code) + assertEquals("s3", harness.manager.activeSessionIdForTest()) + assertEquals(listOf("s2"), harness.stoppedSessions) + } + + @Test + fun `burn in candidate commits without sidecar artifact`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> + response( + sidecarPlan(sessionId = "s2").copy( + subtitle = PlaybackSubtitleDecisionV3( + mode = PlaybackSubtitleModeV3.BURN_IN, + trackId = subtitleTrackId(fileId = 42, index = 4), + ), + ), + ) + }, + ) + harness.start() + + val staged = harness.stageSidecar() + val committed = harness.manager.commitStagedVideoReplan(staged) + + assertIs>(committed) + assertEquals("s2", harness.manager.activeSessionIdForTest()) + harness.awaitStopped("s1") + assertEquals(listOf("s1"), harness.stoppedSessions) + } + + @Test + fun `sidecar candidate without artifact is rejected`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> + response( + sidecarPlan(sessionId = "s2").copy( + subtitle = PlaybackSubtitleDecisionV3( + mode = PlaybackSubtitleModeV3.CONVERT, + trackId = subtitleTrackId(fileId = 42, index = 4), + artifact = null, + ), + ), + ) + }, + ) + harness.start() + + val staged = harness.manager.stageActiveVideoSessionReplan( + classification = "subtitle_track_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + + assertIs(staged) + assertEquals("s1", harness.manager.activeSessionIdForTest()) + assertEquals(listOf("s2"), harness.stoppedSessions) + } + + @Test + fun `sidecar candidate for another server index is rejected`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> + response( + sidecarPlan(sessionId = "s2").copy( + selectedTracks = SelectedPlaybackTracksV3( + audio = audioTrack(fileId = 42), + subtitle = PlaybackTrackIdentityV3( + id = subtitleTrackId(fileId = 42, index = 5), + index = 5, + ), + ), + subtitle = PlaybackSubtitleDecisionV3( + mode = PlaybackSubtitleModeV3.RENDER, + trackId = subtitleTrackId(fileId = 42, index = 5), + artifact = sidecarArtifact(sessionId = "s2", index = 5), + ), + ), + ) + }, + ) + harness.start() + + val staged = harness.manager.stageActiveVideoSessionReplan( + classification = "subtitle_track_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + + assertIs(staged) + assertEquals("s1", harness.manager.activeSessionIdForTest()) + assertEquals(listOf("s2"), harness.stoppedSessions) + } + + @Test + fun `immediate replan wrapper stages and commits replacement`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start() + + val replanned = harness.manager.replanActiveVideoSession( + classification = "subtitle_track_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + + assertEquals( + "s2", + assertIs( + assertIs>(replanned).data, + ).session.sessionId, + ) + assertEquals("s2", harness.manager.activeSessionIdForTest()) + harness.awaitStopped("s1") + assertEquals(listOf("s1"), harness.stoppedSessions) + } + + @Test + fun `content start rejects stage registration until replacement is installed`() = runTest { + val replacementEntered = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + val starts = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + response(basePlan(sessionId = "s3", fileId = 84)), + ) + val harness = Harness( + startResponses = starts, + startResponseOverride = { index -> + if (index == 1) { + replacementEntered.complete(Unit) + releaseReplacement.await() + } + starts[index] + }, + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start(fileId = 42) + + val replacement = async { harness.start(fileId = 84) } + replacementEntered.await() + val duringReset = harness.manager.stageActiveVideoSessionReplan( + classification = "subtitle_track_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + releaseReplacement.complete(Unit) + replacement.await() + + assertEquals("content_reset_in_progress", assertIs(duringReset).error) + assertEquals(emptyList(), harness.replanBodies) + assertEquals("s3", harness.manager.activeSessionIdForTest()) + assertEquals(emptyList(), harness.stoppedSessions) + } + + @Test + fun `unpublished replacement rollback restores predecessor and stops replacement once`() = runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + response(basePlan(sessionId = "s3", fileId = 84)), + ), + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start(fileId = 42) + harness.start(fileId = 84, deferPublication = true) + + harness.manager.rollbackUnpublishedVideoSession("s3") + harness.manager.rollbackUnpublishedVideoSession("s3") + + assertEquals("s1", harness.manager.activeSessionIdForTest()) + harness.awaitStopped("s3") + assertEquals(mapOf("s3" to 1), harness.stoppedSessions.groupingBy { it }.eachCount()) + } + + @Test + fun `default terminal fresh start clears prior active attempt`() = runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + terminalResponse( + sessionId = "s3", + reason = "adaptation_unavailable", + message = "No compatible route.", + ), + ), + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start(fileId = 42) + + harness.start(fileId = 84) + + assertEquals(null, harness.manager.activeSessionIdForTest()) + assertEquals(mapOf("s3" to 1), harness.stoppedSessions.groupingBy { it }.eachCount()) + } + + @Test + fun `deferred terminal fresh start preserves prior active attempt`() = runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + terminalResponse( + sessionId = "s3", + reason = "adaptation_unavailable", + message = "No compatible route.", + ), + ), + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start(fileId = 42) + + harness.start(fileId = 84, deferPublication = true) + + assertEquals("s1", harness.manager.activeSessionIdForTest()) + assertEquals(mapOf("s3" to 1), harness.stoppedSessions.groupingBy { it }.eachCount()) + assertFalse(harness.manager.rollbackUnpublishedVideoSession("s3")) + } + + @Test + fun `deferred legacy fresh start terminal replan restores prior active attempt`() = runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + response( + basePlan(sessionId = "s3", fileId = 84).copy( + engine = PlaybackEngineKind.MPV_DIRECT, + ), + ), + ), + replanResponse = { _, _ -> + terminalResponse( + sessionId = "s4", + reason = "adaptation_unavailable", + message = "No compatible route.", + ) + }, + ) + harness.start(fileId = 42) + + harness.start(fileId = 84, deferPublication = true) + + assertEquals("s1", harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s3" to 1, "s4" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `confirming replacement retains it and stops predecessor once`() = runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + response(basePlan(sessionId = "s3", fileId = 84)), + ), + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start(fileId = 42) + harness.start(fileId = 84, deferPublication = true) + + harness.manager.confirmVideoSessionPublication("s3") + harness.manager.confirmVideoSessionPublication("s3") + + assertEquals("s3", harness.manager.activeSessionIdForTest()) + harness.awaitStopped("s1") + assertEquals(mapOf("s1" to 1), harness.stoppedSessions.groupingBy { it }.eachCount()) + } + + @Test + fun `reverse mutation waits for unpublished replacement rollback then stages from predecessor`() = runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + response(basePlan(sessionId = "s3", fileId = 84)), + ), + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start(fileId = 42) + harness.start(fileId = 84, deferPublication = true) + + val reverseMutation = async { + harness.manager.stageActiveVideoSessionReplan( + classification = "output_route_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + } + yield() + + assertFalse(reverseMutation.isCompleted) + assertEquals(emptyList(), harness.replanBaseSessions) + + harness.manager.rollbackUnpublishedVideoSession("s3") + val staged = assertIs>(reverseMutation.await()).data + + assertEquals("s2", staged.candidateSessionId) + assertEquals(listOf("s1"), harness.replanBaseSessions) + assertEquals("s1", harness.manager.activeSessionIdForTest()) + } + + @Test + fun `new content start waits for unresolved replacement settlement and preserves predecessor`() = + runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + response(basePlan(sessionId = "s3", fileId = 84)), + response(basePlan(sessionId = "s4", fileId = 126)), + ), + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start(fileId = 42) + harness.start(fileId = 84, deferPublication = true) + val nextStart = async { + harness.start(fileId = 126, deferPublication = true) + } + yield() + + assertFalse(nextStart.isCompleted) + assertEquals("s3", harness.manager.activeSessionIdForTest()) + + assertTrue(harness.manager.rollbackUnpublishedVideoSession("s3")) + nextStart.await() + + harness.awaitStopped("s3") + assertEquals("s4", harness.manager.activeSessionIdForTest()) + + harness.manager.rollbackUnpublishedVideoSession("s4") + harness.awaitStopped("s4") + + assertEquals("s1", harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s3" to 1, "s4" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `a start still completes when a deferred publication is never settled`() = runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan()), + response(basePlan(sessionId = "s9", fileId = 43)), + ), + pendingPublicationSettleTimeoutMs = + PlaybackSessionManager.PENDING_PUBLICATION_SETTLE_TIMEOUT_MS, + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start() + val replacement = harness.stageSidecar() + assertIs>( + harness.manager.commitStagedVideoReplan( + staged = replacement, + deferPublication = true, + ), + ) + + // Deliberately leave the publication unresolved. Production must + // eventually roll it back so a later content start cannot wedge. + harness.start(fileId = 43) + + assertEquals("s9", harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s2" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + "the abandoned publication should be rolled back exactly once", + ) + assertFalse(harness.manager.rollbackUnpublishedVideoSession("s2")) + assertFalse(harness.manager.confirmVideoSessionPublication("s2")) + assertTrue(harness.manager.rollbackCurrentPendingVideoPublication()) + assertEquals( + mapOf("s2" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + "late settlement must not issue a second stop", + ) + } + + @Test + fun `stopping unpublished replacement is an idempotent rollback to predecessor`() = runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + response(basePlan(sessionId = "s3", fileId = 84)), + ), + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start(fileId = 42) + harness.start(fileId = 84, deferPublication = true) + + harness.manager.stopSession("s3") + harness.manager.rollbackUnpublishedVideoSession("s3") + + assertEquals("s1", harness.manager.activeSessionIdForTest()) + assertEquals(mapOf("s3" to 1), harness.stoppedSessions.groupingBy { it }.eachCount()) + } + + @Test + fun `stopping predecessor clears unresolved replacement and stops both once`() = runTest { + val harness = Harness( + startResponses = listOf( + response(basePlan(sessionId = "s1", fileId = 42)), + response(basePlan(sessionId = "s3", fileId = 84)), + ), + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start(fileId = 42) + harness.start(fileId = 84, deferPublication = true) + + harness.manager.stopSession("s1") + + assertEquals(null, harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s1" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + assertFalse(harness.manager.confirmVideoSessionPublication("s3")) + assertFalse(harness.manager.rollbackUnpublishedVideoSession("s3")) + assertEquals( + mapOf("s1" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `stop session invalidates and stops every distinct staged candidate`() = runTest { + val harness = Harness( + replanResponse = { index, _ -> + response(sidecarPlan(sessionId = if (index == 0) "s2" else "s3")) + }, + ) + harness.start() + val first = harness.stageSidecar() + val second = harness.stageSidecar() + + harness.manager.stopSession("s1") + + assertEquals(null, harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s1" to 1, "s2" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + assertEquals(409, assertIs(harness.manager.commitStagedVideoReplan(first)).code) + assertEquals(409, assertIs(harness.manager.commitStagedVideoReplan(second)).code) + assertEquals( + mapOf("s1" to 1, "s2" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `stopping active replacement drains stale older base handle`() = runTest { + val harness = Harness( + replanResponse = { index, _ -> + response(sidecarPlan(sessionId = if (index == 0) "s2" else "s3")) + }, + ) + harness.start() + val stale = harness.stageSidecar() + val replacement = harness.stageSidecar() + assertIs>( + harness.manager.commitStagedVideoReplan(replacement), + ) + harness.awaitStopped("s1") + + harness.manager.stopSession("s3") + + assertEquals(null, harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s1" to 1, "s2" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + assertEquals( + 409, + assertIs(harness.manager.commitStagedVideoReplan(stale)).code, + ) + assertEquals( + mapOf("s1" to 1, "s2" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `delayed stop for stale session leaves active staged transaction untouched`() = runTest { + val harness = Harness( + replanResponse = { index, _ -> + response(sidecarPlan(sessionId = if (index == 0) "s2" else "s3")) + }, + ) + harness.start() + val replacement = harness.stageSidecar() + assertIs>( + harness.manager.commitStagedVideoReplan(replacement), + ) + harness.awaitStopped("s1") + val stagedFromS2 = harness.stageSidecar() + + harness.manager.stopSession("s1") + + assertEquals("s2", harness.manager.activeSessionIdForTest()) + assertEquals(mapOf("s1" to 2), harness.stoppedSessions.groupingBy { it }.eachCount()) + + assertIs>( + harness.manager.commitStagedVideoReplan(stagedFromS2), + ) + harness.awaitStopped("s2") + assertEquals("s3", harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s1" to 2, "s2" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `stale stop drains only matching base owner of shared candidate`() = runTest { + val harness = Harness( + replanResponse = { index, _ -> + response( + sidecarPlan( + sessionId = when (index) { + 1 -> "s2" + else -> "s3" + }, + ), + ) + }, + ) + harness.start() + harness.stageSidecar() + val replacement = harness.stageSidecar() + assertIs>( + harness.manager.commitStagedVideoReplan(replacement), + ) + harness.awaitStopped("s1") + val stagedFromS2 = harness.stageSidecar() + + harness.manager.stopSession("s1") + + assertEquals("s2", harness.manager.activeSessionIdForTest()) + assertEquals(mapOf("s1" to 2), harness.stoppedSessions.groupingBy { it }.eachCount()) + + harness.manager.discardStagedVideoReplan(stagedFromS2) + + assertEquals("s2", harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s1" to 2, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `concurrent immediate replans serialize through both stage and commit`() = runTest { + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val harness = Harness( + replanResponse = { index, _ -> + if (index == 0) { + firstEntered.complete(Unit) + releaseFirst.await() + } + response(sidecarPlan(sessionId = if (index == 0) "s2" else "s3")) + }, + ) + harness.start() + + val first = async { + harness.manager.replanActiveVideoSession( + classification = "subtitle_track_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + } + firstEntered.await() + val second = async { + harness.manager.replanActiveVideoSession( + classification = "subtitle_track_changed", + positionSeconds = 43.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + } + repeat(3) { yield() } + releaseFirst.complete(Unit) + + assertIs>(first.await()) + assertIs>(second.await()) + harness.awaitStopped("s1") + harness.awaitStopped("s2") + assertEquals("s3", harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s1" to 1, "s2" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `immediate terminal response preserves typed outcome and teardown`() = runTest { + val harness = Harness( + replanResponse = { index, _ -> + if (index == 0) { + response(sidecarPlan(sessionId = "s2")) + } else { + terminalResponse( + sessionId = "s3", + reason = "adaptation_unavailable", + message = "No compatible route.", + ) + } + }, + ) + harness.start() + val staged = harness.stageSidecar() + + val result = harness.manager.replanActiveVideoSession( + classification = "player_failure", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = null, + ) + + val terminal = assertIs( + assertIs>(result).data, + ) + assertEquals("adaptation_unavailable", terminal.reason) + assertEquals(null, harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s1" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + + harness.manager.stopSession("s1") + + assertEquals( + mapOf("s1" to 2, "s2" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + assertEquals( + 409, + assertIs(harness.manager.commitStagedVideoReplan(staged)).code, + ) + assertEquals( + mapOf("s1" to 2, "s2" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `staged terminal response rejects candidate but keeps active attempt`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> + terminalResponse( + sessionId = "s2", + reason = "adaptation_unavailable", + message = "No compatible route.", + ) + }, + ) + harness.start() + + val result = harness.manager.stageActiveVideoSessionReplan( + classification = "subtitle_track_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + + assertIs(result) + assertEquals("s1", harness.manager.activeSessionIdForTest()) + assertEquals(listOf("s2"), harness.stoppedSessions) + } + + @Test + fun `immediate incompatible response preserves server upgrade outcome`() = runTest { + val harness = Harness( + replanResponse = { index, _ -> + val response = response(sidecarPlan(sessionId = if (index == 0) "s2" else "s3")) + if (index == 0) response else response.copy(protocolVersion = 2) + }, + ) + harness.start() + val staged = harness.stageSidecar() + + val result = harness.manager.replanActiveVideoSession( + classification = "player_failure", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = null, + ) + + assertIs( + assertIs>(result).data, + ) + assertEquals(null, harness.manager.activeSessionIdForTest()) + assertEquals(listOf("s3"), harness.stoppedSessions) + + harness.manager.stopSession("s1") + + assertEquals( + mapOf("s1" to 1, "s2" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + assertEquals( + 409, + assertIs(harness.manager.commitStagedVideoReplan(staged)).code, + ) + assertEquals( + mapOf("s1" to 1, "s2" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `immediate legacy engine response preserves terminal outcome and cleanup`() = runTest { + val harness = Harness( + replanResponse = { index, _ -> + if (index == 0) { + response(sidecarPlan(sessionId = "s2")) + } else { + response( + sidecarPlan(sessionId = "s3").copy( + engine = PlaybackEngineKind.MPV_DIRECT, + ), + ) + } + }, + ) + harness.start() + val staged = harness.stageSidecar() + + val result = harness.manager.replanActiveVideoSession( + classification = "player_failure", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + + val terminal = assertIs( + assertIs>(result).data, + ) + assertEquals("unsupported_legacy_engine", terminal.reason) + assertEquals(null, harness.manager.activeSessionIdForTest()) + assertEquals( + mapOf("s1" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + + harness.manager.stopSession("s1") + + assertEquals( + mapOf("s1" to 2, "s2" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + assertEquals( + 409, + assertIs(harness.manager.commitStagedVideoReplan(staged)).code, + ) + assertEquals( + mapOf("s1" to 2, "s2" to 1, "s3" to 1), + harness.stoppedSessions.groupingBy { it }.eachCount(), + ) + } + + @Test + fun `shared candidate session remains alive until last staged owner discards`() = runTest { + val harness = Harness( + replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, + ) + harness.start() + val first = harness.stageSidecar() + val second = harness.stageSidecar() + + harness.manager.discardStagedVideoReplan(first) + assertEquals(emptyList(), harness.stoppedSessions) + + harness.manager.discardStagedVideoReplan(second) + assertEquals(listOf("s2"), harness.stoppedSessions) + } + + private class Harness( + startResponses: List = listOf(response(basePlan())), + private val startResponseOverride: (suspend (Int) -> PlaybackDecisionResponseV3)? = null, + pendingPublicationSettleTimeoutMs: Long? = PlaybackSessionManager.NEVER_SELF_HEAL, + committedSessionCleanupScope: CoroutineScope? = null, + private val replanResponse: suspend (Int, JsonObject) -> PlaybackDecisionResponseV3, + private val stopBehavior: suspend (String) -> Unit = {}, + ) { + val stoppedSessions: MutableList = Collections.synchronizedList(mutableListOf()) + val stopAttempts: MutableList = Collections.synchronizedList(mutableListOf()) + val replanBodies: MutableList = Collections.synchronizedList(mutableListOf()) + val replanBaseSessions: MutableList = + Collections.synchronizedList(mutableListOf()) + private val stoppedEvents = Channel(Channel.UNLIMITED) + private val stopAttemptEvents = Channel(Channel.UNLIMITED) + private val startIndex = AtomicInteger() + private val replanIndex = AtomicInteger() + private val client = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + val response = when { + path == "/api/v1/playback/start" -> { + val index = startIndex.getAndIncrement() + startResponseOverride?.invoke(index) ?: startResponses[index] + } + path.endsWith("/replan") -> { + replanBaseSessions += path + .substringBeforeLast("/replan") + .substringAfterLast('/') + val body = PrairieJson.parseToJsonElement( + request.body.toByteArray().decodeToString(), + ).jsonObject + replanBodies += body + replanResponse(replanIndex.getAndIncrement(), body) + } + request.method == HttpMethod.Delete && path.startsWith("/api/v1/playback/") -> { + val sessionId = path.substringAfterLast('/') + stopAttempts += sessionId + stopAttemptEvents.send(sessionId) + stopBehavior(sessionId) + stoppedSessions += sessionId + stoppedEvents.send(sessionId) + null + } + else -> null + } + respond( + content = response?.let(PrairieJson::encodeToString) ?: "{}", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val manager = PlaybackSessionManager( + playbackRepository = PlaybackRepository(PlaybackApi(client)), + tokenManager = StagedReplanNoOpTokenManager, + // runTest advances virtual time whenever the scheduler idles. The + // production timeout would therefore self-heal publications inside + // tests that are deliberately asserting the unresolved state. + pendingPublicationSettleTimeoutMs = pendingPublicationSettleTimeoutMs, + committedSessionCleanupScope = committedSessionCleanupScope, + ) + + suspend fun start( + fileId: Int = 42, + deferPublication: Boolean = false, + ) { + assertIs>( + manager.startVideoSessionV3( + fileId = fileId, + profileId = "profile-1", + capabilities = ClientCodecCapabilities( + codecsVideo = listOf("hevc"), + codecsAudio = listOf("eac3"), + containers = listOf("mkv"), + ), + clientPlaybackContext = ClientPlaybackContext( + formFactor = "tv", + appVersion = "test", + output = PlaybackOutputContext(outputRouteGeneration = 7), + ), + audioTrackIndex = 0, + subtitleTrackIndex = null, + qualityPreference = "original", + startPosition = 0.0, + subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, + deferPublication = deferPublication, + ), + ) + } + + suspend fun stageSidecar(): StagedVideoReplan = assertIs>( + manager.stageActiveVideoSessionReplan( + classification = "subtitle_track_changed", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ), + ).data + + suspend fun awaitStopped(sessionId: String) { + if (sessionId in stoppedSessions) return + while (stoppedEvents.receive() != sessionId) { + // Drain unrelated cleanup completions until this owner stops. + } + } + + suspend fun awaitStopAttempts(sessionId: String, count: Int) { + while (stopAttempts.count { it == sessionId } < count) { + stopAttemptEvents.receive() + } + } + } + + private companion object { + fun basePlan( + sessionId: String = "s1", + fileId: Int = 42, + ): PlaybackPlanV3 = PlaybackPlanV3( + planId = "plan-$sessionId", + sessionId = sessionId, + delivery = PlaybackDelivery.SERVER_REMUX_HLS, + engine = PlaybackEngineKind.MEDIA3_HLS, + stream = PlaybackStreamV3( + url = "/stream/$sessionId/master.m3u8", + protocol = PlaybackStreamProtocol.HLS, + container = "mpegts", + mimeType = "application/x-mpegURL", + ), + selectedTracks = SelectedPlaybackTracksV3(audio = audioTrack(fileId)), + effectiveRecipe = PlaybackEffectiveRecipeV3( + videoCodec = "hevc", + audioCodec = "eac3", + ), + decisionReason = "test", + requestedMediaFileId = fileId, + effectiveMediaFileId = fileId, + ) + + fun sidecarPlan(sessionId: String): PlaybackPlanV3 = basePlan(sessionId).copy( + selectedTracks = SelectedPlaybackTracksV3( + audio = audioTrack(fileId = 42), + subtitle = PlaybackTrackIdentityV3( + id = subtitleTrackId(fileId = 42, index = 4), + index = 4, + ), + ), + subtitle = PlaybackSubtitleDecisionV3( + mode = PlaybackSubtitleModeV3.CONVERT, + trackId = subtitleTrackId(fileId = 42, index = 4), + artifact = sidecarArtifact(sessionId = sessionId, index = 4), + ), + ) + + fun sidecarArtifact(sessionId: String, index: Int): PlaybackSubtitleArtifactV3 = + PlaybackSubtitleArtifactV3( + url = "/stream/$sessionId/subtitles/$index.vtt", + mimeType = "text/vtt", + format = "webvtt", + ) + + fun audioTrack(fileId: Int): PlaybackTrackIdentityV3 = + PlaybackTrackIdentityV3("file:$fileId:audio:0", 0) + + fun subtitleTrackId(fileId: Int, index: Int): String = + "file:$fileId:subtitle:$index" + + fun response(plan: PlaybackPlanV3): PlaybackDecisionResponseV3 = + PlaybackDecisionResponseV3( + protocolVersion = 3, + serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + outcome = PlaybackDecisionOutcome.PLAYABLE, + sessionId = plan.sessionId, + playbackPlan = plan, + ) + + fun terminalResponse( + sessionId: String, + reason: String, + message: String, + ): PlaybackDecisionResponseV3 = PlaybackDecisionResponseV3( + protocolVersion = 3, + serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + outcome = PlaybackDecisionOutcome.ADAPTATION_UNAVAILABLE, + sessionId = sessionId, + terminal = PlaybackTerminalV3( + reason = reason, + message = message, + retryable = false, + ), + ) + } +} + +private object StagedReplanNoOpTokenManager : 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/PlaybackTrackSelectionWriteCoordinatorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackTrackSelectionWriteCoordinatorTest.kt new file mode 100644 index 000000000..abcfda24f --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackTrackSelectionWriteCoordinatorTest.kt @@ -0,0 +1,181 @@ +package org.prairieserver.prairie.common.player + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.prairieserver.prairie.common.data.db.PrairieDatabase +import org.prairieserver.prairie.common.data.repository.RoomUserItemStateRepository +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.repository.port.PlaybackWriteScope +import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class PlaybackTrackSelectionWriteCoordinatorTest { + + @Test + fun delayedRetiredAdapterCannotOverwriteNewerReplacementAdapterSelection() = runTest { + val db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + PrairieDatabase::class.java, + ).allowMainThreadQueries().build() + try { + val scope = PlaybackWriteScope( + serverId = "s1", + profileId = "p1", + credentialGenerationId = null, + identityGeneration = 4L, + ) + val repository = RoomUserItemStateRepository( + db = db, + snapshotProvider = { + AuthScopeSnapshot( + serverId = "s1", + profileId = "p1", + serverUrl = "https://s1.example", + profileToken = "p1-token", + identityGeneration = 4L, + ) + }, + ) + val coordinator = PlaybackTrackSelectionWriteCoordinator() + val retiredAdapter = AdapterWriter( + coordinator = coordinator, + repository = repository, + scope = scope, + audioFingerprint = "old-audio", + subtitleFingerprint = "old-subtitle", + ) + val replacementAdapter = AdapterWriter( + coordinator = coordinator, + repository = repository, + scope = scope, + audioFingerprint = "new-audio", + subtitleFingerprint = "new-subtitle", + ) + val releaseOldAdapter = Channel(Channel.CONFLATED) + + val oldWrite = async { + releaseOldAdapter.receive() + retiredAdapter.persist() + } + runCurrent() + + assertTrue(replacementAdapter.persist()) + releaseOldAdapter.send(Unit) + assertTrue(oldWrite.await()) + + val row = db.userItemStateDao().get("s1", "p1", "c1", 7) + assertEquals("new-audio", row?.audioFingerprint) + assertEquals("new-subtitle", row?.subtitleFingerprint) + assertEquals(0, coordinator.activeKeyCount) + } finally { + db.close() + } + } + + @Test + fun completedTicketsReleaseCoordinatorStateAcrossManyPlaybackKeys() = runTest { + val coordinator = PlaybackTrackSelectionWriteCoordinator() + val scope = PlaybackWriteScope( + serverId = "s1", + profileId = "p1", + credentialGenerationId = null, + identityGeneration = 4L, + ) + + repeat(2_000) { index -> + val ticket = coordinator.capture( + scope = scope, + contentId = "content-$index", + fileId = index + 1, + ) + assertTrue(coordinator.write(ticket) { true }) + } + + assertEquals(0, coordinator.activeKeyCount) + } + + @Test + fun abandonedBlockedPrimaryAndFallbackKeepReplacementSerializedUntilOldWritesExit() = runTest { + val coordinator = PlaybackTrackSelectionWriteCoordinator() + val scope = PlaybackWriteScope( + serverId = "s1", + profileId = "p1", + credentialGenerationId = null, + identityGeneration = 4L, + ) + var durableValue = "initial" + val oldPersistStarted = CompletableDeferred() + val releaseOldPersist = CompletableDeferred() + val replacementPersistStarted = CompletableDeferred() + val oldTicket = coordinator.capture(scope, contentId = "c1", fileId = 7) + + val oldWrite = async { + coordinator.write(oldTicket) { + oldPersistStarted.complete(Unit) + releaseOldPersist.await() + durableValue = "A" + true + } + } + oldPersistStarted.await() + + val fallbackWrite = async { + coordinator.write(oldTicket) { + error("An abandoned fallback must not persist.") + } + } + runCurrent() + coordinator.abandon(oldTicket) + coordinator.abandon(oldTicket) + val replacementTicket = coordinator.capture(scope, contentId = "c1", fileId = 7) + val replacementWrite = async { + coordinator.write(replacementTicket) { + replacementPersistStarted.complete(Unit) + durableValue = "B" + true + } + } + runCurrent() + + assertFalse(replacementPersistStarted.isCompleted) + releaseOldPersist.complete(Unit) + assertTrue(oldWrite.await()) + assertFalse(fallbackWrite.await()) + assertTrue(replacementWrite.await()) + assertEquals("B", durableValue) + assertEquals(0, coordinator.activeKeyCount) + } + + private class AdapterWriter( + private val coordinator: PlaybackTrackSelectionWriteCoordinator, + private val repository: RoomUserItemStateRepository, + private val scope: PlaybackWriteScope, + private val audioFingerprint: String, + private val subtitleFingerprint: String, + ) { + private val ticket = coordinator.capture(scope, contentId = "c1", fileId = 7) + + suspend fun persist(): Boolean = coordinator.write(ticket) { + repository.recordTrackSelection( + scope = scope, + contentId = "c1", + fileId = 7, + audioUpdate = TrackSelectionFingerprintUpdate.Set(audioFingerprint), + subtitleUpdate = TrackSelectionFingerprintUpdate.Set(subtitleFingerprint), + ) + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubDiagGateTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubDiagGateTest.kt new file mode 100644 index 000000000..2d8e175e2 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubDiagGateTest.kt @@ -0,0 +1,25 @@ +package org.prairieserver.prairie.common.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SubDiagGateTest { + private val source = File( + "src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleDiagnostics.kt", + ).readText() + + @Test + fun everyEmitIsBehindTheGate() { + val emits = Regex("""Log\.[a-z]\(TAG""").findAll(source).count() + val guards = Regex("""if \(enabled\) Log\.""").findAll(source).count() + assertTrue(emits > 0) + assertTrue(guards >= emits) + } + + @Test + fun tracingDoesNotShipAtErrorLevel() { + assertFalse(source.contains("Log.e(TAG")) + } +} 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 bc6df4848..78623ba4b 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 @@ -17,29 +17,29 @@ import kotlin.test.assertEquals class SubtitleManagerAppearanceTest { @Test - fun defaultSubtitleStyleIsWhiteOutlinedTextWithoutABox() { + fun defaultSubtitleStyleIsWhiteTextWithASoftShadow() { val style = captionStyleFor(SubtitleAppearance.DEFAULT) assertEquals(0xFFFFFFFF.toInt(), style.foregroundColor) assertEquals(0x00000000, style.backgroundColor) assertEquals(0x00000000, style.windowColor) - assertEquals(CaptionStyleCompat.EDGE_TYPE_OUTLINE, style.edgeType) + assertEquals(CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW, style.edgeType) assertEquals(0xFF000000.toInt(), style.edgeColor) } @Test - fun subtitleTextFractionsUseTheStandardScale() { + fun subtitleTextFractionsMatchTheWebScale() { val method = SubtitleManager::class.java.getDeclaredMethod( "fractionalSizeFor", SubtitleFontSizePreset::class.java, ) method.isAccessible = true - assertEquals(0.032f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.Small) as Float) - assertEquals(0.040f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.Medium) as Float) - assertEquals(0.050f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.Large) as Float) - assertEquals(0.060f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.XLarge) as Float) - assertEquals(0.072f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.XXLarge) as Float) + 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) } @Test 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 dcd172514..03ef48910 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 @@ -8,15 +8,308 @@ import androidx.media3.common.TrackGroup import androidx.media3.common.Tracks 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 kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue @OptIn(UnstableApi::class) class SubtitleManagerTrackSelectionTest { + @Test + fun typedLocalSelectionUsesExactMedia3IdAcrossDuplicateMetadata() { + val first = TrackGroup( + subtitle( + label = "English", + language = "en", + sampleMimeType = MimeTypes.TEXT_VTT, + id = "decoder-text-8", + ), + ) + val second = TrackGroup( + subtitle( + label = "English", + language = "en", + sampleMimeType = MimeTypes.TEXT_VTT, + id = "decoder-text-9", + ), + ) + val tracks = Tracks( + listOf(first, second).map { group -> + Tracks.Group( + group, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(false), + ) + }, + ) + + val selection = resolveSubtitleSelection( + tracks, + SubtitleIdentity.LocalMedia3( + SubtitleMediaIdentity( + trackId = "decoder-text-9", + language = "en", + codecFamily = "webvtt", + hearingImpaired = false, + ), + ), + ) + + assertSame(second, selection?.mediaTrackGroup) + assertEquals(0, selection?.trackIndex) + } + + @Test + fun extractedEmbeddedTextArtifactSelectsReservedServerTrackEndToEnd() { + val artifact = TrackGroup( + subtitle( + label = "English", + language = "en", + sampleMimeType = MimeTypes.TEXT_VTT, + id = "prairie-subtitle:7", + ), + ) + val tracks = Tracks( + listOf( + Tracks.Group( + artifact, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(false), + ), + ), + ) + + val selection = resolveSubtitleSelection( + tracks, + PlayerSubtitleInfo( + index = 7, + language = "en", + codec = "webvtt", + label = "English", + source = "embedded", + forced = false, + url = "/stream/s2/subtitles/7.vtt", + ), + ) + + assertSame(artifact, selection?.mediaTrackGroup) + assertEquals(0, selection?.trackIndex) + } + + @Test + fun serverArtifactConfigurationsCarryStableCombinedIndexes() { + val configurations = SubtitleManager().buildSubtitleConfigurations( + subtitles = listOf( + 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", + ) + + assertEquals( + listOf("prairie-subtitle:3", "prairie-subtitle:4"), + configurations.map { it.id }, + ) + } + + @Test + fun serverAndDownloadedConfigurationsUseDisjointStableIds() { + val configurations = SubtitleManager().buildSubtitleConfigurations( + subtitles = listOf( + PlayerSubtitleInfo(3, "en", "webvtt", "English", "server_artifact", false, "/3.vtt"), + PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "webvtt", + label = "English", + source = "downloaded", + forced = false, + url = "/4.vtt", + downloadId = 312, + ), + PlayerSubtitleInfo( + index = 5, + language = "en", + codec = "webvtt", + label = "English", + source = null, + forced = false, + url = "/5.vtt", + catalogSource = "downloaded", + downloadId = 313, + ), + PlayerSubtitleInfo( + index = 6, + language = "en", + codec = "webvtt", + label = "English", + source = "server_artifact", + forced = false, + url = "/6.vtt", + catalogSource = "downloaded", + downloadId = 314, + ), + ), + serverUrl = "https://prairie.example", + ) + + assertEquals( + listOf( + "prairie-subtitle:3", + "prairie-downloaded-subtitle:312", + "prairie-downloaded-subtitle:313", + "prairie-downloaded-subtitle:314", + ), + configurations.map { it.id }, + ) + } + + @Test + fun downloadedConfigurationIdSurvivesArtifactReorderDeletionAndCatalogGrowth() { + fun mountedId(index: Int): String? = + SubtitleManager().buildSubtitleConfigurations( + subtitles = listOf( + PlayerSubtitleInfo( + index = index, + language = "en", + codec = "webvtt", + label = "Downloaded English", + source = "downloaded", + forced = false, + url = "/$index.vtt", + downloadId = 312, + ), + ), + serverUrl = "https://prairie.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)) + } + + @Test + fun legacyDownloadedConfigurationDoesNotFabricateStableIdFromArtifactIndex() { + val configuration = SubtitleManager().buildSubtitleConfigurations( + subtitles = listOf( + PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "webvtt", + label = "Legacy downloaded English", + source = "downloaded", + forced = false, + url = "/4.vtt", + ), + ), + serverUrl = "https://prairie.example", + ).single() + + assertNull(configuration.id) + } + + @Test + fun mobileSelectionResolvesUniqueLegacyDownloadedTrackWithoutStableId() { + val ordinary = TrackGroup( + subtitle( + label = "Legacy English", + language = "en", + sampleMimeType = MimeTypes.TEXT_VTT, + ), + ) + val tracks = Tracks( + listOf( + Tracks.Group( + ordinary, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(false), + ), + ), + ) + + val selection = resolveSubtitleSelection( + tracks, + PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "webvtt", + label = "Legacy English", + source = "downloaded", + forced = false, + url = "/4.vtt", + ), + ) + + assertSame(ordinary, selection?.mediaTrackGroup) + assertEquals(0, selection?.trackIndex) + } + + @Test + fun mobileSelectionUsesDownloadedStableIdAcrossDuplicateLabels() { + val server = TrackGroup( + subtitle("English", "en", id = "prairie-subtitle:3"), + ) + val downloaded = TrackGroup( + subtitle("English", "en", id = "prairie-downloaded-subtitle:312"), + ) + val tracks = Tracks( + listOf( + Tracks.Group(server, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)), + Tracks.Group(downloaded, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)), + ), + ) + + val selection = resolveSubtitleSelection( + tracks, + PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "webvtt", + label = "English", + source = "server_artifact", + forced = false, + url = "/4.vtt", + catalogSource = "downloaded", + downloadId = 312, + ), + ) + + assertSame(downloaded, selection?.mediaTrackGroup) + assertEquals(0, selection?.trackIndex) + } + + @Test + fun mobileMetadataSelectionUsesStableIdAcrossDuplicateRuntimeLabels() { + val forced = TrackGroup( + subtitle("Server subtitle", "en", id = "prairie-subtitle:3", forced = true), + ) + val full = TrackGroup( + subtitle("Server subtitle", "en", id = "prairie-subtitle:4", forced = false), + ) + val tracks = Tracks( + listOf( + Tracks.Group(forced, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)), + Tracks.Group(full, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)), + ), + ) + + val selection = resolveSubtitleSelection( + tracks, + PlayerSubtitleInfo(4, "en", "webvtt", "Server subtitle", "server_artifact", false, "/4.vtt"), + ) + + assertSame(full, selection?.mediaTrackGroup) + assertEquals(0, selection?.trackIndex) + } + @Test fun relativeServerSubtitleUrlsResolveThroughApiStreamMount() { assertEquals( @@ -219,7 +512,7 @@ class SubtitleManagerTrackSelectionTest { } @Test - fun bitmapSubtitleUrlsAreNotMountedAsMedia3TextSidecars() { + fun bitmapSubtitleUrlsAreMountedAsMedia3Sidecars() { val configurations = SubtitleManager().buildSubtitleConfigurations( subtitles = listOf( PlayerSubtitleInfo( @@ -244,9 +537,11 @@ class SubtitleManagerTrackSelectionTest { serverUrl = "https://prairie.example", ) - assertEquals(1, configurations.size) - assertEquals("English", configurations.single().label) - assertEquals(MimeTypes.TEXT_VTT, configurations.single().mimeType) + assertEquals(2, configurations.size) + assertEquals("English", configurations[0].label) + assertEquals(MimeTypes.TEXT_VTT, configurations[0].mimeType) + assertEquals("English (PGS)", configurations[1].label) + assertEquals(MimeTypes.APPLICATION_PGS, configurations[1].mimeType) } @Test @@ -305,11 +600,15 @@ class SubtitleManagerTrackSelectionTest { language: String?, sampleMimeType: String = MimeTypes.APPLICATION_SUBRIP, codecs: String? = null, + id: String? = null, + forced: Boolean = false, ): Format = Format.Builder() + .setId(id) .setLabel(label) .setLanguage(language) .setSampleMimeType(sampleMimeType) .setCodecs(codecs) + .setSelectionFlags(if (forced) C.SELECTION_FLAG_FORCED else 0) .build() } 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 new file mode 100644 index 000000000..2fc016cfb --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolverTest.kt @@ -0,0 +1,666 @@ +package org.prairieserver.prairie.common.player + +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SubtitleMountResolverTest { + + @Test + fun blankExplicitExternalRowCannotMatchEmbeddedMetadata() { + val row = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "hdmv_pgs_subtitle", + label = "English", + source = "external", + forced = false, + url = "", + ) + val embedded = track( + index = 0, + trackId = "decoder-pgs-7", + label = "English", + language = "en", + codec = "application/pgs", + forced = false, + hearingImpaired = false, + ) + + assertNull(resolveMountedSubtitle(row, listOf(embedded))) + } + + @Test + fun catalogTextIdentityMatchesTheWebvttArtifactTheServerMaterialised() { + // A catalog row names its source format; the server serves the artifact + // it materialises for that row as WebVTT. Demanding an exact family + // match meant a picked embedded SubRip track never resolved to the + // sidecar produced for it — the mount deadline fired and the selection + // rolled back to Off. + val identity = SubtitleIdentity.Embedded( + serverIndex = 12, + media = SubtitleMediaIdentity( + trackId = null, + label = "SUBRIP", + language = "nl", + codecFamily = "subrip", + ), + ) + + val match = resolveMountedSubtitle( + identity = identity, + tracks = listOf( + track(index = 0, trackId = "1:prairie-subtitle:12", language = "nl", codec = "text/vtt"), + ), + ) + + assertEquals(0, match?.track?.index) + } + + @Test + fun bitmapIdentityStillRequiresAnExactCodecFamily() { + val identity = SubtitleIdentity.Embedded( + serverIndex = 4, + media = SubtitleMediaIdentity( + trackId = null, + label = "English", + language = "en", + codecFamily = "pgs", + ), + ) + + val match = resolveMountedSubtitle( + identity = identity, + tracks = listOf( + track(index = 0, trackId = "1:prairie-subtitle:4", language = "en", codec = "text/vtt"), + ), + ) + + assertNull(match, "a bitmap identity must not match a text artifact") + } + + @Test + fun blankCatalogExternalRowCannotMatchEmbeddedMetadata() { + val row = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "hdmv_pgs_subtitle", + label = "English", + source = null, + forced = false, + url = "", + catalogSource = "external", + ) + val embedded = track( + index = 0, + trackId = "decoder-pgs-7", + label = "English", + language = "en", + codec = "application/pgs", + forced = false, + hearingImpaired = false, + ) + + assertNull(resolveMountedSubtitle(row, listOf(embedded))) + } + + @Test + fun extractedEmbeddedTextArtifactResolvesItsReservedServerId() { + val row = PlayerSubtitleInfo( + index = 7, + language = "en", + codec = "webvtt", + label = "English", + source = "embedded", + forced = false, + url = "/stream/s2/subtitles/7.vtt", + ) + val artifact = track( + index = 2, + trackId = "prairie-subtitle:7", + label = "English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ) + + assertEquals(2, resolveMountedSubtitle(row, listOf(artifact))?.track?.index) + } + + @Test + fun serverSidecarResolvesExactStableIdAcrossSameLabelTracks() { + val tracks = listOf( + track(index = 3, trackId = "prairie-subtitle:3", label = "Server subtitle"), + track(index = 4, trackId = "prairie-subtitle:4", label = "Server subtitle"), + ) + + val match = resolveMountedSubtitle(SubtitleIdentity.ServerSidecar(4), tracks) + + assertEquals(4, match?.track?.index) + assertEquals("prairie-subtitle:4", match?.track?.trackId) + } + + @Test + fun exactLocalIdHasGlobalPriorityOverEarlierMetadataMatch() { + val identity = SubtitleIdentity.LocalMedia3( + media( + trackId = "decoder-text-9", + label = "English", + language = "en", + codecFamily = "webvtt", + forced = false, + hearingImpaired = false, + ), + ) + val tracks = listOf( + track( + index = 0, + trackId = "decoder-text-8", + label = "English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ), + track( + index = 1, + trackId = "decoder-text-9", + label = "Different runtime label", + language = "fr", + codec = "application/x-subrip", + forced = true, + hearingImpaired = true, + ), + ) + + assertEquals(1, resolveMountedSubtitle(identity, tracks)?.track?.index) + } + + @Test + fun nonServerIdentitiesCannotClaimReservedServerArtifactId() { + val media = media( + trackId = "prairie-subtitle:4", + label = "English", + language = "en", + codecFamily = "webvtt", + forced = false, + hearingImpaired = false, + ) + val serverSidecar = track( + index = 4, + trackId = "prairie-subtitle:4", + label = "English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ) + val identities = listOf( + SubtitleIdentity.Embedded(serverIndex = 4, media = media), + SubtitleIdentity.Downloaded(downloadId = 9, media = media), + SubtitleIdentity.LocalMedia3(media), + ) + + identities.forEach { identity -> + assertNull( + resolveMountedSubtitle(identity, listOf(serverSidecar)), + "reserved ID must not resolve for $identity", + ) + } + assertEquals( + 4, + resolveMountedSubtitle( + SubtitleIdentity.ServerSidecar(serverIndex = 4), + listOf(serverSidecar), + )?.track?.index, + ) + } + + @Test + fun serverDownloadedAndOrdinaryIdentityNamespacesCannotCrossMatch() { + val duplicateLabel = "English" + val tracks = listOf( + track( + index = 0, + trackId = "prairie-subtitle:3", + label = duplicateLabel, + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ), + track( + index = 1, + trackId = "prairie-downloaded-subtitle:99", + label = duplicateLabel, + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ), + track( + index = 2, + trackId = "decoder-text-5", + label = duplicateLabel, + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ), + ) + val downloadedMedia = media( + trackId = "prairie-downloaded-subtitle:4", + label = duplicateLabel, + language = "en", + codecFamily = "webvtt", + forced = false, + hearingImpaired = false, + ) + + assertEquals( + 0, + resolveMountedSubtitle(SubtitleIdentity.ServerSidecar(3), tracks)?.track?.index, + ) + assertEquals( + 1, + resolveMountedSubtitle( + SubtitleIdentity.Downloaded(downloadId = 99, media = downloadedMedia), + tracks, + )?.track?.index, + ) + assertEquals( + 2, + resolveMountedSubtitle( + SubtitleIdentity.LocalMedia3( + downloadedMedia.copy(trackId = "decoder-text-5"), + ), + tracks, + )?.track?.index, + ) + + assertNull( + resolveMountedSubtitle( + SubtitleIdentity.LocalMedia3(downloadedMedia), + tracks, + ), + ) + assertNull( + resolveMountedSubtitle( + SubtitleIdentity.Embedded(serverIndex = 4, media = downloadedMedia), + tracks, + ), + ) + assertNull( + resolveMountedSubtitle( + SubtitleIdentity.Downloaded( + downloadId = 99, + media = downloadedMedia.copy(trackId = "prairie-subtitle:3"), + ), + listOf(tracks.first(), tracks.last()), + ), + ) + } + + @Test + fun downloadedIdentityUsesDomainIdInsteadOfMutableArtifactIndex() { + val tracks = listOf( + track( + index = 0, + trackId = "prairie-downloaded-subtitle:312", + label = "English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ), + track( + index = 1, + trackId = "prairie-downloaded-subtitle:313", + label = "English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ), + ) + val identity = SubtitleIdentity.Downloaded( + downloadId = 312, + media = media( + trackId = "prairie-downloaded-subtitle:7", + label = "English", + language = "en", + codecFamily = "webvtt", + forced = false, + hearingImpaired = false, + ), + ) + + assertEquals(0, resolveMountedSubtitle(identity, tracks)?.track?.index) + assertNull(resolveMountedSubtitle(identity, listOf(tracks[1]))) + } + + @Test + fun legacyDownloadedRowSelectsUniqueOrdinaryTrackByTypedMetadata() { + val row = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "webvtt", + label = "Legacy English", + source = "downloaded", + forced = false, + url = "/4.vtt", + ) + val ordinary = track( + index = 2, + trackId = null, + label = "Legacy English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ) + val reserved = ordinary.copy( + index = 0, + trackId = "prairie-downloaded-subtitle:312", + ) + + assertEquals(2, resolveMountedSubtitle(row, listOf(reserved, ordinary))?.track?.index) + } + + @Test + fun legacyDownloadedRowRejectsSameMetadataOrdinaryTrackAmbiguity() { + val row = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "webvtt", + label = "Legacy English", + source = "downloaded", + forced = false, + url = "/4.vtt", + ) + val ordinary = track( + index = 2, + trackId = null, + label = "Legacy English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ) + + assertNull(resolveMountedSubtitle(row, listOf(ordinary, ordinary.copy(index = 3)))) + } + + @Test + fun legacyDownloadedRowCannotClaimAnyReservedArtifactIdentity() { + val row = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "webvtt", + label = "English", + source = "downloaded", + forced = false, + url = "/4.vtt", + ) + val tracks = listOf( + track( + index = 0, + trackId = "prairie-subtitle:4", + label = "English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ), + track( + index = 1, + trackId = "prairie-downloaded-subtitle:4", + label = "English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ), + track( + index = 2, + trackId = "prairie-downloaded-subtitle:312", + label = "English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ), + ) + + tracks.forEach { reserved -> + assertNull( + resolveMountedSubtitle(row, listOf(reserved)), + "legacy row must not claim reserved track ${reserved.trackId}", + ) + } + } + + @Test + fun modernDownloadedRowRemainsExactDomainIdOnly() { + val row = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "webvtt", + label = "English", + source = "downloaded", + forced = false, + url = "/4.vtt", + downloadId = 312, + ) + val ordinary = track( + index = 2, + trackId = null, + label = "English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ) + + assertNull(resolveMountedSubtitle(row, listOf(ordinary))) + } + + @Test + fun localIdChangeFallsBackToCompleteTypedMetadata() { + val identity = SubtitleIdentity.LocalMedia3( + media( + trackId = "old-decoder-id", + label = "English SDH", + language = "en", + codecFamily = "subrip", + forced = false, + hearingImpaired = true, + ), + ) + val tracks = listOf( + track( + index = 2, + trackId = "new-decoder-id", + label = "English SDH", + language = "EN", + codec = "application/x-subrip", + forced = false, + hearingImpaired = true, + ), + ) + + assertEquals(2, resolveMountedSubtitle(identity, tracks)?.track?.index) + } + + @Test + fun embeddedPgsFallbackSeparatesForcedAndFullDuplicates() { + val full = track( + index = 5, + trackId = null, + label = "English", + language = "eng", + codec = "application/pgs", + forced = false, + hearingImpaired = false, + ) + val forced = full.copy(index = 6, forced = true) + val identity = SubtitleIdentity.Embedded( + serverIndex = 7, + media = media( + label = "English", + language = "en", + codecFamily = "pgs", + forced = true, + hearingImpaired = false, + ), + ) + + assertEquals(6, resolveMountedSubtitle(identity, listOf(full, forced))?.track?.index) + } + + @Test + fun metadataFallbackSeparatesHearingImpairedDuplicate() { + val identity = SubtitleIdentity.LocalMedia3( + media( + label = "English", + language = "en", + codecFamily = "webvtt", + forced = false, + hearingImpaired = true, + ), + ) + val tracks = listOf( + track(1, null, "English", "en", "text/vtt", false, false), + track(2, null, "English", "en", "text/vtt", false, true), + ) + + assertEquals(2, resolveMountedSubtitle(identity, tracks)?.track?.index) + } + + @Test + fun idLessLegacyTextResolvesByTypedMetadata() { + val identity = SubtitleIdentity.LocalMedia3( + media( + language = "fr", + codecFamily = "webvtt", + forced = false, + hearingImpaired = false, + ), + ) + val tracks = listOf( + track(0, null, "Français", "fr", "text/vtt", false, false), + ) + + assertEquals(0, resolveMountedSubtitle(identity, tracks)?.track?.index) + } + + @Test + fun embeddedBitmapAcceptsOrdinaryDecoderTrackId() { + val identity = SubtitleIdentity.Embedded( + serverIndex = 4, + media = media( + trackId = "7", + language = "en", + codecFamily = "pgs", + forced = true, + ), + ) + val tracks = listOf( + track(0, "8", "English", "en", "application/pgs", true, false), + track(1, "7", "English", "en", "application/pgs", false, false), + ) + + assertEquals(1, resolveMountedSubtitle(identity, tracks)?.track?.index) + } + + @Test + fun missingLocalIdCannotFallBackToServerSidecarWithSameMetadata() { + val identity = SubtitleIdentity.LocalMedia3( + media( + trackId = "decoder-text-2", + label = "English", + language = "en", + codecFamily = "webvtt", + forced = false, + hearingImpaired = false, + ), + ) + val tracks = listOf( + track( + index = 0, + trackId = "prairie-subtitle:4", + label = "English", + language = "en", + codec = "text/vtt", + forced = false, + hearingImpaired = false, + ), + ) + + assertNull(resolveMountedSubtitle(identity, tracks)) + } + + @Test + fun displayLabelAloneNeverEstablishesIdentity() { + val identity = SubtitleIdentity.LocalMedia3(media(label = "English")) + + assertNull( + resolveMountedSubtitle( + identity, + listOf(track(index = 0, trackId = null, label = "English")), + ), + ) + } + + @Test + fun offAndBurnInHaveNoMountedTrack() { + val tracks = listOf(track(index = 0, trackId = "prairie-subtitle:3")) + + assertNull(resolveMountedSubtitle(SubtitleIdentity.Off, tracks)) + assertNull(resolveMountedSubtitle(SubtitleIdentity.ServerBurnIn(3), tracks)) + } + + @Test + fun stableArtifactIdUsesCombinedServerIndex() { + assertEquals("prairie-subtitle:0", subtitleArtifactTrackId(0)) + assertEquals("prairie-subtitle:42", subtitleArtifactTrackId(42)) + } + + private fun track( + index: Int, + trackId: String?, + label: String? = null, + language: String? = null, + codec: String? = null, + forced: Boolean? = null, + hearingImpaired: Boolean? = null, + ): MountedSubtitleTrack = MountedSubtitleTrack( + index = index, + trackId = trackId, + label = label, + language = language, + codec = codec, + forced = forced, + hearingImpaired = hearingImpaired, + ) + + private fun media( + trackId: String? = null, + label: String? = null, + language: String? = null, + codecFamily: String? = null, + forced: Boolean? = null, + hearingImpaired: Boolean? = null, + ): SubtitleMediaIdentity = SubtitleMediaIdentity( + trackId = trackId, + label = label, + language = language, + codecFamily = codecFamily, + forced = forced, + hearingImpaired = hearingImpaired, + ) +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/TitleSafeInsetTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/TitleSafeInsetTest.kt new file mode 100644 index 000000000..0de9cdc3f --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/TitleSafeInsetTest.kt @@ -0,0 +1,28 @@ +package org.prairieserver.prairie.common.player + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TitleSafeInsetTest { + @Test + fun pullsTheSurfaceInOnEveryEdge() { + val rect = SubtitleVideoRect(left = 0, top = 0, width = 1920, height = 1080) + val safe = rect.insetByTitleSafe(0.05f) + assertEquals(96, safe.left) + assertEquals(54, safe.top) + assertEquals(1728, safe.width) + assertEquals(972, safe.height) + } + + @Test + fun zeroIsANoOpSoPhonesAreUntouched() { + val rect = SubtitleVideoRect(left = 10, top = 20, width = 800, height = 400) + assertEquals(rect, rect.insetByTitleSafe(0f)) + } + + @Test + fun refusesAnInsetThatWouldConsumeTheSurface() { + val rect = SubtitleVideoRect(left = 0, top = 0, width = 100, height = 100) + assertEquals(rect, rect.insetByTitleSafe(0.6f)) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/Media3PgsParserProbeTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/Media3PgsParserProbeTest.kt new file mode 100644 index 000000000..63b6e60a6 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/Media3PgsParserProbeTest.kt @@ -0,0 +1,59 @@ +package org.prairieserver.prairie.common.player.subtitle + +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.extractor.text.DefaultSubtitleParserFactory +import androidx.media3.extractor.text.SubtitleParser +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Probe against a REAL display set pulled from a server `.sup` extract + * (PCS+WDS+PDS+ODS, 34 KB, the first caption of SNW S01E03's English track). + * Everything upstream of the parser is verified, so this answers the only + * remaining question: does Media3 turn this into a cue? + */ +@RunWith(RobolectricTestRunner::class) +class Media3PgsParserProbeTest { + @Test + fun reportWhatMedia3MakesOfARealDisplaySet() { + val bytes = javaClass.classLoader!! + .getResourceAsStream("pgs/display-set-0.bin")!! + .readBytes() + println("PROBE input bytes=${bytes.size}") + + val format = Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_PGS).build() + val parser = DefaultSubtitleParserFactory().create(format) + println("PROBE parser=${parser.javaClass.name}") + + // Hypothesis: the parser builds the cue when it sees the END section, + // which the extractor currently strips. Try both shapes. + val withEnd = bytes + byteArrayOf(0x80.toByte(), 0, 0) + var endGroups = 0 + var endCues = 0 + DefaultSubtitleParserFactory().create(format) + .parse(withEnd, 0, withEnd.size, SubtitleParser.OutputOptions.allCues()) { out -> + endGroups++ + endCues += out.cues.size + out.cues.forEach { cue -> + println("PROBE withEnd cue bitmap=${cue.bitmap?.width}x${cue.bitmap?.height}") + } + } + println("PROBE withEnd groups=$endGroups cues=$endCues") + + var groups = 0 + var cues = 0 + parser.parse(bytes, 0, bytes.size, SubtitleParser.OutputOptions.allCues()) { out -> + groups++ + cues += out.cues.size + out.cues.forEach { cue -> + println( + "PROBE cue bitmap=${cue.bitmap?.width}x${cue.bitmap?.height} " + + "pos=${cue.position} line=${cue.line} size=${cue.size}", + ) + } + } + println("PROBE groups=$groups cues=$cues") + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/Media3PgsSupportProbeTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/Media3PgsSupportProbeTest.kt new file mode 100644 index 000000000..fbcdb8098 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/Media3PgsSupportProbeTest.kt @@ -0,0 +1,26 @@ +package org.prairieserver.prairie.common.player.subtitle + +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.extractor.text.DefaultSubtitleParserFactory +import org.junit.Test + +/** + * Probe, not a behavioural assertion: does the bundled Media3 parse a raw PGS + * sidecar? Mounting `.sup` client-side only works if it does. + */ +class Media3PgsSupportProbeTest { + @Test + fun reportBitmapSubtitleSupport() { + val factory = DefaultSubtitleParserFactory() + for (mime in listOf( + MimeTypes.APPLICATION_PGS, + MimeTypes.APPLICATION_VOBSUB, + MimeTypes.APPLICATION_DVBSUBS, + MimeTypes.APPLICATION_SUBRIP, + )) { + val format = Format.Builder().setSampleMimeType(mime).build() + println("PROBE $mime supported=${factory.supportsFormat(format)}") + } + } +} 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 new file mode 100644 index 000000000..742c1d7cf --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractorTest.kt @@ -0,0 +1,248 @@ +package org.prairieserver.prairie.common.player.subtitle + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.text.Cue +import androidx.media3.common.util.Consumer +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput +import androidx.media3.extractor.text.CuesWithTiming +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.test.utils.FakeExtractorInput +import androidx.media3.test.utils.FakeExtractorOutput +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayOutputStream + +/** + * Framing only. Building a real PGS bitmap by hand would test libpgs, not this + * extractor; what matters here is that the stream is split into display sets, + * each set reaches the parser in the container-shaped form it expects, and the + * PTS from the `.sup` prefix becomes the sample timestamp. + */ +@RunWith(RobolectricTestRunner::class) +class PgsSupExtractorTest { + + @Test + fun sniffsThePgMagic() { + val extractor = PgsSupExtractor(RecordingParserFactory(), { 0L }, pgsFormat()) + + assertTrue(extractor.sniff(FakeExtractorInput.Builder().setData(supStream()).build())) + assertEquals( + false, + extractor.sniff( + FakeExtractorInput.Builder().setData(byteArrayOf(0x00, 0x01, 0x02)).build(), + ), + ) + } + + @Test + fun eachDisplaySetBecomesOneSampleAtItsOwnPts() { + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { 0L }, pgsFormat()) + val output = FakeExtractorOutput() + extractor.init(output) + + drain(extractor, FakeExtractorInput.Builder().setData(supStream()).build()) + + // Two sets in, two parse calls out — not one call with the whole file, + // which is what made a mounted `.sup` render nothing. + assertEquals(2, factory.parsed.size) + val track = output.trackOutputs[0]!! + assertEquals(2, track.sampleCount) + assertEquals(1_000_000L, track.getSampleTimeUs(0)) + assertEquals(3_000_000L, track.getSampleTimeUs(1)) + } + + // The mount resolver matches on the track's id/language/label, so losing + // them makes a perfectly good sidecar unselectable. + // Sync and the re-anchor delta have to reach these cues; PGS carries no + // cue-relative time for the parser's offset wrapper to shift, so the + // extractor applies it to the sample timestamp. + @Test + fun theOffsetShiftsTheSampleTimestamp() { + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { -500_000L }, pgsFormat()) + val output = FakeExtractorOutput() + extractor.init(output) + + drain(extractor, FakeExtractorInput.Builder().setData(supStream()).build()) + + val track = output.trackOutputs[0]!! + assertEquals(500_000L, track.getSampleTimeUs(0)) + assertEquals(2_500_000L, track.getSampleTimeUs(1)) + } + + @Test + fun theEmittedTrackKeepsTheSidecarIdentity() { + val extractor = PgsSupExtractor(RecordingParserFactory(), { 0L }, pgsFormat()) + val output = FakeExtractorOutput() + extractor.init(output) + + val emitted = output.trackOutputs[0]!!.lastFormat!! + assertEquals("prairie-subtitle:8", emitted.id) + assertEquals("en", emitted.language) + assertEquals("English (SDH)", emitted.label) + assertEquals("application/pgs", emitted.codecs) + } + + @Test + fun segmentsReachTheParserWithoutTheSupPrefix() { + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { 0L }, pgsFormat()) + extractor.init(FakeExtractorOutput()) + + drain(extractor, FakeExtractorInput.Builder().setData(supStream()).build()) + + // [type][len-hi][len-lo][payload] — the shape Matroska hands the parser. + val first = factory.parsed.first() + assertEquals(SEGMENT_TYPE_PCS.toByte(), first[0]) + assertEquals(0, first[1].toInt()) + assertEquals(2, first[2].toInt()) + assertEquals(0xAA.toByte(), first[3]) + assertEquals(0xBB.toByte(), first[4]) + // The END section is kept, not stripped: Media3's PgsParser builds the + // cue when it reads one, so a set without it parses to nothing. + assertEquals(PgsSupExtractor.SEGMENT_TYPE_END.toByte(), first[5]) + assertEquals(8, first.size) + } + + @Test + fun aTruncatedTrailingSegmentDoesNotInventACue() { + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { 0L }, pgsFormat()) + val output = FakeExtractorOutput() + extractor.init(output) + + val truncated = supStream().copyOf(supStream().size - 4) + drain(extractor, FakeExtractorInput.Builder().setData(truncated).build()) + + // The complete first set still parses; the severed one is dropped. + assertEquals(1, factory.parsed.size) + } + + @Test + fun anOversizedDisplaySetWithoutEndFailsClosedBeforeConsumingTheStream() { + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { 0L }, pgsFormat()) + extractor.init(FakeExtractorOutput()) + val oversized = missingEndStream( + segmentCount = 300, + payloadSize = 65_535, + ) + val input = FakeExtractorInput.Builder().setData(oversized).build() + + drain(extractor, input) + + assertTrue(input.position < oversized.size) + assertTrue(factory.parsed.isEmpty()) + } + + @Test + fun tooManySegmentsWithoutEndFailClosedBeforeConsumingTheStream() { + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { 0L }, pgsFormat()) + extractor.init(FakeExtractorOutput()) + val oversized = missingEndStream( + segmentCount = 3_000, + payloadSize = 0, + ) + val input = FakeExtractorInput.Builder().setData(oversized).build() + + drain(extractor, input) + + assertTrue(input.position < oversized.size) + assertTrue(factory.parsed.isEmpty()) + } + + private fun pgsFormat(): Format = Format.Builder() + .setId("prairie-subtitle:8") + .setSampleMimeType("application/pgs") + .setLanguage("en") + .setLabel("English (SDH)") + .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 > 1000) error("extractor did not terminate") + } + } + + /** Two display sets: PTS 1s and 3s, each one PCS segment then END. */ + private fun supStream(): ByteArray { + val out = ByteArrayOutputStream() + out.writeSegment(pts90kHz = 90_000, type = SEGMENT_TYPE_PCS, payload = byteArrayOf(0xAA.toByte(), 0xBB.toByte())) + out.writeSegment(pts90kHz = 90_000, type = PgsSupExtractor.SEGMENT_TYPE_END, payload = ByteArray(0)) + out.writeSegment(pts90kHz = 270_000, type = SEGMENT_TYPE_PCS, payload = byteArrayOf(0xCC.toByte())) + out.writeSegment(pts90kHz = 270_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 } + repeat(segmentCount) { + out.writeSegment( + pts90kHz = 90_000, + type = SEGMENT_TYPE_PCS, + payload = payload, + ) + } + return out.toByteArray() + } + + private fun ByteArrayOutputStream.writeSegment(pts90kHz: Long, type: Int, payload: ByteArray) { + write('P'.code) + write('G'.code) + write((pts90kHz shr 24 and 0xFF).toInt()) + write((pts90kHz shr 16 and 0xFF).toInt()) + write((pts90kHz shr 8 and 0xFF).toInt()) + write((pts90kHz and 0xFF).toInt()) + repeat(4) { write(0) } // DTS + write(type) + write(payload.size shr 8 and 0xFF) + write(payload.size and 0xFF) + write(payload) + } + + private class RecordingParserFactory : SubtitleParser.Factory { + val parsed = mutableListOf() + + override fun supportsFormat(format: Format): Boolean = true + + override fun getCueReplacementBehavior(format: Format): Int = + Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE + + override fun create(format: Format): SubtitleParser = object : SubtitleParser { + override fun getCueReplacementBehavior(): Int = + Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE + + override fun parse( + data: ByteArray, + offset: Int, + length: Int, + outputOptions: SubtitleParser.OutputOptions, + output: Consumer, + ) { + parsed += data.copyOfRange(offset, offset + length) + output.accept( + CuesWithTiming(listOf(Cue.Builder().setText("x").build()), 0L, C.TIME_UNSET), + ) + } + + override fun reset() = Unit + } + } + + private companion object { + const val SEGMENT_TYPE_PCS = 0x16 + } +} 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 9ec20fa21..37dceb781 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 @@ -51,8 +51,8 @@ class VideoTrackSelectionCoordinatorTest { .substringBefore("fun selectAudioTrack(") assertTrue( - methodBody.contains("subtitleManager.selectSubtitle(player, subtitles, selectedIndex)"), - "mounted subtitle re-selection must use metadata-aware SubtitleManager selection", + methodBody.contains("subtitleManager.selectSubtitle(player, identity)"), + "mounted subtitle re-selection must use typed SubtitleManager selection", ) assertTrue( !methodBody.contains("refreshMountedVideoMedia("), 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 278cad2e3..63fcb8711 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 @@ -164,6 +164,7 @@ 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) @@ -192,6 +193,7 @@ 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 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 new file mode 100644 index 000000000..b863d5aa5 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/SubtitleSyncOverridesTest.kt @@ -0,0 +1,36 @@ +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/store/SafePathSegmentTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/store/SafePathSegmentTest.kt new file mode 100644 index 000000000..00846db20 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/store/SafePathSegmentTest.kt @@ -0,0 +1,74 @@ +package org.prairieserver.prairie.common.store + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SafePathSegmentTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `safe identity segment stays unchanged`() { + val uuid = "550e8400-e29b-41d4-a716-446655440000" + + assertEquals(uuid, safePathSegment(uuid)) + } + + @Test + fun `unsafe identity segment is encoded without path separators`() { + val encoded = safePathSegment("../other") + + assertEquals("~Li4vb3RoZXI", encoded) + assertNotEquals("../other", encoded) + assertTrue(encoded.startsWith("~")) + assertTrue('/' !in encoded) + assertTrue('\\' !in encoded) + } + + @Test + fun `dot segments are encoded`() { + assertNotEquals(".", safePathSegment(".")) + assertNotEquals("..", safePathSegment("..")) + } + + @Test + fun `overlong encoded segment is bounded and deterministic`() { + val value = " unsafe/".repeat(100) + + assertEquals(safePathSegment(value), safePathSegment(value)) + assertTrue(safePathSegment(value).length <= 120) + } + + @Test + fun `contained child rejects traversal input`() { + val root = tmp.newFolder("root") + + assertNull(containedChild(root, "../other")) + assertNull(containedChild(root, "..")) + } + + @Test + fun `contained child encodes non traversal unsafe input under root`() { + val root = tmp.newFolder("root").canonicalFile + val child = containedChild(root, "server id", "profile%id") + + assertTrue(child != null) + assertTrue(child.canonicalPath.startsWith(root.canonicalPath + java.io.File.separator)) + assertTrue("server id" !in child.path) + assertTrue("profile%id" !in child.path) + } + + @Test + fun `reserved encoded namespace is never probed as a raw legacy segment`() { + val root = tmp.newFolder("root") + + assertNull(containedLegacyChild(root, "~Pw")) + assertNull(containedLegacyChild(root, "server", "~Pw.json")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStoreTest.kt index 55287b994..8ee31b2d0 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStoreTest.kt @@ -5,6 +5,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.nio.file.Files import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -52,4 +53,79 @@ class ScopedJsonFileStoreTest { assertEquals(Payload(name = "b", count = 2), store.read(file)) assertFalse(File(file.parentFile, "${file.name}.tmp").exists()) } + + @Test + fun `unsafe scope values resolve to an encoded contained target`() { + val root = tmp.newFolder("root").canonicalFile + val store = ScopedJsonFileStore(root, tag = "Test") + + val file = store.fileFor("../server", "profile/name", "content id") + + assertTrue(file.canonicalPath.startsWith(root.canonicalPath + File.separator)) + assertFalse(file.path.contains("../server")) + assertFalse(file.path.contains("profile/name")) + assertFalse(file.path.contains("content id")) + } + + @Test + fun `contained legacy value is read then migrated on the next successful write`() { + val root = tmp.newFolder("root") + val legacy = File(root, "server id/profile%id/content id.json").apply { + parentFile?.mkdirs() + writeText("""{"name":"legacy","count":1}""") + } + val store = ScopedJsonFileStore(root, tag = "Test") + val target = store.fileFor("server id", "profile%id", "content id") + + assertFalse(target.exists()) + assertEquals(Payload(name = "legacy", count = 1), store.read(target)) + assertTrue(legacy.isFile) + + store.write(target, Payload(name = "updated", count = 2)) + + assertFalse(legacy.exists()) + assertEquals(Payload(name = "updated", count = 2), store.read(target)) + } + + @Test + fun `read and write reject a target outside the store root`() { + val root = tmp.newFolder("root") + val sentinel = File(root.parentFile, "sentinel.json").apply { + writeText("""{"name":"keep","count":7}""") + } + val store = ScopedJsonFileStore(root, tag = "Test") + + assertNull(store.read(sentinel)) + store.writeAtomic(sentinel, """{"name":"changed","count":8}""") + + assertEquals("""{"name":"keep","count":7}""", sentinel.readText()) + } + + @Test + fun `raw reserved namespace identity cannot read another identity encoded target`() { + val store = ScopedJsonFileStore(tmp.newFolder("root"), tag = "Test") + val encodedQuestion = store.fileFor("?", "prof", "content") + store.write(encodedQuestion, Payload(name = "question", count = 1)) + + val rawReserved = store.fileFor("~Pw", "prof", "content") + + assertNull(store.read(rawReserved)) + assertEquals(Payload(name = "question", count = 1), store.read(encodedQuestion)) + } + + @Test + fun `atomic write does not follow predictable temp symlink outside root`() { + val root = tmp.newFolder("root") + val store = ScopedJsonFileStore(root, tag = "Test") + val target = store.fileFor("server", "profile", "content") + target.parentFile?.mkdirs() + val sentinel = File(root.parentFile, "sentinel.json").apply { writeText("keep") } + val predictableTmp = File(target.parentFile, "${target.name}.tmp") + Files.createSymbolicLink(predictableTmp.toPath(), sentinel.toPath()) + + store.write(target, Payload(name = "safe", count = 2)) + + assertEquals("keep", sentinel.readText()) + assertEquals(Payload(name = "safe", count = 2), store.read(target)) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/store/ServerScopedJsonStatePurgerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/store/ServerScopedJsonStatePurgerTest.kt new file mode 100644 index 000000000..fde041bdc --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/store/ServerScopedJsonStatePurgerTest.kt @@ -0,0 +1,115 @@ +package org.prairieserver.prairie.common.store + +import java.nio.file.Files +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ServerScopedJsonStatePurgerTest { + + @Test + fun `orphan symlink is removed without touching its target`() { + val base = Files.createTempDirectory("prairie-json-purge").toFile() + val external = Files.createTempDirectory("prairie-json-external") + val sentinel = external.resolve("keep.txt") + Files.write(sentinel, "keep".toByteArray()) + val root = base.toPath().resolve("ebook_state") + Files.createDirectories(root) + val link = root.resolve("orphan-server") + Files.createSymbolicLink(link, external) + + ServerScopedJsonStatePurger(base).deleteJsonOnlyOrphans( + registeredServerIds = emptySet(), + protectedServerIds = emptySet(), + ) + + assertFalse(Files.exists(link, java.nio.file.LinkOption.NOFOLLOW_LINKS)) + assertTrue(Files.exists(sentinel), "purge must not follow a server-directory symlink") + } + + @Test + fun `orphan symlink to a registered sibling removes only the link`() { + val base = Files.createTempDirectory("prairie-json-sibling-link").toFile() + val root = base.toPath().resolve("ebook_state") + val kept = root.resolve("kept-server") + Files.createDirectories(kept) + val sentinel = kept.resolve("keep.txt") + Files.write(sentinel, "keep".toByteArray()) + val removedLink = root.resolve("removed-server") + Files.createSymbolicLink(removedLink, kept) + + ServerScopedJsonStatePurger(base).deleteAllForServer("removed-server") + + assertFalse(Files.exists(removedLink, java.nio.file.LinkOption.NOFOLLOW_LINKS)) + assertTrue(Files.exists(sentinel), "exact purge must not canonicalize into a sibling") + } + + @Test + fun `symlinked store root is rejected without touching its target`() { + val base = Files.createTempDirectory("prairie-json-root-purge").toFile() + val external = Files.createTempDirectory("prairie-json-root-external") + val sentinel = external.resolve("keep.txt") + Files.write(sentinel, "keep".toByteArray()) + Files.createSymbolicLink(base.toPath().resolve("ebook_state"), external) + + ServerScopedJsonStatePurger(base).deleteJsonOnlyOrphans( + registeredServerIds = emptySet(), + protectedServerIds = emptySet(), + ) + + assertTrue(Files.exists(sentinel), "purge must reject a symlinked store root") + } + + @Test + fun `store root swapped to a symlink after construction is rejected`() { + val base = Files.createTempDirectory("prairie-json-root-swap").toFile() + val root = base.toPath().resolve("ebook_state") + Files.createDirectories(root) + val purger = ServerScopedJsonStatePurger(base) + Files.delete(root) + val external = Files.createTempDirectory("prairie-json-root-swap-external") + val sentinel = external.resolve("keep.txt") + Files.write(sentinel, "keep".toByteArray()) + Files.createSymbolicLink(root, external) + + purger.deleteJsonOnlyOrphans(emptySet(), emptySet()) + + assertTrue(Files.exists(sentinel), "purge must revalidate the root on every pass") + } + + @Test + fun `purge waits for an in-flight writer and rejects stale writes afterward`() { + val base = Files.createTempDirectory("prairie-json-writer-race").toFile() + val store = ScopedJsonFileStore(base.resolve("ebook_state"), "test") + val target = store.fileFor("removed-server", "p1", "book") + val writerEntered = CountDownLatch(1) + val releaseWriter = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + try { + val writer = executor.submit { + ServerScopedJsonAccess.withWriter(store.rootDirectory(), safePathSegment("removed-server")) { + writerEntered.countDown() + check(releaseWriter.await(5, TimeUnit.SECONDS)) + store.writeAtomic(target, """{"progress":0.5}""") + } + } + assertTrue(writerEntered.await(5, TimeUnit.SECONDS)) + + val purge = executor.submit { + ServerScopedJsonStatePurger(base).deleteAllForServer("removed-server") + } + releaseWriter.countDown() + writer.get(5, TimeUnit.SECONDS) + purge.get(5, TimeUnit.SECONDS) + assertFalse(target.exists()) + + store.writeAtomic(target, """{"progress":0.75}""") + assertFalse(target.exists(), "a stale writer must not recreate a removed identity") + } finally { + executor.shutdownNow() + } + } +} diff --git a/android-shared/src/androidUnitTest/resources/pgs/display-set-0.bin b/android-shared/src/androidUnitTest/resources/pgs/display-set-0.bin new file mode 100644 index 000000000..0da49e4dd Binary files /dev/null and b/android-shared/src/androidUnitTest/resources/pgs/display-set-0.bin differ diff --git a/android-shared/src/native/dovi/AndroidManifest.xml b/android-shared/src/native/dovi/AndroidManifest.xml new file mode 100644 index 000000000..a899ed56b --- /dev/null +++ b/android-shared/src/native/dovi/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/android-shared/src/native/dovi/Cargo.lock b/android-shared/src/native/dovi/Cargo.lock new file mode 100644 index 000000000..15517c87c --- /dev/null +++ b/android-shared/src/native/dovi/Cargo.lock @@ -0,0 +1,729 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "bitvec_helpers" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e5938870db620c26a8f329f5735aa3450cedd30f4a4bb98475b1b076d8edae" +dependencies = [ + "bitstream-io", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "criterion" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf7af66b0989381bd0be551bd7cc91912a655a58c6918420c9527b1fd8b4679" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dolby_vision" +version = "3.3.2" +dependencies = [ + "anyhow", + "bitvec", + "bitvec_helpers", + "crc", + "criterion", + "libc", + "roxmltree", + "serde", + "serde_json", + "tinyvec", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "serde", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] diff --git a/android-shared/src/native/dovi/THIRD_PARTY_NOTICES.txt b/android-shared/src/native/dovi/THIRD_PARTY_NOTICES.txt index 90b5c0e66..2791bf1c0 100644 --- a/android-shared/src/native/dovi/THIRD_PARTY_NOTICES.txt +++ b/android-shared/src/native/dovi/THIRD_PARTY_NOTICES.txt @@ -2,9 +2,15 @@ libdovi 2.3.1 Copyright (c) quietvoid Licensed under the MIT License. The pinned Android static libraries are from -https://github.com/edde746/libdovi-builds/releases/tag/v2.3.1 and are verified -by SHA-256 in scripts/build-dovi-aar.sh. libdovi source is maintained at -https://github.com/quietvoid/dovi_tool/tree/main/dolby_vision. +the dolby_vision crate in quietvoid/dovi_tool commit +b25558062e4a56973482ec70133bd7b891320e48 and are built from source with the +pinned Rust and Android NDK toolchains recorded in provenance.json. The source +archive, Cargo.lock, per-ABI libraries, and final AAR are verified by SHA-256 +in scripts/build-dovi-aar.sh. The checked-in build lock updates only compatible +crate releases needed to address OSV advisories published after upstream 2.3.1: +anyhow 1.0.103 (RUSTSEC-2026-0190), removal of core2 0.4.0 through +bitvec_helpers 4.0.2 and bitstream-io 4.10.0 (RUSTSEC-2026-0105), and +crossbeam-epoch 0.9.20 (RUSTSEC-2026-0204). Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/android-shared/src/native/dovi/provenance.json b/android-shared/src/native/dovi/provenance.json new file mode 100644 index 000000000..947344b1f --- /dev/null +++ b/android-shared/src/native/dovi/provenance.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 1, + "source": { + "repository": "https://github.com/quietvoid/dovi_tool", + "tag": "2.3.1", + "commit": "b25558062e4a56973482ec70133bd7b891320e48", + "archive": { + "url": "https://codeload.github.com/quietvoid/dovi_tool/tar.gz/b25558062e4a56973482ec70133bd7b891320e48", + "sha256": "595b1c756c9ad4acd5c1ddd851726eb3f0bd1786627c3d374998efc2929209ad" + }, + "rootCargoLockSha256": "a2d6127f9e4bad347fc98602282eb1cd8f711fc46ddf28723abfedc47ef30eac", + "upstreamCargoLockSha256": "e415cb414098495667f07a732633b632393adc1de2e0fa23c38c675b4fa81286", + "cargoLockSha256": "d6e609177b9cf349734f3972b41dca11c05f42aea1d7fac62a6869cb48f7d276" + }, + "toolchains": { + "rust": "1.85.0", + "ndk": "26.3.11579264" + }, + "build": { + "sourceDateEpoch": "1755858563", + "cargoLockPath": "android-shared/src/native/dovi/Cargo.lock", + "cargoManifestAdjustment": "Set dolby_vision [lib] crate-type to staticlib after verifying the source archive", + "cargoLockDerivation": [ + "cargo update -p bitvec_helpers --precise 4.0.2", + "cargo update -p anyhow --precise 1.0.103", + "cargo update -p crossbeam-epoch --precise 0.9.20" + ], + "advisoryRemediation": [ + { + "advisory": "RUSTSEC-2026-0190", + "package": "anyhow", + "from": "1.0.98", + "to": "1.0.103" + }, + { + "advisory": "RUSTSEC-2026-0105", + "package": "core2", + "from": "0.4.0", + "to": null, + "resolution": "bitvec_helpers 4.0.2 and bitstream-io 4.10.0 replace core2 with no_std_io2 0.9.4" + }, + { + "advisory": "RUSTSEC-2026-0204", + "package": "crossbeam-epoch", + "from": "0.9.18", + "to": "0.9.20" + } + ] + }, + "outputs": { + "abis": { + "arm64-v8a": { + "rustTarget": "aarch64-linux-android", + "sha256": "2ccef1b96ba9332441807d6dd4c8e091ee30b0d8c9d93b90ef5ce275f0c72afa" + }, + "armeabi-v7a": { + "rustTarget": "armv7-linux-androideabi", + "sha256": "11eedfc07571b80aa9c69d68d8dfbf27829a016cf91635fc41c2b0b740a430a1" + }, + "x86_64": { + "rustTarget": "x86_64-linux-android", + "sha256": "7090f632abbab1a1ec893a98101b119c04508ba3b93b9041e60b58c349c31e36" + } + }, + "aar": { + "path": "android-shared/libs/prairie-dovi-bridge-2.3.1.aar", + "sha256": "a22161626142aff96d445806377aad386a8421780f060b39e1cfaa3876f7a109" + } + } +} diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index d7ec7fa29..3ac60d930 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -77,6 +77,7 @@ kotlin { implementation(libs.koin.compose.viewmodel) implementation(libs.coil.compose) implementation(libs.coil.network.ktor) + implementation(libs.jsoup) implementation(libs.media3.exoplayer) implementation(libs.media3.exoplayer.hls) implementation(libs.media3.datasource.okhttp) @@ -86,6 +87,7 @@ kotlin { implementation(libs.media3.ui.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.webkit) implementation(libs.koin.androidx.workmanager) implementation(libs.firebase.messaging) // Google Cast (Chromecast) — phone app only. TV app must not depend diff --git a/androidApp/gradle.lockfile b/androidApp/gradle.lockfile new file mode 100644 index 000000000..c8b77996f --- /dev/null +++ b/androidApp/gradle.lockfile @@ -0,0 +1,580 @@ +# 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=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.activity:activity-ktx:1.12.4=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.activity:activity:1.12.4=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.annotation:annotation-experimental:1.4.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +androidx.annotation:annotation-experimental:1.5.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +androidx.annotation:annotation-experimental:1.5.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation-jvm:1.9.1=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation:1.9.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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.appcompat:appcompat-resources:1.7.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.appcompat:appcompat:1.7.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.arch.core:core-common:2.2.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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.arch.core:core-runtime:2.2.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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.asynclayoutinflater:asynclayoutinflater:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +androidx.autofill:autofill:1.0.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection-ktx:1.5.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.collection:collection:1.5.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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.compose.animation:animation-android:1.8.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 +androidx.compose.animation:animation-core-android:1.8.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 +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 +androidx.compose.animation:animation-core:1.8.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 +androidx.compose.animation:animation: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.animation:animation:1.8.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 +androidx.compose.foundation:foundation-android:1.8.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 +androidx.compose.foundation:foundation-layout-android:1.8.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 +androidx.compose.foundation:foundation-layout: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.foundation:foundation-layout:1.8.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 +androidx.compose.foundation:foundation: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.foundation:foundation:1.8.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 +androidx.compose.material3:material3-android:1.3.1=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material3:material3:1.3.1=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-core-android:1.7.6=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-core:1.7.6=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-extended-android:1.7.6=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-extended:1.7.6=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-ripple-android:1.7.6=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-ripple:1.7.6=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime-android:1.9.2=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime-android:1.9.3=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.9.2=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime-annotation-android:1.9.3=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.9.2=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime-annotation:1.9.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime-saveable-android:1.9.2=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime-saveable-android:1.9.3=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime-saveable:1.9.2=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime-saveable:1.9.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.runtime:runtime:1.9.2=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime:1.9.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-android:1.9.0=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-android:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.9.0=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-geometry-android:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,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=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-geometry:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.9.0=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-graphics-android:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,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=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-graphics:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-text-android:1.9.0=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-text-android:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,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=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-text:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.9.0=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-unit-android:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,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=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-unit:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-util-android:1.9.0=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-util-android:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,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=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui-util:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,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=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.ui:ui:1.9.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose:compose-bom:2024.12.01=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.concurrent:concurrent-futures-ktx:1.1.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata +androidx.concurrent:concurrent-futures-ktx:1.2.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +androidx.concurrent:concurrent-futures:1.2.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.coordinatorlayout:coordinatorlayout:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +androidx.core:core-ktx: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.core:core-viewtree: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.core:core: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.cursoradapter:cursoradapter: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.customview:customview-poolingcontainer:1.0.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.customview:customview: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.datastore:datastore-android:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core-android:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core-okio-jvm:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core-okio:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-android:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-core-android:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-core:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-external-protobuf:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-proto:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore:1.2.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.documentfile:documentfile: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.drawerlayout:drawerlayout: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.emoji2:emoji2-views-helper:1.4.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.emoji2:emoji2:1.4.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.exifinterface:exifinterface:1.3.7=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.fragment:fragment-ktx:1.8.8=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.fragment:fragment:1.8.8=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.graphics:graphics-path:1.0.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.interpolator:interpolator: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.legacy:legacy-support-core-ui:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +androidx.legacy:legacy-support-core-utils: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.lifecycle:lifecycle-common-java8:2.10.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-common-jvm:2.10.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 +androidx.lifecycle:lifecycle-common:2.10.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.lifecycle:lifecycle-common:2.3.1=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.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.lifecycle:lifecycle-livedata-core:2.10.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.lifecycle:lifecycle-livedata:2.10.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.lifecycle:lifecycle-process:2.10.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.lifecycle:lifecycle-runtime-android:2.10.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 +androidx.lifecycle:lifecycle-runtime-compose-android:2.10.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 +androidx.lifecycle:lifecycle-runtime-compose:2.10.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.lifecycle:lifecycle-runtime-ktx-android:2.10.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 +androidx.lifecycle:lifecycle-runtime-ktx:2.10.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.lifecycle:lifecycle-runtime:2.10.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.lifecycle:lifecycle-service:2.10.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.lifecycle:lifecycle-viewmodel-android:2.10.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 +androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.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 +androidx.lifecycle:lifecycle-viewmodel-compose:2.10.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.lifecycle:lifecycle-viewmodel-ktx:2.10.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.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.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 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.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.lifecycle:lifecycle-viewmodel:2.10.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.loader:loader: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.localbroadcastmanager:localbroadcastmanager: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.media3:media3-common-ktx:1.10.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.media3:media3-common:1.10.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.media3:media3-container:1.10.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.media3:media3-database:1.10.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.media3:media3-datasource-okhttp:1.10.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.media3:media3-datasource:1.10.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.media3:media3-decoder:1.10.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.media3:media3-effect:1.10.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-exoplayer-hls:1.10.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.media3:media3-exoplayer:1.10.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.media3:media3-extractor:1.10.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.media3:media3-session:1.10.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.media3:media3-ui-compose:1.10.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.media3:media3-ui:1.10.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.media:media:1.6.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +androidx.media:media:1.7.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.mediarouter:mediarouter:1.7.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.navigation:navigation-common-android:2.9.8=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.navigation:navigation-common:2.9.8=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.navigation:navigation-compose-android:2.9.8=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.navigation:navigation-compose:2.9.8=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.navigation:navigation-runtime-android:2.9.8=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.navigation:navigation-runtime:2.9.8=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.navigationevent:navigationevent-android:1.0.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 +androidx.navigationevent:navigationevent-compose-android:1.0.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 +androidx.navigationevent:navigationevent-compose:1.0.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.navigationevent:navigationevent:1.0.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.palette:palette-ktx: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.palette:palette: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.print:print: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.profileinstaller:profileinstaller:1.4.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.recyclerview:recyclerview:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +androidx.recyclerview:recyclerview:1.3.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.resourceinspection:resourceinspection-annotation:1.0.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-common-jvm:2.8.4=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-common:2.8.4=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-ktx:2.8.4=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-runtime-android:2.8.4=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-runtime:2.8.4=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate-android:1.4.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 +androidx.savedstate:savedstate-compose-android:1.4.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 +androidx.savedstate:savedstate-compose: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.savedstate:savedstate-ktx: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.savedstate:savedstate: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.security:security-crypto:1.1.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.slidingpanelayout:slidingpanelayout:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +androidx.sqlite:sqlite-android:2.6.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.sqlite:sqlite-framework-android:2.6.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.sqlite:sqlite-framework:2.6.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.sqlite:sqlite:2.6.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.startup:startup-runtime: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.swiperefreshlayout:swiperefreshlayout:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +androidx.test.espresso:espresso-idling-resource:3.7.0=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.tracing:tracing-ktx:1.2.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.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.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 +co.touchlab:stately-concurrency:2.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrent-collections-jvm:2.1.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrent-collections:2.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-strict-jvm:2.1.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-strict:2.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,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 +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.android.tools:desugar_jdk_libs:2.1.5=coreLibraryDesugaring +com.android.tools:desugar_jdk_libs_configuration:2.1.5=coreLibraryDesugaring +com.google.accompanist:accompanist-drawablepainter:0.36.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.android.datatransport:transport-api:3.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +com.google.android.datatransport:transport-api:3.1.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.android.datatransport:transport-backend-cct:3.1.9=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 +com.google.android.datatransport:transport-runtime:3.1.9=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 +com.google.android.gms:play-services-base:18.5.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +com.google.android.gms:play-services-base:18.9.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.android.gms:play-services-basement:18.8.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +com.google.android.gms:play-services-basement:18.9.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.android.gms:play-services-cast-framework:21.5.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 +com.google.android.gms:play-services-cast:21.5.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 +com.google.android.gms:play-services-cloud-messaging:17.4.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.android.gms:play-services-flags:18.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 +com.google.android.gms:play-services-stats:17.0.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.android.gms:play-services-tasks:18.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 +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.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,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,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,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.crypto.tink:tink-android:1.8.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,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,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,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.firebase:firebase-annotations:17.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 +com.google.firebase:firebase-common:22.0.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 +com.google.firebase:firebase-components:19.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 +com.google.firebase:firebase-datatransport:18.2.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 +com.google.firebase:firebase-encoders-json:18.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 +com.google.firebase:firebase-encoders-proto:16.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 +com.google.firebase:firebase-encoders:17.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 +com.google.firebase:firebase-iid-interop:17.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 +com.google.firebase:firebase-installations-interop:17.3.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 +com.google.firebase:firebase-installations:19.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 +com.google.firebase:firebase-measurement-connector:19.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 +com.google.firebase:firebase-messaging:25.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 +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,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,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,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,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: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,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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 +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,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,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 +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.testparameterinjector:test-parameter-injector:1.18=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,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=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.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 +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 +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 +io.coil-kt.coil3:coil-compose-core:3.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 +io.coil-kt.coil3:coil-compose:3.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 +io.coil-kt.coil3:coil-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 +io.coil-kt.coil3:coil-core:3.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 +io.coil-kt.coil3:coil-network-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 +io.coil-kt.coil3:coil-network-core:3.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 +io.coil-kt.coil3:coil-network-ktor3-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-network-ktor3:3.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 +io.coil-kt.coil3:coil:3.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 +io.github.peerless2012:ass-kt:0.4.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.github.peerless2012:ass-media:0.4.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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=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.insert-koin:koin-androidx-workmanager:4.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 +io.insert-koin:koin-compose-android:4.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.insert-koin:koin-compose-viewmodel-android:4.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.insert-koin:koin-compose-viewmodel:4.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 +io.insert-koin:koin-compose:4.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 +io.insert-koin:koin-core-jvm:4.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.insert-koin:koin-core-viewmodel-android:4.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.insert-koin:koin-core-viewmodel:4.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 +io.insert-koin:koin-core:4.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 +io.ktor:ktor-client-auth-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-auth:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-content-negotiation-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-content-negotiation:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-core-jvm:3.0.3=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-client-core-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-core:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-client-core:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-logging-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-logging:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-mock-jvm:3.1.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-mock:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-okhttp-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-okhttp:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-websockets-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-websockets:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-events-jvm:3.0.3=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-events-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-events:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-events:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-cio-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-cio:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-jvm:3.0.3=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-http-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-http:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-io-jvm:3.0.3=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-io-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-io:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-io:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-network-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-network:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-jvm:3.0.3=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-serialization-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-serialization:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-sse-jvm:3.0.3=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-sse-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-sse:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-sse:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-utils-jvm:3.0.3=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-utils-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-utils:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-utils:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websocket-serialization-jvm:3.0.3=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-websocket-serialization-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websocket-serialization:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-websocket-serialization:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websockets-jvm:3.0.3=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-websockets-jvm:3.1.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websockets:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-websockets:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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,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,allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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 +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.81=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata +org.bouncycastle:bcprov-jdk18on:1.84=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.bouncycastle:bctls-jdk18on:1.84=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.bouncycastle:bcutil-jdk18on:1.84=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,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,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,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.jetbrains.androidx.lifecycle:lifecycle-common:2.9.0-beta01=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.5=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.5=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.5=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0-beta01=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 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.0-beta01=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.5=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.0-beta01=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.5=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.androidx.lifecycle:lifecycle-viewmodel:2.9.6=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.savedstate:savedstate:1.3.0-beta01=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.androidx.savedstate:savedstate:1.3.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.animation:animation-core:1.8.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 +org.jetbrains.compose.animation:animation:1.8.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 +org.jetbrains.compose.annotation-internal:annotation:1.8.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,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.collection-internal:collection:1.8.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.foundation:foundation-layout:1.8.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 +org.jetbrains.compose.foundation:foundation:1.8.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 +org.jetbrains.compose.material3:material3:1.7.3=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 +org.jetbrains.compose.material:material-icons-core:1.7.3=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 +org.jetbrains.compose.material:material-icons-extended:1.7.3=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 +org.jetbrains.compose.material:material-ripple:1.7.3=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 +org.jetbrains.compose.runtime:runtime-saveable:1.8.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 +org.jetbrains.compose.runtime:runtime:1.8.0=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.compose.runtime:runtime:1.9.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.ui:ui-backhandler:1.8.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.compose.ui:ui-geometry:1.8.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 +org.jetbrains.compose.ui:ui-graphics:1.8.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 +org.jetbrains.compose.ui:ui-text:1.8.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 +org.jetbrains.compose.ui:ui-uikit:1.8.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.compose.ui:ui-unit:1.8.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 +org.jetbrains.compose.ui:ui-util:1.8.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 +org.jetbrains.compose.ui:ui:1.8.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 +org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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-compose-compiler-plugin-embeddable:2.1.20=kotlin-extension,kotlinCompilerPluginClasspathAndroidBenchmarkRelease,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidNonMinifiedRelease,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +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,kotlinCompilerPluginClasspathAndroidBenchmarkRelease,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidNonMinifiedRelease,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidBenchmarkRelease,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidNonMinifiedRelease,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidBenchmarkRelease,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidNonMinifiedRelease,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidBenchmarkRelease,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidNonMinifiedRelease,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidBenchmarkRelease,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidNonMinifiedRelease,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.1.20=kotlinCompilerPluginClasspathAndroidBenchmarkRelease,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidNonMinifiedRelease,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +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=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata +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 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21=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 +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-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 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.21=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 +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: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=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseApiDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseCompileOnlyDependenciesMetadata,androidBenchmarkReleaseImplementationDependenciesMetadata,androidBenchmarkReleaseResolvableDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseApiDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseCompileOnlyDependenciesMetadata,androidNonMinifiedReleaseImplementationDependenciesMetadata,androidNonMinifiedReleaseResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathAndroidBenchmarkRelease,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidNonMinifiedRelease,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-junit:2.1.20=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.1.20=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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 +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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 +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=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,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,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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 +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=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.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 +org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.10.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.6.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-io-bytestring-jvm:0.5.4=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.6.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.5.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.6.0=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.5.4=androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.6.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core:0.5.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-core:0.6.0=allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.8.1=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.1=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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 +org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.8.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.8.0=allTestSourceSetsCompileDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.8.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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 +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,kotlinCompilerPluginClasspathAndroidBenchmarkRelease,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidNonMinifiedRelease,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,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.json:json:20240303=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jsoup:jsoup:1.22.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 +org.jspecify:jspecify:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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 +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=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.yaml:snakeyaml:2.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +empty=androidApis,androidBenchmarkReleaseIntransitiveDependenciesMetadata,androidDebugAndroidTestRuntimeClasspath,androidDebugIntransitiveDependenciesMetadata,androidInstrumentedTestDebugIntransitiveDependenciesMetadata,androidInstrumentedTestIntransitiveDependenciesMetadata,androidJdkImage,androidMainIntransitiveDependenciesMetadata,androidNonMinifiedReleaseIntransitiveDependenciesMetadata,androidReleaseIntransitiveDependenciesMetadata,androidTestUtil,androidUnitTestDebugIntransitiveDependenciesMetadata,androidUnitTestIntransitiveDependenciesMetadata,androidUnitTestReleaseIntransitiveDependenciesMetadata,baselineProfile,benchmarkReleaseAnnotationProcessorClasspath,benchmarkReleaseReverseMetadataValues,benchmarkReleaseWearBundling,commonMainIntransitiveDependenciesMetadata,commonTestIntransitiveDependenciesMetadata,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestRuntimeClasspath,debugAnnotationProcessorClasspath,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugWearBundling,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,nonMinifiedReleaseAnnotationProcessorClasspath,nonMinifiedReleaseReverseMetadataValues,nonMinifiedReleaseWearBundling,releaseAnnotationProcessorClasspath,releaseBaselineProfile,releaseReverseMetadataValues,releaseUnitTestAnnotationProcessorClasspath,releaseWearBundling diff --git a/androidApp/src/androidMain/assets/reader/reflow/paginator.js b/androidApp/src/androidMain/assets/reader/reflow/paginator.js index ca2839119..ae5d7d557 100644 --- a/androidApp/src/androidMain/assets/reader/reflow/paginator.js +++ b/androidApp/src/androidMain/assets/reader/reflow/paginator.js @@ -2,6 +2,7 @@ var root = document.getElementById('reflow-root'); var styleEl = document.getElementById('reflow-style'); var page = 0, pageCount = 1; + var privateOrigin = 'https://appassets.androidplatform.net'; // Viewport in CSS px, pushed from the Android side (Compose-measured size). // Android WebView resolves `vh`/`vw` to 0 when the page loads before the view // is laid out, so we never rely on viewport units for the page box. @@ -46,15 +47,51 @@ img.addEventListener('load', onLoad); }); } + function rewriteResourceUrls(html, baseUrl){ + var template = document.createElement('template'); + template.innerHTML = html; + if (!baseUrl) return template.innerHTML; + + var base; + try { + base = new URL(baseUrl, privateOrigin); + var bookPathMatch = base.pathname.match(/^\/epub\/(epub-[0-9a-f]{40})(?:\/|$)/); + if (base.origin !== privateOrigin || !bookPathMatch) { + return template.innerHTML; + } + var allowedBookPath = '/epub/' + bookPathMatch[1] + '/'; + } catch (e) { + return template.innerHTML; + } + + var attributes = ['href', 'src', 'xlink:href']; + Array.prototype.forEach.call(template.content.querySelectorAll('*'), function(element){ + attributes.forEach(function(attribute){ + if (!element.hasAttribute(attribute)) return; + var value = element.getAttribute(attribute); + if (!value || value.charAt(0) === '#') return; + try { + var resolved = new URL(value, base); + if (resolved.origin === privateOrigin && resolved.pathname.indexOf(allowedBookPath) === 0) { + element.setAttribute(attribute, resolved.href); + } else { + element.removeAttribute(attribute); + } + } catch (e) { + element.removeAttribute(attribute); + } + }); + }); + return template.innerHTML; + } window.ReflowApi = { setViewport: function(w, h){ vpW = w; vpH = h; applyViewport(); requestAnimationFrame(remeasureKeepingProgress); }, load: function(html, baseUrl){ - var b = document.querySelector('base'); if(!b){ b=document.createElement('base'); document.head.appendChild(b);} - if(baseUrl) b.href = baseUrl; - root.innerHTML = html; remeasureAfterPendingImages(); page = 0; applyViewport(); apply(); + root.innerHTML = rewriteResourceUrls(html, baseUrl); + remeasureAfterPendingImages(); page = 0; applyViewport(); apply(); requestAnimationFrame(function(){ requestAnimationFrame(function(){ measure(); apply(); relocate(); }); }); }, goToPage: function(n){ page = Math.min(Math.max(0, n), pageCount-1); apply(); relocate(); }, diff --git a/androidApp/src/androidMain/assets/reader/reflow/reader.html b/androidApp/src/androidMain/assets/reader/reflow/reader.html index 3037b29e4..0fbcb114b 100644 --- a/androidApp/src/androidMain/assets/reader/reflow/reader.html +++ b/androidApp/src/androidMain/assets/reader/reflow/reader.html @@ -1,5 +1,6 @@ + + + + + + """.trimIndent() + + val sanitized = sanitizeEpubChapterHtml(html) + val sanitizedDocument = Jsoup.parseBodyFragment(sanitized) + + assertTrue(sanitizedDocument.select("style, script").isEmpty()) + assertTrue(sanitizedDocument.select("[onerror]").isEmpty()) + assertFalse(sanitized.contains("svg-owned", ignoreCase = true)) + } + + @Test + fun sanitizerRemovesActiveDocumentsAndExternalStyleResources() { + val html = """ + + + + +
+ + + + + +

Readable text

+ """.trimIndent() + + val sanitized = sanitizeEpubChapterHtml(html) + + listOf( + "base", + "link", + "style", + "script", + "form", + "input", + "button", + "iframe", + "frame", + "frameset", + "object", + "embed", + ).forEach { tag -> + assertFalse(sanitized.contains("<$tag", ignoreCase = true), "kept <$tag>") + } + assertFalse(sanitized.contains("srcdoc", ignoreCase = true)) + assertFalse(sanitized.contains("onanimationstart", ignoreCase = true)) + assertFalse(sanitized.contains("style=", ignoreCase = true)) + assertFalse(sanitized.contains("tracker.css", ignoreCase = true)) + assertFalse(sanitized.contains("chapter.js", ignoreCase = true)) + assertTrue(sanitized.contains("Readable text")) + } + + @Test + fun sanitizerRejectsEncodedAndNonRelativeResourceUrls() { + val html = """ + percent encoded script + double encoded script + + + + + entity encoded script + email + """.trimIndent() + + val sanitized = sanitizeEpubChapterHtml(html) + val sanitizedDocument = Jsoup.parseBodyFragment(sanitized) + + assertTrue(sanitizedDocument.select("[href], [src], [xlink:href]").isEmpty()) + } + + @Test + fun sanitizerPreservesRichEpubMarkupAndRelativeResources() { + val html = """ +
+

Chapter (one)

+

Diagram

+ + Safe diagram + + + + + + x=12 + + + + + +
Values
Name
Example
+ Next chapter + Chapter start + Cover +
+ """.trimIndent() + + val sanitized = sanitizeEpubChapterHtml(html) + + listOf( + "", + "", + " + assertTrue(sanitized.contains(element, ignoreCase = true), "removed $element") + } + assertTrue(sanitized.contains("../images/shapes.svg#shape")) + assertTrue(sanitized.contains("../text/chapter-2.xhtml#start")) + assertTrue(sanitized.contains("href=\"#chapter-one\"")) + assertTrue(sanitized.contains("../images/cover.jpg")) + } + + @Test + fun sanitizerRejectsNonLocalSvgPaintUrls() { + val html = """ + + + + + + text + + """.trimIndent() + + val sanitizedDocument = Jsoup.parseBodyFragment(sanitizeEpubChapterHtml(html)) + + assertTrue(sanitizedDocument.select("[fill], [stroke]").isEmpty()) + } + + @Test + fun sanitizerPreservesSafeSvgPaintValues() { + val html = """ + + + + + + + + + + text + + """.trimIndent() + + val sanitizedDocument = Jsoup.parseBodyFragment(sanitizeEpubChapterHtml(html)) + + assertEquals("url(#gradient)", sanitizedDocument.getElementById("local-paint")?.attr("fill")) + assertEquals("url('#outline')", sanitizedDocument.getElementById("local-paint")?.attr("stroke")) + assertEquals("#336699", sanitizedDocument.getElementById("colors")?.attr("fill")) + assertEquals("currentColor", sanitizedDocument.getElementById("colors")?.attr("stroke")) + assertEquals("red", sanitizedDocument.getElementById("keywords")?.attr("fill")) + assertEquals("none", sanitizedDocument.getElementById("keywords")?.attr("stroke")) + assertEquals( + "rgb(10 20 30 / 50%)", + sanitizedDocument.getElementById("functional-colors")?.attr("fill"), + ) + assertEquals( + "hsl(120 100% 50%)", + sanitizedDocument.getElementById("functional-colors")?.attr("stroke"), + ) + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/reflow/EpubReflowSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/reflow/EpubReflowSourceTest.kt index 3407031e2..298de27f3 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/reflow/EpubReflowSourceTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/reflow/EpubReflowSourceTest.kt @@ -49,8 +49,12 @@ class EpubReflowSourceTest { val source = EpubReflowSource(book) assertTrue( - source.baseUrl(0).endsWith("/OEBPS/xhtml/"), - "Relative EPUB resources should resolve from the current chapter directory.", + source.baseUrl(0).matches( + Regex( + """https://appassets\.androidplatform\.net/epub/epub-[0-9a-f]{40}/OEBPS/xhtml/""", + ), + ), + "Relative EPUB resources should resolve from the current chapter directory on the private origin.", ) } finally { tempDir.deleteRecursively() diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/reflow/EpubResourcePathHandlerTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/reflow/EpubResourcePathHandlerTest.kt new file mode 100644 index 000000000..836b5010a --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/reflow/EpubResourcePathHandlerTest.kt @@ -0,0 +1,190 @@ +package org.prairieserver.prairie.android.ui.screens.reader.reflow + +import java.nio.file.Files +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = android.app.Application::class) +class EpubResourcePathHandlerTest { + @Test + fun servesCanonicalFilesFromHashedEpubDirectory() { + withFixture { fixture -> + val image = java.io.File(fixture.bookRoot, "OEBPS/images/cover.jpg") + requireNotNull(image.parentFile).mkdirs() + image.writeBytes(byteArrayOf(1, 2, 3)) + + val response = fixture.handler.handle("${fixture.bookRoot.name}/OEBPS/images/cover.jpg") + + assertEquals(200, response.statusCode) + assertEquals("image/jpeg", response.mimeType) + assertContentEquals(byteArrayOf(1, 2, 3), response.data.readBytes()) + } + } + + @Test + fun returnsEmpty404ForTraversalAndOutOfRootPaths() { + withFixture { fixture -> + val outside = java.io.File(fixture.root.parentFile, "outside-reader-secret.txt") + outside.writeText("secret") + try { + listOf( + "../${outside.name}", + "%2e%2e/${outside.name}", + "%252e%252e%252f${outside.name}", + "/${outside.absolutePath}", + "${fixture.bookRoot.name}/../../${outside.name}", + "not-an-epub/private.txt", + ).forEach { path -> + val response = fixture.handler.handle(path) + assertEquals(404, response.statusCode, "accepted $path") + assertContentEquals(byteArrayOf(), response.data.readBytes(), "non-empty 404 for $path") + } + } finally { + outside.delete() + } + } + } + + @Test + fun rejectsAValidNormalizedPathBelongingToAnotherCachedBook() { + withFixture { fixture -> + val otherRoot = fixture.root.resolve("epub-${"b".repeat(40)}").apply { mkdirs() } + val otherChapter = otherRoot.resolve("OEBPS/private.xhtml") + requireNotNull(otherChapter.parentFile).mkdirs() + otherChapter.writeText("other book") + + assertEmpty404( + fixture.handler.handle("${otherRoot.name}/OEBPS/private.xhtml"), + otherChapter.path, + ) + } + } + + @Test + fun returnsEmpty404ForSymlinksEvenWhenTheirTargetIsInsideRoot() { + withFixture { fixture -> + val target = java.io.File(fixture.bookRoot, "OEBPS/images/cover.jpg") + requireNotNull(target.parentFile).mkdirs() + target.writeText("cover") + val link = java.io.File(fixture.bookRoot, "OEBPS/images/link.jpg") + Files.createSymbolicLink(link.toPath(), target.toPath()) + + val response = fixture.handler.handle("${fixture.bookRoot.name}/OEBPS/images/link.jpg") + + assertEquals(404, response.statusCode) + assertContentEquals(byteArrayOf(), response.data.readBytes()) + } + } + + @Test + fun returnsEmpty404ForIntermediateAndOutsideSymlinks() { + withFixture { fixture -> + val realImages = java.io.File(fixture.bookRoot, "OEBPS/real-images").apply { mkdirs() } + java.io.File(realImages, "inside.jpg").writeText("inside") + val intermediateLink = java.io.File(fixture.bookRoot, "OEBPS/images") + Files.createSymbolicLink(intermediateLink.toPath(), realImages.toPath()) + + val outsideDirectory = createTempDirectory("outside-epub-resource").toFile() + try { + java.io.File(outsideDirectory, "outside.jpg").writeText("outside") + val outsideLink = java.io.File(fixture.bookRoot, "OEBPS/outside") + Files.createSymbolicLink(outsideLink.toPath(), outsideDirectory.toPath()) + + listOf( + "${fixture.bookRoot.name}/OEBPS/images/inside.jpg", + "${fixture.bookRoot.name}/OEBPS/outside/outside.jpg", + ).forEach { path -> + assertEmpty404(fixture.handler.handle(path), path) + } + } finally { + outsideDirectory.deleteRecursively() + } + } + } + + @Test + fun returnsEmpty404ForEncodedSeparatorsInvalidEscapesAndNul() { + withFixture { fixture -> + val cover = java.io.File(fixture.bookRoot, "OEBPS/images/cover.jpg") + requireNotNull(cover.parentFile).mkdirs() + cover.writeText("cover") + + listOf( + "${fixture.bookRoot.name}%2fOEBPS%2fimages%2fcover.jpg", + "${fixture.bookRoot.name}%252fOEBPS%252fimages%252fcover.jpg", + "${fixture.bookRoot.name}%5cOEBPS%5cimages%5ccover.jpg", + "${fixture.bookRoot.name}%255cOEBPS%255cimages%255ccover.jpg", + "${fixture.bookRoot.name}/OEBPS/images/%", + "${fixture.bookRoot.name}/OEBPS/images/%2", + "${fixture.bookRoot.name}/OEBPS/images/%00cover.jpg", + ).forEach { path -> + assertEmpty404(fixture.handler.handle(path), path) + } + } + } + + @Test + fun returnsEmpty404ForDirectoriesAndMissingFiles() { + withFixture { fixture -> + java.io.File(fixture.bookRoot, "OEBPS/images").mkdirs() + + listOf( + fixture.bookRoot.name, + "${fixture.bookRoot.name}/OEBPS/images", + "${fixture.bookRoot.name}/OEBPS/images/missing.jpg", + ).forEach { path -> + assertEmpty404(fixture.handler.handle(path), path) + } + } + } + + @Test + fun reportsKnownAndUnknownMimeTypesWithoutGuessing() { + withFixture { fixture -> + val styles = java.io.File(fixture.bookRoot, "OEBPS/styles/book.css") + requireNotNull(styles.parentFile).mkdirs() + styles.writeText("body{}") + val unknown = java.io.File(fixture.bookRoot, "OEBPS/resources/blob.bin") + requireNotNull(unknown.parentFile).mkdirs() + unknown.writeBytes(byteArrayOf(7)) + + val cssResponse = fixture.handler.handle("${fixture.bookRoot.name}/OEBPS/styles/book.css") + val unknownResponse = fixture.handler.handle("${fixture.bookRoot.name}/OEBPS/resources/blob.bin") + + assertEquals(200, cssResponse.statusCode) + assertEquals("text/css", cssResponse.mimeType) + assertEquals("UTF-8", cssResponse.encoding) + assertEquals(200, unknownResponse.statusCode) + assertEquals("application/octet-stream", unknownResponse.mimeType) + assertNull(unknownResponse.encoding) + } + } + + private fun assertEmpty404(response: android.webkit.WebResourceResponse, path: String) { + assertEquals(404, response.statusCode, "accepted $path") + assertContentEquals(byteArrayOf(), response.data.readBytes(), "non-empty 404 for $path") + } + + private fun withFixture(block: (Fixture) -> Unit) { + val root = createTempDirectory("epub-resource-root").toFile() + try { + val bookRoot = root.resolve("epub-${"a".repeat(40)}").apply { mkdirs() } + block(Fixture(root, bookRoot, EpubResourcePathHandler(root, bookRoot.name))) + } finally { + root.deleteRecursively() + } + } + + private data class Fixture( + val root: java.io.File, + val bookRoot: java.io.File, + val handler: EpubResourcePathHandler, + ) +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt index 040bc3f7a..1734a0215 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt @@ -5,6 +5,7 @@ import org.prairieserver.prairie.model.watchtogether.RoomPhase import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.MemberRole import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -41,4 +42,16 @@ class LobbyAutoNavigateTest { @Test fun playing_without_selection_does_not_navigate() { assertNull(lobbyPlayerDestinationOrNull(room(null, phase = RoomPhase.Playing))) } + + @Test fun host_alone_stays_in_lobby_until_a_guest_arrives() { + val host = room("c9", RoomPhase.Playing).copy( + selfRole = MemberRole.Host, + memberCount = 1, + ) + assertNull(lobbyPlayerDestinationOrNull(host)) + assertEquals( + Route.Player(contentId = "c9", fileId = 3, roomId = "r1").route, + lobbyPlayerDestinationOrNull(host.copy(memberCount = 2)), + ) + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/SuggestToRoomSurfaceSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/SuggestToRoomSurfaceSourceTest.kt new file mode 100644 index 000000000..465de6aba --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/SuggestToRoomSurfaceSourceTest.kt @@ -0,0 +1,40 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class SuggestToRoomSurfaceSourceTest { + private val entrySheet = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt", + ).readText() + private val itemDetail = 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 activeRoomExposesSuggestionActionForMoviesAndSeries() { + assertTrue(itemDetail.contains("SuggestToRoomViewModel")) + assertTrue(itemDetail.contains("suggestViewModel.suggest(")) + assertTrue(movieDetail.contains("Suggest to Watch Together")) + assertTrue(seriesDetail.contains("Suggest to Watch Together")) + } + + @Test + fun seriesSuggestionUsesItsPlayableEpisodeIdentity() { + assertTrue(itemDetail.contains("contentType = \"episode\"")) + assertTrue(itemDetail.contains("nextEpisode.stillUrl ?: detail.posterUrl")) + } + + @Test + fun inFlightRoomMutationCannotBeDismissed() { + assertTrue(entrySheet.contains("canDismissRoomEntry(state.busy)")) + assertTrue(entrySheet.contains("confirmValueChange")) + } +} 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 dfe20d702..5e8ac5f2a 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 @@ -5,6 +5,7 @@ import org.prairieserver.prairie.model.watchtogether.RoomPhase import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.MemberRole import kotlin.test.Test import kotlin.test.assertEquals import org.junit.runner.RunWith @@ -50,4 +51,15 @@ class WatchTogetherEntryDestinationTest { val dest = watchTogetherDestination(snapshot(selectedContentId = "c2", selectedFileId = null)) assertEquals(Route.Player(contentId = "c2", fileId = null, roomId = "room-1").route, dest) } + + @Test + fun host_alone_with_selection_stays_in_lobby_to_share_invite() { + val dest = watchTogetherDestination( + snapshot(selectedContentId = "c1").copy( + selfRole = MemberRole.Host, + memberCount = 1, + ), + ) + assertEquals(Route.WatchTogetherLobby(roomId = "room-1").route, dest) + } } diff --git a/androidTvApp/gradle.lockfile b/androidTvApp/gradle.lockfile new file mode 100644 index 000000000..722cb893f --- /dev/null +++ b/androidTvApp/gradle.lockfile @@ -0,0 +1,509 @@ +# 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=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.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: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.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.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.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 +androidx.compose.animation:animation-core:1.8.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.animation:animation: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.animation:animation:1.8.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.foundation:foundation-android:1.8.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.8.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.foundation:foundation-layout: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.foundation:foundation-layout:1.8.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.foundation:foundation: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.foundation:foundation:1.8.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material3:material3-android:1.3.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material3:material3:1.3.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-core-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-core:1.7.6=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.compose.material:material-icons-extended-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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.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.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.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.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.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.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: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.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: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: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 +androidx.datastore:datastore-core-okio-jvm:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core-okio:1.2.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.datastore:datastore-core:1.2.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.datastore:datastore-preferences-android:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-core-android:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-core:1.2.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.datastore:datastore-preferences-external-protobuf:1.2.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-proto:1.2.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences:1.2.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.datastore:datastore:1.2.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.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.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.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: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-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: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-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-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: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-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: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.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-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-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-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: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.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.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 +androidx.media3:media3-common: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 +androidx.media3:media3-container: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 +androidx.media3:media3-database: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 +androidx.media3:media3-datasource-okhttp: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 +androidx.media3:media3-datasource: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 +androidx.media3:media3-decoder: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 +androidx.media3:media3-effect:1.10.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-exoplayer-hls: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 +androidx.media3:media3-exoplayer: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 +androidx.media3:media3-extractor: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 +androidx.media3:media3-session: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 +androidx.media3:media3-ui-compose: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 +androidx.media3:media3-ui: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 +androidx.media:media:1.7.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.navigation:navigation-common-android:2.9.8=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.navigation:navigation-common:2.9.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.navigation:navigation-compose-android:2.9.8=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.navigation:navigation-compose:2.9.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.navigation:navigation-runtime-android:2.9.8=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.navigation:navigation-runtime:2.9.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.navigationevent:navigationevent-android:1.0.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.navigationevent:navigationevent-compose-android:1.0.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.navigationevent:navigationevent-compose:1.0.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.navigationevent:navigationevent:1.0.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.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.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 +androidx.room:room-common-jvm:2.8.4=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-common:2.8.4=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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.4.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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.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.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.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.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.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.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 +co.touchlab:stately-concurrency-jvm:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrency: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-concurrent-collections-jvm:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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.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.android.tools:desugar_jdk_libs:2.1.5=coreLibraryDesugaring +com.android.tools:desugar_jdk_libs_configuration:2.1.5=coreLibraryDesugaring +com.google.accompanist:accompanist-drawablepainter:0.36.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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,androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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_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.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,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: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-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.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.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=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.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 +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 +io.coil-kt.coil3:coil-compose-core-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-core:3.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 +io.coil-kt.coil3:coil-compose:3.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 +io.coil-kt.coil3:coil-core-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-core:3.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 +io.coil-kt.coil3:coil-network-core-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-network-core:3.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 +io.coil-kt.coil3:coil-network-ktor3-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-network-ktor3:3.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 +io.coil-kt.coil3:coil:3.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 +io.github.peerless2012:ass-kt:0.4.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.github.peerless2012:ass-media:0.4.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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=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 +io.insert-koin:koin-androidx-workmanager:4.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 +io.insert-koin:koin-compose-android:4.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.insert-koin:koin-compose-viewmodel-android:4.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.insert-koin:koin-compose-viewmodel:4.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 +io.insert-koin:koin-compose:4.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 +io.insert-koin:koin-core-jvm:4.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.insert-koin:koin-core-viewmodel-android:4.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.insert-koin:koin-core-viewmodel:4.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 +io.insert-koin:koin-core:4.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 +io.ktor:ktor-client-auth-jvm:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-auth:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-content-negotiation-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-content-negotiation:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-core-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-client-core-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-core:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-client-core:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-logging-jvm:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-logging:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-mock-jvm:3.1.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-mock:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-okhttp-jvm:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-okhttp:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-websockets-jvm:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-websockets:3.1.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-events-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-events-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-events:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-events:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-cio-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-cio:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-http-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-http:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-io-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-io-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-io:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-io:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-network-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-network:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-serialization-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-serialization:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-sse-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-sse-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-sse:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-sse:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-utils-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-utils-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-utils:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-utils:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websocket-serialization-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-websocket-serialization-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websocket-serialization:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-websocket-serialization:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websockets-jvm:3.0.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-websockets-jvm:3.1.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websockets:3.0.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +io.ktor:ktor-websockets:3.1.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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=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: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.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=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,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 +org.jetbrains.androidx.lifecycle:lifecycle-runtime: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 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0-beta01=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.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.0-beta01=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate: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-viewmodel:2.9.0-beta01=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.5=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.androidx.lifecycle:lifecycle-viewmodel:2.9.6=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.savedstate:savedstate:1.3.0-beta01=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.androidx.savedstate:savedstate:1.3.2=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.compose.animation:animation-core:1.8.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.compose.animation:animation:1.8.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.compose.annotation-internal:annotation:1.8.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,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.collection-internal:collection:1.8.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 +org.jetbrains.compose.foundation:foundation-layout:1.8.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.compose.foundation:foundation:1.8.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.compose.material3:material3:1.7.3=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.compose.material:material-icons-core:1.7.3=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.compose.material:material-icons-extended:1.7.3=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.compose.material:material-ripple:1.7.3=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.compose.runtime:runtime-saveable:1.8.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.compose.runtime:runtime:1.8.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.compose.runtime:runtime:1.9.1=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.compose.ui:ui-backhandler:1.8.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.compose.ui:ui-geometry:1.8.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.compose.ui:ui-graphics:1.8.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.compose.ui:ui-text:1.8.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.compose.ui:ui-uikit:1.8.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.compose.ui:ui-unit:1.8.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.compose.ui:ui-util:1.8.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.compose.ui:ui:1.8.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.intellij.deps:trove4j:1.0.20200330=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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-compose-compiler-plugin-embeddable:2.1.20=kotlin-extension,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +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,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +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=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata +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 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21=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.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-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 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.21=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.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: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=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCompileClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-junit:2.1.20=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.1.20=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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-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.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 +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=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.10.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.6.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-io-bytestring-jvm:0.5.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.6.0=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.5.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.6.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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.kotlinx:kotlinx-io-core-jvm:0.5.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.6.0=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core:0.5.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,debugAndroidTestCompileClasspath +org.jetbrains.kotlinx:kotlinx-io-core:0.6.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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.kotlinx:kotlinx-serialization-bom:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.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 +org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.8.1=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.8.0=allTestSourceSetsCompileDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.8.1=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.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 +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.slf4j:slf4j-api:2.0.16=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,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/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt index 61ffbedea..04e1491c0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt @@ -40,6 +40,7 @@ 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.network.TokenManager +import org.prairieserver.prairie.network.requiresApproval import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.ProfileRepository @@ -259,16 +260,23 @@ class MainTvActivity : ComponentActivity() { /** * Mirrors the phone app's [org.prairieserver.prairie.android.MainActivity] startup * flow on top of the multi-server [ServerRegistry]. See that file for the - * routing rules — they're identical: registry empty ⇒ ServerList - * (LAN scan; manual URL is secondary), tokens missing ⇒ Login, no - * active profile header scope ⇒ ProfileSelection, else Main. + * routing rules — they're identical: registry empty ⇒ ServerSetup, + * tokens missing ⇒ Login, no active profile header scope ⇒ + * ProfileSelection, else Main. */ private suspend fun resolveStartDestination(): String { val registry = get(ServerRegistry::class.java) val tokenManager = get(TokenManager::class.java) val activeEntry = registry.activeEntry.value - ?: return TvRoute.ServerList.START + ?: return TvRoute.ServerSetup.route + + val cleartextConsent = get( + org.prairieserver.prairie.network.CleartextOriginConsent::class.java, + ) + if (cleartextConsent.requiresApproval(activeEntry.url)) { + return TvRoute.ServerSetup.route + } val accessToken = tokenManager.getAccessToken() if (accessToken.isNullOrBlank()) return TvRoute.Login().route 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 5c283b4e6..482d25a8b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt @@ -72,6 +72,21 @@ class PrairieTvApplication : Application(), Configuration.Provider, SingletonIma }.onFailure { android.util.Log.w("PrairieTvApplication", "Outbox sync starter init failed", it) } + // Reclaim the disk and Room rows of servers the user has removed. + // Derived from "rows with no registry entry", so it is idempotent, also + // cleans installs that removed servers before this existed, and cannot + // strand anything if a pass is interrupted. Guarded — never load-bearing + // for cold start. + runCatching { + org.prairieserver.prairie.common.downloads.installOrphanedServerDataPurge( + context = this@SiloTvApplication, + registry = koinApp.koin.get(), + database = koinApp.koin.get(), + storage = koinApp.koin.get(), + ) + }.onFailure { + android.util.Log.w("SiloTvApplication", "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 // off the main thread, never load-bearing for cold start. 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 611b077ac..8577b2c63 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 @@ -7,6 +7,8 @@ import org.prairieserver.prairie.repository.SettingsRepository 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.CleartextConsentStore +import org.prairieserver.prairie.common.network.DataStoreCleartextConsentStore import org.prairieserver.prairie.common.settings.AndroidServerSettingsCache import android.content.SharedPreferences import org.prairieserver.prairie.network.AndroidServerRegistry @@ -40,8 +42,6 @@ import org.prairieserver.prairie.tv.ui.screens.detail.TvItemDetailViewModel import org.prairieserver.prairie.viewmodel.HomeViewModel import org.prairieserver.prairie.viewmodel.RecommendationsViewModel import org.prairieserver.prairie.viewmodel.MyRequestsViewModel -import org.prairieserver.prairie.viewmodel.LiveTvPlayerViewModel -import org.prairieserver.prairie.viewmodel.LiveTvViewModel import org.prairieserver.prairie.viewmodel.RequestSearchViewModel import org.prairieserver.prairie.viewmodel.RequestsViewModel import org.prairieserver.prairie.tv.ui.screens.libraries.TvLibrariesViewModel @@ -63,6 +63,8 @@ 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. @@ -73,6 +75,8 @@ import org.koin.dsl.module * [org.prairieserver.prairie.tv.PrairieTvApplication]. */ val androidTvModule = module { + single { DataStoreCleartextConsentStore(androidContext()) } + single { get() } // Single encrypted prefs handle shared between the server registry and // the token manager — see the phone module for rationale. single { createSecureSharedPrefs(androidContext()) } @@ -267,7 +271,7 @@ val androidTvModule = module { // machine. A later step wires the UI to PairingReceiver.status. single { org.prairieserver.prairie.common.pairing.PairingReceiver( - authPort = org.prairieserver.prairie.common.pairing.RegistryPairingAuthPort(get(), get()), + authPort = org.prairieserver.prairie.common.pairing.RegistryPairingAuthPort(get(), get(), get()), deviceLogin = org.prairieserver.prairie.common.pairing.DeviceLoginRepositoryPort(get()), identityProvider = { org.prairieserver.prairie.common.pairing.PairingDeviceIdentity( @@ -321,7 +325,7 @@ val androidTvModule = module { } // Auth ViewModels - viewModel { TvServerSetupViewModel(get()) } + viewModel { TvServerSetupViewModel(get(), get()) } viewModel { org.prairieserver.prairie.tv.ui.screens.auth.TvSetupViewModel(get()) } viewModel { org.prairieserver.prairie.tv.ui.screens.auth.TvSignupViewModel(get()) } viewModel { TvLoginViewModel(get(), get(), get()) } @@ -333,7 +337,7 @@ val androidTvModule = module { profileId = params.get(), ) } - viewModel { TvServerListViewModel(get(), get(), get(), get()) } + viewModel { TvServerListViewModel(get(), get(), get()) } // Admin ViewModels viewModel { AdminStatsViewModel(get()) } @@ -427,12 +431,16 @@ val androidTvModule = module { viewModel { org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModel(get()) } + viewModel { + org.prairieserver.prairie.tv.ui.screens.watchtogether.TvSuggestToRoomViewModel(get()) + } // Watch Together lobby — keyed per roomId (koinViewModel key="wt-lobby-$roomId"); // roomId is read from the positional parameter. viewModel { params -> org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherLobbyViewModel( roomId = params.get(), repository = get(), + roomSession = get(), ) } viewModel { params -> @@ -449,7 +457,7 @@ val androidTvModule = module { sleepTimer = get(), subtitlesRepository = get(), userItemStatePort = get(), - outboxSyncScheduler = get(), + finalPlaybackPositionWriter = get(), catalogRepository = get(), serverReachabilityMonitor = get(), launchArgs = params.get(), @@ -483,8 +491,6 @@ val androidTvModule = module { overlayPrefsStore = get(), legacyTvPrefsMigration = get(), tvLibraryScopeStore = getOrNull(), - appUpdateChecker = get(), - appVersionName = org.prairieserver.prairie.tv.BuildConfig.VERSION_NAME, ) } viewModel { TvDiagnosticsViewModel(get()) } 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 3cfdc1e56..59c3e631a 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 @@ -46,6 +46,7 @@ import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnostic 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.TvDiagnosticsViewModel +import org.prairieserver.prairie.model.watchtogether.MemberRole import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherLobbyScreen import org.prairieserver.prairie.common.overlays.ProvideCardOverlays import org.prairieserver.prairie.common.diagnostics.DiagnosticsLifecycleLogger @@ -92,7 +93,7 @@ private val preMainAuthRoutes: Set = setOf( TvRoute.Setup.route, TvRoute.Signup.route, TvRoute.Login.ROUTE, - TvRoute.ServerList.ROUTE, + TvRoute.ServerList.route, TvRoute.ProfileSelection.route, TvRoute.CreateProfile.route, TvRoute.EditProfile.ROUTE, @@ -351,21 +352,8 @@ fun TvAppNavigation( ) } - composable( - route = TvRoute.ServerList.ROUTE, - arguments = listOf( - navArgument(TvRoute.ServerList.ARG_AUTO_SCAN) { - type = NavType.BoolType - defaultValue = false - }, - ), - ) { backStackEntry -> - val autoScan = backStackEntry.arguments - ?.getBoolean(TvRoute.ServerList.ARG_AUTO_SCAN) - ?: false - val canGoBack = navController.previousBackStackEntry != null + composable(TvRoute.ServerList.route) { TvServerListScreen( - autoScan = autoScan, onAddServer = { navController.navigate(TvRoute.ServerSetup.route) }, @@ -378,7 +366,6 @@ fun TvAppNavigation( TvServerSwitchDestination.ProfileSelection -> TvRoute.ProfileSelection.route TvServerSwitchDestination.Login -> TvRoute.Login().route - TvServerSwitchDestination.Setup -> TvRoute.Setup.route } // Landing straight on Home means the target server is already // authenticated, so no Login/ProfileSelection callback fires to @@ -399,11 +386,7 @@ fun TvAppNavigation( launchSingleTop = true } }, - onBack = if (canGoBack) { - { navController.popBackStack() } - } else { - null - }, + onBack = { navController.popBackStack() }, ) } @@ -423,7 +406,7 @@ fun TvAppNavigation( // 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.ServerList.autoScanRoute(autoScan = true)) { + navController.navigate(TvRoute.ServerSetup.route) { popUpTo(TvRoute.Login.ROUTE) { inclusive = true } launchSingleTop = true } @@ -458,13 +441,13 @@ fun TvAppNavigation( navController.navigate(TvRoute.EditProfile(profileId).route) }, onChangeServer = { - navController.navigate(TvRoute.ServerList.autoScanRoute(autoScan = true)) + navController.navigate(TvRoute.ServerList.route) }, onSignOut = { scope.launch { authRepository.logout() watchNextSeeder.clear() - navController.navigate(TvRoute.ServerList.autoScanRoute(autoScan = true)) { + navController.navigate(TvRoute.ServerSetup.route) { popUpTo(0) { inclusive = true } launchSingleTop = true } @@ -504,7 +487,7 @@ fun TvAppNavigation( }, onManageServers = { mainEntry.savedStateHandle[RETURN_TO_MANAGE_SERVERS_KEY] = true - navController.navigate(TvRoute.ServerList.autoScanRoute(autoScan = false)) + navController.navigate(TvRoute.ServerList.route) }, onOpenDiagnostics = { navController.navigate(TvRoute.Diagnostics.route) @@ -581,7 +564,7 @@ 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.autoScanRoute(autoScan = false)) + navController.navigate(TvRoute.ServerList.route) }, onPairDevice = { navController.navigate(TvRoute.PairDevice().route) { @@ -703,7 +686,9 @@ fun TvAppNavigation( // host-with-selection straight to the synced player (carrying // roomId), otherwise into the lobby to wait/vote/pick. onWatchTogether = { snapshot -> - val target = if (!snapshot.selectedContentId.isNullOrBlank()) { + val hostAlone = + snapshot.selfRole == MemberRole.Host && snapshot.memberCount <= 1 + val target = if (!snapshot.selectedContentId.isNullOrBlank() && !hostAlone) { TvRoute.Player( contentId = snapshot.selectedContentId!!, fileId = snapshot.selectedFileId, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/QrCodePanel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/QrCodePanel.kt index 692c2e5f6..92c911608 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/QrCodePanel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/QrCodePanel.kt @@ -3,13 +3,12 @@ package org.prairieserver.prairie.tv.ui.screens.auth import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size -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.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color @@ -39,7 +38,7 @@ fun QrCodePanel( val writer = QRCodeWriter() val hints = mapOf( EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M, - EncodeHintType.MARGIN to 0, // We add our own padding via the surrounding Box. + EncodeHintType.MARGIN to 4, ) // 256 is the requested matrix dimension; ZXing rounds to module count. writer.encode(content, BarcodeFormat.QR_CODE, 256, 256, hints) @@ -48,11 +47,10 @@ fun QrCodePanel( Box( modifier = modifier .size(size) - .clip(RoundedCornerShape(16.dp)) .background(background), contentAlignment = Alignment.Center, ) { - Canvas(modifier = Modifier.size(size)) { + Canvas(modifier = Modifier.fillMaxSize()) { val moduleCount = matrix.width val modulePx = this.size.minDimension / moduleCount for (y in 0 until moduleCount) { 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 886b3ea21..e480c1bbb 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 @@ -78,6 +78,7 @@ 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 @@ -172,6 +173,36 @@ fun TvServerSetupScreen( } } + state.pendingCleartextUrl?.let { origin -> + AlertDialog( + onDismissRequest = viewModel::cancelCleartextConnection, + 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 sign-in. Continue only on a network you trust.", + ) + } + }, + confirmButton = { + AuroraPrimaryButton( + label = "Use HTTP", + onClick = viewModel::confirmCleartextConnection, + modifier = Modifier.width(180.dp), + enabled = !state.isLoading, + ) + }, + dismissButton = { + AuroraGhostButton( + label = "Cancel", + onClick = viewModel::cancelCleartextConnection, + ) + }, + ) + } + Box( modifier = Modifier .fillMaxSize() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupViewModel.kt index d9184349d..4ca572a04 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupViewModel.kt @@ -2,6 +2,8 @@ package org.prairieserver.prairie.tv.ui.screens.auth import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import org.prairieserver.prairie.common.network.CleartextConsentStore +import org.prairieserver.prairie.common.network.cleartextOrigin import org.prairieserver.prairie.model.auth.SetupStatusResponse import org.prairieserver.prairie.model.auth.SignupStatusResponse import org.prairieserver.prairie.network.AndroidServerRegistry @@ -19,6 +21,7 @@ data class TvServerSetupUiState( val error: String? = null, /** Destination after a successful server probe; consumed by the screen. */ val navigateTo: TvServerSetupDestination? = null, + val pendingCleartextUrl: String? = null, ) { /** True when the entered address will connect over unencrypted HTTP. * Informational only — does not block LAN/IP connections. */ @@ -64,10 +67,18 @@ internal sealed class TvServerSetupProbeResult { */ class TvServerSetupViewModel( private val authRepository: AuthRepository, + private val cleartextConsentStore: CleartextConsentStore, + private val getSetupStatus: suspend (String) -> ApiResult = { + authRepository.getSetupStatus(it) + }, + private val getSignupStatus: suspend (String) -> ApiResult = { + authRepository.getSignupStatus(it) + }, ) : ViewModel() { private val _uiState = MutableStateFlow(TvServerSetupUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var pendingCleartextConnection: PendingTvCleartextConnection? = null init { viewModelScope.launch { @@ -100,22 +111,18 @@ class TvServerSetupViewModel( val candidates = serverSetupUrlProbeCandidates(raw) viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } + pendingCleartextConnection = null + _uiState.update { + it.copy(isLoading = true, error = null, pendingCleartextUrl = null) + } when (val result = probeTvServerSetupCandidates( candidates = candidates, - getSetupStatus = { candidate -> authRepository.getSetupStatus(candidate) }, - getSignupStatus = { candidate -> authRepository.getSignupStatus(candidate) }, + getSetupStatus = getSetupStatus, + getSignupStatus = getSignupStatus, )) { is TvServerSetupProbeResult.Success -> { - authRepository.setServerUrl(result.serverUrl) - _uiState.update { - it.copy( - serverUrl = result.serverUrl, - isLoading = false, - navigateTo = result.destination, - ) - } + handleSuccessfulConnection(result.serverUrl, result.destination) } is TvServerSetupProbeResult.Failure -> { _uiState.update { @@ -130,11 +137,89 @@ class TvServerSetupViewModel( } } + fun confirmCleartextConnection() { + if (_uiState.value.isLoading) return + val pending = pendingCleartextConnection ?: return + _uiState.update { it.copy(isLoading = true, error = null) } + viewModelScope.launch { + cleartextConsentStore.approve(pending.origin) + if (pendingCleartextConnection !== pending) return@launch + pendingCleartextConnection = null + persistAndNavigate(pending.serverUrl, pending.destination) + } + } + + fun cancelCleartextConnection() { + pendingCleartextConnection = null + _uiState.update { + it.copy(pendingCleartextUrl = null, isLoading = false) + } + } + fun onNavigationConsumed() { _uiState.update { it.copy(navigateTo = null) } } + + private suspend fun handleSuccessfulConnection( + serverUrl: String, + destination: TvServerSetupDestination, + ) { + if (serverUrl.startsWith("http://", ignoreCase = true)) { + val origin = cleartextOrigin(serverUrl) + if (origin == null) { + _uiState.update { + it.copy( + isLoading = false, + error = "Could not safely identify this HTTP server.", + pendingCleartextUrl = null, + navigateTo = null, + ) + } + return + } + if (cleartextConsentStore.isApproved(origin)) { + persistAndNavigate(serverUrl, destination) + return + } + pendingCleartextConnection = PendingTvCleartextConnection( + serverUrl = serverUrl, + origin = origin, + destination = destination, + ) + _uiState.update { + it.copy( + isLoading = false, + pendingCleartextUrl = origin, + navigateTo = null, + ) + } + return + } + persistAndNavigate(serverUrl, destination) + } + + private suspend fun persistAndNavigate( + serverUrl: String, + destination: TvServerSetupDestination, + ) { + authRepository.setServerUrl(serverUrl) + _uiState.update { + it.copy( + serverUrl = serverUrl, + isLoading = false, + pendingCleartextUrl = null, + navigateTo = destination, + ) + } + } } +private data class PendingTvCleartextConnection( + val serverUrl: String, + val origin: String, + val destination: TvServerSetupDestination, +) + internal suspend fun probeTvServerSetupCandidates( candidates: List, getSetupStatus: suspend (String) -> ApiResult, 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 ed2768a8a..5cd26424a 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 @@ -1,5 +1,6 @@ package org.prairieserver.prairie.tv.ui.screens.detail +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi @@ -34,6 +35,7 @@ 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 @@ -96,8 +98,10 @@ 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.ebook.MediaRelatedItem +import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED 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.components.TvDialogOption import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvHeroActionPill @@ -110,6 +114,10 @@ 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.screens.audiobook.formatAudiobookTime +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvJoinCodeDialog +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvSuggestToRoomViewModel +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherEntryDialog +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModel import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.tv.ui.theme.TvControlCorner import org.prairieserver.prairie.tv.ui.theme.TvSmoothBringIntoViewSpec @@ -118,7 +126,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.koinInject -import androidx.compose.runtime.LaunchedEffect import org.prairieserver.prairie.metadata.DescriptionTranslationPhase import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore import org.prairieserver.prairie.model.metadata.MetadataAiOnView @@ -190,6 +197,7 @@ fun TvItemDetailScreen( onItemDetailReplace = onItemDetailReplace, onSeriesClick = onSeriesClick, onSeasonClick = onSeasonClick, + onWatchTogether = onWatchTogether, onOpenPerson = onOpenPerson, ) } @@ -206,6 +214,7 @@ private fun TvDetailContent( onItemDetailReplace: (contentId: String) -> Unit, onSeriesClick: (seriesId: String) -> Unit, onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, + onWatchTogether: (RoomSnapshot) -> Unit, onOpenPerson: (personId: Long) -> Unit, ) { val playFocus = remember { FocusRequester() } @@ -448,6 +457,7 @@ private fun TvDetailContent( onPlay = onPlay, onSeriesClick = onSeriesClick, onSeasonClick = onSeasonClick, + onWatchTogether = onWatchTogether, ) }, ) @@ -728,8 +738,23 @@ private fun HeroActionRow( onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, subtitleTrackIndex: Int?, itemType: String?, resumePositionSeconds: Double?) -> Unit, onSeriesClick: (seriesId: String) -> Unit, onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, + onWatchTogether: (RoomSnapshot) -> Unit, ) { + val suggestViewModel: TvSuggestToRoomViewModel = koinViewModel() + val activeRoom by suggestViewModel.room.collectAsState() + val suggestState by suggestViewModel.uiState.collectAsState() + val suggestContext = LocalContext.current + LaunchedEffect(suggestState.notice, suggestState.error) { + val message = suggestState.notice ?: suggestState.error + if (message != null) { + Toast.makeText(suggestContext, message, Toast.LENGTH_SHORT).show() + suggestViewModel.consumeNotice() + suggestViewModel.clearError() + } + } var moreOpen by remember(detail.contentId) { mutableStateOf(false) } + var watchTogetherOpen by remember(detail.contentId) { mutableStateOf(false) } + var joinCodeOpen by remember(detail.contentId) { mutableStateOf(false) } var playLaunchPending by remember(detail.contentId) { mutableStateOf(false) } LaunchedEffect(playLaunchPending) { if (playLaunchPending) { @@ -766,7 +791,11 @@ private fun HeroActionRow( // `TVSeasonDetailView.moreMenu`). Movies do not show an overflow button. val hasSeriesNavigation = detail.type in setOf("episode", "season") && detail.seriesId != null val hasOverflowNavigation = hasSeriesNavigation - val hasOverflowMenu = hasOverflowNavigation + 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 hasOverflowMenu = hasOverflowNavigation || hasWatchTogether || canSuggestToRoom // Version set + selection state driving the selector row / Play file id. // Series/season use the next-up episode's versions + the next-up selection; @@ -958,6 +987,38 @@ private fun HeroActionRow( if (moreOpen && hasOverflowMenu) { val options = buildList { + if (canSuggestToRoom) { + add( + TvDialogOption( + key = "suggest-to-room", + title = "Suggest to Watch Together", + subtitle = "Add to the room you are in", + onClick = { + moreOpen = false + suggestViewModel.suggest( + contentId = playContentId, + contentType = playType, + title = nextUp?.title ?: detail.title, + subtitle = if (nextUp != null) detail.title else detail.seriesTitle, + posterUrl = nextUp?.stillUrl ?: detail.posterUrl, + ) + }, + ), + ) + } + if (hasWatchTogether) { + add( + TvDialogOption( + key = "watch-together", + title = "Watch Together", + subtitle = "Host a room or join by code", + onClick = { + moreOpen = false + watchTogetherOpen = true + }, + ), + ) + } if (hasOverflowNavigation) { detail.seriesId?.let { seriesId -> // "Go to Season" is episode-only — a season page is already at @@ -997,6 +1058,52 @@ private fun HeroActionRow( onDismiss = { moreOpen = false }, ) } + + if (watchTogetherOpen && hasWatchTogether) { + val watchTogetherViewModel: TvWatchTogetherViewModel = koinViewModel() + val watchTogetherState by watchTogetherViewModel.uiState.collectAsState() + + LaunchedEffect(watchTogetherState.result) { + val room = watchTogetherState.result ?: return@LaunchedEffect + watchTogetherViewModel.consumeResult() + watchTogetherOpen = false + joinCodeOpen = false + onWatchTogether(room) + } + + if (joinCodeOpen) { + TvJoinCodeDialog( + isBusy = watchTogetherState.isBusy, + error = watchTogetherState.error, + onJoin = watchTogetherViewModel::joinRoom, + onDismiss = { + watchTogetherViewModel.clearError() + joinCodeOpen = false + }, + ) + } else { + TvWatchTogetherEntryDialog( + isBusy = watchTogetherState.isBusy, + error = watchTogetherState.error, + onHost = { watchTogetherViewModel.createRoom(playContentId, playFileId) }, + onHostVote = { + watchTogetherViewModel.createRoom( + playContentId, + playFileId, + RoomSelectionMode.Vote, + ) + }, + onJoin = { + watchTogetherViewModel.clearError() + joinCodeOpen = true + }, + onDismiss = { + watchTogetherViewModel.clearError() + watchTogetherOpen = false + }, + ) + } + } } private fun watchedMarkLabel(detail: ItemDetail): String = 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 edf6f9891..0ada48362 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 @@ -41,6 +41,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -51,6 +52,7 @@ import androidx.compose.ui.draw.clip 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.Color import androidx.compose.ui.graphics.Shadow @@ -133,7 +135,7 @@ private val LocalHudPickerReturnFocus = * Back. */ @Composable -fun TvPlayerHud( +internal fun TvPlayerHud( title: String, positionSec: Double, durationSec: Double, @@ -146,13 +148,12 @@ fun TvPlayerHud( onSelectFileVersion: (Int) -> Unit = {}, subtitleTracks: List, subtitleUrls: List = emptyList(), + subtitlePresentation: TvSubtitleHudPresentation, stats: PlayerStatsSnapshot, playbackPlan: PlaybackExecutionPlan? = null, videoFillMode: VideoFillMode, onSelectAudio: (Int) -> Unit, onSelectVideoQuality: (String) -> Unit, - onSelectSubtitle: (Int) -> Unit, - onSelectServerSubtitle: (Int) -> Unit = {}, onVideoFillModeChanged: (VideoFillMode) -> Unit, playbackSpeed: Double, onPlaybackSpeedChanged: (Double) -> Unit, @@ -167,6 +168,7 @@ fun TvPlayerHud( audioDelayEnabled: Boolean, onAudioDelayChanged: (Int) -> Unit, subtitleDelayMs: Int, + subtitleDelayEnabled: Boolean, onSubtitleDelayChanged: (Int) -> Unit, subtitleAppearance: SubtitleAppearance, onSubtitleAppearanceChanged: (SubtitleAppearance) -> Unit, @@ -242,6 +244,28 @@ fun TvPlayerHud( } } + // Keep an open subtitle picker synchronized with reducer state while + // retaining the same stable focused row through Applying -> committed. + LaunchedEffect(subtitlePresentation, activePicker?.title) { + val current = activePicker + if (current?.title == "Subtitle Track") { + val checkedRow = subtitlePresentation.rows.firstOrNull { it.checked } + val focusedRow = subtitlePresentation.rows.firstOrNull { it.focused } + activePicker = current.copy( + options = subtitlePresentation.rows.map { row -> + HudPickerOption( + id = row.stableId, + label = if (row.applying) "${row.label} · Applying…" else row.label, + ) + }, + selectedId = checkedRow?.stableId + ?: subtitlePresentation.rows.firstOrNull()?.stableId.orEmpty(), + focusedId = focusedRow?.stableId + ?: current.focusedId, + ) + } + } + val presentPicker: (HudPickerPresentation) -> Unit = { activePicker = it } val closePicker: () -> Unit = { activePicker = null } @@ -362,11 +386,9 @@ fun TvPlayerHud( onPresentPicker = presentPicker, ) HudTab.Subtitles -> HudSubtitlesPane( - subtitleTracks = subtitleTracks, - subtitleUrls = subtitleUrls, - onSelectSubtitle = onSelectSubtitle, - onSelectServerSubtitle = onSelectServerSubtitle, + presentation = subtitlePresentation, subtitleDelayMs = subtitleDelayMs, + subtitleDelayEnabled = subtitleDelayEnabled, onSubtitleDelayChanged = onSubtitleDelayChanged, appearance = subtitleAppearance, onAppearanceChanged = onSubtitleAppearanceChanged, @@ -562,9 +584,8 @@ private fun HudInfoPane( // Built label ("Danish SRT (External)") via the mounted row — the raw // Media3 displayLabel echoes sidecar filenames. val subLabel = sub?.let { sel -> - subtitleUrls.withIndex() - .firstOrNull { (_, row) -> sel.matchesMountedSubtitle(row) } - ?.let { (i, row) -> subtitleChoiceLabel(row, i) } + resolveMountedSubtitleRow(sel, subtitleTracks, subtitleUrls) + ?.let { row -> subtitleChoiceLabel(row, subtitleUrls.indexOf(row)) } ?: sel.displayLabel.ifBlank { "On" } } ?: "Off" add("Subtitles" to subLabel) @@ -1193,11 +1214,9 @@ private fun HudAudioPane( */ @Composable private fun HudSubtitlesPane( - subtitleTracks: List, - subtitleUrls: List = emptyList(), - onSelectSubtitle: (Int) -> Unit, - onSelectServerSubtitle: (Int) -> Unit = {}, + presentation: TvSubtitleHudPresentation, subtitleDelayMs: Int, + subtitleDelayEnabled: Boolean, onSubtitleDelayChanged: (Int) -> Unit, appearance: SubtitleAppearance, onAppearanceChanged: (SubtitleAppearance) -> Unit, @@ -1227,101 +1246,56 @@ private fun HudSubtitlesPane( .verticalScroll(rememberScrollState()), ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - val selectedSub = subtitleTracks.firstOrNull { it.isSelected } + val checkedRow = presentation.rows.firstOrNull { row -> row.checked } + val applyingRow = presentation.rows.firstOrNull { row -> row.applying } + val focusedRow = presentation.rows.firstOrNull { row -> row.focused } HudFocusedSettingRow( label = "Subtitles", - // Prefer the mounted row's built label ("Danish SRT") — - // Media3 labels echo server identity strings (sidecar - // filenames) verbatim. - value = selectedSub?.let { sel -> - subtitleUrls.withIndex() - .firstOrNull { (_, row) -> sel.matchesMountedSubtitle(row) } - ?.let { (i, row) -> subtitleChoiceLabel(row, i) } - ?: sel.displayLabel.ifBlank { "On" } - } ?: "Off", + value = applyingRow?.let { "${it.label} · Applying…" } + ?: checkedRow?.label + ?: "Off", enabled = enabled, focusRequester = subtitleTrackFocus, rightFocusRequester = subtitleTextColorFocus, onActivate = { - // The server catalog (subtitleUrls) is the menu source: - // catalog-only/external rows have no mounted Media3 - // track until the V3 planner materializes the chosen - // one, so keying this menu off live player tracks - // showed "no subtitles" for titles with plenty. Falls - // back to Media3 tracks only when the server list is - // empty (e.g. embedded-only discoveries). - if (subtitleUrls.isNotEmpty()) { - // Embedded tracks the player discovered but the - // server catalog does not enumerate (e.g. in-stream - // CEA-608) — keep them selectable alongside the - // catalog, tagged "media:" so onSelect routes them - // to the Media3-index path instead of a replan. - val embeddedOnly = subtitleTracks.filter { t -> - subtitleUrls.none { t.matchesMountedSubtitle(it) } - } - val options = buildList { - add(HudPickerOption(id = "-1", label = "Off")) - subtitleUrls.forEachIndexed { idx, row -> - add( - HudPickerOption( - id = row.index.toString(), - label = subtitleChoiceLabel(row, idx), - ), - ) - } - embeddedOnly.forEach { track -> - add( - HudPickerOption( - id = "media:${track.index}", - label = track.displayLabel.ifBlank { "Embedded" }, - ), - ) - } - } - val selectedId = selectedSub?.let { sel -> - subtitleUrls.firstOrNull { sel.matchesMountedSubtitle(it) }?.index?.toString() - ?: "media:${sel.index}" - } ?: "-1" - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Track", - options = options, - selectedId = selectedId, - onSelect = { id -> - val media = id.removePrefix("media:") - if (media != id) onSelectSubtitle(media.toIntOrNull() ?: -1) - else onSelectServerSubtitle(id.toIntOrNull() ?: -1) - }, - ), - ) - } else { - val options = buildList { - add(HudPickerOption(id = "-1", label = "Off")) - subtitleTracks.forEachIndexed { idx, track -> - add( - HudPickerOption( - id = track.index.toString(), - label = track.displayLabel.ifBlank { "Track ${idx + 1}" }, - ), + onPresentPicker( + HudPickerPresentation( + title = "Subtitle Track", + options = presentation.rows.map { row -> + HudPickerOption( + id = row.stableId, + label = if (row.applying) { + "${row.label} · Applying…" + } else { + row.label + }, ) - } - } - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Track", - options = options, - selectedId = (selectedSub?.index ?: -1).toString(), - onSelect = { id -> onSelectSubtitle(id.toIntOrNull() ?: -1) }, - ), - ) - } + }, + selectedId = checkedRow?.stableId + ?: presentation.rows.firstOrNull()?.stableId.orEmpty(), + focusedId = focusedRow?.stableId + ?: checkedRow?.stableId + ?: presentation.rows.firstOrNull()?.stableId.orEmpty(), + closeOnSelect = false, + onFocused = presentation.onFocused, + onSelect = { stableId -> + presentation.rows + .firstOrNull { row -> row.stableId == stableId } + ?.let { row -> presentation.onSelect(row.identity) } + }, + ), + ) }, ) HudFocusedSettingRow( label = "Delay", - value = delayLabel(subtitleDelayMs), - enabled = enabled, + value = if (subtitleDelayEnabled) { + delayLabel(subtitleDelayMs) + } else { + "Unavailable for burned-in subtitles" + }, + enabled = enabled && subtitleDelayEnabled, rightFocusRequester = subtitleTextColorFocus, onActivate = { onPresentPicker( @@ -1943,6 +1917,9 @@ internal data class HudPickerPresentation( val title: String, val options: List, val selectedId: String, + val focusedId: String = selectedId, + val closeOnSelect: Boolean = true, + val onFocused: (String) -> Unit = {}, val onSelect: (String) -> Unit, ) @@ -2088,8 +2065,11 @@ internal fun HudPickerDialog( modifier: Modifier = Modifier, ) { val options = presentation.options - val selectedIndex = options.indexOfFirst { it.id.equals(presentation.selectedId, ignoreCase = true) } + val selectedIndex = options.indexOfFirst { it.id == presentation.selectedId } .coerceAtLeast(0) + val focusedIndex = options.indexOfFirst { it.id == presentation.focusedId } + .takeIf { it >= 0 } + ?: selectedIndex val focusRequester = remember { FocusRequester() } // Auto-focus the selected option on appear. Because every option is in the @@ -2129,15 +2109,18 @@ internal fun HudPickerDialog( verticalArrangement = Arrangement.spacedBy(4.dp), ) { options.forEachIndexed { index, option -> - HudPickerOptionRow( - option = option, - isSelected = index == selectedIndex, - focusRequester = if (index == selectedIndex) focusRequester else null, - onSelect = { - presentation.onSelect(option.id) - onClose() - }, - ) + key(option.id) { + HudPickerOptionRow( + option = option, + isSelected = index == selectedIndex, + focusRequester = if (index == focusedIndex) focusRequester else null, + onFocused = { presentation.onFocused(option.id) }, + onSelect = { + presentation.onSelect(option.id) + if (presentation.closeOnSelect) onClose() + }, + ) + } } } } @@ -2149,6 +2132,7 @@ private fun HudPickerOptionRow( option: HudPickerOption, isSelected: Boolean, focusRequester: FocusRequester?, + onFocused: () -> Unit, onSelect: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } @@ -2171,6 +2155,7 @@ private fun HudPickerOptionRow( .clip(RoundedCornerShape(8.dp)) .background(bg) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { if (it.isFocused) onFocused() } .clickable(interactionSource = interactionSource, indication = null) { onSelect() } .semantics { this.selected = isSelected } .padding(horizontal = 10.dp, vertical = 8.dp), diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerLoadOwner.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerLoadOwner.kt new file mode 100644 index 000000000..602f8b1af --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerLoadOwner.kt @@ -0,0 +1,197 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext + +internal data class TvPlayerLoadOwner( + val generation: Long, + val contentId: String, + val preferredFileId: Int?, + val preferredQuality: String?, +) + +internal class TvPlayerLoadSupersededException : + kotlinx.coroutines.CancellationException("TV playback load was superseded.") + +/** + * Carries a fresh-load owner through the shared playback coordinator without + * teaching the shared API about the TV ViewModel's ownership implementation. + * + * [isCurrent] is deliberately evaluated by PlaybackSessionLifecycle while its + * own transition mutex is held. Checking it before entering that mutex would + * leave a window where a newer load or exit could win immediately before the + * stale session was published. + */ +internal class TvPlayerLoadOwnership internal constructor( + internal val owner: TvPlayerLoadOwner, + private val registry: TvPlayerLoadOwnerRegistry, +) : AbstractCoroutineContextElement(TvPlayerLoadOwnership) { + companion object Key : CoroutineContext.Key + + fun isCurrent(): Boolean = registry.owns(owner) +} + +internal suspend fun currentTvPlayerLoadOwnership(): TvPlayerLoadOwnership? = + currentCoroutineContext()[TvPlayerLoadOwnership] + +/** + * Monotonic owner for TV fresh-load publication. + * + * Coroutine cancellation is only an optimization: an older, non-cooperative + * load can still return, but it cannot publish after a newer begin/invalidate. + */ +internal class TvPlayerLoadOwnerRegistry { + private var generation = 0L + private var current: TvPlayerLoadOwner? = null + + @Synchronized + fun begin( + contentId: String, + preferredFileId: Int?, + preferredQuality: String?, + ): TvPlayerLoadOwner = TvPlayerLoadOwner( + generation = ++generation, + contentId = contentId, + preferredFileId = preferredFileId, + preferredQuality = preferredQuality, + ).also { current = it } + + @Synchronized + fun owns(owner: TvPlayerLoadOwner): Boolean = current == owner + + suspend fun withOwner( + owner: TvPlayerLoadOwner, + block: suspend () -> T, + ): T = withContext(TvPlayerLoadOwnership(owner, this)) { + block() + } + + @Synchronized + fun runIfOwned(owner: TvPlayerLoadOwner, action: () -> Unit): Boolean { + if (current != owner) return false + action() + return true + } + + suspend fun publishReadyIfOwned( + owner: TvPlayerLoadOwner, + sessionId: String?, + publish: () -> Unit, + stopStaleSession: suspend (String) -> Unit, + ): Boolean { + if (runIfOwned(owner, publish)) return true + val staleSessionId = sessionId?.takeIf(String::isNotBlank) ?: return false + withContext(NonCancellable) { + stopStaleSession(staleSessionId) + } + return false + } + + @Synchronized + fun invalidate() { + generation += 1 + current = null + } +} + +/** + * Exactly-once cleanup token for a Ready session that the shared starter has + * allocated and deferred, but the TV load has not jointly confirmed yet. + * + * Claiming is synchronous so every post-start suspension is covered. Cleanup + * consumes the token before invoking the manager/lifecycle rollback and always + * runs in [NonCancellable]. Confirmation consumes the same token without + * cleanup once ownership has transferred. + */ +internal class TvUnpublishedLoadSessionOwnership( + private val rollbackSession: suspend (sessionId: String) -> Unit, +) { + private var sessionId: String? = null + + @Synchronized + fun acquire(sessionId: String) { + require(sessionId.isNotBlank()) + check(this.sessionId == null) { + "An unpublished TV load session is already owned." + } + this.sessionId = sessionId + } + + suspend fun rollbackIfOwned(sessionId: String) { + val claimed = claim(sessionId) ?: return + withContext(NonCancellable) { + rollbackSession(claimed) + } + } + + suspend fun rollbackIfOwned() { + val claimed = claimAny() ?: return + withContext(NonCancellable) { + rollbackSession(claimed) + } + } + + @Synchronized + fun transferConfirmed(sessionId: String): Boolean { + if (this.sessionId != sessionId) return false + this.sessionId = null + return true + } + + @Synchronized + private fun claim(expectedSessionId: String): String? { + if (sessionId != expectedSessionId) return null + return sessionId.also { sessionId = null } + } + + @Synchronized + private fun claimAny(): String? = + sessionId.also { sessionId = null } +} + +internal data class TvUnpublishedLoadUiSnapshot( + val state: State, + val context: Context, +) + +/** + * Exact UI-side counterpart to [TvUnpublishedLoadSessionOwnership]. + * + * A replacement session keeps its predecessor snapshot until joint + * manager/lifecycle confirmation. Rollback consumes only that session's + * snapshot, so a stale B cleanup can never overwrite an already-published C. + */ +internal class TvUnpublishedLoadUiOwnership { + private val predecessors = + mutableMapOf>() + + @Synchronized + fun register( + sessionId: String, + state: State, + context: Context, + predecessorSessionId: String?, + ) { + require(sessionId.isNotBlank()) + predecessors[sessionId] = predecessors[predecessorSessionId] + ?: TvUnpublishedLoadUiSnapshot(state, context) + } + + @Synchronized + fun confirm(sessionId: String) { + predecessors.remove(sessionId) + } + + @Synchronized + fun snapshotForRollback( + sessionId: String, + ): TvUnpublishedLoadUiSnapshot? = predecessors[sessionId] + + @Synchronized + fun completeRollback(sessionId: String) { + predecessors.remove(sessionId) + } +} 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 f29774e70..82abeed02 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 @@ -44,7 +44,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import androidx.compose.runtime.remember @@ -65,6 +64,8 @@ import androidx.compose.ui.platform.LocalContext 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.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -76,6 +77,7 @@ 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 @@ -89,10 +91,12 @@ import org.prairieserver.prairie.common.player.DisplayHdrProbe import org.prairieserver.prairie.common.player.HdrDisplayController 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.SessionState 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 @@ -108,12 +112,14 @@ import org.prairieserver.prairie.cast.PrairieCastTrack import org.prairieserver.prairie.domain.player.IntroAutoSkipState import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan import org.prairieserver.prairie.model.playback.PlaybackSourceMetadata +import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.executableMedia3ClientTransformations import org.prairieserver.prairie.model.settings.SubtitleAppearance 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 @@ -121,6 +127,7 @@ import org.prairieserver.prairie.tv.cast.TvPrairieCastPlayerAdapter import org.prairieserver.prairie.tv.cast.TvPrairieCastReceiver 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 @@ -263,7 +270,7 @@ fun TvPlayerScreen( // in TvSearchScreen / TvTextInputDialog. val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current LaunchedEffect(Unit) { runCatching { keyboardController?.hide() } } - val state by viewModel.uiState.collectAsState() + val state by viewModel.presentationState.collectAsState() val isInPictureInPictureMode by pictureInPictureCoordinator.isInPictureInPictureMode.collectAsState() // PlayerView surface must bind to THIS, not the MediaController, so the // swap. Mirrors phone PlayerScreen. The MediaController is kept for transport. @@ -291,7 +298,6 @@ fun TvPlayerScreen( val aiTranslate by viewModel.aiTranslate.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current val latestOnExit by rememberUpdatedState(onExit) - val latestPrairieCastState by rememberUpdatedState(state) val latestPrairieCastPlaybackSpeed by rememberUpdatedState(playbackSpeed) val latestPrairieCastSubtitleDelayMs by rememberUpdatedState(subtitleDelayMs) val latestPrairieCastHdrEnabled by rememberUpdatedState(hdrEnabled) @@ -301,10 +307,10 @@ fun TvPlayerScreen( val displayHdr = remember { DisplayHdrProbe.probe(context) } val audioCaps by audioCapabilityManager.capabilities.collectAsState() val rootFocus = remember { FocusRequester() } - val exitScope = rememberCoroutineScope() 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) } @@ -350,15 +356,17 @@ fun TvPlayerScreen( var pictureInPictureSourceRect by remember { mutableStateOf(null) } // Watch Together binding. Built once per roomId; null for solo playback. - // The controller owns the room WS connection + RoomSyncEngine for the - // lifetime of this screen and tears them down on explicit leave. + // The process RoomSession owns the WS; this controller owns only the + // screen's RoomSyncEngine and requests durable teardown on explicit leave. val watchTogetherRepository: org.prairieserver.prairie.repository.WatchTogetherRepository = koinInject() + val roomSession: org.prairieserver.prairie.watchtogether.RoomSession = koinInject() val roomScope = rememberCoroutineScope() val roomController = remember(roomId) { roomId?.takeIf { it.isNotBlank() }?.let { id -> TvRoomSyncController( roomId = id, repository = watchTogetherRepository, + roomSession = roomSession, viewModel = viewModel, scope = roomScope, ) @@ -367,8 +375,9 @@ fun TvPlayerScreen( DisposableEffect(roomController) { roomController?.start() // Repo teardown happens on explicit leave (Leave affordance) or - // room_closed; the connect scope dies with roomScope on dispose. - onDispose { } + // room_closed; only this replaceable controller's child jobs are + // canceled on disposal. + onDispose { roomController?.dispose() } } val roomSnapshot by (roomController?.room ?: kotlinx.coroutines.flow.MutableStateFlow(null)) .collectAsState() @@ -393,6 +402,9 @@ fun TvPlayerScreen( // Connect a MediaController to the PrairiePlaybackService. Async — // downstream effects gate on a non-null controller. var mediaController by remember { mutableStateOf(null) } + val playWhenReadyReconciliationGate = remember(mediaController, roomId) { + PlayWhenReadyReconciliationGate() + } val videoBackend = remember( sessionPlayer, mediaController, @@ -459,7 +471,7 @@ fun TvPlayerScreen( setQuality = { qualityId -> val player = latestPrairieCastMediaController ?: latestPrairieCastSessionPlayer if (player != null && selectVideoQuality(player, qualityId)) { - val resolution = latestPrairieCastState.videoQualities + val resolution = viewModel.uiState.value.videoQualities .firstOrNull { it.id == qualityId } ?.resolution viewModel.onVideoQualitySelectionApplied(resolution) @@ -492,7 +504,7 @@ fun TvPlayerScreen( playNext = viewModel::playNextEpisodeNow, ) val registration = prairieCastReceiver.registerPlayer(adapter) { - latestPrairieCastState.toPrairieCastPlaybackState( + viewModel.uiState.value.toPrairieCastPlaybackState( contentId = contentId, playbackSpeed = latestPrairieCastPlaybackSpeed, hdrEnabled = latestPrairieCastHdrEnabled, @@ -506,6 +518,10 @@ fun TvPlayerScreen( val stopPlaybackAndExit = { if (!exitRequested) { exitRequested = true + // Every terminal player exit must release the process-owned room + // session. Explicit host-close paths enqueue close first, then this + // idempotent local departure follows behind it. + roomController?.leave(closeRoom = false) mediaController?.let { controller -> viewModel.onPositionChanged( controller.currentPosition, @@ -515,10 +531,8 @@ fun TvPlayerScreen( controller.stop() controller.clearMediaItems() } - exitScope.launch { - viewModel.stopSessionForExit() - latestOnExit() - } + viewModel.stopSessionForExitAsync() + latestOnExit() } } // A remote "stop"/"terminate" command tears the screen down like a Back press. @@ -530,6 +544,9 @@ fun TvPlayerScreen( // Back doesn't walk back through a chain of auto-played episodes. LaunchedEffect(Unit) { viewModel.playNextRequests.collect { req -> + if (!shouldNavigateToLocalNext(roomController != null)) { + return@collect + } exitRequested = true mediaController?.let { c -> c.pause(); c.stop(); c.clearMediaItems() } // AWAIT the old session stop before navigating: the lifecycle is a @@ -539,49 +556,26 @@ fun TvPlayerScreen( onPlayNext(req.contentId, req.autoAdvanceCount, req.preferredQuality) } } - val latestPlayerState by rememberUpdatedState(state) val latestIntroSkipState by rememberUpdatedState(introSkipState) val latestRoomSnapshot by rememberUpdatedState(roomSnapshot) val latestShowLeaveDialog by rememberUpdatedState(showLeaveDialog) val latestShowQuickSubtitlePicker by rememberUpdatedState(showQuickSubtitlePicker) - val applyTvSubtitleSelection: (Int, Boolean) -> Unit = selection@{ idx, dismiss -> - val selectedTrack = state.subtitleTracks - .firstOrNull { it.index == idx } - ?.toVideoTrackEntry() - if (idx >= 0 && selectedTrack == null) { - Log.w(TAG, "Subtitle selection ignored: index=$idx not found") - return@selection - } - val backend = videoBackend - if (backend == null) { - Log.w(TAG, "Subtitle selection deferred or failed for index=$idx: backend unavailable") - return@selection - } - if (backend.selectSubtitle(selectedTrack)) { - viewModel.onSubtitleSelectionApplied(idx) - viewModel.onManualSubtitleSelectionIntent(idx) - if (dismiss) viewModel.closeSubtitleMenu() - viewModel.persistSubtitleSelection(idx) - } else { - Log.w(TAG, "Subtitle selection deferred or failed for index=$idx") - } - } - // Server-catalog subtitle selection (HUD/quick-picker menus list the - // server's rows, not Media3 tracks): already-mounted rows resolve to a - // Media3 index and go through the normal path; catalog-only rows kick off - // a materializing replan inside the ViewModel and auto-select on arrival. - val applyTvServerSubtitleSelection: (Int) -> Unit = { serverIdx -> - if (serverIdx == -1) { - // Cancel any in-flight materialization so a pending pick can't - // re-enable itself after the user chose Off. - viewModel.cancelPendingCatalogSubtitle() - applyTvSubtitleSelection(-1, false) - } else { - viewModel.onSelectCatalogSubtitle(serverIdx)?.let { mediaIdx -> - applyTvSubtitleSelection(mediaIdx, false) - } - } - } + val selectTvSubtitle: (SubtitleIdentity) -> Unit = { identity -> + subtitleFocusedStableId = tvSubtitleOptionStableId(identity) + viewModel.selectSubtitleOption(identity) + } + val subtitlePresentation = buildTvSubtitleHudPresentation( + options = buildTvSubtitleHudOptions( + subtitleUrls = state.subtitleUrls, + subtitleTracks = state.subtitleTracks, + ), + committedIdentity = state.committedSubtitleIdentity, + pendingIdentity = state.pendingSubtitleIdentity, + hudOpen = state.hudOpen || showQuickSubtitlePicker, + focusedStableId = subtitleFocusedStableId, + onSelect = selectTvSubtitle, + onFocused = { stableId -> subtitleFocusedStableId = stableId }, + ) fun requestIdleOverlayFocus(target: TvIdleOverlayFocusTarget) { idleOverlayFocusRequest = TvIdleOverlayFocusRequest( @@ -591,7 +585,7 @@ fun TvPlayerScreen( } fun handleSkipIntroNow(): Boolean { - val target = latestPlayerState.intro?.end ?: return false + val target = viewModel.uiState.value.intro?.end ?: return false if (roomController != null) { if (tvRoomTransportGate(latestRoomSnapshot, TvTransportIntent.Seek) != TransportGate.Send) { return true @@ -630,7 +624,7 @@ fun TvPlayerScreen( captureQuickSkipBurst: Boolean = false, ): Boolean { val controller = mediaController ?: return true - val playerState = latestPlayerState + val playerState = viewModel.uiState.value if (roomController != null && tvRoomTransportGate(snapshot, TvTransportIntent.Seek) != TransportGate.Send ) { @@ -699,7 +693,7 @@ fun TvPlayerScreen( quickSkipCaptureJob?.cancel() quickSkipCaptureJob = null quickSkipCaptureActive = false - cleanSeekPreviewSec = latestPlayerState.position.coerceAtLeast(0.0) + cleanSeekPreviewSec = viewModel.uiState.value.position.coerceAtLeast(0.0) cleanSeekRate = if (direction < 0) -1 else 1 cleanSeekTickJob?.cancel() @@ -707,7 +701,7 @@ fun TvPlayerScreen( while (isActive && cleanSeekRate != 0) { cleanSeekPreviewSec = advanceCleanPlaybackSeekPreview( previewSec = cleanSeekPreviewSec, - durationSec = latestPlayerState.duration, + durationSec = viewModel.uiState.value.duration, rate = cleanSeekRate, ) delay(CLEAN_SEEK_TICK_MS) @@ -741,8 +735,8 @@ fun TvPlayerScreen( delay(CLEAN_SEEK_HOLD_THRESHOLD_MS) if (pendingCleanSeekGeneration == generation && pendingCleanSeekDirection == direction && - !latestPlayerState.showControls && - !latestPlayerState.showNextUp + !viewModel.uiState.value.showControls && + !viewModel.uiState.value.showNextUp ) { beginCleanPlaybackSeek(direction, latestRoomSnapshot) } @@ -868,7 +862,7 @@ fun TvPlayerScreen( DisposableEffect(viewModel, roomController) { val handler: (KeyEvent) -> Boolean = handler@{ event -> - val playerState = latestPlayerState + val playerState = viewModel.uiState.value if (playerState.streamUrl == null || playerState.isLoading || playerState.error != null) { return@handler false } @@ -1184,12 +1178,47 @@ fun TvPlayerScreen( } } + val latestLifecycleRoomSnapshot by rememberUpdatedState(roomSnapshot) + // Lifecycle pausing — send pause to the service when we're backgrounded. - DisposableEffect(lifecycleOwner, mediaController, isInPictureInPictureMode) { + DisposableEffect( + lifecycleOwner, + mediaController, + isInPictureInPictureMode, + playWhenReadyReconciliationGate, + ) { val observer = LifecycleEventObserver { _, event -> when (event) { - Lifecycle.Event.ON_PAUSE -> if (!isInPictureInPictureMode) mediaController?.pause() - Lifecycle.Event.ON_STOP -> if (!isInPictureInPictureMode) mediaController?.pause() + Lifecycle.Event.ON_PAUSE, + Lifecycle.Event.ON_STOP -> if (!isInPictureInPictureMode) { + mediaController?.let { controller -> + if (controller.playWhenReady) { + if ( + playWhenReadyReconciliationGate + .requestProgrammaticChange(false) + ) { + controller.pause() + } + } + } + } + Lifecycle.Event.ON_RESUME -> if (roomController != null) { + val desired = latestLifecycleRoomSnapshot?.isPaused?.not() + mediaController?.let { controller -> + if ( + desired != null && + (controller.playWhenReady != desired || + playWhenReadyReconciliationGate.hasPendingChanges) + ) { + if ( + playWhenReadyReconciliationGate + .requestProgrammaticChange(desired) + ) { + controller.playWhenReady = desired + } + } + } + } else -> Unit } } @@ -1219,12 +1248,35 @@ fun TvPlayerScreen( // Player listener → ViewModel. Pushes play/pause state, refreshes the // track menu state on track changes, and drives HDMI display-mode // switching on video size changes. - DisposableEffect(mediaController, state.effectiveFrameRate) { + DisposableEffect( + mediaController, + state.effectiveFrameRate, + playWhenReadyReconciliationGate, + ) { val controller = mediaController if (controller == null) { onDispose { } } else { val listener = object : Player.Listener { + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + val provenance = playWhenReadyReconciliationGate + .onPlayWhenReadyChanged(playWhenReady, reason) + provenance.followUpProgrammaticValue?.let { controller.playWhenReady = it } + if (!provenance.shouldReconcile) return + roomController + ?.onExternalPlayWhenReadyChanged(playWhenReady) + ?.let { authoritative -> + if (controller.playWhenReady != authoritative) { + if ( + playWhenReadyReconciliationGate + .requestProgrammaticChange(authoritative) + ) { + controller.playWhenReady = authoritative + } + } + } + } + override fun onIsPlayingChanged(isPlaying: Boolean) { viewModel.onPlayingChanged(isPlaying) val live = viewModel.uiState.value @@ -1437,6 +1489,7 @@ fun TvPlayerScreen( val plan = state.playbackPlan val delivery = plan?.delivery ?: state.delivery val mediaSpec = VideoPlayerMediaSpec( + contentId = contentId, streamUrl = url, playMethod = method, delivery = delivery, @@ -1447,10 +1500,15 @@ fun TvPlayerScreen( artworkUrl = state.artworkUrl, startPositionSeconds = state.startPosition, timelineOffsetSeconds = plan?.timeline?.timelineOffsetSeconds ?: 0.0, - durationSeconds = state.duration, + durationSeconds = viewModel.uiState.value.duration.takeIf { it > 0.0 } + ?: mediaController?.duration + ?.takeIf { it > 0L } + ?.div(1000.0) + ?: 0.0, audioPassthroughCodecs = plan.validatedPassthroughCodecs(), requestHeaders = state.requestHeaders, expectedDynamicRange = plan?.source?.hdrFormat, + expectedColorRange = plan.validatedColorRangeFallback(), transformations = plan?.executableMedia3ClientTransformations().orEmpty(), runtimeCorrections = plan?.runtimeCorrections.orEmpty(), ) @@ -1487,6 +1545,7 @@ fun TvPlayerScreen( val plan = state.playbackPlan val delivery = plan?.delivery ?: state.delivery val mediaSpec = VideoPlayerMediaSpec( + contentId = contentId, streamUrl = url, playMethod = method, delivery = delivery, @@ -1497,10 +1556,15 @@ fun TvPlayerScreen( artworkUrl = state.artworkUrl, startPositionSeconds = state.startPosition, timelineOffsetSeconds = plan?.timeline?.timelineOffsetSeconds ?: 0.0, - durationSeconds = state.duration, + durationSeconds = viewModel.uiState.value.duration.takeIf { it > 0.0 } + ?: mediaController?.duration + ?.takeIf { it > 0L } + ?.div(1000.0) + ?: 0.0, audioPassthroughCodecs = plan.validatedPassthroughCodecs(), requestHeaders = state.requestHeaders, expectedDynamicRange = plan?.source?.hdrFormat, + expectedColorRange = plan.validatedColorRangeFallback(), transformations = plan?.executableMedia3ClientTransformations().orEmpty(), runtimeCorrections = plan?.runtimeCorrections.orEmpty(), ) @@ -1514,7 +1578,11 @@ fun TvPlayerScreen( val backend = videoBackend ?: return@LaunchedEffect viewModel.subtitleSelectRequests.collect { idx -> if (idx == -1) { - if (backend.selectSubtitle(null)) viewModel.onSubtitleSelectionApplied(idx) + if (backend.selectSubtitle(null)) { + viewModel.onSubtitleSelectionApplied(idx) + } else { + viewModel.onSubtitleSelectionFailed(idx) + } return@collect } val selectedTrack = viewModel.uiState.value.subtitleTracks @@ -1522,59 +1590,27 @@ fun TvPlayerScreen( ?.toVideoTrackEntry() if (selectedTrack != null && backend.selectSubtitle(selectedTrack)) { viewModel.onSubtitleSelectionApplied(idx) + } else { + viewModel.onSubtitleSelectionFailed(idx) } } } - // Remote set_audio_track / set_subtitle_track. These latch in the VM (a - // remote command can arrive before the backend attaches OR before Media3 - // reports its tracks via onTracksChanged), so we combine the latched index - // with the live track list: while tracks are empty the latch is held; once - // they're reported the index applies if it matches, or is dropped if not. - // A non-empty audioTracks is the "tracks have loaded" signal (Media3 reports - // all groups together). - LaunchedEffect(videoBackend) { - val backend = videoBackend ?: return@LaunchedEffect - combine( - viewModel.pendingRemoteAudioIndex, - viewModel.uiState.map { it.audioTracks }.distinctUntilChanged(), - ) { idx, tracks -> idx to tracks }.collect { (idx, tracks) -> - if (idx == null) return@collect - if (tracks.isEmpty()) return@collect // not reported yet — keep latched - tracks.firstOrNull { it.index == idx } - ?.toVideoTrackEntry() - ?.let { backend.selectAudioTrack(it) } - viewModel.clearPendingRemoteAudio(idx) // applied or no-match → consume - } - } - LaunchedEffect(videoBackend) { - val backend = videoBackend ?: return@LaunchedEffect - combine( - viewModel.pendingRemoteSubtitleIndex, - viewModel.uiState.map { it.audioTracks.isNotEmpty() }.distinctUntilChanged(), - ) { idx, tracksReady -> idx to tracksReady }.collect { (idx, tracksReady) -> - if (idx == null) return@collect - if (idx == -1) { - // Disable doesn't depend on the track list — apply immediately. - backend.selectSubtitle(null) - viewModel.onSubtitleSelectionApplied(-1) - viewModel.clearPendingRemoteSubtitle(idx) - return@collect - } - if (!tracksReady) return@collect // wait for tracks to be reported - viewModel.uiState.value.subtitleTracks - .firstOrNull { it.index == idx } - ?.toVideoTrackEntry() - ?.let { if (backend.selectSubtitle(it)) viewModel.onSubtitleSelectionApplied(idx) } - viewModel.clearPendingRemoteSubtitle(idx) // applied or no-match → consume - } - } + // Remote set_audio_track / set_subtitle_track are latched and resolved in + // the ViewModel after stable track identities exist. Only the transaction + // adapter may emit a backend subtitle mount request. // Mirror user-intent pause state into the player. Kept separate from the // onPlayingChanged listener so a transient buffering stall can't flip the // pause icon or cancel the auto-hide timer. - LaunchedEffect(mediaController, state.isPaused) { - mediaController?.playWhenReady = !state.isPaused + LaunchedEffect(mediaController, state.isPaused, playWhenReadyReconciliationGate) { + val controller = mediaController ?: return@LaunchedEffect + val desired = !state.isPaused + if (controller.playWhenReady != desired) { + if (playWhenReadyReconciliationGate.requestProgrammaticChange(desired)) { + controller.playWhenReady = desired + } + } } LaunchedEffect(mediaController) { @@ -1603,8 +1639,15 @@ fun TvPlayerScreen( sessionPlayer, state.videoFillMode, state.subtitleTracks.firstOrNull { it.isSelected }?.index, + state.playbackPlan?.source?.letterboxTopFraction, + state.playbackPlan?.source?.letterboxBottomFraction, ) { val pv = playerViewRef ?: return@LaunchedEffect + subtitleManager.letterbox = LetterboxInsets( + topFraction = (state.playbackPlan?.source?.letterboxTopFraction ?: 0.0).toFloat(), + bottomFraction = (state.playbackPlan?.source?.letterboxBottomFraction ?: 0.0).toFloat(), + ) + subtitleManager.titleSafeFraction = 0.05f subtitleManager.applyAppearance(pv, subtitleAppearance) } @@ -1765,13 +1808,14 @@ fun TvPlayerScreen( (mediaController?.bufferedPosition ?: 0L) - (mediaController?.currentPosition ?: 0L) ).coerceAtLeast(0L) / 1000.0 + TvPlayerClockScope(viewModel) { clock -> TvPlayerIdleOverlay( title = state.title, episodeTag = state.seasonNumber?.let { season -> state.episodeNumber?.let { ep -> "S$season·E$ep" } }, - positionSec = state.position, - durationSec = state.duration, + positionSec = clock.position, + durationSec = clock.duration, isPaused = state.isPaused, isScrubbing = state.isScrubbing, scrubPreviewSec = state.scrubPreviewSec, @@ -1858,6 +1902,7 @@ fun TvPlayerScreen( } }, ) + } } if (!isInPictureInPictureMode && state.hudOpen) { @@ -1869,10 +1914,11 @@ fun TvPlayerScreen( .padding(top = 56.dp), contentAlignment = androidx.compose.ui.Alignment.TopCenter, ) { + TvPlayerClockScope(viewModel) { clock -> TvPlayerHud( title = state.title, - positionSec = state.position, - durationSec = state.duration, + positionSec = clock.position, + durationSec = clock.duration, seasonNumber = state.seasonNumber, episodeNumber = state.episodeNumber, audioTracks = state.audioTracks, @@ -1882,27 +1928,16 @@ fun TvPlayerScreen( onSelectFileVersion = viewModel::onSelectFileVersion, subtitleTracks = state.subtitleTracks, subtitleUrls = state.subtitleUrls, + subtitlePresentation = subtitlePresentation, stats = state.stats, playbackPlan = state.playbackPlan, videoFillMode = state.videoFillMode, - onSelectAudio = { idx -> - val selectedTrack = state.audioTracks - .firstOrNull { it.index == idx } - ?.toVideoTrackEntry() - if (selectedTrack != null) { - videoBackend?.selectAudioTrack(selectedTrack) - viewModel.onAudioSelectionApplied(idx) - } - }, + onSelectAudio = viewModel::selectAudioOption, onSelectVideoQuality = { id -> // Server-transcode quality ladder (tvOS parity): // re-request the session at the chosen rung. viewModel.switchQuality(id) }, - onSelectSubtitle = { idx -> applyTvSubtitleSelection(idx, false) }, - onSelectServerSubtitle = { serverIdx -> - applyTvServerSubtitleSelection(serverIdx) - }, onVideoFillModeChanged = viewModel::onVideoFillModeChanged, playbackSpeed = playbackSpeed, onPlaybackSpeedChanged = viewModel::onSetPlaybackSpeed, @@ -1917,6 +1952,8 @@ fun TvPlayerScreen( audioDelayEnabled = state.playbackPlan?.claims?.audio?.passthrough != true, onAudioDelayChanged = viewModel::onAudioDelayChanged, subtitleDelayMs = subtitleDelayMs, + subtitleDelayEnabled = + state.committedSubtitleIdentity !is SubtitleIdentity.ServerBurnIn, onSubtitleDelayChanged = viewModel::onSubtitleDelayChanged, subtitleAppearance = subtitleAppearance, onSubtitleAppearanceChanged = viewModel::onSetSubtitleAppearance, @@ -1967,6 +2004,7 @@ fun TvPlayerScreen( initialTab = requestedHudTab, onPickerOpenChanged = { hudPickerOpen = it }, ) + } } } @@ -1977,12 +2015,14 @@ fun TvPlayerScreen( .padding(top = 80.dp), contentAlignment = Alignment.TopCenter, ) { - TvHoldSeekIndicator( - isVisible = true, - rate = cleanSeekRate, - previewTimeSec = cleanSeekPreviewSec, - durationSec = state.duration, - ) + TvPlayerClockScope(viewModel) { clock -> + TvHoldSeekIndicator( + isVisible = true, + rate = cleanSeekRate, + previewTimeSec = cleanSeekPreviewSec, + durationSec = clock.duration, + ) + } } } @@ -2011,19 +2051,11 @@ fun TvPlayerScreen( if (!isInPictureInPictureMode && showQuickSubtitlePicker) { TvQuickSubtitlePicker( - tracks = state.subtitleTracks, - subtitleUrls = state.subtitleUrls, - onSelect = { idx -> - applyTvSubtitleSelection(idx, false) + presentation = subtitlePresentation, + onDismiss = { showQuickSubtitlePicker = false viewModel.setControlsVisible(true) }, - onSelectServer = { serverIdx -> - applyTvServerSubtitleSelection(serverIdx) - showQuickSubtitlePicker = false - viewModel.setControlsVisible(true) - }, - onDismiss = { showQuickSubtitlePicker = false }, ) } @@ -2484,62 +2516,16 @@ private fun formatSleepCountdown(seconds: Int): String { @Composable private fun TvQuickSubtitlePicker( - tracks: List, - subtitleUrls: List = emptyList(), - onSelect: (Int) -> Unit, - onSelectServer: (Int) -> Unit = {}, + presentation: TvSubtitleHudPresentation, onDismiss: () -> Unit, ) { - // Server catalog is the menu source (see HudSubtitlesPane): catalog-only - // rows have no Media3 track until chosen, so a tracks-keyed menu shows - // "no subtitles" for titles with plenty. Media3 tracks are the fallback - // for embedded-only discoveries. - val selectedTrack = tracks.firstOrNull { it.isSelected } - val useServerList = subtitleUrls.isNotEmpty() - // Embedded player-discovered tracks not in the server catalog (e.g. in-stream - // CEA-608) stay selectable, tagged "media:" for the Media3-index path. - val embeddedOnly = if (useServerList) { - tracks.filter { t -> subtitleUrls.none { t.matchesMountedSubtitle(it) } } - } else { - emptyList() - } - val options = buildList { - add(HudPickerOption(id = "-1", label = "Off")) - if (useServerList) { - subtitleUrls.forEachIndexed { idx, row -> - add( - HudPickerOption( - id = row.index.toString(), - label = subtitleChoiceLabel(row, idx), - ), - ) - } - embeddedOnly.forEach { track -> - add( - HudPickerOption( - id = "media:${track.index}", - label = track.displayLabel.ifBlank { "Embedded" }, - ), - ) - } - } else { - tracks.forEachIndexed { idx, track -> - add( - HudPickerOption( - id = track.index.toString(), - label = track.displayLabel.ifBlank { "Track ${idx + 1}" }, - ), - ) - } - } - } - val selectedId = if (useServerList) { - selectedTrack?.let { sel -> - subtitleUrls.firstOrNull { sel.matchesMountedSubtitle(it) }?.index?.toString() - ?: "media:${sel.index}" - } ?: "-1" - } else { - (selectedTrack?.index ?: -1).toString() + val checkedRow = presentation.rows.firstOrNull { row -> row.checked } + val focusedRow = presentation.rows.firstOrNull { row -> row.focused } + val options = presentation.rows.map { row -> + HudPickerOption( + id = row.stableId, + label = if (row.applying) "${row.label} · Applying…" else row.label, + ) } // Rendered as an in-window overlay, NOT a Dialog. A Dialog is a separate @@ -2560,15 +2546,17 @@ private fun TvQuickSubtitlePicker( presentation = HudPickerPresentation( title = "Subtitles", options = options, - selectedId = selectedId, - onSelect = { id -> - val media = id.removePrefix("media:") - if (media != id) { - onSelect(media.toIntOrNull() ?: -1) - } else { - val ordinal = id.toIntOrNull() ?: -1 - if (useServerList) onSelectServer(ordinal) else onSelect(ordinal) - } + selectedId = checkedRow?.stableId + ?: presentation.rows.firstOrNull()?.stableId.orEmpty(), + focusedId = focusedRow?.stableId + ?: checkedRow?.stableId + ?: presentation.rows.firstOrNull()?.stableId.orEmpty(), + closeOnSelect = false, + onFocused = presentation.onFocused, + onSelect = { stableId -> + presentation.rows + .firstOrNull { row -> row.stableId == stableId } + ?.let { row -> presentation.onSelect(row.identity) } }, ), onClose = onDismiss, @@ -2853,40 +2841,56 @@ private fun TvRoomCloseConfirmDialog( onClose: () -> Unit, onCancel: () -> Unit, ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.72f)), - contentAlignment = Alignment.Center, + val closeActionFocus = remember { FocusRequester() } + + Popup( + alignment = Alignment.Center, + onDismissRequest = onCancel, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), ) { - Column( + Box( modifier = Modifier - .clip(RoundedCornerShape(24.dp)) - .background(Color.Black.copy(alpha = 0.92f)) - .padding(40.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)), + contentAlignment = Alignment.Center, ) { - androidx.tv.material3.Text( - text = "Close this room?", - color = Color.White, - style = androidx.tv.material3.MaterialTheme.typography.titleLarge, - ) - androidx.tv.material3.Text( - text = "Closing ends Watch Together for everyone in the room.", - color = Color.White.copy(alpha = 0.80f), - style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, - ) - TvDialogActionRow( - title = "Close room for everyone", - onClick = onClose, - modifier = Modifier.width(360.dp), - ) - TvDialogActionRow( - title = "Keep watching", - onClick = onCancel, - modifier = Modifier.width(360.dp), - ) + Column( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .background(Color.Black.copy(alpha = 0.92f)) + .padding(40.dp) + .then(rememberTvDialogInitialFocus(closeActionFocus)), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + androidx.tv.material3.Text( + text = "Close this room?", + color = Color.White, + style = androidx.tv.material3.MaterialTheme.typography.titleLarge, + ) + androidx.tv.material3.Text( + text = "Closing ends Watch Together for everyone in the room.", + color = Color.White.copy(alpha = 0.80f), + style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, + ) + TvDialogActionRow( + title = "Close room for everyone", + onClick = onClose, + modifier = Modifier + .width(360.dp) + .focusRequester(closeActionFocus), + ) + TvDialogActionRow( + title = "Keep watching", + onClick = onCancel, + modifier = Modifier.width(360.dp), + ) + } } } } @@ -2939,10 +2943,14 @@ internal fun extractTrackEntries(tracks: Tracks, type: Int): List Unit, +) { + val clock by viewModel.playbackClock.collectAsState() + content(clock) +} + /** * Apply (or clear, for [VIDEO_QUALITY_AUTO_ID]) a video quality override on the 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 new file mode 100644 index 000000000..65a96ee98 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitlePolicy.kt @@ -0,0 +1,306 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlinx.coroutines.CancellationException +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.model.playback.rebaseDownloadedSubtitleUrl +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.playback.audioTrackFingerprint +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.resolveCatalogSubtitlePreferenceOrdinal +import org.prairieserver.prairie.playback.resolveAudioTrackOrdinal +import org.prairieserver.prairie.playback.subtitleTrackFingerprint +import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate + +internal data class TvFreshSubtitlePreferenceResolution( + val identity: SubtitleIdentity, + val migratedPreference: String? = null, +) + +internal data class TvFreshSubtitleRestoreResult( + val rows: List, + val resolution: TvFreshSubtitlePreferenceResolution?, +) + +/** + * Resolves persisted state against the current playback metadata. Raw indexes + * are never trusted across sessions; a typed catalog identity is rebuilt with + * the current combined server index after its stable metadata matches. + */ +internal fun resolveTvFreshSubtitlePreference( + preference: String?, + catalogTracks: List, + hydratedRows: List, +): TvFreshSubtitlePreferenceResolution? { + val saved = preference?.trim()?.takeIf(String::isNotEmpty) ?: return null + val typed = decodeSubtitleIdentityPreference(saved) + + if (typed == SubtitleIdentity.Off) { + return TvFreshSubtitlePreferenceResolution(SubtitleIdentity.Off) + } + if (typed == null && saved == SUBTITLE_OFF_FINGERPRINT) { + return TvFreshSubtitlePreferenceResolution( + identity = SubtitleIdentity.Off, + migratedPreference = encodeSubtitleIdentityPreference(SubtitleIdentity.Off), + ) + } + + if (typed is SubtitleIdentity.ServerSidecar || + typed is SubtitleIdentity.ServerBurnIn || + typed is SubtitleIdentity.Embedded + ) { + val ordinal = resolveCatalogSubtitlePreferenceOrdinal(catalogTracks, saved) ?: return null + val rebuilt = encodeCatalogSubtitlePreference(catalogTracks, ordinal) + ?.let(::decodeSubtitleIdentityPreference) + ?: return null + return TvFreshSubtitlePreferenceResolution(rebuilt) + } + + if (typed is SubtitleIdentity.Downloaded) { + val row = hydratedRows + .filter { it.downloadId == typed.downloadId } + .singleOrNull() + ?: return null + val rebuilt = tvSubtitleIdentity(row) + return (rebuilt as? SubtitleIdentity.Downloaded) + ?.let(::TvFreshSubtitlePreferenceResolution) + } + + if (typed is SubtitleIdentity.LocalMedia3) { + val localRows = hydratedRows.filter { + tvSubtitleIdentity(it) is SubtitleIdentity.LocalMedia3 + } + if (localRows.isEmpty()) { + // Player-only Media3 tracks do not exist in the fresh server + // response. Preserve the typed intent so the adapter can arm an + // exact remount owner for the first Media3 track snapshot. + return TvFreshSubtitlePreferenceResolution(typed) + } + val candidates = localRows.filter { row -> + row.tvMediaIdentity().matchesPersisted(typed.media) + } + if (candidates.size != 1) return null + return TvFreshSubtitlePreferenceResolution(typed) + } + + // A non-typed value is the legacy fingerprint. Resolve it uniquely, then + // immediately return the typed value that should replace it durably. + val catalogMatches = catalogTracks.indices.filter { ordinal -> + subtitleTrackFingerprint(catalogTracks[ordinal]) == saved + } + if (catalogMatches.size == 1) { + val rebuilt = encodeCatalogSubtitlePreference(catalogTracks, catalogMatches.single()) + ?.let(::decodeSubtitleIdentityPreference) + ?: return null + return TvFreshSubtitlePreferenceResolution( + identity = rebuilt, + migratedPreference = encodeSubtitleIdentityPreference(rebuilt), + ) + } + + val hydratedMatches = hydratedRows.filter { subtitleTrackFingerprint(it) == saved } + if (hydratedMatches.size != 1) return null + val rebuilt = tvSubtitleIdentity(hydratedMatches.single()) + return TvFreshSubtitlePreferenceResolution( + identity = rebuilt, + migratedPreference = encodeSubtitleIdentityPreference(rebuilt), + ) +} + +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. + */ +@Suppress("UNUSED_PARAMETER") +internal suspend fun resolveOwnedTvFreshSubtitleRestore( + owner: TvPlayerLoadOwner, + registry: TvPlayerLoadOwnerRegistry, + preference: String?, + catalogTracks: List, + initialRows: List, + sessionId: String, + serverUrl: String, + hydrateDownloadedRows: suspend () -> ApiResult>, +): TvFreshSubtitleRestoreResult? { + if (!registry.owns(owner)) return null + val hydration = try { + hydrateDownloadedRows() + } catch (cancellation: CancellationException) { + throw cancellation + } + if (!registry.owns(owner)) return null + + return when (hydration) { + is ApiResult.Success -> { + val retained = initialRows.filterNot(PlayerSubtitleInfo::isDownloadedTvPolicyRow) + val downloaded = hydration.data.map { row -> + if (row.isDownloadedTvPolicyRow()) { + row.copy(url = rebaseDownloadedSubtitleUrl(row.url, sessionId)) + } else { + row + } + } + // Hydration returns the full merged set, not downloads alone. + // Prefer its rebased rows and deduplicate by the server index used + // by picker identities and replans. + val rows = (downloaded + retained).distinctBy(PlayerSubtitleInfo::index) + TvFreshSubtitleRestoreResult( + rows = rows, + resolution = resolveTvFreshSubtitlePreference( + preference = preference, + catalogTracks = catalogTracks, + hydratedRows = rows, + ), + ) + } + is ApiResult.Error, + is ApiResult.NetworkError, + -> TvFreshSubtitleRestoreResult(rows = initialRows, resolution = null) + }.takeIf { registry.owns(owner) } +} + +internal class TvPlayerMutationFence( + private val registry: TvPlayerLoadOwnerRegistry, + private val invalidateTransactions: () -> Unit, +) { + fun beginLoad( + contentId: String, + preferredFileId: Int?, + preferredQuality: String?, + ): TvPlayerLoadOwner { + return registry.begin(contentId, preferredFileId, preferredQuality) + } + + fun owns(owner: TvPlayerLoadOwner): Boolean = registry.owns(owner) + + /** + * The inverse side of the load/replan fence. A user mutation against the + * currently mounted session wins over an unpublished replacement load. + */ + fun beginReplan() { + registry.invalidate() + } + + fun invalidateAll() { + registry.invalidate() + invalidateTransactions() + } +} + +internal fun beginTvReplacementLoad( + state: TvPlayerViewModel.UiState, +): TvPlayerViewModel.UiState = state.copy( + isLoading = false, + error = null, + serverUnreachable = false, + subtitleFailureMessage = null, +) + +internal fun failTvReplacementLoad( + state: TvPlayerViewModel.UiState, + message: String, +): TvPlayerViewModel.UiState = state.copy( + isLoading = false, + error = null, + serverUnreachable = false, + subtitleFailureMessage = message, +) + +internal suspend fun stopReplacedTvSessionAfterPublication( + replacedSessionId: String?, + publishedSessionId: String?, + stopSession: suspend (String) -> Unit, +) { + val stale = replacedSessionId + ?.takeIf(String::isNotBlank) + ?.takeUnless { it == publishedSessionId } + ?: return + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { + runCatching { stopSession(stale) } + } +} + +internal fun tvAudioTrackPersistenceUpdate( + committedAudioTrackIndex: Int?, + audioTracks: List, +): TrackSelectionFingerprintUpdate = + committedAudioTrackIndex + ?.let { selected -> audioTracks.singleOrNull { it.index == selected } } + ?.let(::audioTrackFingerprint) + ?.let(TrackSelectionFingerprintUpdate::Set) + ?: TrackSelectionFingerprintUpdate.Preserve + +@Suppress("UNUSED_PARAMETER") +internal fun authoritativeTvSubtitleRows( + snapshotRows: List, + previousRows: List, +): List = snapshotRows + +internal fun tvDownloadedRefreshIdentity( + row: PlayerSubtitleInfo, +): SubtitleIdentity? = + (tvSubtitleIdentity(row) as? SubtitleIdentity.Downloaded) + ?.takeIf { row.downloadId != null } + +internal fun resolveTvRemoteSubtitleIntent( + playerOrdinal: Int, + subtitleTracks: List, + subtitleRows: List, +): SubtitleIdentity? { + if (playerOrdinal == -1) return SubtitleIdentity.Off + val mounted = subtitleTracks.singleOrNull { it.index == playerOrdinal } ?: return null + return resolveMountedSubtitleRow(mounted, subtitleTracks, subtitleRows) + ?.let(::tvSubtitleIdentity) +} + +internal fun resolveTvRemoteAudioIntent( + playerOrdinal: Int, + audioTracks: List, +): Int? = audioTracks.getOrNull(playerOrdinal)?.index + +private fun PlayerSubtitleInfo.isDownloadedTvPolicyRow(): Boolean = + downloadId != null || + source.equals("downloaded", ignoreCase = true) || + catalogSource.equals("downloaded", ignoreCase = true) + +private fun PlayerSubtitleInfo.tvMediaIdentity(): SubtitleMediaIdentity = when ( + val identity = tvSubtitleIdentity(this) +) { + is SubtitleIdentity.ServerSidecar -> identity.media ?: SubtitleMediaIdentity() + is SubtitleIdentity.ServerBurnIn -> identity.media ?: SubtitleMediaIdentity() + is SubtitleIdentity.Embedded -> identity.media + is SubtitleIdentity.Downloaded -> identity.media + 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/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt index 4f0619de1..0b577e816 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 @@ -15,6 +15,8 @@ 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.FinalPlaybackPosition +import org.prairieserver.prairie.common.player.FinalPlaybackPositionWriter import org.prairieserver.prairie.common.player.VideoSessionStartV3 import org.prairieserver.prairie.common.player.PlayerNotice import org.prairieserver.prairie.common.player.PlayerStatsSnapshot @@ -22,7 +24,9 @@ import org.prairieserver.prairie.common.player.SessionState 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 import org.prairieserver.prairie.common.player.seek.PendingSeekPresentationGuard @@ -53,6 +57,7 @@ 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.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.buildPlaybackSubtitleChoices import org.prairieserver.prairie.model.playback.mergeDownloadedSubtitles import org.prairieserver.prairie.model.subtitles.SubtitleAiQuota @@ -62,20 +67,23 @@ 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.trackSelectionFingerprint +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 kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable 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 @@ -91,16 +99,16 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext /** * Renderable audio or subtitle track pulled out of ExoPlayer's current - * `Tracks` object. `id` is just the ordinal position among groups of the - * same type — it's used as the index argument when calling + * `Tracks` object. [index] is the ordinal position among groups of the same + * type and is used as the index argument when calling * [org.prairieserver.prairie.common.player.AudioTrackManager.selectAudioTrack] or - * [org.prairieserver.prairie.common.player.SubtitleManager.selectSubtitle]. [label] - * stays the raw Media3 selector label for matching re-prepared subtitle - * groups; [displayLabel] is the polished user-facing string. + * [org.prairieserver.prairie.common.player.SubtitleManager.selectSubtitle]. + * [trackId] retains Media3's stable selector identity; [label] is presentation + * metadata and [displayLabel] is the polished user-facing string. */ data class PlayerTrackEntry( val index: Int, @@ -112,6 +120,7 @@ data class PlayerTrackEntry( val channelCount: Int = 0, val isForced: Boolean = false, val isHearingImpaired: Boolean = false, + val trackId: String? = null, ) internal fun selectedServerAudioTrackIndex( @@ -122,6 +131,16 @@ internal fun selectedServerAudioTrackIndex( ?.let { catalogAudioTracks?.getOrNull(it)?.index } ?: currentPlanTrackIndex +private fun SubtitleIdentity.serverTrackIndexForTv(): Int = when (this) { + SubtitleIdentity.Off -> -1 + is SubtitleIdentity.ServerSidecar -> serverIndex + is SubtitleIdentity.ServerBurnIn -> serverIndex + is SubtitleIdentity.Embedded -> serverIndex + is SubtitleIdentity.Downloaded, + is SubtitleIdentity.LocalMedia3, + -> -1 +} + private val hearingImpairedSubtitleTokenRegex = Regex( pattern = """(^|[^a-z0-9])(cc|sdh|hi)([^a-z0-9]|$)""", option = RegexOption.IGNORE_CASE, @@ -302,69 +321,47 @@ internal fun resolveInitialSubtitleTrackIndex( ?: mountedSubtitles.getOrNull(requestedOrdinal) ?: return null - return subtitleTracks.firstOrNull { it.matchesMountedSubtitle(requested) }?.index + return resolveMountedSubtitleTrack(requested, subtitleTracks)?.index } -internal fun PlayerTrackEntry.matchesMountedSubtitle(subtitle: PlayerSubtitleInfo): Boolean { - val targetLabel = subtitle.label?.trim()?.takeIf { it.isNotBlank() } - if (targetLabel != null) { - val rawLabel = label.trim() - val friendlyLabel = displayLabel.trim() - if (rawLabel == targetLabel || friendlyLabel == targetLabel) return true - if ( - rawLabel.equals(targetLabel, ignoreCase = true) || - friendlyLabel.equals(targetLabel, ignoreCase = true) - ) { - return true - } - } - - val targetLanguage = normalizedSubtitleLanguage(subtitle.language) - val trackLanguage = normalizedSubtitleLanguage(language) - val targetCodec = normalizedSubtitleCodec(subtitle.codec ?: subtitleCodecFromUrl(subtitle.url)) - val trackCodec = normalizedSubtitleCodec(codecOrMime) - // V3 embedded-bitmap selection metadata intentionally has no synthetic - // label/language: those values belong to the real demuxed Media3 track. - // In that case the codec family is the stable bridge (dvd_subtitle -> - // application/vobsub, for example). - if (targetLanguage == null) { - return targetCodec != null && trackCodec == targetCodec - } - if (trackLanguage != targetLanguage) return false - return targetCodec == null || trackCodec == null || targetCodec == trackCodec +internal fun resolveMountedSubtitleTrack( + subtitle: PlayerSubtitleInfo, + subtitleTracks: List, +): PlayerTrackEntry? { + val match = resolveMountedSubtitle( + subtitle, + subtitleTracks.map(PlayerTrackEntry::toMountedSubtitleTrack), + ) ?: return null + return subtitleTracks.firstOrNull { it.index == match.track.index } } -/** - * Holds the stable server subtitle identity across a protocol replan. Replanning - * remounts the MediaItem and Media3 drops the live text-track override, so the - * freshly reported flat Media3 ordinal must be resolved and selected again. - */ -internal class SubtitleRemountReselection { - private var pendingServerSubtitleIndex: Int? = null - - fun arm(serverSubtitleIndex: Int?) { - pendingServerSubtitleIndex = serverSubtitleIndex - } +internal fun resolveMountedSubtitleRow( + track: PlayerTrackEntry, + subtitleTracks: List, + mountedSubtitles: List, +): PlayerSubtitleInfo? = + mountedSubtitles + .filter { resolveMountedSubtitleTrack(it, subtitleTracks)?.index == track.index } + .singleOrNull() - fun consume( - subtitleTracks: List, - mountedSubtitles: List, - ): Int? { - val serverIndex = pendingServerSubtitleIndex ?: return null - if (serverIndex == -1) { - pendingServerSubtitleIndex = null - return -1 - } - val mounted = mountedSubtitles.firstOrNull { it.index == serverIndex } ?: return null - val track = subtitleTracks.firstOrNull { it.matchesMountedSubtitle(mounted) } ?: return null - pendingServerSubtitleIndex = null - return track.index - } +internal fun resolvedMountedSubtitleTrackIndexes( + subtitleTracks: List, + mountedSubtitles: List, +): Set = + mountedSubtitles + .mapNotNull { resolveMountedSubtitleTrack(it, subtitleTracks)?.index } + .toSet() - fun clear() { - pendingServerSubtitleIndex = null - } -} +private fun PlayerTrackEntry.toMountedSubtitleTrack(): MountedSubtitleTrack = + MountedSubtitleTrack( + index = index, + trackId = trackId, + label = label, + language = language, + codec = codecOrMime, + forced = isForced, + hearingImpaired = isHearingImpaired, + ) private fun normalizedSubtitleLanguage(language: String?): String? { val primary = language @@ -386,29 +383,6 @@ private fun normalizedSubtitleLanguage(language: String?): String? { } } -private fun normalizedSubtitleCodec(codecOrMime: String?): String? { - val normalized = codecOrMime - ?.trim() - ?.takeIf { it.isNotBlank() } - ?.filter { it.isLetterOrDigit() } - ?.lowercase() - ?: return null - return when { - normalized == "ass" || normalized == "ssa" || normalized.contains("xssa") -> "ssa" - normalized == "srt" || normalized.contains("subrip") -> "srt" - normalized == "vtt" || normalized == "textvtt" || normalized.contains("webvtt") -> "vtt" - normalized.contains("pgs") -> "pgs" - normalized.contains("dvd") || normalized.contains("vobsub") -> "vobsub" - normalized.contains("dvbsub") -> "dvbsub" - else -> normalized - } -} - -private fun subtitleCodecFromUrl(url: String): String = - url.substringBefore('?') - .substringBefore('#') - .substringAfterLast('.', "") - /** * How the video surface scales to fill the player area. Session-scoped * (resets to [Fit] on each new playback) — matches tvOS behavior. @@ -526,7 +500,7 @@ class TvPlayerViewModel( private val subtitlesRepository: SubtitlesRepository, // Track B: durable offline-safe position (resume + outbox sync). private val userItemStatePort: org.prairieserver.prairie.repository.port.UserItemStatePort, - private val outboxSyncScheduler: org.prairieserver.prairie.common.data.sync.OutboxSyncScheduler, + private val finalPlaybackPositionWriter: FinalPlaybackPositionWriter, // Next-episode resolution for auto-advance (F2). private val catalogRepository: org.prairieserver.prairie.repository.CatalogRepository, // Pre-play reachability gate (issue #33): drives Retry's fresh probe. @@ -559,6 +533,7 @@ class TvPlayerViewModel( private var lastRecordedKey: String? = null private var lastRecordedPositionSec: Double = -1.0 + private var finalPositionScope: PlaybackWriteScope? = null /** [force] bypasses the time-throttle (used on pause/stop to capture the exact spot). */ private fun maybeRecordPosition(positionSec: Double, durationSec: Double, force: Boolean = false) { @@ -624,7 +599,7 @@ class TvPlayerViewModel( /** Guards [startServerRecoveryFallback] against concurrent fallbacks racing the same session. */ private var recoveryJob: Job? = null - private data class QueuedInvalidationReplan( + private data class QueuedRecoveryReplan( val classification: String, val notice: String, val qualityPreference: String?, @@ -637,7 +612,7 @@ class TvPlayerViewModel( * UiState once that flight completes so the selection isn't silently * dropped; last-write-wins because only the newest selection matters. */ - private var queuedInvalidationReplan: QueuedInvalidationReplan? = null + private var queuedRecoveryReplan: QueuedRecoveryReplan? = null /** * Seek recovery has its own latest-target-wins single flight. It is intentionally separate @@ -667,6 +642,7 @@ class TvPlayerViewModel( * once its coordinator round-trip returns. */ private var contentLoadGeneration = 0L + private val loadOwners = TvPlayerLoadOwnerRegistry() /** Same-route retries spent on transient network errors; reset once playback progresses. */ private var transientNetworkRetries = 0 @@ -772,6 +748,10 @@ class TvPlayerViewModel( // subtitleUrls, so the initial prepare effect stays the only path // for session start / stream-URL changes. val subtitleRefreshNonce: Int = 0, + val committedSubtitleIdentity: SubtitleIdentity = SubtitleIdentity.Off, + val pendingSubtitleIdentity: SubtitleIdentity? = null, + val subtitleApplying: Boolean = false, + val subtitleFailureMessage: String? = null, // Dialog visibility — owned here so HUD rows can request them and // the screen renders the Popups above the open HUD. val showSubtitleSearchDialog: Boolean = false, @@ -829,6 +809,113 @@ class TvPlayerViewModel( private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() + val presentationState: StateFlow = uiState + .map(UiState::withoutPlaybackClock) + .distinctUntilChanged() + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = _uiState.value.withoutPlaybackClock(), + ) + val playbackClock: StateFlow = uiState + .map(UiState::toPlaybackClock) + .distinctUntilChanged() + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = _uiState.value.toPlaybackClock(), + ) + private var subtitleMountGeneration = 0L + private var pendingSubtitleMountAcknowledgement: TvSubtitleRemountOwner? = null + private var lastAdapterMountIdentity: SubtitleIdentity? = null + private val unpublishedSubtitleUi = mutableMapOf() + private val unpublishedTvLoadUi = + TvUnpublishedLoadUiOwnership() + + private val subtitleTransactions = TvSubtitleTransactionAdapter( + scope = viewModelScope, + stagedPort = PlaybackSessionManagerTvSubtitleStagedReplanPort( + playbackSessionManager, + sessionLifecycle, + ), + settlementScope = TvSubtitleSettlementOwner.scope, + persistencePort = object : TvSubtitlePersistencePort { + override suspend fun persist( + committed: org.prairieserver.prairie.model.playback.CommittedSubtitle, + context: TvSubtitlePlaybackContext, + ): Boolean { + val writeScope = context.writeScope ?: return false + return userItemStatePort.recordTrackSelection( + scope = writeScope, + contentId = context.contentId, + fileId = context.mediaFileId, + audioUpdate = tvAudioTrackPersistenceUpdate( + committedAudioTrackIndex = committed.audioTrackIndex, + audioTracks = context.audioTracks, + ), + subtitleUpdate = TrackSelectionFingerprintUpdate.Set( + encodeSubtitleIdentityPreference(committed.identity), + ), + ) + } + }, + onSnapshotChanged = { snapshot -> + val localMountIdentity = snapshot.localMountIdentity + if (localMountIdentity != null && localMountIdentity != lastAdapterMountIdentity) { + subtitleMountGeneration += 1 + subtitleRemountReselection.arm(localMountIdentity, subtitleMountGeneration) + subtitleSnapshotSettlement.reset() + lastAdapterMountIdentity = localMountIdentity + // In-stream captions can already be present and need no media + // rebuild, so settle them against the current snapshot now. + _uiState.value.subtitleTracks + .takeIf(List::isNotEmpty) + ?.let(::resolveSubtitleRemountReselection) + } else if (localMountIdentity == null) { + lastAdapterMountIdentity = null + } + val committedQuality = snapshot.transition.committed.qualityPreference + if (!snapshot.subtitleApplying && committedQuality != null) { + qualityOverride = committedQuality + } + _uiState.update { state -> + state.copy( + committedSubtitleIdentity = snapshot.committedIdentity, + pendingSubtitleIdentity = snapshot.pendingIdentity, + subtitleApplying = snapshot.subtitleApplying, + subtitleFailureMessage = snapshot.failureMessage, + subtitleUrls = authoritativeTvSubtitleRows( + snapshotRows = snapshot.subtitleTracks, + previousRows = state.subtitleUrls, + ), + subtitleRefreshNonce = snapshot.subtitleRefreshNonce + .coerceAtMost(Int.MAX_VALUE.toLong()) + .toInt(), + videoQualities = if (!snapshot.subtitleApplying && committedQuality != null) { + transcodeQualityLadder(state.selectedFileResolution, committedQuality) + } else { + state.videoQualities + }, + ) + } + }, + onCommittedPlayback = ::adoptSubtitlePlayback, + onCommittedPlaybackConfirmed = ::confirmSubtitlePlaybackPublication, + onCommittedPlaybackRollback = ::rollbackSubtitlePlaybackPublication, + onCommittedPlaybackFailure = { message -> + _uiState.update { it.copy(error = message) } + }, + hasMountableTracks = { _uiState.value.subtitleTracks.isNotEmpty() }, + isLocallyMountable = { identity -> + resolveMountedSubtitle( + identity = identity, + tracks = _uiState.value.subtitleTracks.map { it.toMountedTvSubtitleTrack() }, + ) != null + }, + ) + private val playbackMutationFence by lazy { + TvPlayerMutationFence(loadOwners, subtitleTransactions::invalidate) + } /** Intro auto-skip banner state. The screen consumes this directly. */ val introSkipState: StateFlow = introAutoSkipController.state @@ -919,6 +1006,9 @@ class TvPlayerViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, true) val dolbyVisionEnabled: StateFlow = playerSettingsStore.dolbyVisionEnabledFlow .stateIn(viewModelScope, SharingStarted.Eagerly, true) + private val dvProfile7Hdr10Fallback: StateFlow = + playerSettingsStore.dvProfile7HDR10FallbackFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, false) val matchContentFrameRate: StateFlow = playerSettingsStore.matchContentFrameRateFlow .stateIn(viewModelScope, SharingStarted.Eagerly, false) // Effective = custom appearance unless "Match Device Settings" is on @@ -956,16 +1046,8 @@ class TvPlayerViewModel( private var aiStatusRequested = false private var aiJobPollJob: Job? = null private var activeAiJobId: Long? = null - private var pendingSubtitleSelectLabel: String? = null - // The server catalog row (PlayerSubtitleInfo.index) a user asked for that is - // still being materialized by a replan. Preferred over the label for - // resolving the mounted track once it arrives: unique, so it disambiguates - // two same-language externals and handles rows with null label AND language - // (which have no usable label key). Cleared on resolve, on Off, and on any - // replan failure so a stale pick can't re-enable later. - private var pendingSubtitleSelectServerIndex: Int? = null - private var pendingSubtitleSelectPersist: Boolean = false private val subtitleRemountReselection = SubtitleRemountReselection() + private val subtitleSnapshotSettlement = TvSubtitleSnapshotSettlementTracker() init { // Keep the process-wide active-file marker in sync (phone parity), so @@ -1025,10 +1107,10 @@ class TvPlayerViewModel( capabilityDetector.outputRouteGeneration.drop(1).collect { val state = _uiState.value if (state.sessionId != null && state.playbackPlan != null) { - startProtocolV3Replan( - classification = "output_route_changed", - notice = "Audio or display output changed. Revalidating playback.", - state = state, + playbackMutationFence.beginReplan() + subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) + subtitleTransactions.updateOutputRouteGeneration( + capabilityDetector.outputRouteGeneration.value, ) } } @@ -1070,16 +1152,203 @@ class TvPlayerViewModel( // Media3 does not carry a live text-track override across MediaItem // replacement. Every same-content remount declares the stable server // subtitle index to restore; the first mount explicitly passes null. - subtitleServerIndexToRestore?.let(subtitleRemountReselection::arm) transportMountSequence = if (transportMountSequence == Long.MAX_VALUE) { 1L } else { transportMountSequence + 1L } + subtitleServerIndexToRestore?.let { serverIndex -> + subtitleSnapshotSettlement.reset() + subtitleRemountReselection.arm( + identity = if (serverIndex == -1) { + SubtitleIdentity.Off + } else { + SubtitleIdentity.ServerSidecar(serverIndex) + }, + generation = transportMountSequence, + ) + } + transportMountGate.expect(transportMountSequence) + return transportMountSequence + } + + private fun nextTypedSubtitleMountNonce(identity: SubtitleIdentity): Long { + transportMountSequence = if (transportMountSequence == Long.MAX_VALUE) { + 1L + } else { + transportMountSequence + 1L + } + subtitleMountGeneration += 1 + if (subtitleRemountReselection.requiresRemount(identity)) { + subtitleSnapshotSettlement.reset() + subtitleRemountReselection.arm(identity, subtitleMountGeneration) + } transportMountGate.expect(transportMountSequence) return transportMountSequence } + private fun subtitlePlaybackContext(state: UiState): 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, + ) + return TvSubtitlePlaybackContext( + contentId = contentId, + mediaFileId = fileId, + versionId = "$fileId:${state.playbackPlan?.planId.orEmpty()}", + sessionId = state.sessionId, + positionSeconds = state.position, + audioTrackIndex = selectedAudio, + qualityPreference = qualityOverride + ?: preferredQuality + ?: PlaybackQuality.Auto.wireValue, + 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, + ), + writeScope = finalPositionScope, + ) + } + + private suspend fun adoptSubtitlePlayback( + adoption: TvSubtitlePlaybackAdoption, + ): TvSubtitleAdoptionResult { + val ready = adoption.playback.ready ?: return TvSubtitleAdoptionResult.Superseded + if (!adoption.isCurrent()) return TvSubtitleAdoptionResult.Superseded + val before = _uiState.value + val fileId = ready.session.mediaFileId.takeIf { it > 0 } + ?: ready.plan.effectiveMediaFileId + ?: before.selectedFileId + ?: 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, + audioTrackIndex = adoption.committed.audioTrackIndex, + subtitleTrackIndex = adoption.committed.identity.serverTrackIndexForTv(), + qualityPreference = adoption.committed.qualityPreference, + startPosition = ready.session.position, + ), + session = ready.session, + renewMissingSessionWithLegacyStart = false, + deferPublication = true, + isCurrent = adoption::isCurrent, + ) + if (!adopted) return TvSubtitleAdoptionResult.Superseded + 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( + catalogTracks = version?.subtitleTracks.orEmpty(), + plannedTracks = planned + retained, + ) + val duration = ready.session.durationSeconds + ?: version?.duration?.takeIf { it > 0.0 } + ?: before.duration + val mountNonce = nextTypedSubtitleMountNonce(adoption.committed.identity) + _uiState.update { state -> + state.copy( + error = null, + sessionId = ready.session.sessionId, + playMethod = ready.session.playMethod, + playbackPlan = ready.session.playbackPlan, + delivery = ready.plan.delivery, + streamUrl = ready.plan.stream.url, + transportMountNonce = mountNonce, + requestHeaders = ready.plan.stream.headers, + selectedFileId = fileId, + mediaFileId = fileId, + selectedFileResolution = version?.resolution + ?: ready.plan.effectiveRecipe.height?.let { "${it}p" }, + 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, + ) + } + return TvSubtitleAdoptionResult.Adopted + } + + private suspend fun confirmSubtitlePlaybackPublication( + playback: TvSubtitleCommittedPlayback, + ): Boolean { + unpublishedSubtitleUi.remove(playback.sessionId) + return true + } + + private suspend fun rollbackSubtitlePlaybackPublication( + playback: TvSubtitleCommittedPlayback, + restoreUi: Boolean, + ): Boolean { + val predecessor = unpublishedSubtitleUi.remove(playback.sessionId) + if (restoreUi && predecessor != null) { + val identity = predecessor.committedSubtitleIdentity + _uiState.value = predecessor.copy( + transportMountNonce = nextTypedSubtitleMountNonce(identity), + ) + } + return true + } + + private suspend fun rollbackUnpublishedTvLoadSession(sessionId: String) { + val predecessor = unpublishedTvLoadUi.snapshotForRollback(sessionId) + val jointlyRolledBack = sessionLifecycle.settlePendingPublicationIfCurrent( + sessionId = sessionId, + confirm = false, + settleManager = { + playbackSessionManager.rollbackUnpublishedVideoSession(sessionId) + }, + ) + if (!jointlyRolledBack) { + playbackSessionManager.rollbackUnpublishedVideoSession(sessionId) + } + try { + if (predecessor != null && _uiState.value.sessionId == sessionId) { + val identity = predecessor.state.committedSubtitleIdentity + _uiState.value = predecessor.state.copy( + transportMountNonce = nextTypedSubtitleMountNonce(identity), + ) + subtitleTransactions.resetContent( + context = predecessor.context, + committedIdentity = identity, + ) + } + } finally { + unpublishedTvLoadUi.completeRollback(sessionId) + } + } + /** * Called after [org.prairieserver.prairie.common.player.backend.VideoPlaybackBackend.mount] has * synchronously replaced the Media3 item. Nonce qualification prevents an older cancelled @@ -1104,7 +1373,7 @@ class TvPlayerViewModel( private fun resetSeekRecoveryForContentChange() { recoveryJob?.cancel() recoveryJob = null - queuedInvalidationReplan = null + queuedRecoveryReplan = null // A budget exhausted on the previous content/version must not leak // into the next one (phone parity: resetPlaybackRecoveryState). transientNetworkRetries = 0 @@ -1127,10 +1396,18 @@ class TvPlayerViewModel( // Try Anyway escape hatch (issue #33): bypass the pre-play reachability // gate and attempt the server even while it reports unreachable. force: Boolean = false, + // Version replacement is transactional: keep the mounted version + // visible until the replacement has won ownership and is ready. + preserveCurrentPlaybackOnFailure: Boolean = false, ) { // Capture this pipeline's generation; a later loadContent bump makes // this one inert before it can touch _uiState. val generation = ++contentLoadGeneration + val loadOwner = playbackMutationFence.beginLoad( + contentId = contentId, + preferredFileId = preferredFileIdOverride ?: preferredFileId, + preferredQuality = qualityOverride ?: preferredQuality, + ) hasRenderedFirstFrame = false resetSeekRecoveryForContentChange() transportMountGate.beginLoad() @@ -1138,12 +1415,20 @@ class TvPlayerViewModel( manualSubtitleSelectionApplied = false _uiState.update { it.copy(isBuffering = false) } - _uiState.update { it.copy(isLoading = true, error = null, serverUnreachable = false) } + _uiState.update { + if (preserveCurrentPlaybackOnFailure) beginTvReplacementLoad(it) + else it.copy(isLoading = true, error = null, serverUnreachable = false) + } + finalPositionScope = null viewModelScope.launch { + finalPositionScope = finalPlaybackPositionWriter.captureScope() + val unpublishedReadySession = + TvUnpublishedLoadSessionOwnership(::rollbackUnpublishedTvLoadSession) try { + if (!subtitleTransactions.invalidateAndAwaitSettlement()) return@launch runCatching { playerSettingsStore.refreshFromServer() } - val result = videoPlaybackCoordinator.start( - VideoPlaybackStartRequest( + if (!loadOwners.owns(loadOwner)) return@launch + val request = VideoPlaybackStartRequest( contentId = contentId, preferredFileId = preferredFileIdOverride ?: preferredFileId, roomId = roomId, @@ -1154,17 +1439,103 @@ class TvPlayerViewModel( playbackQualityIntent = qualityOverride, suppressResumeRewind = suppressResumeRewind, force = force, - ), - ) + ) + val result = loadOwners.withOwner(loadOwner) { + videoPlaybackCoordinator.start(request) + } // A newer loadContent superseded this pipeline while start() // was in flight — its results must not clobber the newer // pipeline's session state. - if (generation != contentLoadGeneration) return@launch + if (generation != contentLoadGeneration && result !is VideoPlayerUiState.Ready) { + return@launch + } when (result) { is VideoPlayerUiState.Ready -> { - val transportMountNonce = nextTransportMountNonce(null) + val allocatedSessionId = result.sessionId + ?.takeIf(String::isNotBlank) + ?: run { + fail("Playback start returned no session.") + return@launch + } + unpublishedReadySession.acquire(allocatedSessionId) + if (!loadOwners.owns(loadOwner)) { + loadOwners.publishReadyIfOwned( + owner = loadOwner, + sessionId = allocatedSessionId, + publish = {}, + stopStaleSession = unpublishedReadySession::rollbackIfOwned, + ) + return@launch + } val localTrackSelection = result.fileId ?.let { fileId -> userItemStatePort.localTrackSelection(contentId, fileId) } + if (!loadOwners.owns(loadOwner)) { + loadOwners.publishReadyIfOwned( + owner = loadOwner, + sessionId = allocatedSessionId, + publish = {}, + stopStaleSession = unpublishedReadySession::rollbackIfOwned, + ) + return@launch + } + val readyMediaFileId = result.mediaFileId + val readySessionId = result.sessionId + val catalogSubtitleTracks = result.versions + .firstOrNull { it.fileId == (result.fileId ?: readyMediaFileId) } + ?.subtitleTracks + .orEmpty() + val restorePreference = if (pendingInitialSubtitleIndex == null) { + localTrackSelection?.subtitleFingerprint + } else { + null + } + val freshRestore = if ( + readyMediaFileId != null && + readySessionId != null + ) { + resolveOwnedTvFreshSubtitleRestore( + owner = loadOwner, + registry = loadOwners, + preference = restorePreference, + catalogTracks = catalogSubtitleTracks, + initialRows = result.subtitleUrls, + 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 + } + }, + ) + } else { + TvFreshSubtitleRestoreResult( + rows = result.subtitleUrls, + resolution = resolveTvFreshSubtitlePreference( + preference = restorePreference, + catalogTracks = catalogSubtitleTracks, + hydratedRows = result.subtitleUrls, + ), + ) + } + if (freshRestore == null || !loadOwners.owns(loadOwner)) { + loadOwners.publishReadyIfOwned( + owner = loadOwner, + sessionId = allocatedSessionId, + publish = {}, + stopStaleSession = unpublishedReadySession::rollbackIfOwned, + ) + return@launch + } + val hydratedSubtitleUrls = freshRestore.rows pendingPersistedAudioFingerprint = if (initialAudioTrackIndex == null) { localTrackSelection?.audioFingerprint } else { @@ -1177,9 +1548,30 @@ class TvPlayerViewModel( // auto) instead of stranding subtitles Off all session. // The suppression now gates on the pick actually resolving // (see resolvePendingInitialSubtitle), not the bare intent. - pendingPersistedSubtitleFingerprint = localTrackSelection?.subtitleFingerprint - _uiState.update { - it.copy( + pendingPersistedSubtitleFingerprint = null + val committedIdentity = result.playbackPlan + ?.selectedTracks + ?.subtitleIndex + ?.let { selected -> + hydratedSubtitleUrls.firstOrNull { it.index == selected } + } + ?.let(::tvSubtitleIdentity) + ?: SubtitleIdentity.Off + val predecessorUi = _uiState.value + val predecessorSubtitleContext = subtitlePlaybackContext(predecessorUi) + val published = loadOwners.publishReadyIfOwned( + owner = loadOwner, + sessionId = allocatedSessionId, + publish = { + unpublishedTvLoadUi.register( + sessionId = allocatedSessionId, + state = predecessorUi, + context = predecessorSubtitleContext, + predecessorSessionId = predecessorUi.sessionId, + ) + val transportMountNonce = nextTransportMountNonce(null) + _uiState.update { + it.copy( isLoading = false, error = null, title = result.title, @@ -1214,7 +1606,7 @@ class TvPlayerViewModel( duration = result.durationSeconds, serverDuration = result.durationSeconds, isPaused = false, - subtitleUrls = result.subtitleUrls, + subtitleUrls = hydratedSubtitleUrls, preferredAudioLanguage = result.preferredAudioLanguage, preferredTextLanguage = result.preferredTextLanguage, preferredSubtitleMode = result.preferredSubtitleMode, @@ -1238,25 +1630,82 @@ class TvPlayerViewModel( // would see a stale nonce>0 and re-fire a spurious // second refresh racing the primary mount effect. subtitleRefreshNonce = 0, - ) + ) + } + subtitleTransactions.resetContent( + context = subtitlePlaybackContext(_uiState.value), + committedIdentity = committedIdentity, + ) + freshRestore.resolution?.let { resolution -> + subtitleTransactions.restoreFreshPreference( + identity = resolution.identity, + migrationRequired = resolution.migratedPreference != null, + ) + } + }, + stopStaleSession = unpublishedReadySession::rollbackIfOwned, + ) + if (!published) return@launch + val publishedSessionId = allocatedSessionId + val jointlyConfirmed = withContext(NonCancellable) { + sessionLifecycle.settlePendingPublicationIfCurrent( + sessionId = publishedSessionId, + confirm = true, + settleManager = { + playbackSessionManager + .confirmVideoSessionPublication(publishedSessionId) + }, + ).also { confirmed -> + if (confirmed) { + check( + unpublishedReadySession + .transferConfirmed(publishedSessionId), + ) + unpublishedTvLoadUi.confirm(publishedSessionId) + } + } + } + if (!jointlyConfirmed) { + unpublishedReadySession.rollbackIfOwned(publishedSessionId) + fail("Playback publication could not be confirmed.") + return@launch } startIntroAutoSkipObserver() resolveNextEpisode() } - is VideoPlayerUiState.Error -> fail(result.message) + is VideoPlayerUiState.Error -> { + if (preserveCurrentPlaybackOnFailure) { + _uiState.update { failTvReplacementLoad(it, result.message) } + } else { + fail(result.message) + } + } is VideoPlayerUiState.ServerUnreachable -> _uiState.update { - it.copy( - isLoading = false, - error = SERVER_UNREACHABLE_MESSAGE, - serverUnreachable = true, - ) + if (preserveCurrentPlaybackOnFailure) { + failTvReplacementLoad(it, SERVER_UNREACHABLE_MESSAGE) + } else { + it.copy( + isLoading = false, + error = SERVER_UNREACHABLE_MESSAGE, + serverUnreachable = true, + ) + } } is VideoPlayerUiState.Loading -> Unit } + } catch (cancellation: CancellationException) { + unpublishedReadySession.rollbackIfOwned() + throw cancellation } catch (e: Exception) { + unpublishedReadySession.rollbackIfOwned() Log.e(TAG, "Error loading content", e) - if (generation != contentLoadGeneration) return@launch - fail("Unexpected error: ${e.message}") + if (generation != contentLoadGeneration || !loadOwners.owns(loadOwner)) return@launch + val message = "Unexpected error: ${e.message}" + if (preserveCurrentPlaybackOnFailure) { + _uiState.update { failTvReplacementLoad(it, message) } + } else { + fail(message) + } } } } @@ -1363,7 +1812,7 @@ class TvPlayerViewModel( // re-drive it when the in-flight recovery completes. Failure-driven // replans stay dropped — onPlayerError re-raises those. if (classification in PlaybackSessionManager.USER_INVALIDATION_CLASSIFICATIONS) { - queuedInvalidationReplan = QueuedInvalidationReplan( + queuedRecoveryReplan = QueuedRecoveryReplan( classification = classification, notice = notice, qualityPreference = qualityPreference, @@ -1436,7 +1885,7 @@ class TvPlayerViewModel( ?: effectiveVersion?.duration?.takeIf { it > 0.0 } ?: state.duration.takeIf { effectiveFileId == fileId } ?: 0.0 - sessionLifecycle.adoptActiveSession( + val adopted = sessionLifecycle.adoptActiveSessionIfCurrent( params = StartParams( contentId = contentId, fileId = effectiveFileId, @@ -1447,7 +1896,17 @@ class TvPlayerViewModel( ), session = decision.session, renewMissingSessionWithLegacyStart = false, + isCurrent = { + recoveryContentGeneration == contentLoadGeneration && + isActive + }, ) + if (!adopted) { + runCatching { + playbackSessionManager.stopSession(decision.session.sessionId) + } + return@launch + } coroutineContext.ensureActive() if (recoveryContentGeneration != contentLoadGeneration) return@launch val transportMountNonce = nextTransportMountNonce(selectedSubtitle) @@ -1512,14 +1971,14 @@ class TvPlayerViewModel( // Cancellation means a content change / reset already cleared the // queue; only a completed flight re-drives a queued user selection. job.invokeOnCompletion { cause -> - if (cause == null) redriveQueuedInvalidationReplan() + if (cause == null) redriveQueuedRecoveryReplan() } } } - private fun redriveQueuedInvalidationReplan() { - val queued = queuedInvalidationReplan ?: return - queuedInvalidationReplan = null + private fun redriveQueuedRecoveryReplan() { + val queued = queuedRecoveryReplan ?: return + queuedRecoveryReplan = null // Current state, not the queuing-time state, so the replan carries the // latest committed track/quality selection. startProtocolV3Replan( @@ -1596,9 +2055,11 @@ class TvPlayerViewModel( // embedded CEA-608 the player discovered, not in the sidecar list) returns // null = keep-current, so a server-recovery transcode preserves the user's // subtitles instead of forcing them Off. - return state.subtitleUrls - .firstOrNull { selected.matchesMountedSubtitle(it) } - ?.index + return resolveMountedSubtitleRow( + track = selected, + subtitleTracks = state.subtitleTracks, + mountedSubtitles = state.subtitleUrls, + )?.index } fun onPositionChanged(positionMs: Long, durationMs: Long) { @@ -2071,7 +2532,7 @@ class TvPlayerViewModel( val dolbyVision = playerSettingsStore.dolbyVisionPolicySnapshot() if (!isCurrentSeekRecovery(request)) return val selectedSubtitle = selectedSubtitleTrackIndex(before) - sessionLifecycle.adoptActiveSession( + val adopted = sessionLifecycle.adoptActiveSessionIfCurrent( params = StartParams( contentId = contentId, fileId = fileId, @@ -2082,7 +2543,12 @@ class TvPlayerViewModel( ), session = decision.session, renewMissingSessionWithLegacyStart = false, + isCurrent = { isCurrentSeekRecovery(request) }, ) + if (!adopted) { + runCatching { playbackSessionManager.stopSession(decision.session.sessionId) } + return + } if (!isCurrentSeekRecovery(request)) return val transportMountNonce = nextTransportMountNonce(selectedSubtitle) _uiState.update { @@ -2163,15 +2629,51 @@ class TvPlayerViewModel( } fun clearRemoteMessage() { _remoteMessage.value = null } - // Track selection on TV applies through the player backend (held by the - // screen). Switching audio re-selects among the CURRENT stream's audio - // groups (mirrors the TV audio menu); it does not trigger a server-side - // audio re-mux the way mobile does. The screen validates the index against - // the live track list at apply time, so a bogus remote index ends up a no-op - // rather than (for subtitles) silently turning captions off — only an - // explicit -1 disables subtitles. - fun remoteSelectAudio(index: Int) { _pendingRemoteAudioIndex.value = index } - fun remoteSelectSubtitle(index: Int) { _pendingRemoteSubtitleIndex.value = index } + // Remote track commands resolve player ordinals to stable server/typed + // identities, then enter the same transactional replan path as the HUD. + // An unresolved command remains latched until a later track snapshot can + // resolve it; only an explicit subtitle -1 means Off. + fun remoteSelectAudio(index: Int) { + val state = _uiState.value + val selected = resolveTvRemoteAudioIntent( + playerOrdinal = index, + audioTracks = state.fileVersions + .firstOrNull { it.fileId == (state.selectedFileId ?: state.mediaFileId) } + ?.audioTracks + .orEmpty(), + ) + if (selected != null) { + _pendingRemoteAudioIndex.compareAndSet(index, null) + pendingPersistedAudioFingerprint = null + playbackMutationFence.beginReplan() + subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) + subtitleTransactions.selectAudio(selected) + } else { + _pendingRemoteAudioIndex.value = index + } + } + + fun remoteSelectSubtitle(index: Int) { + val state = _uiState.value + val identity = resolveTvRemoteSubtitleIntent( + playerOrdinal = index, + subtitleTracks = state.subtitleTracks, + subtitleRows = state.subtitleUrls, + ) + if (identity != null) { + _pendingRemoteSubtitleIndex.compareAndSet(index, null) + playbackMutationFence.beginReplan() + subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(_uiState.value)) + subtitleTransactions.select(identity) + } else { + _pendingRemoteSubtitleIndex.value = index + } + } + + private fun retryPendingRemoteTrackIntents() { + _pendingRemoteAudioIndex.value?.let(::remoteSelectAudio) + _pendingRemoteSubtitleIndex.value?.let(::remoteSelectSubtitle) + } /** * Adopt server-recomputed intro/credits ranges (a `markers_updated` event). @@ -2309,7 +2811,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 silo-apple#86, which + * the only difference is what asked for it. Mirrors prairie-apple#86, which * added the equivalent control to the tvOS transport. * * The countdown is deliberately NOT started here: someone who opened this @@ -2403,8 +2905,11 @@ class TvPlayerViewModel( // suppress the persisted/auto fallback (and an unresolvable one lets it // proceed) before persisted reads its fingerprint. resolvePendingInitialSubtitle(subtitle) + if (_pendingRemoteAudioIndex.value != null) { + pendingPersistedAudioFingerprint = null + } resolvePendingPersistedTrackSelection(audio, subtitle) - resolvePendingSubtitleSelection(subtitle) + retryPendingRemoteTrackIntents() resolveAutoPreferredTextSubtitle(audio, subtitle) } @@ -2428,8 +2933,11 @@ class TvPlayerViewModel( // suppress the persisted/auto fallback (and an unresolvable one lets it // proceed) before persisted reads its fingerprint. resolvePendingInitialSubtitle(subtitle) + if (_pendingRemoteAudioIndex.value != null) { + pendingPersistedAudioFingerprint = null + } resolvePendingPersistedTrackSelection(audio, subtitle) - resolvePendingSubtitleSelection(subtitle) + retryPendingRemoteTrackIntents() resolveAutoPreferredTextSubtitle(audio, subtitle) } @@ -2440,8 +2948,16 @@ class TvPlayerViewModel( pendingPersistedAudioFingerprint?.let { fingerprint -> if (audio.isNotEmpty()) { pendingPersistedAudioFingerprint = null - audio.firstOrNull { it.selectionFingerprint() == fingerprint } - ?.let { _pendingRemoteAudioIndex.value = it.index } + 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 } } } @@ -2463,7 +2979,7 @@ class TvPlayerViewModel( val mounted = resolveMountedSubtitleOrdinal(_uiState.value.subtitleUrls, fingerprint) ?.let { _uiState.value.subtitleUrls.getOrNull(it) } ?: return - subtitle.firstOrNull { it.matchesMountedSubtitle(mounted) } + resolveMountedSubtitleTrack(mounted, subtitle) ?.let { manualSubtitleSelectionApplied = true _subtitleSelectRequests.tryEmit(it.index) @@ -2562,52 +3078,28 @@ class TvPlayerViewModel( } fun onSubtitleSelectionApplied(index: Int) { - _uiState.update { - it.copy(subtitleTracks = subtitleTracksWithSelection(it.subtitleTracks, index)) - } - } - - fun persistSubtitleSelection(index: Int) { - persistSubtitleTrackSelection(index) - } - - fun onAudioSelectionApplied(index: Int) { - _uiState.update { current -> - current.copy(audioTracks = current.audioTracks.map { it.copy(isSelected = it.index == index) }) - } - val state = _uiState.value - val fileId = state.selectedFileId ?: state.mediaFileId ?: return - val fingerprint = state.audioTracks.firstOrNull { it.index == index }?.selectionFingerprint() ?: return - viewModelScope.launch { - userItemStatePort.recordAudioTrackSelection(contentId, fileId, fingerprint) - } - if (state.sessionId != null) { - startProtocolV3Replan( - classification = "audio_track_changed", - notice = "Applying audio selection.", - state = state, - ) - } + val owner = pendingSubtitleMountAcknowledgement ?: return + pendingSubtitleMountAcknowledgement = null + subtitleTransactions.reportMountedSelection( + identity = owner.identity, + selected = true, + snapshotKey = "tv-mounted:${owner.generation}:$index", + settled = true, + ) } - private fun persistSubtitleTrackSelection(index: Int) { + fun selectAudioOption(index: Int) { val state = _uiState.value - val fileId = state.selectedFileId ?: state.mediaFileId ?: return - val fingerprint = if (index == -1) { - SUBTITLE_OFF_FINGERPRINT - } else { - // Fingerprint on the STABLE server subtitle index (PlayerSubtitleInfo), - // not the Media3 flat text ordinal (PlayerTrackEntry.index) which shifts - // as tracks are discovered — otherwise the saved choice never restores - // across sessions (phone parity). Map the selected flat track onto its - // mounted server subtitle before fingerprinting. - val selected = state.subtitleTracks.firstOrNull { it.index == index } ?: return - val mounted = state.subtitleUrls.firstOrNull { selected.matchesMountedSubtitle(it) } ?: return - subtitleTrackFingerprint(mounted) - } - viewModelScope.launch { - userItemStatePort.recordSubtitleTrackSelection(contentId, fileId, fingerprint) - } + val selected = selectedServerAudioTrackIndex( + selectedPlayerOrdinal = index, + catalogAudioTracks = state.fileVersions + .firstOrNull { it.fileId == (state.selectedFileId ?: state.mediaFileId) } + ?.audioTracks, + currentPlanTrackIndex = null, + ) ?: return + playbackMutationFence.beginReplan() + subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) + subtitleTransactions.selectAudio(selected) } /** @@ -2622,21 +3114,35 @@ class TvPlayerViewModel( fun onSelectCatalogSubtitle(serverIndex: Int): Int? { val state = _uiState.value val row = state.subtitleUrls.firstOrNull { it.index == serverIndex } ?: return null - state.subtitleTracks.firstOrNull { it.matchesMountedSubtitle(row) }?.let { return it.index } - pendingSubtitleSelectServerIndex = serverIndex - pendingSubtitleSelectLabel = row.label?.trim()?.takeIf { it.isNotBlank() } - ?: row.language?.trim()?.takeIf { it.isNotBlank() } - pendingSubtitleSelectPersist = true + selectSubtitleOption(tvSubtitleIdentity(row)) + return null + } + + fun selectSubtitleOption(identity: SubtitleIdentity) { manualSubtitleSelectionApplied = true - if (state.sessionId != null) { - startProtocolV3Replan( - classification = "subtitle_track_changed", - notice = "Applying subtitle selection.", - state = state, - subtitleTrackIndexOverride = serverIndex, - ) + playbackMutationFence.beginReplan() + subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(_uiState.value)) + subtitleTransactions.select(identity) + } + + fun selectSubtitleOption(serverIndex: Int) { + if (serverIndex == -1) { + selectSubtitleOption(SubtitleIdentity.Off) + return } - return null + val row = _uiState.value.subtitleUrls.firstOrNull { it.index == serverIndex } ?: return + selectSubtitleOption(tvSubtitleIdentity(row)) + } + + fun onSubtitleSelectionFailed(index: Int) { + val owner = pendingSubtitleMountAcknowledgement ?: return + pendingSubtitleMountAcknowledgement = null + subtitleTransactions.reportMountedSelection( + identity = owner.identity, + selected = false, + snapshotKey = "tv-mount-failed:${owner.generation}:$index", + settled = true, + ) } /** @@ -2645,59 +3151,38 @@ class TvPlayerViewModel( * re-enable itself when a later track refresh arrives. */ fun cancelPendingCatalogSubtitle() { - pendingSubtitleSelectServerIndex = null - pendingSubtitleSelectLabel = null - pendingSubtitleSelectPersist = false subtitleRemountReselection.clear() + subtitleSnapshotSettlement.reset() + pendingSubtitleMountAcknowledgement = null } private fun resolveSubtitleRemountReselection(subtitle: List) { - subtitleRemountReselection - .consume(subtitle, _uiState.value.subtitleUrls) - ?.let(_subtitleSelectRequests::tryEmit) - } - - fun onManualSubtitleSelectionIntent(index: Int) { - manualSubtitleSelectionApplied = true - val state = _uiState.value - if (state.sessionId != null) { - startProtocolV3Replan( - classification = "subtitle_track_changed", - notice = "Applying subtitle selection.", - state = state, + val snapshotKey = subtitle + .takeIf(List::isNotEmpty) + ?.joinToString("|") { "${it.index}:${it.trackId}:${it.isSelected}" } + when ( + val event = subtitleRemountReselection.consume( + subtitleTracks = subtitle, + snapshotKey = snapshotKey, + settled = subtitleSnapshotSettlement.observe(subtitle), + ) + ) { + is TvSubtitleRemountEvent.Select -> { + pendingSubtitleMountAcknowledgement = event.owner + _subtitleSelectRequests.tryEmit(event.trackIndex) + } + is TvSubtitleRemountEvent.Failed -> subtitleTransactions.reportMountedSelection( + identity = event.owner.identity, + selected = false, + snapshotKey = snapshotKey, + settled = true, ) + null -> Unit } } - /** - * After refreshSubtitles bumps the nonce, the screen re-prepares the item - * and a fresh onTracksChanged arrives. Sidecar tracks expose their - * SubtitleConfiguration label as Format.label, which extractTrackEntries - * keeps in PlayerTrackEntry.label even when displayLabel is friendlier — - * so matching by raw label is exact. - * Emits the ordinal text-group index for SubtitleManager.selectSubtitle. - */ - private fun resolvePendingSubtitleSelection(subtitle: List) { - // Prefer the exact server row the user asked for: unique, so two - // same-language externals or a null-label/null-language row resolve - // correctly where a label match would pick the wrong (or no) track. - val serverIndex = pendingSubtitleSelectServerIndex - val match = if (serverIndex != null) { - _uiState.value.subtitleUrls.firstOrNull { it.index == serverIndex } - ?.let { row -> subtitle.firstOrNull { it.matchesMountedSubtitle(row) } } - } else { - val label = pendingSubtitleSelectLabel ?: return - subtitle.firstOrNull { it.label == label || it.displayLabel == label } - } ?: return - val persist = pendingSubtitleSelectPersist - pendingSubtitleSelectServerIndex = null - pendingSubtitleSelectLabel = null - pendingSubtitleSelectPersist = false - _subtitleSelectRequests.tryEmit(match.index) - // Catalog-only selections auto-select after materialization; persist - // here (the mounted path persists in applyTvSubtitleSelection) so the - // choice survives a restart — phone parity. - if (persist) persistSubtitleTrackSelection(match.index) + fun onManualSubtitleSelectionIntent(index: Int) { + manualSubtitleSelectionApplied = true } fun beginScrub() { @@ -2774,19 +3259,10 @@ class TvPlayerViewModel( fun switchQuality(wireValue: String) { val current = qualityOverride ?: preferredQuality ?: PlaybackQuality.Auto.wireValue if (wireValue == current) return - qualityOverride = wireValue - _uiState.update { - it.copy(videoQualities = transcodeQualityLadder(it.selectedFileResolution, wireValue)) - } val state = _uiState.value - if (state.sessionId != null) { - startProtocolV3Replan( - classification = "quality_changed", - notice = "Applying playback quality.", - state = state, - qualityPreference = wireValue, - ) - } + playbackMutationFence.beginReplan() + subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) + subtitleTransactions.selectQuality(wireValue) } /** @@ -2955,7 +3431,10 @@ class TvPlayerViewModel( ) when (val r = subtitlesRepository.download(request)) { is ApiResult.Success -> { - refreshSubtitles(autoSelectSubtitleId = r.data.subtitle.id) + refreshSubtitles( + autoSelectSubtitleId = r.data.subtitle.id, + source = TvSubtitleRefreshSource.Download, + ) _subtitleSearch.update { it.copy(downloadingResultId = null, completedNonce = it.completedNonce + 1) } @@ -2979,51 +3458,47 @@ 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). */ - suspend fun refreshSubtitles(autoSelectSubtitleId: Int?) { + internal suspend fun refreshSubtitles( + autoSelectSubtitleId: Int?, + source: TvSubtitleRefreshSource = TvSubtitleRefreshSource.Realtime, + ) { val state = _uiState.value val mediaFileId = state.mediaFileId ?: return - // Inert without a remote session — merged track URLs are session-scoped. val sessionId = state.sessionId ?: return - val downloaded = when (val r = subtitlesRepository.list(mediaFileId)) { + subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) + val owner = subtitleTransactions.beginRefresh(source) + 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 } is ApiResult.NetworkError -> { Log.w(TAG, "refreshSubtitles network error", r.exception) + subtitleTransactions.completeRefreshFailure( + owner, + r.exception.message ?: "Subtitle refresh failed.", + ) return } + } + } catch (cancellation: CancellationException) { + subtitleTransactions.cancelRefresh(owner) + throw cancellation } - if (downloaded.isEmpty()) return - val merged = mergeDownloadedSubtitles( - existing = state.subtitleUrls, + val downloadedRows = mergeDownloadedSubtitles( + existing = emptyList(), downloaded = downloaded, sessionId = sessionId, serverUrl = state.serverUrl, ) - // Label of the track to auto-select, located via the merge contract: - // downloaded entries occupy the merged list's tail in listing order - // (same positional contract mobile's downloadedTrackIndex relies on). - val autoSelectLabel = autoSelectSubtitleId?.let { id -> - val pos = downloaded.indexOfFirst { it.id == id } - if (pos < 0) null else merged.getOrNull(merged.size - downloaded.size + pos)?.label - } - if (merged == state.subtitleUrls) { - // Nothing new to mount (e.g. re-download of an existing entry) — - // honor auto-select against the already-mounted tracks and skip - // the rebuild entirely. - autoSelectLabel?.let { label -> - state.subtitleTracks.firstOrNull { it.label == label || it.displayLabel == label } - ?.let { _subtitleSelectRequests.tryEmit(it.index) } - } - return - } - pendingSubtitleSelectLabel = autoSelectLabel - ?: state.subtitleTracks.firstOrNull { it.isSelected }?.label - _uiState.update { - it.copy(subtitleUrls = merged, subtitleRefreshNonce = it.subtitleRefreshNonce + 1) - } + subtitleTransactions.applyRefresh( + owner = owner, + subtitleTracks = downloadedRows, + autoSelectDownloadId = autoSelectSubtitleId, + ) } // ---- Subtitle suite: AI translate / transcribe ------------------------------- @@ -3101,7 +3576,10 @@ class TvPlayerViewModel( activeAiJobId = null when (outcome) { is SubtitlesRepository.SubtitleJobOutcome.Completed -> { - refreshSubtitles(autoSelectSubtitleId = outcome.resultSubtitleId) + refreshSubtitles( + autoSelectSubtitleId = outcome.resultSubtitleId, + source = TvSubtitleRefreshSource.AiCompletion, + ) _aiTranslate.update { it.copy(phase = AiJobPhase.Idle, completedNonce = it.completedNonce + 1) } @@ -3179,7 +3657,7 @@ class TvPlayerViewModel( * mid-playback by dropping already-buffered cues). */ fun onSubtitleDelayChanged(delayMs: Int) { - viewModelScope.launch { playerSettingsStore.setSubtitleSyncMs(delayMs) } + viewModelScope.launch { playerSettingsStore.setSubtitleSyncMsFor(contentId, delayMs) } } // ---- Sleep timer setters --------------------------------------------------- @@ -3194,25 +3672,35 @@ class TvPlayerViewModel( sleepTimer.cancel() } - suspend fun stopSessionForExit() { + @Volatile + private var lastAdoptedSessionId: String? = null + + private val exitSessionId: String? + get() = _uiState.value.sessionId ?: lastAdoptedSessionId + + private fun prepareSessionExit() { + contentLoadGeneration++ + subtitleSnapshotSettlement.reset() resetSeekRecoveryForContentChange() transportMountGate.reset() - // Track B: durably record the final position + prompt a drain (covers the - // online offline-download case with no live session / connectivity change). + val state = _uiState.value val fileId = _uiState.value.selectedFileId ?: _uiState.value.mediaFileId - if (contentId.isNotBlank() && fileId != null) { - userItemStatePort.recordPosition( - contentId, - fileId, - _uiState.value.position, - _uiState.value.duration.takeIf { it > 0.0 }, + val scope = finalPositionScope + if (scope != null && contentId.isNotBlank() && fileId != null) { + finalPlaybackPositionWriter.submit( + FinalPlaybackPosition( + scope = scope, + contentId = contentId, + fileId = fileId, + positionSeconds = state.position, + durationSeconds = state.duration.takeIf { it > 0.0 }, + ), ) - outboxSyncScheduler.requestSync() } - sessionLifecycle.stop() introObserveJob?.cancel() nextUpCountdownJob?.cancel() introAutoSkipController.reset() + _uiState.value.sessionId?.let { lastAdoptedSessionId = it } _uiState.update { it.copy( isLoading = false, @@ -3229,8 +3717,28 @@ class TvPlayerViewModel( } } + /** Ordered path used by auto-advance before the singleton lifecycle starts the next item. */ + suspend fun stopSessionForExit() { + subtitleTransactions.invalidateAndAwaitSettlement() + playbackMutationFence.invalidateAll() + prepareSessionExit() + subtitleTransactions.persistCommittedSelectionAndFlush() + sessionLifecycle.stop(expectedSessionId = exitSessionId) + } + + /** Ordinary Back/remote-stop path: snapshot locally and return to detail immediately. */ + fun stopSessionForExitAsync() { + 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) + } + fun onExit() { - viewModelScope.launch { stopSessionForExit() } + stopSessionForExitAsync() } /** @@ -3396,25 +3904,16 @@ class TvPlayerViewModel( resetSeekRecoveryForContentChange() transportMountGate.beginLoad() val resumeAt = state.position.takeIf { it > 0.0 } - val staleSessionId = state.sessionId - // Single-flight: supersede any in-flight switch/retry so two rapid picks - // can't run concurrent load pipelines and orphan a server session. Cancelling - // here only interrupts the pre-loadContent stopSession round-trip (loadContent - // flips isLoading synchronously), so this never leaves isLoading stuck. + // 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 { - if (staleSessionId != null) { - runCatching { playbackSessionManager.stopSession(staleSessionId) } - } - // stopSession's safeApiCall swallows the CancellationException a - // superseding pick raises mid-round-trip, so re-check the job's - // cancelled flag explicitly — otherwise this stale coroutine would - // proceed into loadContent and race the pipeline that replaced it. coroutineContext.ensureActive() loadContent( startPositionOverride = resumeAt, preferredFileIdOverride = fileId, suppressResumeRewind = true, + preserveCurrentPlaybackOnFailure = true, ) } } @@ -3461,53 +3960,53 @@ class TvPlayerViewModel( } override fun onCleared() { + val teardownSessionId = exitSessionId + val subtitlePersistenceReservation = + subtitleTransactions.reserveDurableFinalPersistence() + subtitleTransactions.invalidateAndSettleAsync(restoreUi = false) { + subtitlePersistenceReservation?.let( + subtitleTransactions::requestDurableFinalPersistence, + ) + playbackMutationFence.invalidateAll() + sessionLifecycle.stop(expectedSessionId = teardownSessionId) + } + subtitleSnapshotSettlement.reset() org.prairieserver.prairie.common.player.debug.PlaybackDebugState.screenError = null org.prairieserver.prairie.common.player.ActivePlaybackFile.clear( _uiState.value.selectedFileId ?: _uiState.value.mediaFileId, ) super.onCleared() - // Guarantee the final resume position is persisted on teardown. The - // periodic write runs in viewModelScope, which is cancelling here — so - // AWAIT one last write under NonCancellable (a brief local Room write off - // the main thread). Mirrors phone PlayerViewModel.onCleared; without it, - // exiting while playing loses the last spot. + // The application-owned writer survives ViewModel cancellation and + // preserves the final local resume point without blocking main. val cid = contentId.takeIf { it.isNotBlank() } val fid = _uiState.value.selectedFileId ?: _uiState.value.mediaFileId - if (cid != null && fid != null) { - runCatching { - runBlocking(NonCancellable + Dispatchers.IO) { - userItemStatePort.recordPosition( - cid, - fid, - _uiState.value.position, - _uiState.value.duration.takeIf { it > 0.0 }, - ) - } - } + val scope = finalPositionScope + if (scope != null && cid != null && fid != null) { + finalPlaybackPositionWriter.submit( + FinalPlaybackPosition( + scope = scope, + contentId = cid, + fileId = fid, + positionSeconds = _uiState.value.position, + durationSeconds = _uiState.value.duration.takeIf { it > 0.0 }, + ), + ) } introObserveJob?.cancel() lifecycleObserveJob?.cancel() nextUpCountdownJob?.cancel() introAutoSkipController.reset() - val sessionId = _uiState.value.sessionId - if (sessionId != null) { - // androidx lifecycle 2.10 closes viewModelScope BEFORE onCleared, so a - // launch here never runs and the server session leaks until timeout. - // Unlike the resume-position write above (a brief local Room write), - // stop() makes up to two HTTP calls — blocking main for those network - // timeouts is an ANR. stopAsync launches the stop on the lifecycle's - // own singleton scope under NonCancellable. Best-effort. - sessionLifecycle.stopAsync() - } } } -private fun PlayerTrackEntry.selectionFingerprint(): String = - trackSelectionFingerprint( - index = index, - language = language, - codec = codecOrMime, - title = label, - forced = isForced, - ) +data class PlaybackClock( + val position: Double, + val duration: Double, +) + +internal fun TvPlayerViewModel.UiState.withoutPlaybackClock(): TvPlayerViewModel.UiState = + copy(position = 0.0, duration = 0.0) + +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/TvRoomSyncController.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt index b0e68e747..8161d6a2b 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 @@ -11,12 +11,20 @@ import org.prairieserver.prairie.model.watchtogether.TransportAction import org.prairieserver.prairie.repository.PongSample import org.prairieserver.prairie.repository.ScheduledTransportCommand import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.RoomCommandDispatcher +import org.prairieserver.prairie.watchtogether.RoomControllerLifetime +import org.prairieserver.prairie.watchtogether.RoomPendingExecutions +import org.prairieserver.prairie.watchtogether.externalPlayPauseDecision +import org.prairieserver.prairie.watchtogether.scheduledCommandStillCurrent +import kotlinx.coroutines.CoroutineStart import org.prairieserver.prairie.watchtogether.RoomTransportIntent import org.prairieserver.prairie.watchtogether.roomTransportAuthorized import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive @@ -115,6 +123,7 @@ fun tvShouldEmitStateReport( class TvRoomSyncController( private val roomId: String, private val repository: WatchTogetherRepository, + private val roomSession: RoomSession, private val viewModel: TvPlayerViewModel, private val scope: CoroutineScope, private val engine: RoomSyncEngine = RoomSyncEngine(), @@ -138,79 +147,61 @@ class TvRoomSyncController( // Monotonic-domain state-report cadence bookkeeping. @Volatile private var lastReportMs = 0L - @Volatile private var pendingExecuteAtMs: Long? = null - - // Session id we have already SENT attach_session for. We send attach_session - // AT MOST ONCE per session-id resolution; this latch suppresses re-sends on - // the many intervening pre-echo snapshots. Re-armed on a genuine server-side - // detach (see [serverHadOurSession]) so a reconnect re-attaches exactly once. - @Volatile private var sentAttachForSessionId: String? = null - - // Tracks whether the LAST snapshot had the server associated with our - // session. A true -> false transition is a genuine server-side detach - // (mid-session reconnect drops the association); that — not the pre-echo - // window — re-arms the latch to re-send attach_session once. - @Volatile private var serverHadOurSession: Boolean = false + private val lifetime = RoomControllerLifetime(scope) + private val controllerScope = lifetime.scope + private val deliveryLatch = repository.roomDeliveryLatch + private val commandDispatcher = RoomCommandDispatcher(controllerScope) + private val pendingExecutions = RoomPendingExecutions() fun start() { - // Reconnect-with-backoff loop. The lobby's connect() ran in its own - // (now-dead) scope (the lobby route was popped on hand-off), so the - // player owns the live connection and re-runs connect(roomId). - scope.launch { repository.connect(roomId) } - - // Attach the player's own playback session id, and RE-attach on WS - // reconnect. We combine the local session id with each server snapshot - // and use a SENT latch distinct from the echo-confirmed check: - // - send attach_session AT MOST ONCE per session-id resolution, the - // moment the id first resolves (set the latch immediately so - // intervening pre-echo snapshots don't re-send); - // - re-arm the latch (and re-send once) ONLY on a genuine server-side - // detach: a snapshot transition where the server HAD our session and - // now doesn't ([serverHadOurSession] true -> false). A mid-session - // reconnect produces exactly that transition. - scope.launch { + controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { combine( viewModel.uiState.map { it.sessionId }.distinctUntilChanged(), - repository.roomSnapshot, - ) { sessionId, snapshot -> sessionId to snapshot } - .collect { (sessionId, snapshot) -> - if (sessionId == null) { - sentAttachForSessionId = null - serverHadOurSession = false - return@collect - } - val serverHasOurSession = snapshot?.attachedSessionId == sessionId - if (serverHadOurSession && !serverHasOurSession) { - sentAttachForSessionId = null - } - serverHadOurSession = serverHasOurSession - - if (!serverHasOurSession && sentAttachForSessionId != sessionId) { - sentAttachForSessionId = sessionId - repository.attachSession(sessionId) + repository.connectionState, + ) { sessionId, connection -> deliveryLatch.keyOrNull(connection, sessionId) } + .distinctUntilChanged() + .collectLatest { key -> + key ?: return@collectLatest + while (isActive && deliveryLatch.needsAttach(key)) { + val delivered = repository.attachSession(key.playbackSessionId) + deliveryLatch.recordAttach(key, delivered) + if (!delivered) { + delay(500) + } } } } // Clock-sync: drive pings (once on start + every PING_INTERVAL_MS) and // fold pongs into the engine's offset estimate. - scope.launch { - while (isActive) { - repository.ping(clientSentAt = nowWallClockRfc3339()) - delay(PING_INTERVAL_MS) + controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { + combine( + viewModel.uiState.map { it.sessionId }.distinctUntilChanged(), + repository.connectionState, + ) { sessionId, connection -> + deliveryLatch.keyOrNull(connection, sessionId) to connection.writable } + .distinctUntilChanged() + .collectLatest { (key, writable) -> + if (!writable || key == null) return@collectLatest + while (!deliveryLatch.isAttached(key)) delay(10) + while (isActive) { + repository.ping(clientSentAt = nowWallClockRfc3339()) + delay(PING_INTERVAL_MS) + } + } } - scope.launch { + controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { repository.pongs.collect { pong -> recordPong(pong) } } // Apply engine decisions for each scheduled transport command. - scope.launch { + controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { repository.transportCommands.collect { scheduled -> handleCommand(scheduled) } } // Drift reporting loop (suppressed around a pending execute). - scope.launch { + controllerScope.launch { while (isActive) { val state = viewModel.uiState.value val sessionId = state.sessionId @@ -220,7 +211,7 @@ class TvRoomSyncController( now, lastReportMs, REPORT_CADENCE_MS, - pendingExecuteAtMs, + pendingExecutions.nearestTo(now), SUPPRESS_WINDOW_MS, ) ) { @@ -236,21 +227,47 @@ class TvRoomSyncController( } // ready / buffering during the waiting barrier. - scope.launch { - var lastBuffering: Boolean? = null - viewModel.uiState.collect { state -> - val waiting = repository.roomSnapshot.value?.playbackState == RoomPlaybackState.Waiting - val sessionId = state.sessionId - if (waiting && sessionId != null && state.isBuffering != lastBuffering) { - lastBuffering = state.isBuffering - if (state.isBuffering) { - repository.buffering(sessionId, state.position, state.isPaused) - } else { - repository.ready(sessionId, state.position, state.isPaused) + controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { + combine( + viewModel.uiState + .map { it.sessionId to it.isBuffering } + .distinctUntilChanged(), + repository.roomSnapshot + .map { it?.playbackState } + .distinctUntilChanged(), + repository.connectionState, + ) { readiness, playbackState, connection -> Triple(readiness, playbackState, connection) } + .collectLatest { (readiness, playbackState, connection) -> + val (sessionId, buffering) = readiness + val key = deliveryLatch.keyOrNull(connection, sessionId) + if (playbackState != RoomPlaybackState.Waiting || key == null) { + return@collectLatest + } + while (!deliveryLatch.isAttached(key)) delay(10) + while (isActive && deliveryLatch.needsReadiness(key, buffering)) { + val currentState = viewModel.uiState.value + val delivered = if (buffering) { + repository.buffering( + key.playbackSessionId, + currentState.position, + currentState.isPaused, + ) + } else { + repository.ready( + key.playbackSessionId, + currentState.position, + currentState.isPaused, + ) + } + deliveryLatch.recordReadiness(key, buffering, delivered) + if (!delivered) { + delay(500) + } } } - } } + + roomSession.adopt(roomId) } /** @@ -270,7 +287,12 @@ class TvRoomSyncController( ) } - private suspend fun handleCommand(scheduled: ScheduledTransportCommand) { + private fun handleCommand(scheduled: ScheduledTransportCommand) { + if (!scheduled.connection.writable || + scheduled.connection != repository.connectionState.value + ) { + return + } val command = scheduled.command val state = viewModel.uiState.value val snapshot = repository.roomSnapshot.value @@ -288,32 +310,57 @@ class TvRoomSyncController( attachedSessionId = snapshot?.attachedSessionId ?: state.sessionId, ) ?: return // duplicate / revision / session gate → ignore - applyDecision(decision, state.sessionId) + commandDispatcher.dispatch { + applyDecision( + decision = decision, + sessionId = state.sessionId, + command = command, + acceptedConnection = scheduled.connection, + ) + } } - private suspend fun applyDecision(decision: SyncDecision, sessionId: String?) { + private suspend fun applyDecision( + decision: SyncDecision, + sessionId: String?, + command: org.prairieserver.prairie.model.watchtogether.TransportCommand, + acceptedConnection: org.prairieserver.prairie.repository.WatchTogetherConnectionState, + ) { val delayMs = decision.localExecuteDelayMs.coerceAtLeast(0L) // Record the pending execute against the MONOTONIC clock for the // state-report suppression gate, then wait the engine-computed delay. - pendingExecuteAtMs = monotonicMs() + delayMs - if (delayMs > 0) delay(delayMs) - - // Use the deadband-free immediate-seek path: room corrective seeks can - // be as small as the engine's 0.35s DRIFT_THRESHOLD_MS, and a normal - // seekRequest would be fine on TV (no position-mirror deadband), but we - // route through seekImmediate to match mobile's contract exactly and - // guarantee the MediaController moves regardless of any future deadband. - decision.seekToMs?.let { ms -> viewModel.seekImmediate(ms / 1000.0) } - // Idempotent pause: set the desired state directly (NOT a toggle) so a - // duplicate command can't flip us the wrong way. - viewModel.setPaused(!decision.setPlaying) - pendingExecuteAtMs = null - - // Auto-emit ready on the waiting barrier (command.playback_state == waiting). - if (decision.shouldEmitReady && sessionId != null) { - val s = viewModel.uiState.value - repository.ready(sessionId, s.position, s.isPaused) + val pendingToken = pendingExecutions.record(monotonicMs() + delayMs) + try { + if (delayMs > 0) delay(delayMs) + if ( + !scheduledCommandStillCurrent( + command = command, + acceptedPlaybackSessionId = sessionId, + acceptedRoomId = roomId, + acceptedConnection = acceptedConnection, + currentPlaybackSessionId = viewModel.uiState.value.sessionId, + currentRoom = repository.roomSnapshot.value, + currentConnection = repository.connectionState.value, + ) + ) { + return + } + + // Use the deadband-free immediate-seek path: room corrective seeks can + // be as small as the engine's 0.35s DRIFT_THRESHOLD_MS, and a normal + // seekRequest would be fine on TV (no position-mirror deadband), but we + // route through seekImmediate to match mobile's contract exactly and + // guarantee the MediaController moves regardless of any future deadband. + decision.seekToMs?.let { ms -> viewModel.seekImmediate(ms / 1000.0) } + // Idempotent pause: set the desired state directly (NOT a toggle) so a + // duplicate command can't flip us the wrong way. + viewModel.setPaused(!decision.setPlaying) + } finally { + pendingExecutions.finish(pendingToken) } + + // The epoch/session-keyed readiness collector owns ready delivery and + // retries. Do not advance an untracked one-shot latch here. } // ---- User-initiated transport: route through the room instead of local apply ---- @@ -324,42 +371,81 @@ class TvRoomSyncController( * the defensive backstop. */ fun onUserPlayPause() { - if (tvRoomTransportGate(repository.roomSnapshot.value, TvTransportIntent.PlayPause) != TransportGate.Send) { + val authorization = repository.currentTransportAuthorization() ?: return + val snapshot = authorization.snapshot + if (tvRoomTransportGate(snapshot, TvTransportIntent.PlayPause) != TransportGate.Send) { return } val state = viewModel.uiState.value val willPause = !state.isPaused - scope.launch { - repository.transportRequest( + controllerScope.launch { + val delivered = repository.transportRequestForAuthorization( + authorization = authorization, + intent = RoomTransportIntent.PlayPause, action = if (willPause) TransportAction.Pause.wire else TransportAction.Play.wire, positionSeconds = state.position, isPaused = willPause, ) + if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") } } /** Route a local seek through the room. Guests never seek (gated). */ fun onUserSeek(positionSeconds: Double) { - if (tvRoomTransportGate(repository.roomSnapshot.value, TvTransportIntent.Seek) != TransportGate.Send) { + val authorization = repository.currentTransportAuthorization() ?: return + val snapshot = authorization.snapshot + if (tvRoomTransportGate(snapshot, TvTransportIntent.Seek) != TransportGate.Send) { return } val state = viewModel.uiState.value - scope.launch { - repository.transportRequest( + controllerScope.launch { + val delivered = repository.transportRequestForAuthorization( + authorization = authorization, + intent = RoomTransportIntent.Seek, action = TransportAction.Seek.wire, positionSeconds = positionSeconds, isPaused = state.isPaused, ) + if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") } } + /** Reconcile a deliberate external MediaSession play/pause through room authority. */ + fun onExternalPlayWhenReadyChanged(localPlayWhenReady: Boolean): Boolean? { + val authorization = repository.currentTransportAuthorization() + val snapshot = authorization?.snapshot + val decision = externalPlayPauseDecision(snapshot, localPlayWhenReady) ?: return null + if (authorization == null) return decision.restorePlayWhenReady + decision.requestIsPaused?.let { requestIsPaused -> + val state = viewModel.uiState.value + controllerScope.launch { + val delivered = repository.transportRequestForAuthorization( + authorization = authorization, + intent = RoomTransportIntent.PlayPause, + action = if (requestIsPaused) { + TransportAction.Pause.wire + } else { + TransportAction.Play.wire + }, + positionSeconds = state.position, + isPaused = requestIsPaused, + ) + if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") + } + } + return decision.restorePlayWhenReady + } + /** Leave the room. Host close tears the room down for everyone; both reset local state. */ fun leave(closeRoom: Boolean) { - scope.launch { - if (closeRoom) repository.closeRoom() - engine.reset() - repository.reset() - } + engine.reset() + roomSession.depart(closeRoom) + } + + /** Cancel this screen binding without ending the process-owned room lease. */ + fun dispose() { + engine.reset() + lifetime.dispose() } /** Wall-clock RFC3339 string for the ping `client_sent_at`. */ 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 new file mode 100644 index 000000000..91dbdc3dc --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudState.kt @@ -0,0 +1,104 @@ +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 + +internal data class TvSubtitleHudOption( + val stableId: String, + val identity: SubtitleIdentity, + val label: String, +) + +internal data class TvSubtitleHudRow( + val stableId: String, + val identity: SubtitleIdentity, + val label: String, + val checked: Boolean, + val applying: Boolean, + val focused: Boolean, +) { + val status: String? + get() = if (applying) "Applying…" else null +} + +internal data class TvSubtitleHudPresentation( + val rows: List, + val hudOpen: Boolean, + val focusedStableId: String?, + val focusTrapActive: Boolean, + val onSelect: (SubtitleIdentity) -> Unit = {}, + val onFocused: (String) -> Unit = {}, +) + +internal fun tvSubtitleOptionStableId(identity: SubtitleIdentity): String = + encodeSubtitleIdentityPreference(identity) + +internal fun buildTvSubtitleHudPresentation( + options: List, + committedIdentity: SubtitleIdentity, + pendingIdentity: SubtitleIdentity?, + hudOpen: Boolean, + focusedStableId: String?, + onSelect: (SubtitleIdentity) -> Unit = {}, + onFocused: (String) -> Unit = {}, +): TvSubtitleHudPresentation { + val optionIds = options.mapTo(mutableSetOf(), TvSubtitleHudOption::stableId) + val resolvedFocus = focusedStableId + ?.takeIf(optionIds::contains) + ?: options.firstOrNull { it.identity == committedIdentity }?.stableId + ?: options.firstOrNull()?.stableId + return TvSubtitleHudPresentation( + rows = options.map { option -> + TvSubtitleHudRow( + stableId = option.stableId, + identity = option.identity, + label = option.label, + checked = option.identity == committedIdentity, + applying = option.identity == pendingIdentity && + pendingIdentity != committedIdentity, + focused = option.stableId == resolvedFocus, + ) + }, + hudOpen = hudOpen, + focusedStableId = resolvedFocus, + focusTrapActive = hudOpen, + onSelect = onSelect, + onFocused = onFocused, + ) +} + +internal fun buildTvSubtitleHudOptions( + subtitleUrls: List, + subtitleTracks: List, +): List { + val mountedTrackIndexes = resolvedMountedSubtitleTrackIndexes(subtitleTracks, subtitleUrls) + val playerOnlyTracks = if (subtitleUrls.isEmpty()) { + subtitleTracks + } else { + subtitleTracks.filterNot { it.index in mountedTrackIndexes } + } + return buildList { + add(tvSubtitleHudOption(SubtitleIdentity.Off, "Off")) + subtitleUrls.forEachIndexed { position, row -> + add(tvSubtitleHudOption(tvSubtitleIdentity(row), subtitleChoiceLabel(row, position))) + } + playerOnlyTracks.forEachIndexed { position, track -> + add( + tvSubtitleHudOption( + tvSubtitleIdentity(track), + track.displayLabel.ifBlank { "Track ${position + 1}" }, + ), + ) + } + }.distinctBy(TvSubtitleHudOption::stableId) +} + +private fun tvSubtitleHudOption( + identity: SubtitleIdentity, + label: String, +): TvSubtitleHudOption = TvSubtitleHudOption( + stableId = tvSubtitleOptionStableId(identity), + identity = identity, + label = label, +) 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 new file mode 100644 index 000000000..2636ff433 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleIdentity.kt @@ -0,0 +1,96 @@ +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.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 + +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) + } + + 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) + } + } + + 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 tvSubtitleIdentity(track: PlayerTrackEntry): SubtitleIdentity = + SubtitleIdentity.LocalMedia3( + SubtitleMediaIdentity( + trackId = track.trackId, + label = track.displayLabel.ifBlank { track.label }, + language = canonicalSubtitleLanguage(track.language), + codecFamily = canonicalSubtitleCodecFamily(track.codecOrMime), + forced = track.isForced, + hearingImpaired = track.isHearingImpaired, + ), + ) 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 new file mode 100644 index 000000000..0672b1ea6 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRemountReselection.kt @@ -0,0 +1,266 @@ +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.SubtitleIdentity + +/** + * Who asked for a subtitle mount, 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. + * + * Higher ordinal wins. + */ +internal enum class TvSubtitleMountPriority { + /** Automatic language/forced heuristics; may never override a real choice. */ + Auto, + + /** Persisted preference, detail-page pick, transport remount restore. */ + Restore, + + /** An explicit in-flight user selection. Outranks everything. */ + UserTransaction, +} + +internal data class TvSubtitleRemountOwner( + val identity: SubtitleIdentity, + val generation: Long, + val priority: TvSubtitleMountPriority = TvSubtitleMountPriority.UserTransaction, +) + +internal class TvSubtitleSnapshotSettlementTracker { + private var previousKey: String? = null + + fun observe(tracks: List): Boolean { + val key = tracks + .takeIf(List::isNotEmpty) + ?.joinToString("|") { + "${it.index}:${it.trackId}:${it.label}:${it.language}:${it.codecOrMime}:${it.isSelected}" + } + val settled = key != null && key == previousKey + previousKey = key + return settled + } + + fun reset() { + previousKey = null + } +} + +internal enum class TvSubtitleRemountFailure { + Missing, + Ambiguous, +} + +internal sealed interface TvSubtitleRemountEvent { + val owner: TvSubtitleRemountOwner + + data class Select( + override val owner: TvSubtitleRemountOwner, + val trackIndex: Int, + ) : TvSubtitleRemountEvent + + data class Failed( + override val owner: TvSubtitleRemountOwner, + val reason: TvSubtitleRemountFailure, + ) : TvSubtitleRemountEvent +} + +internal class SubtitleRemountReselection( + private val maxMeaningfulSnapshots: Int = 3, +) { + private var pendingOwner: TvSubtitleRemountOwner? = null + private val meaningfulSnapshotKeys = linkedSetOf() + + /** + * The owner whose match has been emitted but not yet acknowledged. + * + * consume() used to clear() the instant it matched, leaving the latch + * unowned while the request travelled to the screen — and a lower-authority + * arm landing in that window silently took over the mount. Authority has to + * survive until the mount is acknowledged, so a resolved owner keeps + * defending its claim. + * + * Released by [acknowledgeResolved] on mount success/failure, and by + * [releaseResolved] when the acknowledgement can no longer arrive (backend + * swap, mount deadline) — without which a dropped acknowledgement would + * wedge subtitle mounting for the rest of the session. + */ + private var resolvedOwner: TvSubtitleRemountOwner? = null + + val hasPendingOwner: Boolean + get() = pendingOwner != null + + fun requiresRemount(identity: SubtitleIdentity): Boolean = when (identity) { + SubtitleIdentity.Off, + is SubtitleIdentity.ServerSidecar, + is SubtitleIdentity.Embedded, + is SubtitleIdentity.Downloaded, + is SubtitleIdentity.LocalMedia3, + -> true + is SubtitleIdentity.ServerBurnIn -> false + } + + /** Priority defending the mount: a pending owner, or one awaiting its ack. */ + val pendingPriority: TvSubtitleMountPriority? + get() = pendingOwner?.priority ?: resolvedOwner?.priority + + /** Clears the resolved claim once its mount has been acknowledged. */ + fun acknowledgeResolved(generation: Long) { + if (resolvedOwner?.generation == generation) resolvedOwner = null + } + + /** + * Drops the resolved claim when its acknowledgement can no longer arrive. + * + * The acknowledgement travels over a replay-0 shared flow collected inside + * a backend-scoped effect, so a backend swap — an auto-advance or version + * switch — tears the collector down and the emission is lost. Without this + * release the claim would defend a mount that can never be confirmed. + */ + fun releaseResolved() { + resolvedOwner = null + } + + fun arm( + identity: SubtitleIdentity, + generation: Long, + priority: TvSubtitleMountPriority = TvSubtitleMountPriority.UserTransaction, + ) { + // A lower-authority source must not evict a mount the user asked for. + // That collision is what turned an applied subtitle back off: a rollback + // armed the pre-transaction identity (typically Off) over the selection + // that had just been applied. + // Equal authority always replaces — a user's newest pick must win over + // their previous one, and a replan legitimately re-arms the identity it + // is rebasing. Only STRICTLY lower authority is refused, which is what + // stops a rollback-armed Off (Restore) from evicting a selection the + // user is applying (UserTransaction). + val holder = pendingOwner ?: resolvedOwner + val blocked = holder != null && holder.priority > priority + if (blocked) { + SubDiag.log("REMOUNT arm IGNORED $identity prio=$priority held=${holder?.priority}") + return + } + SubDiag.log("REMOUNT arm $identity gen=$generation prio=$priority") + pendingOwner = if (requiresRemount(identity)) { + TvSubtitleRemountOwner(identity, generation, priority) + } else { + null + } + meaningfulSnapshotKeys.clear() + } + + fun consume( + subtitleTracks: List, + snapshotKey: String?, + settled: Boolean, + ): TvSubtitleRemountEvent? { + val owner = pendingOwner ?: return null + if (owner.identity == SubtitleIdentity.Off) { + // Disabling the text renderer needs no track to exist, so this + // deliberately resolves without inspecting the snapshot. A stale + // Off owner reaching here at all is an OWNERSHIP failure, fixed by + // arming rollback-derived identities below user authority — not by + // adding evidence checks here, which would break a legitimate Off + // applied while the stream is still publishing. + clear() + resolvedOwner = owner + return TvSubtitleRemountEvent.Select(owner, trackIndex = -1) + } + + val mounted = subtitleTracks.map(PlayerTrackEntry::toMountedTvSubtitleTrack) + val exactTrackId = owner.identity.exactTvMountTrackId() + val matchIndex = if (exactTrackId != null) { + // 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 + // 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) } + .minByOrNull { it.index } + ?.index + } else { + resolveMountedSubtitle(identity = owner.identity, tracks = mounted)?.track?.index + } + SubDiag.log("REMOUNT consume id=${owner.identity} exact=$exactTrackId mounted=${mounted.map { it.trackId }} settled=$settled match=$matchIndex") + if (matchIndex != null) { + clear() + resolvedOwner = owner + return TvSubtitleRemountEvent.Select(owner, matchIndex) + } + + val meaningfulKey = snapshotKey?.takeIf { subtitleTracks.isNotEmpty() } + if (meaningfulKey != null) meaningfulSnapshotKeys += meaningfulKey + // An empty track list is not evidence that the wanted track is missing: + // a replanned stream publishes its tracks a moment after the player + // reports READY. Concluding Missing here clears the pending owner, so + // the real tracks arrive with nobody left to match them. + if (subtitleTracks.isEmpty()) return null + if (!settled && meaningfulSnapshotKeys.size < maxMeaningfulSnapshots) return null + + clear() + return TvSubtitleRemountEvent.Failed( + owner = owner, + reason = if (owner.identity.hasAmbiguousTvLabel(subtitleTracks)) { + TvSubtitleRemountFailure.Ambiguous + } else { + TvSubtitleRemountFailure.Missing + }, + ) + } + + fun clear() { + pendingOwner = null + meaningfulSnapshotKeys.clear() + } +} + +private fun SubtitleIdentity.exactTvMountTrackId(): String? = when (this) { + is SubtitleIdentity.ServerSidecar -> subtitleArtifactTrackId(serverIndex) + is SubtitleIdentity.Downloaded -> downloadedSubtitleArtifactTrackId(downloadId) + is SubtitleIdentity.Embedded -> media.trackId?.trim()?.takeIf(String::isNotEmpty) + is SubtitleIdentity.LocalMedia3 -> media.trackId?.trim()?.takeIf(String::isNotEmpty) + SubtitleIdentity.Off, + is SubtitleIdentity.ServerBurnIn, + -> null +} + +internal fun PlayerTrackEntry.toMountedTvSubtitleTrack(): MountedSubtitleTrack = + MountedSubtitleTrack( + index = index, + trackId = trackId, + label = label, + language = language, + codec = codecOrMime, + forced = isForced, + hearingImpaired = isHearingImpaired, + ) + +private fun SubtitleIdentity.hasAmbiguousTvLabel(tracks: List): Boolean { + val label = when (this) { + is SubtitleIdentity.ServerSidecar -> media?.label + is SubtitleIdentity.ServerBurnIn -> media?.label + is SubtitleIdentity.Embedded -> media.label + is SubtitleIdentity.Downloaded -> media.label + is SubtitleIdentity.LocalMedia3 -> media.label + SubtitleIdentity.Off -> null + }?.trim()?.lowercase() ?: return false + return tracks.count { + it.label.trim().lowercase() == label || + it.displayLabel.trim().lowercase() == label + } > 1 +} 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 new file mode 100644 index 000000000..8d63eb3cf --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt @@ -0,0 +1,2537 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.prairieserver.prairie.common.player.PlaybackSessionManager +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 +import org.prairieserver.prairie.model.playback.ClientPlaybackContext +import org.prairieserver.prairie.model.playback.CommittedSubtitle +import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 +import org.prairieserver.prairie.model.playback.PendingSubtitle +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SelectSubtitle +import org.prairieserver.prairie.model.playback.StagedSubtitleCandidate +import org.prairieserver.prairie.model.playback.StagedSubtitleFailed +import org.prairieserver.prairie.model.playback.StagedSubtitleValidated +import org.prairieserver.prairie.model.playback.SubtitleContentReset +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.UpdateQualityPreference +import org.prairieserver.prairie.model.playback.rebaseDownloadedSubtitleUrl +import org.prairieserver.prairie.model.playback.reduceSubtitleTransition +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.port.PlaybackWriteScope + +internal data class TvSubtitlePlaybackContext( + val contentId: String, + val mediaFileId: Int, + val versionId: String, + val sessionId: String?, + val positionSeconds: Double, + val audioTrackIndex: Int?, + val qualityPreference: String?, + val subtitleTracks: List, + val audioTracks: List = emptyList(), + val outputRouteGeneration: Long = 0L, + val capabilities: ClientCodecCapabilities = ClientCodecCapabilities(), + val clientPlaybackContext: ClientPlaybackContext = ClientPlaybackContext( + formFactor = "tv", + appVersion = "unknown", + ), + val writeScope: PlaybackWriteScope? = null, +) + +private fun TvSubtitlePlaybackContext.withLatestPlanningEvidence( + latest: TvSubtitlePlaybackContext, +): TvSubtitlePlaybackContext = copy( + positionSeconds = latest.positionSeconds, + outputRouteGeneration = latest.outputRouteGeneration, + capabilities = latest.capabilities, + clientPlaybackContext = latest.clientPlaybackContext, +) + +private fun replayValidatedIntent( + rollbackState: SubtitleTransitionState, + requested: PendingSubtitle, +): SubtitleTransitionState = rollbackState.copy( + pending = requested.copy(generation = rollbackState.nextGeneration), + nextGeneration = rollbackState.nextGeneration + 1, +) + +internal data class TvSubtitleStageRequest( + val generation: Long, + val contentId: String, + val mediaFileId: Int, + val versionId: String, + val sessionId: String, + val positionSeconds: Double, + val audioTrackIndex: Int?, + val qualityPreference: String?, + val subtitleTrackIndex: Int, + val outputRouteGeneration: Long = 0L, + val classification: String = "subtitle_track_changed", + val capabilities: ClientCodecCapabilities, + val clientPlaybackContext: ClientPlaybackContext, +) + +internal data class TvSubtitleManagerStageInput( + val capabilities: ClientCodecCapabilities, + val clientPlaybackContext: ClientPlaybackContext, + val outputRouteGeneration: Long, +) + +internal fun TvSubtitleStageRequest.toManagerStageInput(): ApiResult { + if (clientPlaybackContext.output.outputRouteGeneration != outputRouteGeneration) { + return ApiResult.Error( + code = 409, + error = "stale_output_route_context", + message = "The playback context does not match the requested output route.", + ) + } + return ApiResult.Success( + TvSubtitleManagerStageInput( + capabilities = capabilities, + clientPlaybackContext = clientPlaybackContext, + outputRouteGeneration = outputRouteGeneration, + ), + ) +} + +internal data class TvStagedSubtitleCandidate( + val id: String, + val sessionId: String, + val selectedAudioIndex: Int?, + val selectedSubtitleIndex: Int?, + val subtitleMode: PlaybackSubtitleModeV3, + val hasSidecar: Boolean, + val subtitleTracks: List, + val qualityPreference: String? = null, + val outputRouteGeneration: Long = 0L, + internal val managerHandle: StagedVideoReplan? = null, +) + +internal data class TvSubtitleCommittedPlayback( + val sessionId: String, + val subtitleTracks: List, + val ready: VideoSessionStartV3.Ready? = null, + val outputRouteGeneration: Long = 0L, +) + +internal interface TvSubtitleStagedReplanPort { + suspend fun stage(request: TvSubtitleStageRequest): ApiResult + + suspend fun commit( + candidate: TvStagedSubtitleCandidate, + ): ApiResult + + suspend fun discard(candidate: TvStagedSubtitleCandidate) + + suspend fun confirmCommitted(playback: TvSubtitleCommittedPlayback) = Unit + + suspend fun abandonCommitted(playback: TvSubtitleCommittedPlayback) +} + +internal interface TvSubtitlePersistencePort { + suspend fun persist( + committed: CommittedSubtitle, + context: TvSubtitlePlaybackContext, + ): Boolean +} + +/** + * Process-lifetime owner for bounded final preference writes. Keeping this + * scope outside each adapter prevents one unbounded SupervisorJob per player. + */ +private object TvSubtitleDurablePersistenceOwner { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) +} + +/** Serial process owner for publication settlement; survives ViewModel scope cancellation. */ +internal object TvSubtitleSettlementOwner { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) +} + +internal class TvSubtitleDurablePersistenceReservation internal constructor( + internal val ticket: PlaybackTrackSelectionWriteCoordinator.Ticket, + internal val context: TvSubtitlePlaybackContext, +) + +internal data class TvSubtitleTransactionSnapshot( + val transition: SubtitleTransitionState, + val pendingIdentity: SubtitleIdentity? = transition.pending?.identity, + val localMountIdentity: SubtitleIdentity? = null, + val failureMessage: String? = null, + val committedOutputRouteGeneration: Long = 0L, + val desiredOutputRouteGeneration: Long = committedOutputRouteGeneration, + val subtitleTracks: List = emptyList(), + val subtitleRefreshNonce: Long = 0L, +) { + val committedIdentity: SubtitleIdentity + get() = transition.committed.identity + + val subtitleApplying: Boolean + get() = pendingIdentity != null +} + +internal data class TvSubtitleRefreshOwner( + val contentGeneration: Long, + val contentId: String, + val mediaFileId: Int, + val versionId: String, + val sessionId: String?, + val refreshGeneration: Long, + val subtitleIntentGeneration: Long, + val source: TvSubtitleRefreshSource = TvSubtitleRefreshSource.Realtime, +) + +internal enum class TvSubtitleRefreshSource { + Download, + AiCompletion, + Realtime, +} + +internal enum class TvSubtitleAdoptionResult { + Adopted, + Superseded, +} + +internal class TvSubtitlePlaybackAdoption internal constructor( + val playback: TvSubtitleCommittedPlayback, + val committed: CommittedSubtitle, + private val currentOwner: () -> Boolean, + private val currentPendingIdentity: () -> SubtitleIdentity?, +) { + fun isCurrent(): Boolean = currentOwner() + + fun pendingIdentity(): SubtitleIdentity? = + currentPendingIdentity().takeIf { isCurrent() } +} + +/** + * Tv execution adapter for the shared subtitle reducer. + * + * One conflated worker serializes staged server requests. A newer intent does + * not cancel an in-flight HTTP request; its eventual candidate is discarded + * and only the newest queued intent is staged from the still-committed session. + */ +internal class TvSubtitleTransactionAdapter( + private val scope: CoroutineScope, + private val stagedPort: TvSubtitleStagedReplanPort, + private val persistencePort: TvSubtitlePersistencePort, + private val durablePersistenceScope: CoroutineScope = + TvSubtitleDurablePersistenceOwner.scope, + private val persistenceCoordinator: PlaybackTrackSelectionWriteCoordinator = + PlaybackTrackSelectionWriteCoordinator.Process, + private val settlementScope: CoroutineScope = scope, + private val onSnapshotChanged: (TvSubtitleTransactionSnapshot) -> Unit = {}, + private val onCommittedPlayback: suspend ( + TvSubtitlePlaybackAdoption, + ) -> TvSubtitleAdoptionResult = { TvSubtitleAdoptionResult.Adopted }, + private val onCommittedPlaybackConfirmed: suspend ( + TvSubtitleCommittedPlayback, + ) -> Boolean = { true }, + private val onCommittedPlaybackRollback: suspend ( + TvSubtitleCommittedPlayback, + Boolean, + ) -> Boolean = { _, _ -> true }, + private val onCommittedPlaybackFailure: suspend (String) -> Unit = {}, + /** + * Whether the player currently exposes any text track to mount against. + * The mount deadline must not run while the answer is "none": a freshly + * replanned stream reports READY seconds before publishing its tracks. + */ + private val hasMountableTracks: () -> Boolean = { true }, + /** + * Whether THIS identity can be satisfied by a text track the player already + * exposes. Catalog-only rows (embedded tracks on a remuxed or transcoded + * route) carry a blank URL and never become Media3 tracks, so committing + * them locally could only ever end at the mount deadline and roll back. + * Answering false sends the pick down the staged-replan path instead, which + * is what asks the server to materialise the artifact. + */ + private val isLocallyMountable: (SubtitleIdentity) -> Boolean = { true }, +) { + private data class PendingLocalSelection( + val generation: Long, + val identity: SubtitleIdentity, + val proposedState: SubtitleTransitionState, + val context: TvSubtitlePlaybackContext, + val rollbackState: SubtitleTransitionState, + val rollbackContext: TvSubtitlePlaybackContext, + val rollbackOutputRouteGeneration: Long, + val replayStateAfterRollback: SubtitleTransitionState? = null, + val committedPlayback: TvSubtitleCommittedPlayback? = null, + val persistOnSuccess: Boolean = true, + val mountedBeforeAdoption: Boolean = false, + val rollbackIncludesLifecycle: Boolean = true, + val exposesMountBoundary: Boolean = true, + ) + + private enum class PublicationSettlementPhase { + AwaitingCommit, + Confirming, + RollingBack, + Compensating, + } + + private data class PublicationSettlement( + var ownerGeneration: Long, + val completion: CompletableDeferred, + var phase: PublicationSettlementPhase, + /** + * Identity whose mount was confirmed while this settlement was open. + * + * A mount reported during a settlement used to be dropped on the floor, + * so a settlement could go on rolling back a publication whose mount had + * in fact succeeded — the proof arrived and was thrown away. + */ + var mountedIdentity: SubtitleIdentity? = null, + ) + + private data class PendingLocalRestore( + val generation: Long, + val identity: SubtitleIdentity, + val persistence: PersistenceRequest? = null, + ) + + private data class PendingFreshRestore( + val generation: Long, + val identity: SubtitleIdentity, + val priorState: SubtitleTransitionState, + ) + + private data class PersistenceRequest( + val ticket: PlaybackTrackSelectionWriteCoordinator.Ticket, + val committed: CommittedSubtitle, + val context: TvSubtitlePlaybackContext, + val completion: CompletableDeferred? = null, + ) + + private val stagedRequests = Channel( + capacity = Channel.CONFLATED, + ) + private val persistenceRequests = Channel(capacity = Channel.UNLIMITED) + + private var transition = SubtitleTransitionState.committed(SubtitleIdentity.Off) + private var context: TvSubtitlePlaybackContext? = null + private var contentGeneration = 0L + private var refreshGeneration = 0L + private var subtitleIntentGeneration = 0L + private var failureMessage: String? = null + private var pendingLocalSelection: PendingLocalSelection? = null + private var pendingLocalRestore: PendingLocalRestore? = null + private var pendingFreshRestore: PendingFreshRestore? = null + private var localMountGeneration = 0L + private var localMountTimeout: Job? = null + private val queuedMutations = mutableListOf() + private var commitInFlight = false + private var resetDuringCommit = false + private var adoptionGeneration = 0L + private var committedOutputRouteGeneration = 0L + private var desiredOutputRouteGeneration = 0L + private var subtitleRefreshNonce = 0L + private var compensatingGeneration: Long? = null + private var publicationSettlement: PublicationSettlement? = null + private val settlementInFlight: Boolean + get() = publicationSettlement != null + private val settlementCompletion: CompletableDeferred? + get() = publicationSettlement?.completion + private var discardQueuedAfterSettlement = false + private var resetAfterSettlement: Pair? = null + + /** A fresh-preference restore deferred because a publication was unsettled. */ + private data class DeferredFreshRestore( + val identity: SubtitleIdentity, + val migrationRequired: Boolean, + // The content it was captured for. The TV load path calls resetContent + // and restoreFreshPreference one line apart for the SAME new content, + // so "a reset is pending" is not evidence the restore is stale — only a + // reset to DIFFERENT content is. + val contentId: String, + val mediaFileId: Int, + ) + + private var pendingFreshRestoreAfterSettlement: DeferredFreshRestore? = null + + val snapshot: TvSubtitleTransactionSnapshot + get() { + val queuedIdentity = queuedPreviewState()?.pending?.identity + val publicationIdentity = pendingLocalSelection?.identity + val localIdentity = pendingLocalSelection + ?.takeIf { it.exposesMountBoundary } + ?.identity + ?: pendingLocalRestore?.identity + return TvSubtitleTransactionSnapshot( + transition = transition, + pendingIdentity = queuedIdentity + ?: publicationIdentity + ?: localIdentity + ?: transition.pending?.identity, + localMountIdentity = if (queuedIdentity == null) localIdentity else null, + failureMessage = failureMessage, + committedOutputRouteGeneration = committedOutputRouteGeneration, + desiredOutputRouteGeneration = desiredOutputRouteGeneration, + subtitleTracks = context?.subtitleTracks.orEmpty(), + subtitleRefreshNonce = subtitleRefreshNonce, + ) + } + + val hasActiveTransaction: Boolean + get() = settlementInFlight || + commitInFlight || + transition.pending != null || + pendingLocalSelection != null || + pendingLocalRestore != null || + queuedMutations.isNotEmpty() + + init { + scope.launch { + for (pending in stagedRequests) { + processStagedRequest(pending) + } + } + scope.launch { + var shutdownCause: CancellationException? = null + try { + for (request in persistenceRequests) { + try { + val success = writePersistenceRequest(request) + if (!success && request.completion == null) { + persistenceCoordinator.abandon(request.ticket) + } + request.completion?.complete(success) + } catch (cancellation: CancellationException) { + if (request.completion == null) { + persistenceCoordinator.abandon(request.ticket) + } + request.completion?.completeExceptionally(cancellation) + throw cancellation + } + } + } catch (cancellation: CancellationException) { + shutdownCause = cancellation + throw cancellation + } finally { + val cause = shutdownCause + ?: CancellationException("Subtitle persistence worker stopped.") + persistenceRequests.close(cause) + while (true) { + val queued = persistenceRequests.tryReceive().getOrNull() ?: break + if (queued.completion == null) { + persistenceCoordinator.abandon(queued.ticket) + } + queued.completion?.completeExceptionally(cause) + } + } + } + } + + fun resetContent( + context: TvSubtitlePlaybackContext, + committedIdentity: SubtitleIdentity, + ) { + SubDiag.trace("ADAPTER resetContent session=${context.sessionId} committed=$committedIdentity") + val unpublished = pendingLocalSelection + ?.takeIf { it.committedPlayback != null } + if (commitInFlight && unpublished == null) { + resetAfterSettlement = context to committedIdentity + discardQueuedAfterSettlement = true + queuedMutations.clear() + resetDuringCommit = true + beginAwaitingCommitSettlement() + return + } + if (unpublished != null || settlementInFlight) { + resetAfterSettlement = context to committedIdentity + discardQueuedAfterSettlement = true + queuedMutations.clear() + // resetContent is installing NEW content and its own UI, and the + // TV rollback path has already restored the predecessor UI before + // calling here. Restoring again would re-apply the predecessor's + // state over the incoming content and re-arm its identity. + if (unpublished != null) requestSupersessionSettlement(unpublished, restoreUi = false) + return + } + resetContentNow(context, committedIdentity) + } + + private fun resetContentNow( + context: TvSubtitlePlaybackContext, + committedIdentity: SubtitleIdentity, + ) { + resetAfterSettlement = null + discardQueuedAfterSettlement = false + if (commitInFlight) { + resetDuringCommit = true + queuedMutations.clear() + } + adoptionGeneration += 1 + contentGeneration += 1 + refreshGeneration += 1 + subtitleIntentGeneration += 1 + this.context = context + committedOutputRouteGeneration = context.outputRouteGeneration + desiredOutputRouteGeneration = context.outputRouteGeneration + subtitleRefreshNonce = 0L + invalidateLocalMount() + pendingFreshRestore = null + compensatingGeneration = null + transition = reduceSubtitleTransition( + transition, + SubtitleContentReset(committedIdentity), + ).state.copy( + committed = CommittedSubtitle( + identity = committedIdentity, + audioTrackIndex = context.audioTrackIndex, + qualityPreference = context.qualityPreference, + ), + ) + failureMessage = null + publish() + } + + fun replaceSession(sessionId: String, subtitleTracks: List? = null) { + val current = context ?: return + if (commitInFlight) { + resetDuringCommit = true + queuedMutations.clear() + } + adoptionGeneration += 1 + refreshGeneration += 1 + subtitleIntentGeneration += 1 + invalidateLocalMount() + pendingFreshRestore = null + context = current.copy( + sessionId = sessionId, + subtitleTracks = subtitleTracks ?: current.subtitleTracks, + ) + transition = reduceSubtitleTransition( + transition, + SubtitleContentReset(transition.committed.identity), + ).state.copy(committed = transition.committed) + failureMessage = null + publish() + } + + fun updatePlaybackContext(updated: TvSubtitlePlaybackContext) { + val current = context + // Content identity is the item and its file. versionId is + // ":", so its stable half is already covered by + // mediaFileId and the rest is the plan — which every replan changes by + // design. Treating a new plan as new content made the subtitle + // transaction destroy itself: its own replan produced a new planId, this + // check called it a content change, and resetContent tore down the + // in-flight selection microseconds after its mount had matched. + if (current == null || + current.contentId != updated.contentId || + current.mediaFileId != updated.mediaFileId + ) { + resetContent(updated, transition.committed.identity) + return + } + if (current.sessionId != updated.sessionId) { + if (commitInFlight) { + resetContent(updated, transition.committed.identity) + return + } + val publicationOwner = pendingLocalSelection + ?.takeIf { it.committedPlayback != null } + if (publicationOwner != null || publicationSettlement != null) { + val resetIdentity = when (publicationSettlement?.phase) { + PublicationSettlementPhase.Confirming -> + publicationOwner?.proposedState?.committed?.identity + PublicationSettlementPhase.AwaitingCommit, + PublicationSettlementPhase.RollingBack, + PublicationSettlementPhase.Compensating, + null, + -> publicationOwner?.rollbackState?.committed?.identity + } ?: transition.committed.identity + resetAfterSettlement = updated to resetIdentity + discardQueuedAfterSettlement = true + queuedMutations.clear() + if (publicationSettlement == null && publicationOwner != null) { + // Same as resetContent: this branch is adopting a new + // session and installs its own UI. + requestSupersessionSettlement(publicationOwner, restoreUi = false) + } + return + } + val nextSessionId = updated.sessionId + if (nextSessionId == null) { + if (commitInFlight) { + resetDuringCommit = true + queuedMutations.clear() + } + adoptionGeneration += 1 + contentGeneration += 1 + refreshGeneration += 1 + subtitleIntentGeneration += 1 + context = updated + invalidateLocalMount() + transition = reduceSubtitleTransition( + transition, + SubtitleContentReset(transition.committed.identity), + ).state.copy(committed = transition.committed) + failureMessage = null + publish() + } else { + replaceSession(nextSessionId, updated.subtitleTracks) + context = updated + } + return + } + pendingLocalSelection = pendingLocalSelection?.let { pending -> + pending.copy( + rollbackContext = pending.rollbackContext.withLatestPlanningEvidence(updated), + ) + } + context = updated + } + + fun select(identity: SubtitleIdentity) { + SubDiag.log("ADAPTER select $identity") + mutate(SelectSubtitle(identity), explicit = true) + } + + /** + * Restores a saved fresh-load preference without declaring it committed + * before both the server replan and the player backend have accepted it. + */ + fun restoreFreshPreference( + identity: SubtitleIdentity, + migrationRequired: Boolean = false, + ) { + val current = context ?: return + val priorState = transition + if (identity == transition.committed.identity) { + if (identity is SubtitleIdentity.ServerBurnIn) { + if (migrationRequired) persist(transition.committed, current) + } else { + // mutate() refuses to touch local state while a publication is + // unsettled; this branch reached beginLocalSelection directly, + // which nulls pendingLocalSelection via invalidateLocalMount. If + // that owner still held an unsettled committedPlayback the + // server publication was orphaned — never confirmed, never + // rolled back — and the in-flight settlement lost its owner. + // The TV load path calls this on the line after resetContent, + // i.e. exactly while such a settlement is open. + val unpublished = pendingLocalSelection + ?.takeIf { it.committedPlayback != null } + if (unpublished != null || settlementInFlight) { + pendingFreshRestoreAfterSettlement = DeferredFreshRestore( + identity = identity, + migrationRequired = migrationRequired, + contentId = current.contentId, + mediaFileId = current.mediaFileId, + ) + if (unpublished != null) { + requestSupersessionSettlement(unpublished, restoreUi = false) + } + return + } + beginLocalSelection( + identity = identity, + proposedState = transition, + selectionContext = current, + ) + } + return + } + + mutate(SelectSubtitle(identity), explicit = false) + val generation = transition.pending?.generation + if (generation != null) { + pendingFreshRestore = PendingFreshRestore( + generation = generation, + identity = identity, + priorState = priorState, + ) + } + } + + fun selectAudio(audioTrackIndex: Int?) { + mutate(UpdateAudioPreference(audioTrackIndex), explicit = true) + } + + fun selectQuality(qualityPreference: String?) { + mutate(UpdateQualityPreference(qualityPreference), explicit = true) + } + + fun updateOutputRouteGeneration(generation: Long) { + if (generation == desiredOutputRouteGeneration) return + desiredOutputRouteGeneration = generation + val identity = snapshot.pendingIdentity ?: transition.committed.identity + mutate(SelectSubtitle(identity), explicit = true) + } + + fun invalidate() { + val unpublished = pendingLocalSelection + ?.takeIf { it.committedPlayback != null } + if (commitInFlight && unpublished == null) { + discardQueuedAfterSettlement = true + queuedMutations.clear() + resetDuringCommit = true + beginAwaitingCommitSettlement() + return + } + if (unpublished != null || settlementInFlight) { + discardQueuedAfterSettlement = true + queuedMutations.clear() + if (unpublished != null) requestSupersessionSettlement(unpublished, restoreUi = true) + return + } + invalidateNow() + } + + suspend fun invalidateAndSettle(restoreUi: Boolean = true): Boolean { + discardQueuedAfterSettlement = true + queuedMutations.clear() + val unpublished = pendingLocalSelection + ?.takeIf { it.committedPlayback != null } + if (commitInFlight && unpublished == null) { + resetDuringCommit = true + return beginAwaitingCommitSettlement().completion.await() + } + val completion = when { + unpublished != null -> requestSupersessionSettlement(unpublished, restoreUi) + settlementInFlight -> settlementCompletion + else -> null + } + if (completion == null) { + invalidateNow() + return true + } + return completion.await() + } + + suspend fun invalidateAndAwaitSettlement(): Boolean = + invalidateAndSettle(restoreUi = true) + + fun invalidateAndSettleAsync( + restoreUi: Boolean = false, + afterSettlement: suspend () -> Unit, + ) { + settlementScope.launch { + runCatching { invalidateAndSettle(restoreUi) } + runCatching { afterSettlement() } + } + } + + private fun invalidateNow() { + resetAfterSettlement = null + discardQueuedAfterSettlement = false + adoptionGeneration += 1 + contentGeneration += 1 + refreshGeneration += 1 + subtitleIntentGeneration += 1 + desiredOutputRouteGeneration = committedOutputRouteGeneration + invalidateLocalMount() + pendingFreshRestore = null + queuedMutations.clear() + if (commitInFlight) resetDuringCommit = true + transition = reduceSubtitleTransition( + transition, + SubtitleContentReset(transition.committed.identity), + ).state.copy(committed = transition.committed) + failureMessage = null + publish() + } + + fun persistCommittedSelection() { + context?.let { persist(transition.committed, it) } + } + + suspend fun persistCommittedSelectionAndFlush(): Boolean { + val request = capturePersistenceRequest( + completion = CompletableDeferred(), + ) ?: return false + val primarySucceeded = try { + withTimeoutOrNull(PRIMARY_PERSISTENCE_TIMEOUT_MS) { + persistenceRequests.send(request) + requireNotNull(request.completion).await() + } ?: false + } catch (_: Exception) { + false + } + if (primarySucceeded) return true + val durableSucceeded = awaitBoundedDurablePersistence(request) + if (!durableSucceeded) persistenceCoordinator.abandon(request.ticket) + return durableSucceeded + } + + fun requestDurableFinalPersistence() { + val reservation = reserveDurableFinalPersistence() ?: return + requestDurableFinalPersistence(reservation) + } + + fun reserveDurableFinalPersistence(): TvSubtitleDurablePersistenceReservation? { + val committedContext = context ?: return null + val writeScope = committedContext.writeScope ?: return null + return TvSubtitleDurablePersistenceReservation( + ticket = persistenceCoordinator.capture( + scope = writeScope, + contentId = committedContext.contentId, + fileId = committedContext.mediaFileId, + ), + context = committedContext, + ) + } + + fun requestDurableFinalPersistence( + reservation: TvSubtitleDurablePersistenceReservation, + ) { + val request = PersistenceRequest( + ticket = reservation.ticket, + committed = transition.committed, + context = reservation.context, + ) + durablePersistenceScope.launch { + val success = withTimeoutOrNull(DURABLE_PERSISTENCE_TIMEOUT_MS) { + runCatching { writePersistenceRequest(request) }.getOrDefault(false) + } ?: false + if (!success) persistenceCoordinator.abandon(request.ticket) + } + } + + fun restoreCommittedLocalMount() { + val identity = transition.committed.identity + if (context?.sessionId != null && identity.requiresLocalMountConfirmation()) { + beginLocalRestore(identity) + } + } + + fun beginRefresh( + source: TvSubtitleRefreshSource = TvSubtitleRefreshSource.Realtime, + ): TvSubtitleRefreshOwner { + refreshGeneration += 1 + val current = requireNotNull(context) { + "Subtitle refresh cannot start before playback context is installed." + } + return TvSubtitleRefreshOwner( + contentGeneration = contentGeneration, + contentId = current.contentId, + mediaFileId = current.mediaFileId, + versionId = current.versionId, + sessionId = current.sessionId, + refreshGeneration = refreshGeneration, + subtitleIntentGeneration = subtitleIntentGeneration, + source = source, + ) + } + + fun ownsRefresh(owner: TvSubtitleRefreshOwner): Boolean { + val current = context ?: return false + return owner.contentGeneration == contentGeneration && + owner.contentId == current.contentId && + owner.mediaFileId == current.mediaFileId && + owner.versionId == current.versionId && + owner.sessionId == current.sessionId && + owner.refreshGeneration == refreshGeneration && + owner.subtitleIntentGeneration == subtitleIntentGeneration + } + + fun selectFromRefresh( + owner: TvSubtitleRefreshOwner, + identity: SubtitleIdentity, + ): Boolean { + if (!ownsRefresh(owner)) return false + mutate(SelectSubtitle(identity), explicit = false) + return true + } + + fun applyRefresh( + owner: TvSubtitleRefreshOwner, + subtitleTracks: List, + autoSelectDownloadId: Int?, + ): 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) + subtitleRefreshNonce += 1 + publish() + + val selectedRow = autoSelectDownloadId + ?.let { id -> rebased.filter { it.downloadId == id }.singleOrNull() } + if (selectedRow != null) { + tvDownloadedRefreshIdentity(selectedRow)?.let { identity -> + mutate(SelectSubtitle(identity), explicit = false) + } + } + return true + } + + fun completeRefreshFailure(owner: TvSubtitleRefreshOwner, message: String): Boolean { + if (!ownsRefresh(owner)) return false + refreshGeneration += 1 + return true + } + + fun cancelRefresh(owner: TvSubtitleRefreshOwner) { + if (ownsRefresh(owner)) refreshGeneration += 1 + } + + fun reportMountedSelection( + identity: SubtitleIdentity, + selected: Boolean, + snapshotKey: String?, + settled: Boolean = false, + ) { + SubDiag.log("REPORT mounted=$identity selected=$selected settled=$settled pendingLocal=${pendingLocalSelection?.identity}") + if (settlementInFlight) { + // Record it rather than discarding it: a settlement that is + // rolling back a publication whose mount has actually succeeded is + // deciding against the evidence. + if (selected) publicationSettlement?.mountedIdentity = identity + return + } + val pendingSelection = pendingLocalSelection + ?.takeIf { it.identity == identity && it.exposesMountBoundary } + if (pendingSelection != null) { + if (selected) { + if (pendingSelection.proposedState.pending != null) { + localMountTimeout?.cancel() + localMountTimeout = null + pendingLocalSelection = pendingSelection.copy(mountedBeforeAdoption = true) + failureMessage = null + publish() + } else if (pendingSelection.committedPlayback != null) { + confirmMountedCommittedSelection(pendingSelection) + } else { + transition = pendingSelection.proposedState + invalidateLocalMount() + failureMessage = null + publish() + if (pendingSelection.persistOnSuccess) { + persist(transition.committed, pendingSelection.context) + } + } + } else if (settled && !snapshotKey.isNullOrBlank()) { + failLocalMount(pendingSelection.generation) + } + return + } + + val pendingRestore = pendingLocalRestore?.takeIf { it.identity == identity } ?: return + if (selected) { + val persistence = pendingRestore.persistence + invalidateLocalMount() + failureMessage = null + publish() + persistence?.let { persist(it.committed, it.context) } + } else if (settled && !snapshotKey.isNullOrBlank()) { + failLocalMount(pendingRestore.generation) + } + } + + private fun mutate(event: SubtitleTransitionEvent, explicit: Boolean) { + if (explicit) refreshGeneration += 1 + subtitleIntentGeneration += 1 + failureMessage = null + if (explicit && settlementInFlight && resetAfterSettlement == null) { + discardQueuedAfterSettlement = false + } + + val unpublished = pendingLocalSelection + ?.takeIf { it.committedPlayback != null } + if (unpublished != null || settlementInFlight) { + queuedMutations += event + if (unpublished != null) { + requestSupersessionSettlement(unpublished, restoreUi = true) + } + publish() + return + } + + if (commitInFlight) { + queuedMutations += event + publish() + return + } + + val localSelection = pendingLocalSelection + if (localSelection != null && event !is SelectSubtitle) { + applyMutationToPendingLocalSelection(localSelection, event) + return + } + + when (event) { + is SelectSubtitle -> applySelection(event.identity) + else -> applyPreferenceMutation(event) + } + } + + private fun applyMutationToPendingLocalSelection( + pendingSelection: PendingLocalSelection, + event: SubtitleTransitionEvent, + ) { + val updated = reduceSubtitleTransition(pendingSelection.proposedState, event).state + pendingLocalSelection = pendingSelection.copy(proposedState = updated) + transition = transition.copy( + pending = updated.pending, + nextGeneration = updated.nextGeneration, + ) + publish() + transition.pending?.let(stagedRequests::trySend) + } + + private fun applyPreferenceMutation(event: SubtitleTransitionEvent) { + val updated = reduceSubtitleTransition(transition, event) + val current = context + if (current?.sessionId == null) { + val pending = updated.state.pending + val committedState = if (pending == null) { + updated.state + } else { + reduceSubtitleTransition( + updated.state, + StagedSubtitleValidated( + generation = pending.generation, + candidate = StagedSubtitleCandidate("tv-preplay"), + ), + ).state + } + transition = committedState + invalidateLocalMount() + publish() + current?.let { persist(committedState.committed, it) } + return + } + + invalidateLocalMount() + transition = updated.state + publish() + transition.pending?.let(stagedRequests::trySend) + } + + /** + * Commits a locally-mountable identity — an embedded (muxed) track the + * stream already carries — without a server round trip. + * + * Replanning to reach a track that is already in the stream tears it down + * and restarts it: black flash, rebuffer, re-seek, for the same picture. A + * pending is force-validated against the synthetic `tv-local` candidate so + * the state machine reaches committed with no server involved. + * + * Returns false — meaning "you must replan after all" — when the pending + * also carries an audio, quality or output-route preference. Those can only + * be applied by the server, and short-circuiting it would drop them + * silently. Both callers must respect that: this is shared precisely + * because the two paths had drifted, and a subtitle press replayed out of + * `queuedMutations` was replanning where a direct press did not. + */ + private fun commitLocallyMountableSelection( + identity: SubtitleIdentity, + state: SubtitleTransitionState, + ): Boolean { + val selectionContext = context ?: return false + if (!isLocallyMountable(identity)) { + SubDiag.log("commitLocal SKIP not mountable id=$identity") + return false + } + val pending = state.pending + if ( + pending != null && + ( + pending.audioPreferenceSpecified || + pending.qualityPreferenceSpecified || + desiredOutputRouteGeneration != committedOutputRouteGeneration + ) + ) { + return false + } + val proposedState = if (pending == null) { + state + } else { + reduceSubtitleTransition( + state, + StagedSubtitleValidated( + generation = pending.generation, + candidate = StagedSubtitleCandidate("tv-local"), + ), + ).state + } + beginLocalSelection( + identity = identity, + proposedState = proposedState, + selectionContext = selectionContext, + ) + return true + } + + private fun applySelection(identity: SubtitleIdentity) { + val selected = reduceSubtitleTransition(transition, SelectSubtitle(identity)) + val current = context + val commitsSynchronously = current?.sessionId == null + + if (commitsSynchronously) { + val committedState = if (selected.state.pending == null) { + selected.state + } else { + reduceSubtitleTransition( + selected.state, + StagedSubtitleValidated( + generation = selected.state.pending!!.generation, + candidate = StagedSubtitleCandidate("tv-local"), + ), + ).state + } + transition = committedState + invalidateLocalMount() + publish() + current?.let { persist(committedState.committed, it) } + return + } + + if ( + identity.isClientOwnedSubtitle() && + selected.state.pending != null + ) { + invalidateLocalMount() + transition = selected.state + publish() + stagedRequests.trySend(requireNotNull(transition.pending)) + return + } + + if ( + identity.requiresLocalMountConfirmation() && + commitLocallyMountableSelection(identity, selected.state) + ) { + return + } + + invalidateLocalMount() + transition = selected.state + publish() + transition.pending?.let(stagedRequests::trySend) + } + + private suspend fun processStagedRequest( + requested: org.prairieserver.prairie.model.playback.PendingSubtitle, + ) { + val requestContext = context ?: return + val requestSessionId = requestContext.sessionId ?: return + if (transition.pending?.generation != requested.generation) return + + val request = TvSubtitleStageRequest( + generation = requested.generation, + contentId = requestContext.contentId, + mediaFileId = requestContext.mediaFileId, + versionId = requestContext.versionId, + sessionId = requestSessionId, + positionSeconds = requestContext.positionSeconds, + audioTrackIndex = requested.audioTrackIndex, + qualityPreference = requested.qualityPreference, + subtitleTrackIndex = requested.identity.serverTrackIndex(), + outputRouteGeneration = desiredOutputRouteGeneration, + capabilities = requestContext.capabilities, + clientPlaybackContext = requestContext.clientPlaybackContext, + classification = when { + desiredOutputRouteGeneration != committedOutputRouteGeneration -> + "output_route_changed" + requested.qualityPreference != transition.committed.qualityPreference -> + "quality_changed" + requested.audioTrackIndex != transition.committed.audioTrackIndex -> + "audio_track_changed" + else -> "subtitle_track_changed" + }, + ) + val staged = try { + stagedPort.stage(request) + } catch (cancellation: CancellationException) { + if (!currentCoroutineContext().isActive) throw cancellation + ApiResult.NetworkError(cancellation) + } catch (error: Exception) { + ApiResult.NetworkError(error) + } + + when (staged) { + is ApiResult.Success -> processCandidate( + requested = requested, + stagingContext = requestContext, + request = request, + candidate = staged.data, + ) + is ApiResult.Error -> fail(requested.generation, staged.message) + is ApiResult.NetworkError -> fail( + requested.generation, + staged.exception.message ?: "Subtitle selection failed.", + ) + } + } + + private suspend fun processCandidate( + requested: org.prairieserver.prairie.model.playback.PendingSubtitle, + stagingContext: TvSubtitlePlaybackContext, + request: TvSubtitleStageRequest, + candidate: TvStagedSubtitleCandidate, + ) { + val validationFailure = candidate.validationFailure( + requested = requested, + expectedSubtitleIndex = request.subtitleTrackIndex, + expectedOutputRouteGeneration = request.outputRouteGeneration, + ) + if (validationFailure != null) { + discardCandidateBestEffort(candidate) + fail(requested.generation, validationFailure) + return + } + + val validated = reduceSubtitleTransition( + transition, + StagedSubtitleValidated( + generation = requested.generation, + candidate = StagedSubtitleCandidate(candidate.id), + ), + ) + if (validated.state == transition) { + discardCandidateBestEffort(candidate) + return + } + + commitInFlight = true + val commitResult = withContext(NonCancellable) { + try { + stagedPort.commit(candidate) + } catch (cancellation: CancellationException) { + ApiResult.NetworkError(cancellation) + } catch (error: Exception) { + ApiResult.NetworkError(error) + } + } + + when (val committed = commitResult) { + is ApiResult.Success -> { + if (resetDuringCommit) { + val owner = installCommittedPublicationOwner( + requested = requested, + validatedState = validated.state, + playback = committed.data, + adoptionContext = stagingContext, + rollbackIncludesLifecycle = false, + exposesMountBoundary = false, + persistOnSuccess = false, + ) + val settlement = publicationSettlement + ?: beginPublicationSettlement( + owner = owner, + phase = PublicationSettlementPhase.RollingBack, + ) + settlement.ownerGeneration = owner.generation + settlement.phase = PublicationSettlementPhase.RollingBack + val rolledBack = abandonCommittedPlayback(committed.data) + if (!rolledBack) { + failureMessage = "Playback publication could not be rolled back." + publish() + finishSettlement(settlement, false) + return + } + restoreRollbackOwner(owner) + failureMessage = null + drainSettledPublication( + owner = owner, + confirmed = false, + compensating = false, + ) + finishSettlement(settlement, true) + return + } + + val adoptionContext = context ?: run { + abandonCommittedPlayback(committed.data) + commitInFlight = false + resetDuringCommit = false + return + } + val playback = committed.data.withRebasedDownloads(adoptionContext) + val ownerGeneration = adoptionGeneration + val adoption = TvSubtitlePlaybackAdoption( + playback = playback, + committed = validated.state.committed, + currentOwner = { + ownerGeneration == adoptionGeneration && + !resetDuringCommit + }, + currentPendingIdentity = { + queuedPreviewState()?.pending?.identity + }, + ) + val adoptionOutcome = withContext(NonCancellable) { + try { + if (!adoption.isCurrent()) { + AdoptionOutcome.Superseded + } else { + when (onCommittedPlayback(adoption)) { + TvSubtitleAdoptionResult.Adopted -> + if (adoption.isCurrent()) AdoptionOutcome.Adopted + else AdoptionOutcome.Superseded + TvSubtitleAdoptionResult.Superseded -> + AdoptionOutcome.Superseded + } + } + } catch (error: Exception) { + AdoptionOutcome.Failed(error) + } + } + when (adoptionOutcome) { + AdoptionOutcome.Adopted -> finishSuccessfulAdoption( + requested = requested, + requestedGeneration = requested.generation, + validatedState = validated.state, + playback = playback, + adoptionContext = adoptionContext, + ) + AdoptionOutcome.Superseded -> { + if (rollbackCommittedPlayback(playback, restoreUi = false)) { + finishSupersededAdoption() + } else { + retainFailedAdoptionPublication( + requested = requested, + validatedState = validated.state, + playback = playback, + adoptionContext = adoptionContext, + ) + } + } + is AdoptionOutcome.Failed -> { + val message = "Subtitle playback adoption failed." + if (rollbackCommittedPlayback(playback, restoreUi = false)) { + commitInFlight = false + finishFailedCommit(requested.generation, message) + } else { + retainFailedAdoptionPublication( + requested = requested, + validatedState = validated.state, + playback = playback, + adoptionContext = adoptionContext, + ) + } + withContext(NonCancellable) { + try { + onCommittedPlaybackFailure( + adoptionOutcome.error.message ?: message, + ) + } catch (_: Exception) { + // Recovery notification is best effort; the worker + // must remain alive after a committed-session fault. + } + } + } + } + } + is ApiResult.Error -> { + commitInFlight = false + if (finishAwaitingCommitWithoutPublication()) return + finishFailedCommit( + generation = requested.generation, + message = committed.message, + ) + } + is ApiResult.NetworkError -> { + commitInFlight = false + if (finishAwaitingCommitWithoutPublication()) return + finishFailedCommit( + generation = requested.generation, + message = committed.exception.message ?: "Subtitle selection failed.", + ) + } + } + } + + private fun retainFailedAdoptionPublication( + requested: PendingSubtitle, + validatedState: SubtitleTransitionState, + playback: TvSubtitleCommittedPlayback, + adoptionContext: TvSubtitlePlaybackContext, + ) { + val owner = installCommittedPublicationOwner( + requested = requested, + validatedState = validatedState, + playback = playback, + adoptionContext = adoptionContext, + rollbackIncludesLifecycle = true, + exposesMountBoundary = true, + persistOnSuccess = true, + ) + publicationSettlement + ?.takeIf { it.phase == PublicationSettlementPhase.AwaitingCommit } + ?.let { settlement -> + settlement.ownerGeneration = owner.generation + settlement.phase = PublicationSettlementPhase.RollingBack + finishSettlement(settlement, false) + } + failureMessage = "Playback publication could not be rolled back." + publish() + } + + private fun installCommittedPublicationOwner( + requested: PendingSubtitle, + validatedState: SubtitleTransitionState, + playback: TvSubtitleCommittedPlayback, + adoptionContext: TvSubtitlePlaybackContext, + rollbackIncludesLifecycle: Boolean, + exposesMountBoundary: Boolean, + persistOnSuccess: Boolean, + ): PendingLocalSelection { + val rollbackOutputRouteGeneration = committedOutputRouteGeneration + val priorState = transition.copy(pending = null) + val liveContext = context + ?.takeIf { + it.contentId == adoptionContext.contentId && + it.mediaFileId == adoptionContext.mediaFileId && + it.versionId == adoptionContext.versionId + } + ?: adoptionContext + val rollbackContext = adoptionContext.withLatestPlanningEvidence(liveContext) + context = liveContext.copy( + sessionId = playback.sessionId, + subtitleTracks = playback.subtitleTracks, + audioTrackIndex = validatedState.committed.audioTrackIndex, + qualityPreference = validatedState.committed.qualityPreference, + ) + transition = priorState + committedOutputRouteGeneration = playback.outputRouteGeneration + commitInFlight = false + resetDuringCommit = false + invalidateLocalMount() + val owner = PendingLocalSelection( + generation = localMountGeneration, + identity = validatedState.committed.identity, + proposedState = validatedState, + context = requireNotNull(context), + rollbackState = priorState, + rollbackContext = rollbackContext, + rollbackOutputRouteGeneration = rollbackOutputRouteGeneration, + replayStateAfterRollback = replayValidatedIntent( + rollbackState = priorState, + requested = requested, + ), + committedPlayback = playback, + persistOnSuccess = persistOnSuccess, + rollbackIncludesLifecycle = rollbackIncludesLifecycle, + exposesMountBoundary = exposesMountBoundary, + ) + pendingLocalSelection = owner + return owner + } + + private suspend fun finishSuccessfulAdoption( + requested: PendingSubtitle, + requestedGeneration: Long, + validatedState: SubtitleTransitionState, + playback: TvSubtitleCommittedPlayback, + adoptionContext: TvSubtitlePlaybackContext, + ) { + val rollbackOutputRouteGeneration = committedOutputRouteGeneration + val freshRestore = pendingFreshRestore + ?.takeIf { it.generation < validatedState.nextGeneration } + ?.takeIf { it.identity == validatedState.committed.identity } + val priorState = freshRestore?.priorState ?: transition.copy(pending = null) + transition = if (validatedState.committed.identity.requiresPlayerBoundaryConfirmation()) { + priorState + } else { + validatedState + } + committedOutputRouteGeneration = playback.outputRouteGeneration + if (queuedMutations.isEmpty()) { + desiredOutputRouteGeneration = playback.outputRouteGeneration + } + val liveContext = context + ?.takeIf { + it.contentId == adoptionContext.contentId && + it.mediaFileId == adoptionContext.mediaFileId && + it.versionId == adoptionContext.versionId + } + ?: adoptionContext + val rollbackContext = adoptionContext.withLatestPlanningEvidence(liveContext) + context = liveContext.copy( + sessionId = playback.sessionId, + subtitleTracks = playback.subtitleTracks, + audioTrackIndex = validatedState.committed.audioTrackIndex, + qualityPreference = validatedState.committed.qualityPreference, + ) + refreshGeneration += 1 + failureMessage = null + commitInFlight = false + resetDuringCommit = false + if (queuedMutations.isEmpty()) { + if (validatedState.committed.identity.requiresPlayerBoundaryConfirmation()) { + pendingFreshRestore = null + beginLocalSelection( + identity = validatedState.committed.identity, + proposedState = validatedState, + selectionContext = requireNotNull(context), + committedPlayback = playback, + persistOnSuccess = compensatingGeneration != requestedGeneration, + rollbackState = priorState, + rollbackContext = rollbackContext, + rollbackOutputRouteGeneration = rollbackOutputRouteGeneration, + replayStateAfterRollback = replayValidatedIntent( + rollbackState = priorState, + requested = requested, + ), + ) + } else { + pendingFreshRestore = null + transition = priorState + invalidateLocalMount() + val owner = PendingLocalSelection( + generation = localMountGeneration, + identity = validatedState.committed.identity, + proposedState = validatedState, + context = requireNotNull(context), + committedPlayback = playback, + persistOnSuccess = compensatingGeneration != requestedGeneration, + rollbackState = priorState, + rollbackContext = rollbackContext, + rollbackOutputRouteGeneration = rollbackOutputRouteGeneration, + replayStateAfterRollback = replayValidatedIntent( + rollbackState = priorState, + requested = requested, + ), + ) + pendingLocalSelection = owner + publish() + confirmMountedCommittedSelection(owner) + } + } else { + pendingFreshRestore = null + transition = priorState + invalidateLocalMount() + val owner = PendingLocalSelection( + generation = localMountGeneration, + identity = validatedState.committed.identity, + proposedState = validatedState, + context = requireNotNull(context), + rollbackState = priorState, + rollbackContext = rollbackContext, + rollbackOutputRouteGeneration = rollbackOutputRouteGeneration, + replayStateAfterRollback = replayValidatedIntent( + rollbackState = priorState, + requested = requested, + ), + committedPlayback = playback, + persistOnSuccess = false, + ) + pendingLocalSelection = owner + publish() + requestSupersessionSettlement(owner, restoreUi = true) + } + } + + private fun finishSupersededAdoption() { + commitInFlight = false + resetDuringCommit = false + if (finishAwaitingCommitWithoutPublication()) return + applyQueuedMutations() + } + + private fun finishAwaitingCommitWithoutPublication(): Boolean { + val settlement = publicationSettlement + ?.takeIf { it.phase == PublicationSettlementPhase.AwaitingCommit } + ?: return false + resetDuringCommit = false + val reset = resetAfterSettlement + resetAfterSettlement = null + val discard = discardQueuedAfterSettlement + discardQueuedAfterSettlement = false + when { + reset != null -> { + queuedMutations.clear() + resetContentNow(reset.first, reset.second) + } + discard -> { + queuedMutations.clear() + invalidateNow() + } + queuedMutations.isNotEmpty() -> applyQueuedMutations() + else -> publish() + } + finishSettlement(settlement, true) + return true + } + + private suspend fun abandonCommittedPlayback( + playback: TvSubtitleCommittedPlayback, + ): Boolean = withContext(NonCancellable) { + try { + stagedPort.abandonCommitted(playback) + true + } catch (_: Exception) { + // The manager owns authoritative cleanup; a cleanup transport + // failure must not kill the serialized transaction worker. + false + } + } + + + /** + * Re-points a committed Embedded identity at the artifact the server just + * materialised for it. + * + * A catalog-only embedded row is picked as `Embedded(n)`, but committing it + * makes the server produce a sidecar, and the row then comes back as a + * server artifact. Leaving the committed identity as `Embedded(n)` makes it + * match no row afterwards: the picker finds nothing checked and falls back + * to marking "Off" while that subtitle is plainly on screen, and a restore + * later has to re-derive the same thing. Adopt the row's own identity once + * the artifact exists. + */ + private fun SubtitleTransitionState.reconcileMaterialisedIdentity( + playback: TvSubtitleCommittedPlayback, + ): SubtitleTransitionState { + val committedIdentity = committed.identity as? SubtitleIdentity.Embedded ?: return this + val row = playback.subtitleTracks + .firstOrNull { it.index == committedIdentity.serverIndex && it.url.isNotBlank() } + ?: return this + val materialised = tvSubtitleIdentity(row) + if (materialised == committedIdentity) return this + SubDiag.log("RECONCILE committed $committedIdentity -> $materialised") + return copy(committed = committed.copy(identity = materialised)) + } + + private fun confirmMountedCommittedSelection(owner: PendingLocalSelection) { + if (publicationSettlement != null) return + val settlement = beginPublicationSettlement( + owner = owner, + phase = PublicationSettlementPhase.Confirming, + ) + settlementScope.launch { + val playback = owner.committedPlayback ?: return@launch + val confirmed = confirmCommittedPlayback(playback) + val currentOwner = currentSettlementOwner(settlement) + if (currentOwner == null) { + drainOrphanedSettlement(settlement) + finishSettlement(settlement, false) + return@launch + } + if (confirmed) { + transition = currentOwner.proposedState.reconcileMaterialisedIdentity(playback) + committedOutputRouteGeneration = playback.outputRouteGeneration + invalidateLocalMount() + failureMessage = null + drainSettledPublication( + owner = currentOwner, + confirmed = true, + compensating = false, + ) + finishSettlement(settlement, true) + return@launch + } + + // If the mount this settlement is compensating for actually + // succeeded while the settlement was open, there is nothing to + // compensate: rolling back would tear down a subtitle the user can + // see, and re-arm the predecessor identity over it. + if (settlement.mountedIdentity == owner.identity) { + SubDiag.log("SETTLE skip compensation, mount confirmed ${owner.identity}") + // Adopt it like a confirm. Returning here left the HUD stuck on + // "Applying", the choice unpersisted, and — worse — + // resetAfterSettlement undrained, so a later unrelated + // settlement picked it up and reset to dead content. Everything + // the confirmed branch does EXCEPT committedOutputRouteGeneration, + // which must follow an actual server confirm rather than a + // local mount observation. + val settledOwner = currentSettlementOwner(settlement) + if (settledOwner == null) { + drainOrphanedSettlement(settlement) + finishSettlement(settlement, true) + return@launch + } + transition = settledOwner.proposedState + invalidateLocalMount() + failureMessage = null + drainSettledPublication( + owner = settledOwner, + confirmed = true, + compensating = false, + ) + finishSettlement(settlement, true) + return@launch + } + settlement.phase = PublicationSettlementPhase.Compensating + if (!rollbackCommittedPlayback(playback, restoreUi = true)) { + failureMessage = "Playback publication could not be rolled back." + publish() + finishSettlement(settlement, false) + return@launch + } + val compensatedOwner = currentSettlementOwner(settlement) + if (compensatedOwner == null) { + drainOrphanedSettlement(settlement) + finishSettlement(settlement, false) + return@launch + } + restoreRollbackOwner(compensatedOwner) + failureMessage = "The selected subtitle could not be mounted." + drainSettledPublication( + owner = compensatedOwner, + confirmed = false, + compensating = true, + ) + finishSettlement(settlement, true) + } + } + + private fun requestSupersessionSettlement( + owner: PendingLocalSelection, + restoreUi: Boolean, + ): CompletableDeferred { + publicationSettlement?.let { return it.completion } + val settlement = beginPublicationSettlement( + owner = owner, + phase = PublicationSettlementPhase.RollingBack, + ) + settlementScope.launch { + val settled = owner.committedPlayback?.let { playback -> + if (owner.rollbackIncludesLifecycle) { + rollbackCommittedPlayback(playback, restoreUi) + } else { + abandonCommittedPlayback(playback) + } + } ?: true + val currentOwner = currentSettlementOwner(settlement) + if (currentOwner == null) { + drainOrphanedSettlement(settlement) + finishSettlement(settlement, settled) + return@launch + } + if (!settled) { + failureMessage = "Playback publication could not be rolled back." + publish() + finishSettlement(settlement, false) + return@launch + } + + restoreRollbackOwner(currentOwner) + failureMessage = null + drainSettledPublication( + owner = currentOwner, + confirmed = false, + compensating = false, + ) + finishSettlement(settlement, true) + } + return settlement.completion + } + + private fun requestCompensationSettlement( + owner: PendingLocalSelection, + ): CompletableDeferred { + publicationSettlement?.let { return it.completion } + val settlement = beginPublicationSettlement( + owner = owner, + phase = PublicationSettlementPhase.Compensating, + ) + settlementScope.launch { + val playback = owner.committedPlayback + val rolledBack = playback == null || + rollbackCommittedPlayback(playback, restoreUi = true) + val currentOwner = currentSettlementOwner(settlement) + if (currentOwner == null) { + drainOrphanedSettlement(settlement) + finishSettlement(settlement, rolledBack) + return@launch + } + if (!rolledBack) { + failureMessage = "Playback publication could not be rolled back." + publish() + finishSettlement(settlement, false) + return@launch + } + restoreRollbackOwner(currentOwner) + failureMessage = "The selected subtitle could not be mounted." + drainSettledPublication( + owner = currentOwner, + confirmed = false, + compensating = true, + ) + finishSettlement(settlement, true) + } + return settlement.completion + } + + private fun beginPublicationSettlement( + owner: PendingLocalSelection, + phase: PublicationSettlementPhase, + ): PublicationSettlement { + localMountTimeout?.cancel() + localMountTimeout = null + return PublicationSettlement( + ownerGeneration = owner.generation, + completion = CompletableDeferred(), + phase = phase, + ).also { publicationSettlement = it } + } + + private fun beginAwaitingCommitSettlement(): PublicationSettlement { + publicationSettlement?.let { return it } + return PublicationSettlement( + ownerGeneration = transition.pending?.generation ?: -1L, + completion = CompletableDeferred(), + phase = PublicationSettlementPhase.AwaitingCommit, + ).also { publicationSettlement = it } + } + + private fun currentSettlementOwner( + settlement: PublicationSettlement, + ): PendingLocalSelection? { + if (publicationSettlement !== settlement) return null + return pendingLocalSelection + ?.takeIf { it.generation == settlement.ownerGeneration } + } + + private fun restoreRollbackOwner(owner: PendingLocalSelection) { + context = owner.rollbackContext + transition = owner.rollbackState + committedOutputRouteGeneration = owner.rollbackOutputRouteGeneration + desiredOutputRouteGeneration = owner.rollbackContext.outputRouteGeneration + invalidateLocalMount() + } + + + /** + * Handles a settlement whose owner has been replaced or invalidated. + * + * These paths used to `finishSettlement` and return, leaving + * `resetAfterSettlement` / `discardQueuedAfterSettlement` / `queuedMutations` + * undrained. The deferred reset then leaked and was picked up by an + * unrelated later settlement, which reset to a dead content generation while + * `context` still pointed at the abandoned session — so the next staged + * replan targeted it. + * + * When no owner remains at all, the deferred work is ours to run. When a + * NEWER owner exists it has already rewritten `transition`/`context`, so we + * only clear the bookkeeping and must not write state back over it. + */ + private fun drainOrphanedSettlement(settlement: PublicationSettlement) { + val supersededByNewerOwner = pendingLocalSelection != null + val reset = resetAfterSettlement + val deferredRestore = pendingFreshRestoreAfterSettlement + val discardQueued = discardQueuedAfterSettlement + resetAfterSettlement = null + pendingFreshRestoreAfterSettlement = null + discardQueuedAfterSettlement = false + + if (supersededByNewerOwner) { + // The newer owner owns the state; drop only what we were holding. + if (discardQueued) queuedMutations.clear() + return + } + queuedMutations.clear() + when { + reset != null -> resetContentNow(reset.first, reset.second) + discardQueued -> invalidateNow() + } + // After the reset, not instead of it: the load path resets and restores + // for the SAME content one line apart, so dropping the restore whenever + // a reset was pending silently ignored the user's saved preference. + deferredRestore?.let(::applyDeferredFreshRestore) + } + + /** + * Applies a deferred restore without re-entering the admission guard. + * + * The drain runs while the settlement it is completing is still installed, + * so routing back through restoreFreshPreference would simply defer again — + * and nothing would drain it a second time. + */ + private fun applyDeferredFreshRestore(deferred: DeferredFreshRestore) { + val current = context ?: return + if (current.contentId != deferred.contentId || + current.mediaFileId != deferred.mediaFileId + ) { + return + } + if (deferred.identity != transition.committed.identity) { + restoreFreshPreference(deferred.identity, deferred.migrationRequired) + return + } + if (deferred.identity is SubtitleIdentity.ServerBurnIn) { + if (deferred.migrationRequired) persist(transition.committed, current) + return + } + beginLocalSelection( + identity = deferred.identity, + proposedState = transition, + selectionContext = current, + ) + } + + private fun drainSettledPublication( + owner: PendingLocalSelection, + confirmed: Boolean, + compensating: Boolean, + ) { + val reset = resetAfterSettlement + resetAfterSettlement = null + val discardQueued = discardQueuedAfterSettlement + discardQueuedAfterSettlement = false + // A restore deferred by restoreFreshPreference runs once the settlement + // it was blocked on is done. It is applied AFTER the reset below, and + // only when it belongs to the content that reset installed. + val deferredRestore = pendingFreshRestoreAfterSettlement + pendingFreshRestoreAfterSettlement = null + when { + reset != null -> { + queuedMutations.clear() + resetContentNow(reset.first, reset.second) + } + discardQueued -> { + queuedMutations.clear() + invalidateNow() + } + queuedMutations.isNotEmpty() -> { + if (confirmed && owner.persistOnSuccess) { + persist(transition.committed, owner.context) + } + if (!confirmed) { + transition = owner.replayStateAfterRollback ?: transition + } + applyQueuedMutations() + } + confirmed -> { + publish() + if (owner.persistOnSuccess) { + persist(transition.committed, owner.context) + } else { + compensatingGeneration = null + } + } + compensating -> stageCompensatingRestore(owner) + else -> publish() + } + deferredRestore?.let(::applyDeferredFreshRestore) + } + + private fun stageCompensatingRestore(owner: PendingLocalSelection) { + val priorState = owner.rollbackState + val priorIdentity = priorState.committed.identity + if (priorIdentity.isClientOwnedSubtitle()) { + transition = priorState + beginLocalRestore(priorIdentity) + return + } + val restore = reduceSubtitleTransition( + owner.proposedState, + SelectSubtitle(priorIdentity), + ).state.copy(committed = priorState.committed) + val pending = restore.pending + if (pending == null) { + transition = priorState + publish() + return + } + transition = restore + compensatingGeneration = pending.generation + publish() + stagedRequests.trySend(pending) + } + + private fun finishSettlement( + settlement: PublicationSettlement, + success: Boolean, + ) { + if (publicationSettlement === settlement) { + publicationSettlement = null + } + settlement.completion.complete(success) + } + + private suspend fun confirmCommittedPlayback( + playback: TvSubtitleCommittedPlayback, + ): Boolean = withContext(NonCancellable) { + try { + stagedPort.confirmCommitted(playback) + onCommittedPlaybackConfirmed(playback) + } catch (_: Exception) { + false + } + } + + private suspend fun rollbackCommittedPlayback( + playback: TvSubtitleCommittedPlayback, + restoreUi: Boolean, + ): Boolean = withContext(NonCancellable) { + val managerRolledBack = try { + stagedPort.abandonCommitted(playback) + true + } catch (_: Exception) { + false + } + if (!managerRolledBack) return@withContext false + val playbackRolledBack = try { + onCommittedPlaybackRollback(playback, restoreUi) + } catch (_: Exception) { + false + } + playbackRolledBack + } + + private suspend fun discardCandidateBestEffort(candidate: TvStagedSubtitleCandidate) { + withContext(NonCancellable) { + try { + stagedPort.discard(candidate) + } catch (_: Throwable) { + // Discard is cleanup after the reducer has already rejected + // this candidate. Its transport failure must not skip the + // owned rollback or terminate the serialized worker. + } + } + } + + private sealed interface AdoptionOutcome { + data object Adopted : AdoptionOutcome + data object Superseded : AdoptionOutcome + data class Failed(val error: Exception) : AdoptionOutcome + } + + private fun finishFailedCommit(generation: Long, message: String) { + resetDuringCommit = false + if (queuedMutations.isEmpty()) { + fail(generation, message) + return + } + + transition = reduceSubtitleTransition( + transition, + StagedSubtitleFailed( + generation = generation, + message = message, + ), + ).state + failureMessage = null + applyQueuedMutations() + } + + private fun queuedPreviewState(): SubtitleTransitionState? { + if (queuedMutations.isEmpty()) return null + return queuedMutations.fold(transition) { state, event -> + reduceSubtitleTransition(state, event).state + } + } + + private fun applyQueuedMutations() { + if (queuedMutations.isEmpty()) return + val events = queuedMutations.toList() + queuedMutations.clear() + val finalState = events.fold(transition) { state, event -> + reduceSubtitleTransition(state, event).state + } + val finalIdentity = finalState.pending?.identity ?: finalState.committed.identity + if ( + finalIdentity.requiresLocalMountConfirmation() && + commitLocallyMountableSelection(finalIdentity, finalState) + ) { + return + } + invalidateLocalMount() + transition = finalState + publish() + transition.pending?.let(stagedRequests::trySend) + } + + private fun fail(generation: Long, message: String) { + if (pendingFreshRestore?.generation == generation) { + pendingFreshRestore = null + } + val failedLocalOwner = pendingLocalSelection?.takeIf { owner -> + owner.proposedState.pending?.generation == generation + } + val failed = reduceSubtitleTransition( + transition, + StagedSubtitleFailed( + generation = generation, + message = message, + ), + ) + if (failed.state == transition && failed.effects.isEmpty()) return + if (failedLocalOwner != null) { + invalidateLocalMount() + } + transition = failed.state + failureMessage = message + val priorIdentity = transition.committed.identity + if ( + failedLocalOwner?.mountedBeforeAdoption == true && + priorIdentity.requiresLocalMountConfirmation() && + context?.sessionId != null + ) { + beginLocalRestore(priorIdentity) + } else { + publish() + } + } + + private fun failLocalMount(generation: Long) { + val ownedSelection = pendingLocalSelection?.generation == generation + val ownedRestore = pendingLocalRestore?.generation == generation + if (!ownedSelection && !ownedRestore) return + val selection = pendingLocalSelection + if (ownedSelection && selection?.committedPlayback != null) { + requestCompensationSettlement(selection) + return + } + invalidateLocalMount() + failureMessage = "The selected subtitle could not be mounted." + publish() + } + + private fun beginLocalSelection( + identity: SubtitleIdentity, + proposedState: SubtitleTransitionState, + selectionContext: TvSubtitlePlaybackContext, + committedPlayback: TvSubtitleCommittedPlayback? = null, + persistOnSuccess: Boolean = true, + rollbackState: SubtitleTransitionState = transition.copy(pending = null), + rollbackContext: TvSubtitlePlaybackContext = selectionContext, + rollbackOutputRouteGeneration: Long = committedOutputRouteGeneration, + replayStateAfterRollback: SubtitleTransitionState? = null, + ) { + transition = transition.copy( + pending = proposedState.pending, + nextGeneration = proposedState.nextGeneration, + ) + invalidateLocalMount() + val generation = localMountGeneration + pendingLocalSelection = PendingLocalSelection( + generation = generation, + identity = identity, + proposedState = proposedState, + context = selectionContext, + committedPlayback = committedPlayback, + persistOnSuccess = persistOnSuccess, + rollbackState = rollbackState, + rollbackContext = rollbackContext, + rollbackOutputRouteGeneration = rollbackOutputRouteGeneration, + replayStateAfterRollback = replayStateAfterRollback, + ) + scheduleLocalMountTimeout(generation) + publish() + transition.pending?.let(stagedRequests::trySend) + } + + private fun beginLocalRestore( + identity: SubtitleIdentity, + persistence: PersistenceRequest? = null, + ) { + invalidateLocalMount() + val generation = localMountGeneration + pendingLocalRestore = PendingLocalRestore( + generation = generation, + identity = identity, + persistence = persistence, + ) + scheduleLocalMountTimeout(generation) + publish() + } + + private fun scheduleLocalMountTimeout(generation: Long) { + localMountTimeout?.cancel() + localMountTimeout = scope.launch { + var waited = 0L + while (true) { + delay(LOCAL_MOUNT_TIMEOUT_MS) + waited += LOCAL_MOUNT_TIMEOUT_MS + // Only count against the mount once there is something to + // mount; the onTracksChanged callback may not arrive inside the + // window at all, so ask rather than wait to be told. + if (hasMountableTracks() || waited >= MAX_LOCAL_MOUNT_WAIT_MS) break + } + SubDiag.log("mountDeadline FAIL gen=$generation") + failLocalMount(generation) + } + } + + private fun invalidateLocalMount() { + localMountGeneration += 1 + pendingLocalSelection = null + pendingLocalRestore = null + localMountTimeout?.cancel() + localMountTimeout = null + } + + private fun persist( + committed: CommittedSubtitle, + committedContext: TvSubtitlePlaybackContext, + ) { + newPersistenceRequest( + committed = committed, + context = committedContext, + )?.let { request -> + if (persistenceRequests.trySend(request).isFailure) { + persistenceCoordinator.abandon(request.ticket) + } + } + } + + private fun capturePersistenceRequest( + completion: CompletableDeferred? = null, + ): PersistenceRequest? { + val committedContext = context ?: return null + return newPersistenceRequest( + committed = transition.committed, + context = committedContext, + completion = completion, + ) + } + + private fun newPersistenceRequest( + committed: CommittedSubtitle, + context: TvSubtitlePlaybackContext, + completion: CompletableDeferred? = null, + ): PersistenceRequest? { + val writeScope = context.writeScope ?: return null + return PersistenceRequest( + ticket = persistenceCoordinator.capture( + scope = writeScope, + contentId = context.contentId, + fileId = context.mediaFileId, + ), + committed = committed, + context = context, + completion = completion, + ) + } + + private suspend fun writePersistenceRequest(request: PersistenceRequest): Boolean = + persistenceCoordinator.write(request.ticket) { + repeat(PERSISTENCE_ATTEMPTS) { + try { + if (persistencePort.persist(request.committed, request.context)) { + return@write true + } + } catch (cancellation: CancellationException) { + if (!currentCoroutineContext().isActive) throw cancellation + } catch (_: Exception) { + // The bounded loop owns retry and containment. + } + } + false + } + + private suspend fun awaitBoundedDurablePersistence( + request: PersistenceRequest, + ): Boolean { + val completion = CompletableDeferred() + val job = durablePersistenceScope.launch { + val success = withTimeoutOrNull(DURABLE_PERSISTENCE_TIMEOUT_MS) { + runCatching { writePersistenceRequest(request) }.getOrDefault(false) + } ?: false + completion.complete(success) + } + job.invokeOnCompletion { cause -> + if (cause != null) completion.complete(false) + } + return withContext(NonCancellable) { + withTimeoutOrNull(DURABLE_PERSISTENCE_TIMEOUT_MS) { + completion.await() + } ?: false + } + } + + private fun publish() { + SubDiag.log("SNAPSHOT applying=${snapshot.subtitleApplying} committed=${snapshot.committedIdentity} pending=${snapshot.pendingIdentity} localMount=${snapshot.localMountIdentity} failure=${snapshot.failureMessage}") + onSnapshotChanged(snapshot) + } + + private companion object { + const val LOCAL_MOUNT_TIMEOUT_MS = 5_000L + const val MAX_LOCAL_MOUNT_WAIT_MS = 30_000L + const val PERSISTENCE_ATTEMPTS = 2 + const val PRIMARY_PERSISTENCE_TIMEOUT_MS = 5_000L + const val DURABLE_PERSISTENCE_TIMEOUT_MS = 5_000L + } +} + +internal class PlaybackSessionManagerTvSubtitleStagedReplanPort( + private val manager: PlaybackSessionManager, + private val lifecycle: PlaybackSessionLifecycle, +) : TvSubtitleStagedReplanPort { + override suspend fun stage(request: TvSubtitleStageRequest): ApiResult { + val input = when (val mapped = request.toManagerStageInput()) { + is ApiResult.Success -> mapped.data + is ApiResult.Error -> return mapped + is ApiResult.NetworkError -> return mapped + } + return when ( + val result = manager.stageActiveVideoSessionReplan( + classification = request.classification, + message = when (request.classification) { + "quality_changed" -> "Applying playback quality." + "output_route_changed" -> "Applying output route." + "audio_track_changed" -> "Applying audio selection." + else -> "Applying subtitle selection." + }, + positionSeconds = request.positionSeconds, + audioTrackIndex = request.audioTrackIndex, + subtitleTrackIndex = request.subtitleTrackIndex, + qualityPreference = request.qualityPreference, + capabilities = input.capabilities, + clientPlaybackContext = input.clientPlaybackContext, + ) + ) { + is ApiResult.Success -> { + val handle = result.data + val ready = handle.candidate + ApiResult.Success( + TvStagedSubtitleCandidate( + id = handle.candidateSessionId, + sessionId = handle.candidateSessionId, + selectedAudioIndex = ready.plan.selectedTracks.audio?.index, + selectedSubtitleIndex = ready.plan.selectedTracks.subtitle?.index, + subtitleMode = ready.plan.subtitle.mode, + hasSidecar = ready.plan.subtitle.artifact?.url?.isNotBlank() == true, + subtitleTracks = ready.session.subtitleUrls.orEmpty(), + qualityPreference = request.qualityPreference, + outputRouteGeneration = handle.outputRouteGeneration, + managerHandle = handle, + ), + ) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + override suspend fun commit( + candidate: TvStagedSubtitleCandidate, + ): ApiResult { + val handle = candidate.managerHandle ?: return ApiResult.Error( + code = 409, + error = "missing_staged_subtitle_handle", + message = "The staged subtitle candidate no longer has a commit handle.", + ) + return when ( + val result = manager.commitStagedVideoReplan( + staged = handle, + deferPublication = true, + ) + ) { + is ApiResult.Success -> ApiResult.Success( + TvSubtitleCommittedPlayback( + sessionId = result.data.session.sessionId, + subtitleTracks = result.data.session.subtitleUrls.orEmpty(), + ready = result.data, + outputRouteGeneration = candidate.outputRouteGeneration, + ), + ) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + override suspend fun discard(candidate: TvStagedSubtitleCandidate) { + candidate.managerHandle?.let { manager.discardStagedVideoReplan(it) } + } + + override suspend fun confirmCommitted(playback: TvSubtitleCommittedPlayback) { + check( + lifecycle.settlePendingPublicationIfCurrent( + sessionId = playback.sessionId, + confirm = true, + settleManager = { + manager.confirmVideoSessionPublication(playback.sessionId) + }, + ), + ) { + "The staged subtitle session was no longer awaiting publication." + } + } + + override suspend fun abandonCommitted(playback: TvSubtitleCommittedPlayback) { + val jointlyRolledBack = lifecycle.settlePendingPublicationIfCurrent( + sessionId = playback.sessionId, + confirm = false, + settleManager = { + manager.rollbackUnpublishedVideoSession(playback.sessionId) + }, + ) + check( + jointlyRolledBack || + manager.rollbackUnpublishedVideoSession(playback.sessionId), + ) { + "The staged subtitle session was no longer awaiting rollback." + } + } +} + +private fun SubtitleIdentity.serverTrackIndex(): Int = when (this) { + SubtitleIdentity.Off -> -1 + is SubtitleIdentity.ServerSidecar -> serverIndex + is SubtitleIdentity.ServerBurnIn -> serverIndex + is SubtitleIdentity.Embedded -> serverIndex + is SubtitleIdentity.Downloaded, + is SubtitleIdentity.LocalMedia3, + -> -1 +} + +private fun SubtitleIdentity.requiresLocalMountConfirmation(): Boolean = + this is SubtitleIdentity.LocalMedia3 || + this is SubtitleIdentity.Downloaded || + this is SubtitleIdentity.Embedded + +private fun SubtitleIdentity.requiresPlayerBoundaryConfirmation(): Boolean = + this !is SubtitleIdentity.ServerBurnIn + +private fun SubtitleIdentity.isClientOwnedSubtitle(): Boolean = + this is SubtitleIdentity.LocalMedia3 || this is SubtitleIdentity.Downloaded + +private fun TvStagedSubtitleCandidate.validationFailure( + requested: org.prairieserver.prairie.model.playback.PendingSubtitle, + expectedSubtitleIndex: Int, + expectedOutputRouteGeneration: Long, +): String? { + if (outputRouteGeneration != expectedOutputRouteGeneration) { + return "The candidate was planned for a stale output route." + } + if (requested.qualityPreferenceSpecified && + qualityPreference != requested.qualityPreference + ) { + return "The candidate did not preserve the requested quality." + } + if (requested.audioPreferenceSpecified && + selectedAudioIndex != requested.audioTrackIndex + ) { + return "The candidate did not select the requested audio track." + } + return when (requested.identity) { + is SubtitleIdentity.Embedded, + is SubtitleIdentity.Downloaded, + is SubtitleIdentity.LocalMedia3, + -> when { + (selectedSubtitleIndex ?: -1) != expectedSubtitleIndex -> + "The candidate did not preserve the mounted subtitle." + expectedSubtitleIndex < 0 && subtitleMode != PlaybackSubtitleModeV3.OFF -> + "The candidate did not keep server subtitles off for the client-mounted subtitle." + subtitleMode == PlaybackSubtitleModeV3.BURN_IN -> + "The candidate unexpectedly burned in the mounted subtitle." + else -> null + } + else -> validationFailure(requested.identity) + } +} + +private fun PlayerSubtitleInfo.isDownloadedTvRow(): Boolean = + downloadId != null || + source.equals("downloaded", ignoreCase = true) || + catalogSource.equals("downloaded", ignoreCase = true) + +private fun PlayerSubtitleInfo.toDownloadedTvIdentity(): SubtitleIdentity.Downloaded { + val id = requireNotNull(downloadId) + return SubtitleIdentity.Downloaded( + downloadId = id, + media = org.prairieserver.prairie.model.playback.SubtitleMediaIdentity( + trackId = downloadedSubtitleArtifactTrackId(id), + label = label, + language = language, + codecFamily = codec, + forced = forced ?: false, + hearingImpaired = label + ?.takeIf(::subtitleLabelIndicatesHearingImpaired) + ?.let { true } + ?: false, + ), + ) +} + +private fun TvStagedSubtitleCandidate.validationFailure( + identity: SubtitleIdentity, +): String? = when (identity) { + SubtitleIdentity.Off -> if ( + (selectedSubtitleIndex ?: -1) == -1 && + subtitleMode == PlaybackSubtitleModeV3.OFF && + !hasSidecar + ) { + null + } else { + "The candidate did not keep subtitles off." + } + is SubtitleIdentity.ServerSidecar -> when { + selectedSubtitleIndex != identity.serverIndex -> + "The candidate did not select the requested subtitle." + subtitleMode != PlaybackSubtitleModeV3.RENDER && + subtitleMode != PlaybackSubtitleModeV3.CONVERT -> + "The candidate did not render the requested sidecar." + !hasSidecar -> "The candidate omitted the requested subtitle sidecar." + else -> null + } + is SubtitleIdentity.ServerBurnIn -> when { + selectedSubtitleIndex != identity.serverIndex -> + "The candidate did not select the requested subtitle." + subtitleMode != PlaybackSubtitleModeV3.BURN_IN -> + "The candidate did not burn in the requested subtitle." + else -> null + } + is SubtitleIdentity.Embedded, + is SubtitleIdentity.Downloaded, + is SubtitleIdentity.LocalMedia3, + -> "A local subtitle identity unexpectedly reached staged validation." +} + +private fun TvSubtitleCommittedPlayback.withRebasedDownloads( + oldContext: TvSubtitlePlaybackContext, +): TvSubtitleCommittedPlayback { + val downloadedPredicate: (PlayerSubtitleInfo) -> Boolean = { + it.downloadId != null || it.source.equals("downloaded", ignoreCase = true) + } + val downloaded = oldContext.subtitleTracks + .filter(downloadedPredicate) + .map { track -> + track.copy(url = rebaseDownloadedSubtitleUrl(track.url, sessionId)) + } + val candidateByIndex = subtitleTracks + .filterNot(downloadedPredicate) + .associateBy(PlayerSubtitleInfo::index) + val retainedCatalog = oldContext.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 = "") + } + val retainedIndexes = retainedCatalog.mapTo(mutableSetOf(), PlayerSubtitleInfo::index) + val additionalCandidates = subtitleTracks.filterNot(downloadedPredicate) + .filterNot { it.index in retainedIndexes } + return copy( + subtitleTracks = retainedCatalog + additionalCandidates + downloaded, + ) +} 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 ff6ee4ccb..a150866e2 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 @@ -23,6 +23,7 @@ import org.prairieserver.prairie.playback.selectPlaybackVersion import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.tv.BuildConfig +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first class TvVideoPlaybackStarter( @@ -42,6 +43,7 @@ class TvVideoPlaybackStarter( if (!shouldReachServerForPlayback(reachabilityMonitor, request.force)) { return VideoPlaybackStartResult.ServerUnreachable(request.contentId) } + val ownershipEpoch = sessionLifecycle.acquireOwnershipEpoch() return try { val watchDetail = when (val r = catalogRepository.getWatchDetail(request.contentId)) { is ApiResult.Success -> r.data @@ -131,6 +133,7 @@ class TvVideoPlaybackStarter( subtitleTrackIndex = request.subtitleTrackIndex, qualityPreference = playbackQualityIntent, startPosition = startRequestPosition, + deferPublication = true, ) ) { is ApiResult.Success -> r.data @@ -180,7 +183,7 @@ class TvVideoPlaybackStarter( ?: startRequestPosition ?: playerStartPos - sessionLifecycle.adoptActiveSession( + val adopted = sessionLifecycle.adoptActiveSessionIfCurrent( params = StartParams( contentId = request.contentId, fileId = effectiveFileId, @@ -193,7 +196,16 @@ class TvVideoPlaybackStarter( ), session = resolved, renewMissingSessionWithLegacyStart = false, + deferPublication = true, + expectedOwnershipEpoch = ownershipEpoch, ) + if (!adopted) { + return failure( + request.contentId, + "Playback start was superseded.", + diagnosticsCode = PlaybackDiagnosticsCode.START_REQUEST, + ) + } VideoPlaybackStartResult.Ready( contentId = request.contentId, @@ -238,6 +250,8 @@ class TvVideoPlaybackStarter( seasonNumber = watchDetail.seasonNumber, episodeNumber = watchDetail.episodeNumber, ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "Error loading content", e) failure(request.contentId, "Unexpected error: ${e.message}", e, PlaybackDiagnosticsCode.UNEXPECTED) 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 fce4c63b8..b65cfb00d 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 @@ -43,6 +43,7 @@ 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 import org.prairieserver.prairie.tv.ui.theme.FocusedContent +import org.prairieserver.prairie.watchtogether.canDismissRoomEntry /** * Pure 8-char D-pad join-code accumulator. Appends only A–Z / 0–9 (uppercasing @@ -85,11 +86,13 @@ fun TvJoinCodeDialog( Popup( alignment = Alignment.Center, - onDismissRequest = onDismiss, + onDismissRequest = { + if (canDismissRoomEntry(isBusy)) onDismiss() + }, properties = PopupProperties( focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = true, + dismissOnBackPress = canDismissRoomEntry(isBusy), + dismissOnClickOutside = canDismissRoomEntry(isBusy), clippingEnabled = false, ), ) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvSuggestToRoomViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvSuggestToRoomViewModel.kt new file mode 100644 index 000000000..219f21a68 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvSuggestToRoomViewModel.kt @@ -0,0 +1,61 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +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.watchtogether.AddSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage +import org.prairieserver.prairie.repository.WatchTogetherRepository + +class TvSuggestToRoomViewModel( + private val repository: WatchTogetherRepository, +) : ViewModel() { + data class UiState( + val isBusy: Boolean = false, + val notice: String? = null, + val error: String? = null, + ) + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + val room: StateFlow = repository.roomSnapshot + + fun suggest( + contentId: String, + contentType: String, + title: String, + subtitle: String?, + posterUrl: String?, + ) { + if (_uiState.value.isBusy) return + _uiState.update { it.copy(isBusy = true, error = null, notice = null) } + viewModelScope.launch { + val result = repository.addSuggestion( + AddSuggestionRequest( + contentId = contentId, + contentType = contentType, + title = title, + subtitle = subtitle, + posterUrl = posterUrl, + ), + ) + when (result) { + is ApiResult.Success -> + _uiState.update { it.copy(isBusy = false, notice = "Suggested to the room") } + is ApiResult.Error, is ApiResult.NetworkError -> + _uiState.update { + it.copy(isBusy = false, error = result.errorMessage("Could not suggest that")) + } + } + } + } + + fun consumeNotice() = _uiState.update { it.copy(notice = null) } + fun clearError() = _uiState.update { it.copy(error = null) } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt index 289208881..3f4b84cd4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt @@ -26,6 +26,7 @@ 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 /** * D-pad Watch Together entry dialog. Panel + row idiom mirrors @@ -38,6 +39,7 @@ fun TvWatchTogetherEntryDialog( isBusy: Boolean, error: String?, onHost: () -> Unit, + onHostVote: () -> Unit, onJoin: () -> Unit, onDismiss: () -> Unit, ) { @@ -45,11 +47,13 @@ fun TvWatchTogetherEntryDialog( Popup( alignment = Alignment.Center, - onDismissRequest = onDismiss, + onDismissRequest = { + if (canDismissRoomEntry(isBusy)) onDismiss() + }, properties = PopupProperties( focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = true, + dismissOnBackPress = canDismissRoomEntry(isBusy), + dismissOnClickOutside = canDismissRoomEntry(isBusy), clippingEnabled = false, ), ) { @@ -87,6 +91,12 @@ fun TvWatchTogetherEntryDialog( modifier = Modifier.focusRequester(hostFocus), ) + TvDialogActionRow( + title = if (isBusy) "Working…" else "Host a vote room", + enabled = !isBusy, + onClick = onHostVote, + ) + TvDialogActionRow( title = "Join by code", enabled = !isBusy, 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 9e4c20153..4fb4e3861 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 @@ -17,13 +17,14 @@ 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -53,10 +54,14 @@ import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.Suggestion import org.prairieserver.prairie.tv.ui.navigation.TvRoute import org.prairieserver.prairie.tv.ui.screens.auth.QrCodePanel +import org.prairieserver.prairie.tv.ui.screens.player.TvDialogActionRow import org.prairieserver.prairie.tv.ui.screens.player.TvDialogCyclerRow import org.prairieserver.prairie.tv.ui.theme.DarkBackground import org.prairieserver.prairie.tv.ui.theme.FocusedContainer import org.prairieserver.prairie.tv.ui.theme.FocusedContent +import org.prairieserver.prairie.tv.ui.theme.PrairieBlue +import org.prairieserver.prairie.watchtogether.isVoteRoom +import org.prairieserver.prairie.watchtogether.roomVoteWinner import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -66,8 +71,11 @@ import org.koin.core.parameter.parametersOf * the phone lobby's `lobbyPlayerDestinationOrNull` semantics (enum compare + * isNullOrBlank, NOT a wire-string compare). */ -fun shouldEnterSyncedPlayer(s: RoomSnapshot?): Boolean = - s != null && s.phase == RoomPhase.Playing && !s.selectedContentId.isNullOrBlank() +fun shouldEnterSyncedPlayer(s: RoomSnapshot?): Boolean { + if (s == null || s.phase != RoomPhase.Playing || s.selectedContentId.isNullOrBlank()) return false + if (s.selfRole == MemberRole.Host && s.memberCount <= 1) return false + return true +} /** * Watch Together lobby (TV). Shows member count / role / selection mode, the @@ -106,9 +114,16 @@ fun TvWatchTogetherLobbyScreen( val snapshot = room val isHostLabel = snapshot?.selfRole == MemberRole.Host val canManage = snapshot?.selfCanManageRoom == true + val isVoteRoom = snapshot.isVoteRoom() // Auto-hand-off into the synced player once the room is playing + selected. - LaunchedEffect(room?.phase, room?.selectedContentId, room?.selectedFileId) { + LaunchedEffect( + room?.phase, + room?.selectedContentId, + room?.selectedFileId, + room?.memberCount, + room?.selfRole, + ) { val snapshot = room ?: return@LaunchedEffect if (shouldEnterSyncedPlayer(snapshot)) { onNavigateToPlayer( @@ -146,10 +161,14 @@ fun TvWatchTogetherLobbyScreen( .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - Column( + Row( modifier = Modifier .fillMaxSize() - .padding(horizontal = 56.dp, vertical = 40.dp), + .padding(start = 56.dp, end = 56.dp, top = 40.dp, bottom = 48.dp), + horizontalArrangement = Arrangement.spacedBy(48.dp), + ) { + Column( + modifier = Modifier.weight(0.42f), verticalArrangement = Arrangement.spacedBy(20.dp), ) { // --- Header -------------------------------------------------------- @@ -231,6 +250,19 @@ fun TvWatchTogetherLobbyScreen( ) } } + } + } + } + + Column( + modifier = Modifier + .weight(0.58f) + .verticalScroll(rememberScrollState()) + .padding(bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + if (snapshot != null) { + if (canManage) { // Selection mode is fixed at room creation — shown read-only. TvDialogCyclerRow( title = "Selection mode", @@ -251,6 +283,16 @@ fun TvWatchTogetherLobbyScreen( CloseRoomButton(onClick = viewModel::closeRoom) } + val winner = roomVoteWinner(suggestions) + if (isVoteRoom && canManage) { + TvDialogActionRow( + title = winner?.let { "Start winner: ${it.title}" } + ?: "Start winner — no votes yet", + enabled = winner != null, + onClick = { winner?.let { viewModel.promote(it.id) } }, + ) + } + Text( text = "SUGGESTIONS", style = MaterialTheme.typography.labelLarge.copy(letterSpacing = 2.sp), @@ -263,25 +305,29 @@ fun TvWatchTogetherLobbyScreen( color = Color.White.copy(alpha = 0.5f), ) } else { - LazyColumn( + Column( verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth(), ) { - items(suggestions, key = { it.id }) { s -> - SuggestionRow( - suggestion = s, - canManage = canManage, - onVote = { - if (s.votedByMe) viewModel.unvote(s.id) else viewModel.vote(s.id) - }, - onPromote = { viewModel.promote(s.id) }, - onRemove = { viewModel.removeSuggestion(s.id) }, - ) + suggestions.forEach { s -> + key(s.id) { + SuggestionRow( + suggestion = s, + canManage = canManage, + isWinning = isVoteRoom && winner?.id == s.id, + onVote = { + if (s.votedByMe) viewModel.unvote(s.id) else viewModel.vote(s.id) + }, + onPromote = { viewModel.promote(s.id) }, + onRemove = { viewModel.removeSuggestion(s.id) }, + ) + } } } } } } + } } } @@ -314,6 +360,7 @@ private fun nextPolicy(policy: GuestControlPolicy): GuestControlPolicy = when (p private fun SuggestionRow( suggestion: Suggestion, canManage: Boolean, + isWinning: Boolean, onVote: () -> Unit, onPromote: () -> Unit, onRemove: () -> Unit, @@ -413,6 +460,17 @@ private fun SuggestionRow( style = MaterialTheme.typography.titleMedium, color = if (isFocused) FocusedContent else Color.White, ) + if (isWinning) { + Spacer(Modifier.width(8.dp)) + Text( + text = "WINNING", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + ), + color = if (isFocused) FocusedContent else PrairieBlue, + ) + } if (canManage) { Spacer(Modifier.width(8.dp)) RowAction(text = "Pick", onClick = onPromote) 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 c6dde0dd7..6ea581e66 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 @@ -7,6 +7,7 @@ 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.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RoomSession import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn @@ -20,19 +21,17 @@ import kotlinx.coroutines.launch * * The repository owns the room JWT and reads the active roomId from its own * snapshot, so the room-scoped ops take only request/id params (not a roomId). - * [WatchTogetherRepository.connect] is suspend and runs the reconnect-with-backoff - * loop until the scope is cancelled, so it is launched in [viewModelScope] (NOT - * called bare in init). + * The application-scoped [RoomSession] owns reconnect and survives navigation + * from this lobby into the synced player. */ class TvWatchTogetherLobbyViewModel( private val roomId: String, private val repository: WatchTogetherRepository, + private val roomSession: RoomSession, ) : ViewModel() { init { - // The repo owns reconnect/backoff; we just bind on enter. connect() - // suspends for the lifetime of the socket, so launch it. - viewModelScope.launch { repository.connect(roomId) } + roomSession.adopt(roomId) } val room: StateFlow = repository.roomSnapshot @@ -71,7 +70,7 @@ class TvWatchTogetherLobbyViewModel( * network call against viewModelScope cancellation during screen dispose. */ fun leave() { - repository.reset() + roomSession.depart() } override fun onCleared() { 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 5b8409e50..6bc0f5358 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +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 @@ -46,12 +47,24 @@ class TvWatchTogetherViewModel( val uiState: StateFlow = _uiState.asStateFlow() /** Host flow: create a room with this title pre-selected as the room selection. */ - fun createRoom(contentId: String, fileId: Int?) { + fun createRoom( + contentId: String, + fileId: Int?, + selectionMode: RoomSelectionMode = RoomSelectionMode.HostPick, + ) { if (_uiState.value.isBusy) return _uiState.update { it.copy(isBusy = true, error = null) } viewModelScope.launch { - when (val created = repository.createRoom(CreateRoomRequest())) { + when ( + val created = repository.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 // already stored the snapshot synchronously, so setSelection 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 f028b8f12..da3e80406 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 @@ -248,6 +248,8 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { 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) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/pip/TvPictureInPictureSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/pip/TvPictureInPictureSourceTest.kt index 491012ffe..2fca09307 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/pip/TvPictureInPictureSourceTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/pip/TvPictureInPictureSourceTest.kt @@ -31,7 +31,11 @@ class TvPictureInPictureSourceTest { assertTrue(player.contains("isPlaying = state.isPlaying && !state.isPaused")) assertTrue(player.contains("!isInPictureInPictureMode")) assertTrue(player.contains("if (!isInPictureInPictureMode && state.showControls")) - assertTrue(player.contains("Lifecycle.Event.ON_PAUSE -> if (!isInPictureInPictureMode)")) + val lifecyclePauseBranch = player + .substringAfter("Lifecycle.Event.ON_PAUSE,") + .substringBefore("Lifecycle.Event.ON_RESUME") + assertTrue(lifecyclePauseBranch.contains("Lifecycle.Event.ON_STOP")) + assertTrue(lifecyclePauseBranch.contains("if (!isInPictureInPictureMode)")) } @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupPersistenceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupPersistenceTest.kt index 81e5696a8..e17922ecb 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupPersistenceTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupPersistenceTest.kt @@ -7,23 +7,33 @@ import io.ktor.serialization.kotlinx.json.json import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import androidx.lifecycle.viewModelScope import kotlinx.coroutines.cancel import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout import org.prairieserver.prairie.network.PrairieAuthPlugin import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.common.network.CleartextConsentStore +import org.prairieserver.prairie.model.auth.SetupStatusResponse +import org.prairieserver.prairie.model.auth.SignupStatusResponse import org.prairieserver.prairie.repository.AuthRepository import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) class TvServerSetupPersistenceTest { @@ -70,7 +80,7 @@ class TvServerSetupPersistenceTest { ), tokenManager = tokenManager, ) - val viewModel = track(TvServerSetupViewModel(repository)) + val viewModel = track(TvServerSetupViewModel(repository, FakeCleartextConsentStore())) viewModel.onServerUrlChanged("bad.prairie") viewModel.onConnectClick() @@ -83,10 +93,155 @@ class TvServerSetupPersistenceTest { "Failed probes must not persist candidate URLs.", ) } + + @Test + fun successfulHttpFallbackStopsForConfirmationBeforePersistence() = runTest(dispatcher) { + val tokenManager = RecordingTokenManager() + val viewModel = track(viewModelFor(tokenManager)) + + viewModel.onServerUrlChanged("prairie.lan") + viewModel.onConnectClick() + awaitSettled(viewModel) + + assertEquals("http://prairie.lan", viewModel.uiState.value.pendingCleartextUrl) + assertEquals(emptyList(), tokenManager.serverUrlWrites) + assertNull(viewModel.uiState.value.navigateTo) + + viewModel.confirmCleartextConnection() + awaitSettled(viewModel) + + assertEquals(listOf("http://prairie.lan"), tokenManager.serverUrlWrites) + assertEquals(TvServerSetupDestination.Login(signupEnabled = true), viewModel.uiState.value.navigateTo) + } + + @Test + fun cancelCleartextConnectionClearsPendingWithoutPersistence() = runTest(dispatcher) { + val tokenManager = RecordingTokenManager() + val viewModel = track(viewModelFor(tokenManager)) + + viewModel.onServerUrlChanged("prairie.lan") + viewModel.onConnectClick() + awaitSettled(viewModel) + viewModel.cancelCleartextConnection() + + assertNull(viewModel.uiState.value.pendingCleartextUrl) + assertEquals(emptyList(), tokenManager.serverUrlWrites) + assertNull(viewModel.uiState.value.navigateTo) + } + + @Test + fun cleartextApprovalIsOriginSpecific() = runTest(dispatcher) { + val tokenManager = RecordingTokenManager() + val approvals = FakeCleartextConsentStore().apply { approve("http://other.lan") } + val viewModel = track(viewModelFor(tokenManager, approvals)) + + viewModel.onServerUrlChanged("prairie.lan") + viewModel.onConnectClick() + awaitSettled(viewModel) + + assertEquals("http://prairie.lan", viewModel.uiState.value.pendingCleartextUrl) + assertEquals(emptyList(), tokenManager.serverUrlWrites) + } + + @Test + fun priorOriginApprovalAndHttpsBypassConfirmation() = runTest(dispatcher) { + val approvedTokens = RecordingTokenManager() + val approvals = FakeCleartextConsentStore().apply { approve("http://prairie.lan") } + val approvedViewModel = track(viewModelFor(approvedTokens, approvals)) + + approvedViewModel.onServerUrlChanged("prairie.lan") + approvedViewModel.onConnectClick() + awaitSettled(approvedViewModel) + + assertNull(approvedViewModel.uiState.value.pendingCleartextUrl) + assertEquals(listOf("http://prairie.lan"), approvedTokens.serverUrlWrites) + + val httpsTokens = RecordingTokenManager() + val httpsViewModel = track(viewModelFor(httpsTokens, httpsSucceeds = true)) + httpsViewModel.onServerUrlChanged("secure.prairie") + httpsViewModel.onConnectClick() + awaitSettled(httpsViewModel) + + assertNull(httpsViewModel.uiState.value.pendingCleartextUrl) + assertEquals(listOf("https://secure.prairie"), httpsTokens.serverUrlWrites) + } + + @Test + fun cancelWinsRaceWithInFlightCleartextApproval() = runTest(dispatcher) { + val tokenManager = RecordingTokenManager() + val approvals = BlockingCleartextConsentStore() + val viewModel = track(viewModelFor(tokenManager, approvals)) + viewModel.onServerUrlChanged("prairie.lan") + viewModel.onConnectClick() + awaitSettled(viewModel) + + viewModel.confirmCleartextConnection() + runCurrent() + approvals.approveStarted.await() + viewModel.cancelCleartextConnection() + approvals.releaseApproval.complete(Unit) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.pendingCleartextUrl) + assertNull(viewModel.uiState.value.navigateTo) + assertEquals(emptyList(), tokenManager.serverUrlWrites) + } + + @Test + fun successfulUnnormalizableHttpCandidateFailsClosed() = runTest(dispatcher) { + val tokenManager = RecordingTokenManager() + val viewModel = track(viewModelFor(tokenManager)) + + viewModel.onServerUrlChanged("http://prairie_lan") + viewModel.onConnectClick() + awaitSettled(viewModel) + + assertEquals(emptyList(), tokenManager.serverUrlWrites) + assertNull(viewModel.uiState.value.navigateTo) + assertEquals("Could not safely identify this HTTP server.", viewModel.uiState.value.error) + } + + private fun viewModelFor( + tokenManager: RecordingTokenManager, + approvals: FakeCleartextConsentStore = FakeCleartextConsentStore(), + httpsSucceeds: Boolean = false, + ) = TvServerSetupViewModel( + authRepository = repositoryFor(tokenManager), + cleartextConsentStore = approvals, + getSetupStatus = { candidate -> + if (candidate.startsWith("https://") && !httpsSucceeds) { + ApiResult.NetworkError(IOException("TLS unavailable")) + } else { + ApiResult.Success(SetupStatusResponse(needsSetup = false)) + } + }, + getSignupStatus = { + ApiResult.Success(SignupStatusResponse(enabled = true)) + }, + ) + + private fun repositoryFor( + tokenManager: RecordingTokenManager, + ): AuthRepository = AuthRepository( + authApi = AuthApi( + HttpClient(MockEngine { throw IOException("Unexpected network request") }) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + }, + ), + tokenManager = tokenManager, + ) + + private suspend fun TestScope.awaitSettled(viewModel: TvServerSetupViewModel) { + runCurrent() + withTimeout(5_000) { + viewModel.uiState.first { !it.isLoading } + } + } } private class RecordingTokenManager( - private var serverUrl: String, + private var serverUrl: String = "", ) : TokenManager { val serverUrlWrites = mutableListOf() override val sessionExpired = MutableSharedFlow() @@ -108,3 +263,24 @@ private class RecordingTokenManager( override suspend fun switchActiveServer(serverId: String?) = Unit override suspend fun signOutCurrentServer() = Unit } + +private open class FakeCleartextConsentStore : CleartextConsentStore { + private val approved = mutableSetOf() + + override suspend fun isApproved(origin: String): Boolean = origin in approved + + override suspend fun approve(origin: String) { + approved += origin + } +} + +private class BlockingCleartextConsentStore : FakeCleartextConsentStore() { + val approveStarted = CompletableDeferred() + val releaseApproval = CompletableDeferred() + + override suspend fun approve(origin: String) { + approveStarted.complete(Unit) + releaseApproval.await() + super.approve(origin) + } +} 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 928103e38..15cca926f 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 @@ -1,51 +1,378 @@ package org.prairieserver.prairie.tv.ui.screens.player -import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.common.player.subtitleArtifactTrackId +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity import java.io.File 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 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")) + + assertFalse(tracker.observe(first)) + assertTrue(tracker.observe(first)) + } + + @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")) + + assertFalse(tracker.observe(first)) + assertFalse(tracker.observe(changed)) + assertTrue(tracker.observe(changed)) + tracker.reset() + assertFalse(tracker.observe(changed)) + } + private val viewModelSource = File( "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt", ).readText() @Test - fun selectedServerSubtitleIsReappliedWhenTheReplannedTrackArrives() { + fun `same-label forced and full external rows resolve the exact typed identity`() { val latch = SubtitleRemountReselection() - val mounted = PlayerSubtitleInfo( - index = 7, - language = "en", - codec = "webvtt", + val forced = track( + index = 2, + trackId = "forced", label = "English", - url = "/subtitles/7.vtt", + forced = true, ) - val remountedTrack = PlayerTrackEntry( + val full = track( index = 3, + trackId = "full", label = "English", - language = "en", - codecOrMime = "text/vtt", - isSelected = false, + forced = false, + ) + val identity = SubtitleIdentity.LocalMedia3( + media = media(trackId = "forced", label = "English", forced = true), + ) + + latch.arm(identity = identity, generation = 1) + + val event = assertIs( + latch.consume(listOf(full, forced), snapshotKey = "ready", settled = true), + ) + assertEquals(2, event.trackIndex) + assertEquals(identity, event.owner.identity) + } + + @Test + fun `duplicate English forced and full PGS rows safely miss without an exact identity`() { + val latch = SubtitleRemountReselection() + val ambiguous = SubtitleIdentity.LocalMedia3( + media = media(label = "English", codec = "pgs", forced = null), + ) + latch.arm(identity = ambiguous, generation = 1) + + val event = latch.consume( + subtitleTracks = listOf( + track(index = 2, trackId = null, label = "English", codec = "pgs", forced = true), + track(index = 3, trackId = null, label = "English", codec = "pgs", forced = false), + ), + snapshotKey = "ready", + settled = true, + ) + + assertIs(event) + } + + @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 c = SubtitleIdentity.Embedded(8, media(trackId = "embedded-c")) + latch.arm(b, generation = 1) + latch.arm(c, generation = 2) + + val event = assertIs( + latch.consume( + subtitleTracks = listOf( + track(index = 4, trackId = "prairie-subtitle:4"), + track(index = 8, trackId = "embedded-c"), + ), + snapshotKey = "ready", + settled = true, + ), + ) + + assertEquals(8, event.trackIndex) + assertEquals(c, event.owner.identity) + assertNull(latch.consume(listOf(track(index = 4, trackId = "prairie-subtitle:4")), "late-b", true)) + } + + @Test + fun `catalog B followed by local C remounts only C`() { + val latch = SubtitleRemountReselection() + val c = SubtitleIdentity.LocalMedia3(media(trackId = "local-c")) + latch.arm(SubtitleIdentity.ServerSidecar(4), generation = 1) + latch.arm(c, generation = 2) + + val event = assertIs( + latch.consume( + subtitleTracks = listOf( + track(index = 4, trackId = "prairie-subtitle:4"), + track(index = 9, trackId = "local-c"), + ), + snapshotKey = "ready", + settled = true, + ), + ) + + assertEquals(9, event.trackIndex) + assertEquals(c, event.owner.identity) + } + + @Test + fun `remount failure retains the committed typed identity and clears Applying`() { + val latch = SubtitleRemountReselection() + val identity = SubtitleIdentity.LocalMedia3(media(trackId = "missing")) + latch.arm(identity, generation = 7) + + val event = assertIs( + latch.consume( + subtitleTracks = listOf(track(index = 2, trackId = "other")), + snapshotKey = "settled", + settled = true, + ), + ) + + assertEquals(identity, event.owner.identity) + assertFalse(latch.hasPendingOwner) + } + + @Test + fun `remount cancellation cannot select a superseded identity`() { + val latch = SubtitleRemountReselection() + latch.arm(SubtitleIdentity.LocalMedia3(media(trackId = "old")), generation = 1) + latch.clear() + + assertNull( + latch.consume( + subtitleTracks = listOf(track(index = 2, trackId = "old")), + snapshotKey = "late", + settled = true, + ), + ) + } + + @Test + fun `stale remount callback after new intent emits no selection`() { + val latch = SubtitleRemountReselection() + latch.arm(SubtitleIdentity.LocalMedia3(media(trackId = "b")), generation = 1) + latch.arm(SubtitleIdentity.LocalMedia3(media(trackId = "c")), generation = 2) + + val lateB = latch.consume( + subtitleTracks = listOf(track(index = 2, trackId = "b")), + snapshotKey = "late-b", + settled = false, + ) + + assertNull(lateB) + assertTrue(latch.hasPendingOwner) + } + + @Test + fun `reset while remount is pending invalidates the owner`() { + val latch = SubtitleRemountReselection() + latch.arm(SubtitleIdentity.LocalMedia3(media(trackId = "b")), generation = 1) + + latch.clear() + + assertFalse(latch.hasPendingOwner) + assertNull(latch.consume(listOf(track(index = 2, trackId = "b")), "late", true)) + } + + @Test + fun `exit while remount is pending emits no selection`() { + val latch = SubtitleRemountReselection() + latch.arm(SubtitleIdentity.ServerSidecar(4), generation = 1) + + latch.clear() + + assertNull(latch.consume(listOf(track(index = 4, trackId = "prairie-subtitle:4")), "late", true)) + } + + @Test + fun `repeated and empty remount snapshots do not consume the meaningful-snapshot bound`() { + val latch = SubtitleRemountReselection(maxMeaningfulSnapshots = 2) + val identity = SubtitleIdentity.LocalMedia3(media(trackId = "target")) + latch.arm(identity, generation = 1) + + repeat(5) { + assertNull(latch.consume(emptyList(), snapshotKey = null, settled = false)) + assertNull( + latch.consume( + listOf(track(index = 2, trackId = "other")), + snapshotKey = "same-transient", + settled = false, + ), + ) + } + + assertTrue(latch.hasPendingOwner) + val event = assertIs( + latch.consume(listOf(track(index = 8, trackId = "target")), "ready", true), + ) + assertEquals(8, event.trackIndex) + } + + @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". + // Exact equality never matched, so the mount timed out and the whole + // subtitle transaction rolled back to Off. + val latch = SubtitleRemountReselection() + latch.arm(SubtitleIdentity.ServerSidecar(serverIndex = 0), generation = 1) + + val event = assertIs( + latch.consume( + listOf( + track(index = 0, trackId = "0:3"), + track(index = 19, trackId = "0:22"), + track(index = 20, trackId = "1:" + subtitleArtifactTrackId(0)), + ), + snapshotKey = "merged", + settled = true, + ), + ) + assertEquals(20, event.trackIndex) + } + + @Test + fun `settled empty snapshot never fails the mount before tracks publish`() { + // A replanned server stream reports READY before it publishes its text + // tracks. Failing here cleared the pending owner, so the tracks arrived + // with nobody to match them and the transaction rolled back to Off -- + // subtitles silently refused to turn on. + val latch = SubtitleRemountReselection() + val identity = SubtitleIdentity.ServerSidecar(serverIndex = 3) + latch.arm(identity, generation = 1) + + assertNull(latch.consume(emptyList(), snapshotKey = "ready-no-tracks", settled = true)) + assertTrue(latch.hasPendingOwner) + + val event = assertIs( + latch.consume( + listOf(track(index = 4, trackId = subtitleArtifactTrackId(3))), + snapshotKey = "tracks-arrived", + settled = true, + ), + ) + assertEquals(4, event.trackIndex) + } + + @Test + fun `settled unique remount miss rolls back without selecting another row`() { + val latch = SubtitleRemountReselection() + latch.arm( + SubtitleIdentity.LocalMedia3(media(trackId = "target", language = "en")), + generation = 1, ) - latch.arm(serverSubtitleIndex = 7) + val event = assertIs( + latch.consume( + listOf(track(index = 2, trackId = "other", language = "en")), + snapshotKey = "settled", + settled = true, + ), + ) + + assertEquals(TvSubtitleRemountFailure.Missing, event.reason) + } + + @Test + fun `settled ambiguous remount snapshot rolls back without label fallback`() { + val latch = SubtitleRemountReselection() + latch.arm( + SubtitleIdentity.LocalMedia3(media(label = "English", language = "en")), + generation = 1, + ) + + val event = assertIs( + latch.consume( + listOf( + track(index = 2, trackId = null, label = "English", language = "en"), + track(index = 3, trackId = null, label = "English", language = "en"), + ), + snapshotKey = "settled", + settled = true, + ), + ) + + assertEquals(TvSubtitleRemountFailure.Ambiguous, event.reason) + } + + @Test + fun `downloaded remount resolves the exact unique downloadId`() { + val latch = SubtitleRemountReselection() + val identity = SubtitleIdentity.Downloaded(91, media(label = "English")) + latch.arm(identity, generation = 1) + + 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"), + ), + snapshotKey = "ready", + settled = true, + ), + ) - assertNull(latch.consume(emptyList(), listOf(mounted))) - assertEquals(3, latch.consume(listOf(remountedTrack), listOf(mounted))) - assertNull(latch.consume(listOf(remountedTrack), listOf(mounted))) + assertEquals(3, event.trackIndex) } @Test - fun subtitleOffIsReappliedOnceAfterReplan() { + fun `server sidecar remount resolves the exact artifact trackId`() { val latch = SubtitleRemountReselection() + latch.arm(SubtitleIdentity.ServerSidecar(7), generation = 1) + + val event = assertIs( + latch.consume( + listOf( + track(index = 2, trackId = "prairie-subtitle:8"), + track(index = 3, trackId = "prairie-subtitle:7"), + ), + snapshotKey = "ready", + settled = true, + ), + ) + + assertEquals(3, event.trackIndex) + } - latch.arm(serverSubtitleIndex = -1) + @Test + fun `burn-in commit completes without a mounted Media3 subtitle`() { + val latch = SubtitleRemountReselection() - assertEquals(-1, latch.consume(emptyList(), emptyList())) - assertNull(latch.consume(emptyList(), emptyList())) + assertFalse(latch.requiresRemount(SubtitleIdentity.ServerBurnIn(8))) + assertFalse(latch.hasPendingOwner) + } + + @Test + fun `Off emits exactly one owned disable request`() { + val latch = SubtitleRemountReselection() + latch.arm(SubtitleIdentity.Off, generation = 5) + // A track is actually selected, so there is something to turn off. + val event = assertIs( + latch.consume(emptyList(), snapshotKey = null, settled = false), + ) + + assertEquals(-1, event.trackIndex) + assertEquals(5, event.owner.generation) + assertNull(latch.consume(emptyList(), snapshotKey = null, settled = false)) } @Test @@ -66,4 +393,38 @@ class SubtitleRemountReselectionTest { assertTrue(seekRecoveryBlock.contains("subtitleTrackIndex = selectedSubtitle")) assertTrue(seekRecoveryBlock.contains("nextTransportMountNonce(selectedSubtitle)")) } + + private fun media( + trackId: String? = null, + label: String? = "English", + language: String? = "en", + codec: String? = "webvtt", + forced: Boolean? = false, + ): SubtitleMediaIdentity = SubtitleMediaIdentity( + trackId = trackId, + label = label, + language = language, + codecFamily = codec, + forced = forced, + hearingImpaired = false, + ) + + private fun track( + index: Int, + trackId: String?, + label: String = "English", + language: String? = "en", + codec: String? = "webvtt", + forced: Boolean = false, + selected: Boolean = false, + ): PlayerTrackEntry = PlayerTrackEntry( + index = index, + trackId = trackId, + label = label, + displayLabel = label, + language = language, + codecOrMime = codec, + isSelected = selected, + isForced = forced, + ) } 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 new file mode 100644 index 000000000..8ee2e6fdf --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleTransactionIntegrationTest.kt @@ -0,0 +1,969 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +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 java.util.Collections +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.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +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.serialization.encodeToString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.intOrNull +import org.prairieserver.prairie.common.player.PlaybackSessionLifecycle +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 +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.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.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.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.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.TokenManager +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 + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +class SubtitleTransactionIntegrationTest { + @Test + fun `sidecar replacement remains unpublished until exact typed Media3 mount`() = runTest { + val harness = harness( + replanResponse = { _, _ -> response(sidecarPlan("s2", FILE_ID, B_INDEX)) }, + ) + harness.start(sidecarA) + + harness.adapter.select(sidecarB) + runCurrent() + harness.awaitReplans(1) + harness.awaitAdopted("s2") + + assertEquals(listOf("s1"), harness.replanBaseSessions) + assertReplan(harness.replanBodies.single(), audioIndex = 0, subtitleIndex = B_INDEX) + harness.assertActiveSession("s2") + assertTrue(harness.stoppedSessions.isEmpty()) + assertTrue(harness.persistence.isEmpty()) + + harness.mountPending( + expectedSessionId = "s2", + tracks = + listOf( + media3Track( + index = 6, + trackId = "label-decoy", + label = "English", + ), + harness.sidecarMountedTrack( + expectedSessionId = "s2", + serverIndex = B_INDEX, + playerIndex = 9, + ), + ), + ) + runCurrent() + harness.awaitStopped("s1") + harness.awaitPersistence(1) + runCurrent() + + 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 }) + assertEquals("s2", harness.persistence.single().second.sessionId) + harness.assertNoOrphans() + } + + @Test + fun `off supersedes an in-flight sidecar and replans from the committed session`() = runTest { + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val harness = harness( + replanResponse = { index, _ -> + if (index == 0) { + firstEntered.complete(Unit) + releaseFirst.await() + response(sidecarPlan("s2", FILE_ID, B_INDEX)) + } else { + response(basePlan("s3", FILE_ID, audioIndex = 0)) + } + }, + ) + harness.start(sidecarA) + + harness.adapter.select(sidecarB) + firstEntered.await() + harness.adapter.select(SubtitleIdentity.Off) + releaseFirst.complete(Unit) + runCurrent() + harness.awaitReplans(2) + harness.awaitStopped("s2") + harness.awaitAdopted("s3") + runCurrent() + + assertEquals(listOf("s1", "s1"), harness.replanBaseSessions) + assertReplan(harness.replanBodies[0], audioIndex = 0, subtitleIndex = B_INDEX) + assertReplan(harness.replanBodies[1], audioIndex = 0, subtitleIndex = -1) + harness.assertActiveSession("s3") + assertEquals(mapOf("s2" to 1), harness.stopCounts()) + assertTrue(harness.persistence.isEmpty()) + + harness.mountPending(expectedSessionId = "s3", tracks = emptyList()) + runCurrent() + harness.awaitStopped("s1") + harness.awaitPersistence(1) + runCurrent() + + assertEquals(listOf(Harness.MountedSelection("s3", -1)), harness.media3Selections) + harness.assertActiveSession("s3") + assertEquals(mapOf("s2" to 1, "s1" to 1), harness.stopCounts()) + assertEquals(listOf(SubtitleIdentity.Off), harness.persistence.map { it.first.identity }) + assertEquals("s3", harness.persistence.single().second.sessionId) + harness.assertNoOrphans() + } + + @Test + fun `burn-in commits without a Media3 text selection`() = runTest { + val burnIn = SubtitleIdentity.ServerBurnIn(B_INDEX) + val harness = harness( + replanResponse = { _, _ -> response(burnInPlan("s2", FILE_ID, B_INDEX)) }, + ) + harness.start(sidecarA) + + harness.adapter.select(burnIn) + runCurrent() + harness.awaitReplans(1) + harness.awaitAdopted("s2") + harness.awaitStopped("s1") + harness.awaitPersistence(1) + runCurrent() + + assertEquals(listOf("s1"), harness.replanBaseSessions) + assertReplan(harness.replanBodies.single(), audioIndex = 0, subtitleIndex = B_INDEX) + harness.assertActiveSession("s2") + 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 }) + assertEquals("s2", harness.persistence.single().second.sessionId) + harness.assertNoOrphans() + } + + @Test + fun `audio replan rebases downloaded subtitle and waits for exact download mount`() = runTest { + val downloaded = downloadedIdentity(DOWNLOAD_ID) + val originalDownloaded = downloadedRow( + index = 40, + downloadId = DOWNLOAD_ID, + url = "/stream/s1/subtitles/$DOWNLOAD_ID.vtt", + ) + val harness = harness( + replanResponse = { _, _ -> response(basePlan("s2", FILE_ID, audioIndex = 2)) }, + ) + harness.start( + committedIdentity = downloaded, + subtitleTracks = listOf(originalDownloaded), + audioTracks = listOf(AudioTrack(index = 0), AudioTrack(index = 2)), + ) + assertStart(harness.startBodies.single(), audioIndex = 0, subtitleIndex = -1) + harness.assertActiveSession("s1") + assertTrue(harness.adapter.snapshot.subtitleTracks.none { it.source == "server_artifact" }) + harness.adapter.restoreCommittedLocalMount() + assertEquals(downloaded, harness.adapter.snapshot.localMountIdentity) + harness.mountPending( + expectedSessionId = "s1", + tracks = listOf( + harness.downloadedMountedTrack( + expectedSessionId = "s1", + downloadId = DOWNLOAD_ID, + playerIndex = 7, + ), + ), + ) + runCurrent() + assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.isEmpty()) + assertEquals( + listOf(Harness.MountedSelection("s1", 7)), + harness.media3Selections, + ) + + harness.adapter.selectAudio(2) + runCurrent() + harness.awaitReplans(1) + harness.awaitAdopted("s2") + + assertEquals(listOf("s1"), harness.replanBaseSessions) + assertReplan(harness.replanBodies.single(), audioIndex = 2, subtitleIndex = -1) + harness.assertActiveSession("s2") + assertTrue(harness.stoppedSessions.isEmpty()) + assertTrue(harness.persistence.isEmpty()) + assertEquals( + "/stream/s2/subtitles/$DOWNLOAD_ID.vtt", + harness.adapter.snapshot.subtitleTracks.single { it.downloadId == DOWNLOAD_ID }.url, + ) + + harness.mountPending( + expectedSessionId = "s2", + tracks = + listOf( + media3Track(3, "download-decoy", "English"), + harness.downloadedMountedTrack( + expectedSessionId = "s2", + downloadId = DOWNLOAD_ID, + playerIndex = 8, + ), + ), + ) + runCurrent() + harness.awaitStopped("s1") + harness.awaitPersistence(1) + runCurrent() + + assertEquals( + listOf( + Harness.MountedSelection("s1", 7), + Harness.MountedSelection("s2", 8), + ), + harness.media3Selections, + ) + harness.assertActiveSession("s2") + assertEquals(mapOf("s1" to 1), harness.stopCounts()) + assertEquals(1, harness.persistence.size) + assertEquals(downloaded, harness.persistence.single().first.identity) + assertEquals(2, harness.persistence.single().first.audioTrackIndex) + assertEquals("s2", harness.persistence.single().second.sessionId) + harness.assertNoOrphans() + } + + @Test + fun `refresh owner cannot cross a content reset and a fresh owner remains live`() = runTest { + val harness = harness(replanResponse = { _, _ -> error("No replan expected") }) + harness.start(sidecarA) + val staleOwner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + val staleRow = downloadedRow(50, 501, "/stream/s1/subtitles/downloaded/501.vtt") + + val resetContext = harness.replaceContent( + contentId = "content-2", + mediaFileId = 84, + versionId = "version-84", + subtitleTracks = emptyList(), + ) + harness.adapter.resetContent(resetContext, SubtitleIdentity.Off) + harness.awaitStopped("s1") + runCurrent() + val afterReset = harness.adapter.snapshot + val persistenceBefore = harness.persistence.toList() + val selectionsBefore = harness.media3Selections.toList() + + assertFalse(harness.adapter.applyRefresh(staleOwner, listOf(staleRow), 501)) + assertFalse(harness.adapter.selectFromRefresh(staleOwner, downloadedIdentity(501))) + runCurrent() + + assertEquals(afterReset, harness.adapter.snapshot) + assertEquals(persistenceBefore, harness.persistence) + assertEquals(selectionsBefore, harness.media3Selections) + assertTrue(harness.replanBodies.isEmpty()) + harness.assertActiveSession("s2") + assertEquals(mapOf("s1" to 1), harness.stopCounts()) + harness.assertNoOrphans() + + val freshOwner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + val freshRow = downloadedRow(51, 502, "/stream/s2/subtitles/downloaded/502.vtt") + assertTrue(harness.adapter.applyRefresh(freshOwner, listOf(freshRow), null)) + + assertEquals(afterReset.subtitleRefreshNonce + 1, harness.adapter.snapshot.subtitleRefreshNonce) + assertEquals( + "/stream/s2/subtitles/downloaded/502.vtt", + harness.adapter.snapshot.subtitleTracks.single { it.downloadId == 502 }.url, + ) + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(persistenceBefore, harness.persistence) + assertTrue(harness.replanBodies.isEmpty()) + } + + private fun TestScope.harness( + replanResponse: suspend (Int, JsonObject) -> PlaybackDecisionResponseV3, + ): Harness = Harness( + scope = backgroundScope, + replanResponse = replanResponse, + ) + + private class Harness( + private val scope: CoroutineScope, + private val replanResponse: suspend (Int, JsonObject) -> PlaybackDecisionResponseV3, + ) { + val stoppedSessions: MutableList = + Collections.synchronizedList(mutableListOf()) + val replanBodies: MutableList = + Collections.synchronizedList(mutableListOf()) + val startBodies: MutableList = + Collections.synchronizedList(mutableListOf()) + val replanBaseSessions: MutableList = + Collections.synchronizedList(mutableListOf()) + val persistence: MutableList> = + Collections.synchronizedList(mutableListOf()) + private val adoptedPlaybackRows: MutableMap> = + Collections.synchronizedMap(mutableMapOf()) + 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() + private val remount = SubtitleRemountReselection() + private var mountGeneration = 0L + private val client = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + val payload = when { + path == "/api/v1/playback/start" -> { + val body = PrairieJson.parseToJsonElement( + request.body.toByteArray().decodeToString(), + ).jsonObject + startBodies += body + when (startIndex.getAndIncrement()) { + 0 -> if ( + (body["subtitle_track_index"]?.jsonPrimitive?.intOrNull ?: -1) == -1 + ) { + response(basePlan("s1", FILE_ID, audioIndex = 0)) + } else { + response(sidecarPlan("s1", FILE_ID, A_INDEX)) + } + 1 -> response(basePlan("s2", 84, audioIndex = 0)) + else -> error("Unexpected playback start") + } + } + path.endsWith("/replan") -> { + replanBaseSessions += path + .substringBeforeLast("/replan") + .substringAfterLast('/') + val body = PrairieJson.parseToJsonElement( + request.body.toByteArray().decodeToString(), + ).jsonObject + replanBodies += body + replanEvents.send(Unit) + replanResponse(replanIndex.getAndIncrement(), body) + } + request.method == HttpMethod.Delete && + path.startsWith("/api/v1/playback/") -> { + val sessionId = path.substringAfterLast('/') + stoppedSessions += sessionId + stoppedEvents.send(sessionId) + null + } + else -> null + } + respond( + content = payload?.let(PrairieJson::encodeToString) ?: "{}", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val manager = PlaybackSessionManager( + playbackRepository = PlaybackRepository(PlaybackApi(client)), + tokenManager = IntegrationTokenManager, + committedSessionCleanupScope = scope, + ) + val lifecycle = PlaybackSessionLifecycle( + sessionManager = manager, + profileRepository = IntegrationProfileRepository(), + healthApi = IntegrationHealthApi(), + personalDataRepository = IntegrationPersonalDataRepository(), + scope = scope, + ) + lateinit var adapter: TvSubtitleTransactionAdapter + private set + + suspend fun start( + committedIdentity: SubtitleIdentity, + subtitleTracks: List = emptyList(), + audioTracks: List = listOf(AudioTrack(index = 0)), + ) { + val initialSubtitleIndex = committedIdentity.serverTrackIndex() + val startResult = manager.startVideoSessionV3( + fileId = FILE_ID, + profileId = PROFILE_ID, + capabilities = capabilities, + clientPlaybackContext = playbackContext, + audioTrackIndex = 0, + subtitleTrackIndex = initialSubtitleIndex, + qualityPreference = "original", + startPosition = 42.0, + subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, + ) + val success = assertIs>( + startResult, + (startResult as? ApiResult.NetworkError)?.exception?.stackTraceToString(), + ) + val ready = assertIs( + success.data, + ) + if (committedIdentity is SubtitleIdentity.Downloaded) { + assertEquals(PlaybackSubtitleModeV3.OFF, ready.plan.subtitle.mode) + assertNull(ready.plan.subtitle.artifact) + assertNull(ready.plan.selectedTracks.subtitle) + assertTrue(ready.session.subtitleUrls.isNullOrEmpty()) + } + lifecycle.adoptActiveSession( + params = startParams( + contentId = CONTENT_ID, + fileId = FILE_ID, + audioTrackIndex = 0, + subtitleTrackIndex = initialSubtitleIndex, + ), + session = ready.session, + manageProgress = false, + renewMissingSessionWithLegacyStart = false, + ) + adapter = TvSubtitleTransactionAdapter( + scope = scope, + stagedPort = PlaybackSessionManagerTvSubtitleStagedReplanPort(manager, lifecycle), + persistencePort = object : TvSubtitlePersistencePort { + override suspend fun persist( + committed: CommittedSubtitle, + context: TvSubtitlePlaybackContext, + ): Boolean { + persistence += committed to context + persistenceEvents.send(Unit) + return true + } + }, + durablePersistenceScope = scope, + settlementScope = scope, + onCommittedPlayback = { adoption -> + val candidate = requireNotNull(adoption.playback.ready) + val adopted = lifecycle.adoptActiveSessionIfCurrent( + params = startParams( + contentId = CONTENT_ID, + fileId = candidate.session.mediaFileId, + audioTrackIndex = adoption.committed.audioTrackIndex, + subtitleTrackIndex = adoption.committed.identity.serverTrackIndex(), + ), + 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) + TvSubtitleAdoptionResult.Adopted + } else { + TvSubtitleAdoptionResult.Superseded + } + }, + onCommittedPlaybackConfirmed = { true }, + onCommittedPlaybackRollback = { _, _ -> true }, + ) + adapter.resetContent( + context = context( + subtitleTracks = subtitleTracks, + audioTracks = audioTracks, + ), + committedIdentity = committedIdentity, + ) + } + + suspend fun replaceContent( + contentId: String, + mediaFileId: Int, + versionId: String, + subtitleTracks: List, + ): TvSubtitlePlaybackContext { + val ready = assertIs( + assertIs>( + manager.startVideoSessionV3( + fileId = mediaFileId, + profileId = PROFILE_ID, + capabilities = capabilities, + clientPlaybackContext = playbackContext, + audioTrackIndex = 0, + subtitleTrackIndex = -1, + qualityPreference = "original", + startPosition = 0.0, + subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, + ), + ).data, + ) + lifecycle.adoptActiveSession( + params = startParams( + contentId = contentId, + fileId = mediaFileId, + audioTrackIndex = 0, + subtitleTrackIndex = -1, + ), + session = ready.session, + manageProgress = false, + renewMissingSessionWithLegacyStart = false, + ) + assertIs>(manager.stopSession("s1")) + return context( + contentId = contentId, + mediaFileId = mediaFileId, + versionId = versionId, + sessionId = ready.session.sessionId, + subtitleTracks = subtitleTracks, + ) + } + + fun context( + contentId: String = CONTENT_ID, + mediaFileId: Int = FILE_ID, + versionId: String = "version-$FILE_ID", + sessionId: String = "s1", + subtitleTracks: List = emptyList(), + audioTracks: List = listOf(AudioTrack(index = 0)), + ): TvSubtitlePlaybackContext = TvSubtitlePlaybackContext( + contentId = contentId, + mediaFileId = mediaFileId, + versionId = versionId, + sessionId = sessionId, + positionSeconds = 42.0, + audioTrackIndex = 0, + qualityPreference = "original", + subtitleTracks = subtitleTracks, + audioTracks = audioTracks, + outputRouteGeneration = OUTPUT_GENERATION, + capabilities = capabilities, + clientPlaybackContext = playbackContext, + writeScope = tvTestPlaybackWriteScope, + ) + + fun mountPending( + expectedSessionId: String, + tracks: List, + ) { + assertActiveSession(expectedSessionId) + val identity = requireNotNull(adapter.snapshot.localMountIdentity) + mountGeneration += 1 + remount.arm(identity, mountGeneration) + val event = assertIs( + remount.consume( + subtitleTracks = tracks, + snapshotKey = "mount-$mountGeneration", + settled = true, + ), + ) + media3Selections += MountedSelection(expectedSessionId, event.trackIndex) + adapter.reportMountedSelection( + identity = event.owner.identity, + selected = true, + snapshotKey = "mount-$mountGeneration", + settled = true, + ) + } + + fun sidecarMountedTrack( + expectedSessionId: String, + serverIndex: Int, + playerIndex: Int, + ): PlayerTrackEntry { + val row = mountedRow(expectedSessionId) { + it.index == serverIndex && it.source == "server_artifact" + } + assertEquals("/stream/$expectedSessionId/subtitles/$serverIndex.vtt", row.url) + val artifactTrackId = subtitleArtifactTrackId(row.index) + assertEquals(subtitleArtifactTrackId(serverIndex), artifactTrackId) + return media3Track( + index = playerIndex, + trackId = artifactTrackId, + label = row.label ?: error("Adopted sidecar row omitted its label"), + ) + } + + fun downloadedMountedTrack( + expectedSessionId: String, + downloadId: Int, + playerIndex: Int, + ): PlayerTrackEntry { + val row = mountedRow(expectedSessionId) { it.downloadId == downloadId } + assertEquals("/stream/$expectedSessionId/subtitles/$downloadId.vtt", row.url) + val artifactTrackId = downloadedSubtitleArtifactTrackId( + requireNotNull(row.downloadId), + ) + assertEquals(downloadedSubtitleArtifactTrackId(downloadId), artifactTrackId) + return media3Track( + index = playerIndex, + trackId = artifactTrackId, + label = row.label ?: error("Downloaded row omitted its label"), + ) + } + + private fun mountedRow( + expectedSessionId: String, + predicate: (PlayerSubtitleInfo) -> Boolean, + ): PlayerSubtitleInfo { + assertActiveSession(expectedSessionId) + val snapshotRow = adapter.snapshot.subtitleTracks.single(predicate) + adoptedPlaybackRows[expectedSessionId]?.let { adoptedRows -> + assertEquals(snapshotRow, adoptedRows.single(predicate)) + } + return snapshotRow + } + + 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. + } + } + } + } + + suspend fun awaitReplans(count: Int) { + while (replanBodies.size < count) { + withContext(Dispatchers.Default) { + withTimeout(5_000) { 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. + } + } + } + } + + suspend fun awaitPersistence(count: Int) { + while (persistence.size < count) { + withContext(Dispatchers.Default) { + withTimeout(5_000) { persistenceEvents.receive() } + } + } + } + + fun stopCounts(): Map = + stoppedSessions.groupingBy { it }.eachCount() + + fun assertActiveSession(expectedSessionId: String) { + assertEquals(expectedSessionId, manager.activeSessionIdForTest()) + assertEquals(expectedSessionId, lifecycle.activeSessionId()) + } + + suspend fun assertNoOrphans() { + withContext(Dispatchers.Default) { + withTimeout(EVENT_TIMEOUT_MS) { + while (manager.orphanedSessionIdsForTest().isNotEmpty()) { + kotlinx.coroutines.yield() + } + } + } + assertEquals(emptySet(), manager.orphanedSessionIdsForTest()) + } + + data class MountedSelection( + val sessionId: String, + val trackIndex: Int, + ) + } + + private companion object { + // Publication cleanup, orphan drainage, replan, adoption, and + // persistence are all owned by this harness's structured scope. Keep a + // short deadlock backstop: hosted scheduling must not require a wider + // wall-clock allowance. + const val EVENT_TIMEOUT_MS = 5_000L + + const val CONTENT_ID = "content-1" + const val FILE_ID = 42 + const val PROFILE_ID = "profile-1" + const val A_INDEX = 3 + const val B_INDEX = 4 + const val DOWNLOAD_ID = 312 + const val OUTPUT_GENERATION = 7L + + val sidecarA = SubtitleIdentity.ServerSidecar(A_INDEX) + val sidecarB = SubtitleIdentity.ServerSidecar( + B_INDEX, + SubtitleMediaIdentity(label = "English", language = "en", codecFamily = "webvtt"), + ) + val capabilities = ClientCodecCapabilities( + codecsVideo = listOf("hevc"), + codecsAudio = listOf("eac3"), + containers = listOf("mkv"), + ) + val playbackContext = ClientPlaybackContext( + formFactor = "tv", + appVersion = "integration-test", + output = PlaybackOutputContext(outputRouteGeneration = OUTPUT_GENERATION), + ) + + fun startParams( + contentId: String, + fileId: Int, + audioTrackIndex: Int?, + subtitleTrackIndex: Int?, + ) = StartParams( + contentId = contentId, + fileId = fileId, + capabilities = capabilities, + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + qualityPreference = "original", + startPosition = 42.0, + clientPlaybackContext = playbackContext, + ) + + fun response(plan: PlaybackPlanV3) = PlaybackDecisionResponseV3( + protocolVersion = 3, + serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + outcome = PlaybackDecisionOutcome.PLAYABLE, + sessionId = plan.sessionId, + playbackPlan = plan, + ) + + fun basePlan( + sessionId: String, + fileId: Int, + audioIndex: Int, + ) = PlaybackPlanV3( + planId = "plan-$sessionId", + sessionId = sessionId, + delivery = PlaybackDelivery.SERVER_REMUX_HLS, + engine = PlaybackEngineKind.MEDIA3_HLS, + stream = PlaybackStreamV3( + url = "/stream/$sessionId/master.m3u8", + protocol = PlaybackStreamProtocol.HLS, + container = "mpegts", + mimeType = "application/x-mpegURL", + ), + selectedTracks = SelectedPlaybackTracksV3( + audio = PlaybackTrackIdentityV3("file:$fileId:audio:$audioIndex", audioIndex), + ), + effectiveRecipe = PlaybackEffectiveRecipeV3( + videoCodec = "hevc", + audioCodec = "eac3", + ), + decisionReason = "integration-test", + requestedMediaFileId = fileId, + effectiveMediaFileId = fileId, + ) + + fun sidecarPlan( + sessionId: String, + fileId: Int, + subtitleIndex: Int, + ) = basePlan(sessionId, fileId, audioIndex = 0).copy( + selectedTracks = SelectedPlaybackTracksV3( + audio = PlaybackTrackIdentityV3("file:$fileId:audio:0", 0), + subtitle = PlaybackTrackIdentityV3( + "file:$fileId:subtitle:$subtitleIndex", + subtitleIndex, + ), + ), + subtitle = PlaybackSubtitleDecisionV3( + mode = PlaybackSubtitleModeV3.CONVERT, + trackId = "file:$fileId:subtitle:$subtitleIndex", + artifact = PlaybackSubtitleArtifactV3( + url = "/stream/$sessionId/subtitles/$subtitleIndex.vtt", + mimeType = "text/vtt", + format = "webvtt", + ), + ), + ) + + fun burnInPlan( + sessionId: String, + fileId: Int, + subtitleIndex: Int, + ) = basePlan(sessionId, fileId, audioIndex = 0).copy( + selectedTracks = SelectedPlaybackTracksV3( + audio = PlaybackTrackIdentityV3("file:$fileId:audio:0", 0), + subtitle = PlaybackTrackIdentityV3( + "file:$fileId:subtitle:$subtitleIndex", + subtitleIndex, + ), + ), + subtitle = PlaybackSubtitleDecisionV3( + mode = PlaybackSubtitleModeV3.BURN_IN, + trackId = "file:$fileId:subtitle:$subtitleIndex", + ), + ) + + 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) + if (subtitleIndex < 0) { + assertTrue(selected["subtitle"] == null || selected["subtitle"].toString() == "null") + } else { + assertEquals( + subtitleIndex, + selected.getValue("subtitle").jsonObject.getValue("index").jsonPrimitive.int, + ) + } + assertEquals(OUTPUT_GENERATION, body.getValue("output_route_generation").jsonPrimitive.content.toLong()) + } + + fun assertStart(body: JsonObject, audioIndex: Int, subtitleIndex: Int) { + assertEquals(audioIndex, body.getValue("audio_track_index").jsonPrimitive.int) + assertEquals( + subtitleIndex, + body["subtitle_track_index"]?.jsonPrimitive?.intOrNull ?: -1, + ) + assertEquals( + OUTPUT_GENERATION, + body.getValue("output_route_generation").jsonPrimitive.content.toLong(), + ) + } + + fun downloadedIdentity(downloadId: Int) = SubtitleIdentity.Downloaded( + downloadId = downloadId, + media = SubtitleMediaIdentity( + trackId = downloadedSubtitleArtifactTrackId(downloadId), + label = "English", + language = "en", + codecFamily = "webvtt", + forced = false, + hearingImpaired = false, + ), + ) + + fun downloadedRow(index: Int, downloadId: Int, url: String) = PlayerSubtitleInfo( + index = index, + language = "en", + codec = "webvtt", + label = "English", + source = "downloaded", + forced = false, + url = url, + downloadId = downloadId, + ) + + fun media3Track(index: Int, trackId: String, label: String) = PlayerTrackEntry( + index = index, + trackId = trackId, + label = label, + displayLabel = label, + language = "en", + codecOrMime = "text/vtt", + isSelected = false, + ) + } +} + +private fun PlaybackSessionLifecycle.activeSessionId(): String? = + (state.value as? SessionState.Active)?.session?.sessionId + +private fun SubtitleIdentity.serverTrackIndex(): Int = when (this) { + SubtitleIdentity.Off -> -1 + is SubtitleIdentity.ServerSidecar -> serverIndex + is SubtitleIdentity.ServerBurnIn -> serverIndex + is SubtitleIdentity.Embedded -> serverIndex + is SubtitleIdentity.Downloaded, + is SubtitleIdentity.LocalMedia3, + -> -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")) +} + +private class IntegrationPersonalDataRepository : PersonalDataRepository( + personalDataApi = PersonalDataApi(HttpClient()), +) { + override suspend fun syncProgress(items: List): ApiResult = + ApiResult.Success(Unit) +} + +private object IntegrationTokenManager : 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/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TestPlaybackWriteScope.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TestPlaybackWriteScope.kt new file mode 100644 index 000000000..8a26dbdf4 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TestPlaybackWriteScope.kt @@ -0,0 +1,10 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.repository.port.PlaybackWriteScope + +internal val tvTestPlaybackWriteScope = PlaybackWriteScope( + serverId = "server-test", + profileId = "profile-test", + credentialGenerationId = null, + identityGeneration = 1L, +) 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 new file mode 100644 index 000000000..1d2b6f743 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackFreshLoadOwnershipTest.kt @@ -0,0 +1,632 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +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.network.ApiResult +import org.prairieserver.prairie.playback.encodeSubtitleIdentityPreference + +class TvPlaybackFreshLoadOwnershipTest { + @Test + fun `fresh TV starter defers manager and lifecycle publication until UI is ready`() { + val source = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt", + ).readText() + val managerStart = source + .substringAfter("playbackSessionManager.startVideoSessionV3(") + .substringBefore("\n )") + val lifecycleAdoption = source + .substringAfter("sessionLifecycle.adoptActiveSessionIfCurrent(") + .substringBefore("\n )") + + assertTrue(managerStart.contains("deferPublication = true")) + assertTrue(lifecycleAdoption.contains("deferPublication = true")) + } + + @Test + fun `post publication failure restores exact predecessor while stale B cannot overwrite C`() { + data class State(val sessionId: String) + + val ownership = TvUnpublishedLoadUiOwnership() + val a = State("session-a") + val b = State("session-b") + val c = State("session-c") + var current = a + + ownership.register( + "session-b", + state = a, + context = "context-a", + predecessorSessionId = a.sessionId, + ) + current = b + val failure = assertFailsWith { + throw IllegalStateException("subtitle reset failed after UI publication") + } + assertEquals("subtitle reset failed after UI publication", failure.message) + ownership.snapshotForRollback("session-b")?.let { predecessor -> + if (current.sessionId == "session-b") current = predecessor.state + } + ownership.completeRollback("session-b") + assertEquals(a, current) + + ownership.register( + "session-b", + state = a, + context = "context-a", + predecessorSessionId = a.sessionId, + ) + current = c + ownership.snapshotForRollback("session-b")?.let { predecessor -> + if (current.sessionId == "session-b") current = predecessor.state + } + ownership.completeRollback("session-b") + assertEquals(c, current) + + ownership.register( + "session-b", + state = a, + context = "context-a", + predecessorSessionId = a.sessionId, + ) + ownership.register( + "session-c", + state = b, + context = "context-b", + predecessorSessionId = b.sessionId, + ) + assertEquals( + a, + ownership.snapshotForRollback("session-c")?.state, + "C must inherit authoritative A while unpublished B is rolling back.", + ) + } + + @Test + fun `fresh Ready ViewModel wires UI predecessor before mutation and releases it on confirm`() { + 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 rollback = source + .substringAfter("private suspend fun rollbackUnpublishedTvLoadSession") + .substringBefore("/**", missingDelimiterValue = source) + + assertTrue( + ready.indexOf("unpublishedTvLoadUi.register(") < + ready.indexOf("_uiState.update"), + ) + assertTrue( + ready.indexOf("unpublishedTvLoadUi.confirm(publishedSessionId)") > + ready.indexOf("settlePendingPublicationIfCurrent("), + ) + assertTrue(rollback.contains("snapshotForRollback(sessionId)")) + assertTrue(rollback.contains("completeRollback(sessionId)")) + assertTrue(rollback.contains("_uiState.value.sessionId == sessionId")) + assertTrue(rollback.contains("subtitleTransactions.resetContent(")) + } + + @Test + fun `post Ready local selection hydration and publish exceptions rollback exactly once`() = + runTest { + for (stage in listOf("local-selection", "hydration", "publish")) { + val rolledBack = mutableListOf() + val ownership = TvUnpublishedLoadSessionOwnership { rolledBack += it } + ownership.acquire("session-b") + + try { + throw IllegalStateException(stage) + } catch (_: IllegalStateException) { + ownership.rollbackIfOwned() + } + ownership.rollbackIfOwned() + + assertEquals(listOf("session-b"), rolledBack, stage) + } + } + + @Test + fun `post Ready cancellation and stale cleanup share one non cancellable rollback`() = + runTest { + val rollbackContexts = mutableListOf() + val rolledBack = mutableListOf() + val ownership = TvUnpublishedLoadSessionOwnership { sessionId -> + rollbackContexts += currentCoroutineContext().isActive + rolledBack += sessionId + } + ownership.acquire("session-b") + + ownership.rollbackIfOwned("session-b") + ownership.rollbackIfOwned() + + assertEquals(listOf("session-b"), rolledBack) + assertEquals(listOf(true), rollbackContexts) + } + + @Test + fun `confirmed Ready transfers ownership and cannot be rolled back by later cancellation`() = + runTest { + val ownership = TvUnpublishedLoadSessionOwnership { + error("confirmed manager and lifecycle ownership must not be rolled back") + } + ownership.acquire("session-b") + + assertTrue(ownership.transferConfirmed("session-b")) + ownership.rollbackIfOwned() + + assertFalse(ownership.transferConfirmed("session-b")) + } + + @Test + fun `T18 fresh TV playback hydrates downloaded rows before restoring downloadId`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val owner = registry.begin("movie", 11, "original") + val wanted = SubtitleIdentity.Downloaded( + downloadId = 91, + media = media(trackId = "prairie-downloaded-subtitle:91"), + ) + + val result = resolveOwnedTvFreshSubtitleRestore( + owner = owner, + registry = registry, + preference = encodeSubtitleIdentityPreference(wanted), + catalogTracks = emptyList(), + initialRows = emptyList(), + sessionId = "session-1", + serverUrl = "https://prairie.test", + hydrateDownloadedRows = { + ApiResult.Success(listOf(downloadedRow(index = 5, downloadId = 91))) + }, + ) + + val ownedResult = requireNotNull(result) + val resolution = assertIs(ownedResult.resolution) + assertEquals(91, assertIs(resolution.identity).downloadId) + assertEquals(91, ownedResult.rows.single().downloadId) + } + + @Test + fun `fresh restore does not publish the server rows twice`() = runTest { + // The hydrate lambda in TvPlayerViewModel calls mergeDownloadedSubtitles + // with the server rows as `existing`, so it returns the FULL set, not + // just the downloaded ones. Concatenating the retained rows onto that + // duplicated every server row: 21 rows became 42, the sidecar builder + // mounted each twice, and the picker listed every track twice. + val registry = TvPlayerLoadOwnerRegistry() + val owner = registry.begin("movie", 11, "original") + val serverRows = listOf( + sidecarRow(index = 0, label = "English"), + sidecarRow(index = 1, label = "Forced"), + sidecarRow(index = 2, label = "SDH"), + ) + + val result = resolveOwnedTvFreshSubtitleRestore( + owner = owner, + registry = registry, + preference = null, + catalogTracks = emptyList(), + initialRows = serverRows, + sessionId = "session-1", + serverUrl = "https://prairie.test", + hydrateDownloadedRows = { + // Exactly what the production lambda returns: existing + downloaded. + ApiResult.Success(serverRows + downloadedRow(index = 3, downloadId = 91)) + }, + ) + + val rows = requireNotNull(result).rows + assertEquals( + listOf(0, 1, 2, 3), + rows.map { it.index }, + "every row must appear once, keyed by its combined index", + ) + assertEquals(91, rows.single { it.index == 3 }.downloadId) + } + + @Test + fun `T19 hydration failure does not replace the committed server subtitle`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val owner = registry.begin("movie", 11, "original") + val committed = sidecarRow(index = 3, label = "French") + val wanted = SubtitleIdentity.ServerSidecar( + serverIndex = 4, + media = media(label = "English"), + ) + + val result = resolveOwnedTvFreshSubtitleRestore( + owner = owner, + registry = registry, + preference = encodeSubtitleIdentityPreference(wanted), + catalogTracks = listOf(externalTrack(index = 4, label = "English")), + initialRows = listOf(committed), + sessionId = "session-1", + serverUrl = "https://prairie.test", + hydrateDownloadedRows = { + ApiResult.NetworkError(IllegalStateException("hydration failed")) + }, + ) + + assertEquals(listOf(committed), result?.rows) + assertNull(result?.resolution) + } + + @Test + fun `T20 cancelled hydration cannot publish`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val owner = registry.begin("movie", 11, "original") + + assertFailsWith { + resolveOwnedTvFreshSubtitleRestore( + owner = owner, + registry = registry, + preference = null, + catalogTracks = emptyList(), + initialRows = emptyList(), + sessionId = "session-1", + serverUrl = "https://prairie.test", + hydrateDownloadedRows = { throw CancellationException("cancelled") }, + ) + } + } + + @Test + fun `T21 stale hydration response cannot publish into a newer session`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val oldOwner = registry.begin("movie", 11, "original") + val hydration = CompletableDeferred>>() + val stale = async { + resolveOwnedTvFreshSubtitleRestore( + owner = oldOwner, + registry = registry, + preference = encodeSubtitleIdentityPreference(SubtitleIdentity.Off), + catalogTracks = emptyList(), + initialRows = emptyList(), + sessionId = "old-session", + serverUrl = "https://prairie.test", + hydrateDownloadedRows = { hydration.await() }, + ) + } + runCurrent() + + registry.begin("movie", 22, "720p") + hydration.complete(ApiResult.Success(emptyList())) + + assertNull(stale.await()) + } + + @Test + fun `T22 restart restores only the current content file quality owner`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val oldOwner = registry.begin("movie", 11, "original") + val newestOwner = registry.begin("movie", 22, "720p") + + val stale = resolveOwnedTvFreshSubtitleRestore( + owner = oldOwner, + registry = registry, + preference = encodeSubtitleIdentityPreference(SubtitleIdentity.Off), + catalogTracks = emptyList(), + initialRows = emptyList(), + sessionId = "old-session", + serverUrl = "https://prairie.test", + hydrateDownloadedRows = { ApiResult.Success(emptyList()) }, + ) + val current = resolveOwnedTvFreshSubtitleRestore( + owner = newestOwner, + registry = registry, + preference = encodeSubtitleIdentityPreference(SubtitleIdentity.Off), + catalogTracks = emptyList(), + initialRows = emptyList(), + sessionId = "new-session", + serverUrl = "https://prairie.test", + hydrateDownloadedRows = { ApiResult.Success(emptyList()) }, + ) + + assertNull(stale) + assertEquals(SubtitleIdentity.Off, current?.resolution?.identity) + } + + @Test + fun `T23 exit invalidates fresh restore before publication`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val owner = registry.begin("movie", 11, "original") + registry.invalidate() + + val result = resolveOwnedTvFreshSubtitleRestore( + owner = owner, + registry = registry, + preference = encodeSubtitleIdentityPreference(SubtitleIdentity.Off), + catalogTracks = emptyList(), + initialRows = emptyList(), + sessionId = "unpublished-session", + serverUrl = "https://prairie.test", + hydrateDownloadedRows = { ApiResult.Success(emptyList()) }, + ) + + assertNull(result) + } + + @Test + fun `T63 version load fences an older quality transaction before startup`() { + val registry = TvPlayerLoadOwnerRegistry() + var transactionInvalidations = 0 + val fence = TvPlayerMutationFence(registry) { transactionInvalidations += 1 } + val oldOwner = fence.beginLoad("movie", 11, "original") + + val newOwner = fence.beginLoad("movie", 22, "720p") + + assertFalse(fence.owns(oldOwner)) + assertTrue(fence.owns(newOwner)) + assertEquals( + 0, + transactionInvalidations, + "Load ownership is synchronous; adapter invalidation now awaits settlement in the load worker.", + ) + } + + @Test + fun `T64 version load fences an older output route transaction before startup`() { + val registry = TvPlayerLoadOwnerRegistry() + val events = mutableListOf() + val fence = TvPlayerMutationFence(registry) { events += "invalidate-route-and-subtitle" } + + fence.beginLoad("movie", 11, "original") + events += "start-quality-or-route-work" + fence.beginLoad("movie", 22, "original") + events += "start-version-B" + + assertEquals( + listOf( + "start-quality-or-route-work", + "start-version-B", + ), + events, + ) + } + + @Test + fun `T89 exit invalidates load refresh mount and persistence owners together`() { + val registry = TvPlayerLoadOwnerRegistry() + var transactionInvalidations = 0 + val fence = TvPlayerMutationFence(registry) { transactionInvalidations += 1 } + val owner = fence.beginLoad("movie", 11, "original") + + fence.invalidateAll() + + assertFalse(fence.owns(owner)) + assertEquals(1, transactionInvalidations) + } + + @Test + fun `T90 failed version B load keeps version A mounted and committed`() { + val versionA = TvPlayerViewModel.UiState( + isLoading = false, + error = null, + sessionId = "session-a", + streamUrl = "https://prairie.test/session-a/master.m3u8", + selectedFileId = 11, + mediaFileId = 11, + committedSubtitleIdentity = SubtitleIdentity.ServerSidecar(3), + ) + + val applying = beginTvReplacementLoad(versionA) + val failed = failTvReplacementLoad(applying, "Version B failed") + + assertFalse(applying.isLoading) + assertEquals(versionA.sessionId, applying.sessionId) + assertEquals(versionA.streamUrl, applying.streamUrl) + assertEquals(versionA.selectedFileId, applying.selectedFileId) + assertEquals(versionA.committedSubtitleIdentity, applying.committedSubtitleIdentity) + assertFalse(failed.isLoading) + assertNull(failed.error) + assertEquals(versionA.sessionId, failed.sessionId) + assertEquals(versionA.streamUrl, failed.streamUrl) + assertEquals(versionA.selectedFileId, failed.selectedFileId) + assertEquals(versionA.committedSubtitleIdentity, failed.committedSubtitleIdentity) + } + + @Test + fun `T59 same content file and quality loads publish the newest owner only`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val first = registry.begin( + contentId = "movie", + preferredFileId = 11, + preferredQuality = "original", + ) + val newest = registry.begin( + contentId = "movie", + preferredFileId = 22, + preferredQuality = "720p", + ) + val published = mutableListOf() + + assertFalse(registry.runIfOwned(first) { published += "first" }) + assertTrue(registry.runIfOwned(newest) { published += "newest" }) + assertEquals(listOf("newest"), published) + } + + @Test + fun `different content file and quality completions reject every stale owner`() { + val registry = TvPlayerLoadOwnerRegistry() + val contentA = registry.begin("content-a", 11, "original") + val versionA = registry.begin("content-a", 22, "original") + val qualityA = registry.begin("content-a", 22, "720p") + val contentB = registry.begin("content-b", 33, "auto") + + assertFalse(registry.owns(contentA)) + assertFalse(registry.owns(versionA)) + assertFalse(registry.owns(qualityA)) + assertTrue(registry.owns(contentB)) + } + + @Test + fun `owned start context retains the exact generation across suspensions`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val owner = registry.begin("movie", 11, "720p") + + val ownership = registry.withOwner(owner) { + currentTvPlayerLoadOwnership() + } + + assertEquals(owner, ownership?.owner) + assertTrue(requireNotNull(ownership).isCurrent()) + } + + @Test + fun `T60 version switch stops a non cooperative stale Ready without publication`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val oldOwner = registry.begin("movie", 11, "original") + val starter = NonCooperativeReadyStarter() + val published = mutableListOf() + val stopped = mutableListOf() + val oldLoad = launch { + val sessionId = starter.start() + registry.publishReadyIfOwned( + owner = oldOwner, + sessionId = sessionId, + publish = { published += sessionId }, + stopStaleSession = { stopped += it }, + ) + } + starter.awaitStarted() + + val newOwner = registry.begin("movie", 22, "720p") + starter.complete("stale-session") + runCurrent() + + assertTrue(registry.owns(newOwner)) + assertEquals(emptyList(), published) + assertEquals(listOf("stale-session"), stopped) + oldLoad.join() + } + + @Test + fun `T61 exit invalidates the current owner and cleans a late Ready`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val owner = registry.begin("movie", 11, "original") + val stopped = mutableListOf() + var published = false + + registry.invalidate() + val accepted = registry.publishReadyIfOwned( + owner = owner, + sessionId = "late-session", + publish = { published = true }, + stopStaleSession = { stopped += it }, + ) + + assertFalse(accepted) + assertFalse(published) + assertEquals(listOf("late-session"), stopped) + } + + @Test + fun `clear invalidates the current owner and rejects a late failure`() { + val registry = TvPlayerLoadOwnerRegistry() + val owner = registry.begin("movie", 11, "original") + var staleFailurePublished = false + + registry.invalidate() + + assertFalse(registry.runIfOwned(owner) { staleFailurePublished = true }) + assertFalse(staleFailurePublished) + } + + @Test + fun `missing stale session id rejects publication without cleanup call`() = runTest { + val registry = TvPlayerLoadOwnerRegistry() + val owner = registry.begin("movie", 11, "original") + var cleanupCalls = 0 + + registry.invalidate() + val accepted = registry.publishReadyIfOwned( + owner = owner, + sessionId = null, + publish = { error("stale load published") }, + stopStaleSession = { cleanupCalls += 1 }, + ) + + assertFalse(accepted) + assertEquals(0, cleanupCalls) + } + + private fun downloadedRow(index: Int, downloadId: Int) = PlayerSubtitleInfo( + index = index, + language = "en", + codec = "vtt", + label = "English", + source = "downloaded", + forced = false, + url = "/subtitles/$downloadId.vtt", + downloadId = downloadId, + mediaTrackId = "prairie-downloaded-subtitle:$downloadId", + ) + + private fun sidecarRow(index: Int, label: String) = PlayerSubtitleInfo( + index = index, + language = "en", + codec = "srt", + label = label, + source = "server_artifact", + forced = false, + url = "/subtitles/$index.srt", + ) + + private fun externalTrack(index: Int, label: String) = SubtitleTrack( + index = index, + codec = "srt", + language = "en", + title = label, + external = true, + ) + + private fun media( + trackId: String? = null, + label: String? = "English", + ) = SubtitleMediaIdentity( + trackId = trackId, + label = label, + language = "en", + codecFamily = "subrip", + forced = false, + ) +} + +private class NonCooperativeReadyStarter { + private val started = CompletableDeferred() + private var continuation: Continuation? = null + + suspend fun start(): String = suspendCoroutine { pending -> + continuation = pending + started.complete(Unit) + } + + suspend fun awaitStarted() { + started.await() + } + + fun complete(sessionId: String) { + requireNotNull(continuation).resume(sessionId) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerPresentationStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerPresentationStateTest.kt new file mode 100644 index 000000000..8bb4bd0db --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerPresentationStateTest.kt @@ -0,0 +1,55 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvPlayerPresentationStateTest { + @Test + fun positionOnlyChangesProduceEqualPresentationState() { + val first = TvPlayerViewModel.UiState(position = 12.0, duration = 100.0) + val second = first.copy(position = 12.5) + + assertEquals(first.withoutPlaybackClock(), second.withoutPlaybackClock()) + assertEquals(PlaybackClock(12.0, 100.0), first.toPlaybackClock()) + assertEquals(PlaybackClock(12.5, 100.0), second.toPlaybackClock()) + } + + @Test + fun durationOnlyChangesProduceEqualPresentationState() { + val first = TvPlayerViewModel.UiState(position = 12.0, duration = 100.0) + val second = first.copy(duration = 101.0) + + assertEquals(first.withoutPlaybackClock(), second.withoutPlaybackClock()) + assertEquals(PlaybackClock(12.0, 100.0), first.toPlaybackClock()) + assertEquals(PlaybackClock(12.0, 101.0), second.toPlaybackClock()) + } + + @Test + fun screenCollectsStructuralStateAtRootAndClockOnlyInsideWrapper() { + val source = playerScreenSource() + + assertTrue(source.contains("viewModel.presentationState.collectAsState()")) + assertFalse(source.contains("val state by viewModel.uiState.collectAsState()")) + assertTrue(source.contains("viewModel.playbackClock.collectAsState()")) + assertTrue(source.contains("viewModel.uiState.value.toPrairieCastPlaybackState(")) + assertFalse(source.contains("latestPlayerState")) + } + + @Test + fun structuralStateIsNeverUsedForPlaybackDuration() { + val source = playerScreenSource() + listOf( + "durationSeconds = state.duration", + "durationSec = state.duration", + ).forEach { forbidden -> + assertFalse(source.contains(forbidden), "forbidden: $forbidden") + } + } + + private fun playerScreenSource(): String = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt", + ).readText() +} 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 new file mode 100644 index 000000000..0dd6d2426 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt @@ -0,0 +1,220 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals +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.playback.audioTrackFingerprint +import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate +import kotlin.test.assertIs + +class TvPlayerSubtitleIntegrationPolicyTest { + @Test + fun `unresolved audio during subtitle persistence preserves the existing preference`() { + val update = tvAudioTrackPersistenceUpdate( + committedAudioTrackIndex = 7, + audioTracks = listOf(AudioTrack(index = 2, language = "en", codec = "aac")), + ) + + assertEquals(TrackSelectionFingerprintUpdate.Preserve, update) + } + + @Test + fun `resolved audio during subtitle persistence writes the exact fingerprint`() { + val selected = AudioTrack(index = 7, language = "ja", codec = "ac3") + + val update = tvAudioTrackPersistenceUpdate( + committedAudioTrackIndex = 7, + audioTracks = listOf(selected), + ) + + assertEquals( + TrackSelectionFingerprintUpdate.Set(audioTrackFingerprint(selected)), + update, + ) + } + + @Test + fun `missing audio intent during subtitle persistence preserves rather than clears`() { + assertEquals( + TrackSelectionFingerprintUpdate.Preserve, + tvAudioTrackPersistenceUpdate( + committedAudioTrackIndex = null, + audioTracks = emptyList(), + ), + ) + } + + @Test + fun `authoritative empty adapter snapshot clears stale downloaded UI rows`() { + val stale = listOf(downloadedRow(index = 4, downloadId = 91)) + + assertEquals( + emptyList(), + authoritativeTvSubtitleRows(snapshotRows = emptyList(), previousRows = stale), + ) + } + + @Test + fun `download auto selection uses the same canonical identity as the HUD row`() { + val row = downloadedRow(index = 4, downloadId = 91) + + assertEquals( + tvSubtitleIdentity(row), + tvDownloadedRefreshIdentity(row), + ) + } + + @Test + fun `download auto selection with missing domain id safely returns null`() { + val legacy = downloadedRow(index = 4, downloadId = null) + + assertEquals(null, tvDownloadedRefreshIdentity(legacy)) + } + + @Test + fun `embedded PGS stays a client-mounted identity`() { + val identity = tvSubtitleIdentity( + PlayerSubtitleInfo( + index = 8, + language = "en", + codec = "hdmv_pgs_subtitle", + label = "English PGS", + source = "embedded", + forced = false, + url = "", + ), + ) + + assertIs(identity) + assertEquals(8, identity.serverIndex) + } + + @Test + fun `embedded bitmap without a sidecar route requests burn-in`() { + for (codec in listOf("dvd_subtitle", "dvb_subtitle", "vobsub")) { + val identity = tvSubtitleIdentity( + PlayerSubtitleInfo( + index = 5, + language = "en", + codec = codec, + label = "English bitmap", + source = "embedded", + forced = false, + url = "", + ), + ) + + assertIs(identity) + assertEquals(5, identity.serverIndex, codec) + } + } + + @Test + fun `materialized PGS artifact uses a server sidecar identity`() { + val identity = tvSubtitleIdentity( + PlayerSubtitleInfo( + index = 8, + language = "en", + codec = "hdmv_pgs_subtitle", + label = "English PGS", + source = "server_artifact", + forced = false, + url = "/stream/s1/subtitles/8.sup", + ), + ) + + assertIs(identity) + assertEquals(8, identity.serverIndex) + } + + @Test + fun `T91 remote subtitle intent resolves a typed identity instead of a backend ordinal`() { + val row = PlayerSubtitleInfo( + index = 8, + language = "en", + codec = "srt", + label = "English", + source = "server_artifact", + forced = false, + url = "/subtitles/8.srt", + ) + val mounted = PlayerTrackEntry( + index = 2, + label = "English", + language = "en", + isSelected = false, + displayLabel = "English", + codecOrMime = "srt", + trackId = "prairie-subtitle:8", + ) + + val identity = resolveTvRemoteSubtitleIntent( + playerOrdinal = 2, + subtitleTracks = listOf(mounted), + subtitleRows = listOf(row), + ) + + assertEquals(tvSubtitleIdentity(row), identity) + } + + @Test + fun `T91 remote Off intent is typed and does not need mounted tracks`() { + assertEquals( + SubtitleIdentity.Off, + resolveTvRemoteSubtitleIntent( + playerOrdinal = -1, + subtitleTracks = emptyList(), + subtitleRows = emptyList(), + ), + ) + } + + @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"), + ), + ) + + assertEquals(9, identity) + } + + @Test + fun `invalid pre-mount remote intents remain unresolved rather than disabling subtitles`() { + assertEquals( + null, + resolveTvRemoteSubtitleIntent( + playerOrdinal = 4, + subtitleTracks = emptyList(), + subtitleRows = emptyList(), + ), + ) + assertEquals( + null, + resolveTvRemoteAudioIntent( + playerOrdinal = 4, + audioTracks = emptyList(), + ), + ) + } + + private fun downloadedRow( + index: Int, + downloadId: Int?, + ) = PlayerSubtitleInfo( + index = index, + language = "en", + codec = "vtt", + label = "English", + source = "downloaded", + forced = false, + url = "/subtitles/${downloadId ?: "legacy"}.vtt", + downloadId = downloadId, + mediaTrackId = downloadId?.let { "prairie-downloaded-subtitle:$it" }, + ) +} 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 new file mode 100644 index 000000000..fcb0c49c6 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleFinalRollbackTest.kt @@ -0,0 +1,408 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.playback.CommittedSubtitle +import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.network.ApiResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class TvSubtitleFinalRollbackTest { + @Test + fun `ordinary sidecar keeps A committed until backend acknowledgement succeeds`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecarB) + runCurrent() + harness.port.completeStage(sidecarCandidate()) + runCurrent() + + assertEquals(sidecarA, harness.adapter.snapshot.committedIdentity) + assertEquals(sidecarB, harness.adapter.snapshot.pendingIdentity) + assertEquals(sidecarB, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = true, + snapshotKey = "ordinary-sidecar-mounted", + settled = true, + ) + runCurrent() + + assertEquals(sidecarB, harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertEquals(listOf(sidecarB), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `ordinary sidecar backend rejection rolls back to A without persistence`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecarB) + runCurrent() + harness.port.completeStage(sidecarCandidate()) + runCurrent() + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = false, + snapshotKey = "ordinary-sidecar-rejected", + settled = true, + ) + runCurrent() + + assertEquals(sidecarA, harness.adapter.snapshot.committedIdentity) + assertEquals(sidecarA, harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertEquals( + "The selected subtitle could not be mounted.", + harness.adapter.snapshot.failureMessage, + ) + assertEquals(listOf("session-sidecar-b"), harness.port.rolledBackSessions) + assertEquals("session-1", harness.port.managerSession) + assertEquals("session-1", harness.port.lifecycleSession) + assertEquals(sidecarA, harness.port.backendIdentity) + + harness.port.completeStage(restoreCandidate()) + runCurrent() + harness.adapter.reportMountedSelection( + identity = sidecarA, + selected = true, + snapshotKey = "ordinary-sidecar-restored", + settled = true, + ) + runCurrent() + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `ordinary sidecar backend timeout rolls back to A without persistence`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecarB) + runCurrent() + harness.port.completeStage(sidecarCandidate()) + runCurrent() + advanceTimeBy(5_001L) + runCurrent() + + assertEquals(sidecarA, harness.adapter.snapshot.committedIdentity) + assertEquals(sidecarA, harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertEquals( + "The selected subtitle could not be mounted.", + harness.adapter.snapshot.failureMessage, + ) + assertEquals(listOf("session-sidecar-b"), harness.port.rolledBackSessions) + assertEquals("session-1", harness.port.managerSession) + assertEquals("session-1", harness.port.lifecycleSession) + assertEquals(sidecarA, harness.port.backendIdentity) + + harness.port.completeStage(restoreCandidate()) + runCurrent() + harness.adapter.reportMountedSelection( + identity = sidecarA, + selected = true, + snapshotKey = "ordinary-sidecar-timeout-restored", + settled = true, + ) + runCurrent() + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `ordinary sidecar reset invalidates backend acknowledgement without persistence`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecarB) + runCurrent() + harness.port.completeStage(sidecarCandidate()) + runCurrent() + harness.adapter.resetContent( + context = context(contentId = "content-2", sessionId = "session-2"), + committedIdentity = sidecarC, + ) + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = true, + snapshotKey = "stale-sidecar-after-reset", + settled = true, + ) + runCurrent() + + assertEquals(sidecarC, harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertEquals(listOf("session-sidecar-b"), harness.port.rolledBackSessions) + } + + @Test + fun `ordinary Off keeps A committed until backend acknowledgement succeeds`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(SubtitleIdentity.Off) + runCurrent() + harness.port.completeStage(offCandidate()) + runCurrent() + + assertEquals(sidecarA, harness.adapter.snapshot.committedIdentity) + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.pendingIdentity) + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.adapter.reportMountedSelection( + identity = SubtitleIdentity.Off, + selected = true, + snapshotKey = "ordinary-off-disabled", + settled = true, + ) + runCurrent() + + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertEquals( + listOf(SubtitleIdentity.Off), + harness.persistence.persisted.map { it.identity }, + ) + } + + @Test + fun `ordinary Off backend rejection rolls back to A without persistence`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(SubtitleIdentity.Off) + runCurrent() + harness.port.completeStage(offCandidate()) + runCurrent() + harness.adapter.reportMountedSelection( + identity = SubtitleIdentity.Off, + selected = false, + snapshotKey = "ordinary-off-rejected", + settled = true, + ) + runCurrent() + + assertEquals(sidecarA, harness.adapter.snapshot.committedIdentity) + assertEquals(sidecarA, harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertEquals( + "The selected subtitle could not be mounted.", + harness.adapter.snapshot.failureMessage, + ) + assertEquals(listOf("session-off"), harness.port.rolledBackSessions) + assertEquals("session-1", harness.port.managerSession) + assertEquals("session-1", harness.port.lifecycleSession) + assertEquals(sidecarA, harness.port.backendIdentity) + + harness.port.completeStage(restoreCandidate()) + runCurrent() + harness.adapter.reportMountedSelection( + identity = sidecarA, + selected = true, + snapshotKey = "ordinary-off-restored", + settled = true, + ) + runCurrent() + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `ordinary Off backend timeout rolls back to A without persistence`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(SubtitleIdentity.Off) + runCurrent() + harness.port.completeStage(offCandidate()) + runCurrent() + advanceTimeBy(5_001L) + runCurrent() + + assertEquals(sidecarA, harness.adapter.snapshot.committedIdentity) + assertEquals(sidecarA, harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertEquals( + "The selected subtitle could not be mounted.", + harness.adapter.snapshot.failureMessage, + ) + assertEquals(listOf("session-off"), harness.port.rolledBackSessions) + assertEquals("session-1", harness.port.managerSession) + assertEquals("session-1", harness.port.lifecycleSession) + assertEquals(sidecarA, harness.port.backendIdentity) + + harness.port.completeStage(restoreCandidate()) + runCurrent() + harness.adapter.reportMountedSelection( + identity = sidecarA, + selected = true, + snapshotKey = "ordinary-off-timeout-restored", + settled = true, + ) + runCurrent() + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `ordinary Off reset invalidates backend acknowledgement without persistence`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(SubtitleIdentity.Off) + runCurrent() + harness.port.completeStage(offCandidate()) + runCurrent() + harness.adapter.resetContent( + context = context(contentId = "content-2", sessionId = "session-2"), + committedIdentity = sidecarC, + ) + harness.adapter.reportMountedSelection( + identity = SubtitleIdentity.Off, + selected = true, + snapshotKey = "stale-off-after-reset", + settled = true, + ) + runCurrent() + + assertEquals(sidecarC, harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertEquals(listOf("session-off"), harness.port.rolledBackSessions) + } + + private fun harness(scope: CoroutineScope): Harness { + val port = FinalRollbackStagedPort() + val persistence = FinalRollbackPersistence() + val adapter = TvSubtitleTransactionAdapter( + scope = scope, + stagedPort = port, + persistencePort = persistence, + durablePersistenceScope = scope, + onCommittedPlayback = { adoption -> + port.lifecycleSession = adoption.playback.sessionId + port.backendIdentity = adoption.committed.identity + TvSubtitleAdoptionResult.Adopted + }, + ) + adapter.resetContent(context(), committedIdentity = sidecarA) + return Harness(adapter, port, persistence) + } + + private fun context( + contentId: String = "content-1", + sessionId: String = "session-1", + ): TvSubtitlePlaybackContext = TvSubtitlePlaybackContext( + contentId = contentId, + mediaFileId = 11, + versionId = "version-11", + sessionId = sessionId, + positionSeconds = 42.0, + audioTrackIndex = 2, + qualityPreference = "auto", + subtitleTracks = emptyList(), + writeScope = tvTestPlaybackWriteScope, + ) + + private fun sidecarCandidate(): TvStagedSubtitleCandidate = TvStagedSubtitleCandidate( + id = "sidecar-b", + sessionId = "session-sidecar-b", + selectedAudioIndex = 2, + selectedSubtitleIndex = 4, + subtitleMode = PlaybackSubtitleModeV3.RENDER, + hasSidecar = true, + subtitleTracks = emptyList(), + qualityPreference = "auto", + ) + + private fun offCandidate(): TvStagedSubtitleCandidate = TvStagedSubtitleCandidate( + id = "off", + sessionId = "session-off", + selectedAudioIndex = 2, + selectedSubtitleIndex = -1, + subtitleMode = PlaybackSubtitleModeV3.OFF, + hasSidecar = false, + subtitleTracks = emptyList(), + qualityPreference = "auto", + ) + + private fun restoreCandidate(): TvStagedSubtitleCandidate = TvStagedSubtitleCandidate( + id = "restore-a", + sessionId = "session-restore-a", + selectedAudioIndex = 2, + selectedSubtitleIndex = 3, + subtitleMode = PlaybackSubtitleModeV3.RENDER, + hasSidecar = true, + subtitleTracks = emptyList(), + qualityPreference = "auto", + ) + + private data class Harness( + val adapter: TvSubtitleTransactionAdapter, + val port: FinalRollbackStagedPort, + val persistence: FinalRollbackPersistence, + ) + + private companion object { + val sidecarA: SubtitleIdentity = SubtitleIdentity.ServerSidecar(3) + val sidecarB: SubtitleIdentity = SubtitleIdentity.ServerSidecar(4) + val sidecarC: SubtitleIdentity = SubtitleIdentity.ServerSidecar(5) + } +} + +private class FinalRollbackStagedPort : TvSubtitleStagedReplanPort { + private val staged = Channel(Channel.UNLIMITED) + var managerSession: String = "session-1" + var lifecycleSession: String = "session-1" + var backendIdentity: SubtitleIdentity = SubtitleIdentity.ServerSidecar(3) + val rolledBackSessions = mutableListOf() + + override suspend fun stage( + request: TvSubtitleStageRequest, + ): ApiResult = ApiResult.Success(staged.receive()) + + override suspend fun commit( + candidate: TvStagedSubtitleCandidate, + ): ApiResult { + managerSession = candidate.sessionId + return ApiResult.Success(TvSubtitleCommittedPlayback( + sessionId = candidate.sessionId, + subtitleTracks = candidate.subtitleTracks, + outputRouteGeneration = candidate.outputRouteGeneration, + )) + } + + override suspend fun discard(candidate: TvStagedSubtitleCandidate) = Unit + + override suspend fun abandonCommitted(playback: TvSubtitleCommittedPlayback) { + rolledBackSessions += playback.sessionId + managerSession = "session-1" + lifecycleSession = "session-1" + backendIdentity = SubtitleIdentity.ServerSidecar(3) + } + + suspend fun completeStage(candidate: TvStagedSubtitleCandidate) { + staged.send(candidate) + } +} + +private class FinalRollbackPersistence : TvSubtitlePersistencePort { + val persisted = mutableListOf() + + override suspend fun persist( + committed: CommittedSubtitle, + context: TvSubtitlePlaybackContext, + ): Boolean { + persisted += committed + return true + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudStateTest.kt new file mode 100644 index 000000000..a506a6231 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudStateTest.kt @@ -0,0 +1,220 @@ +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.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class TvSubtitleHudStateTest { + @Test + fun `pending HUD state keeps committed row checked and labels pending row Applying`() { + val presentation = present( + committed = sidecar(3), + pending = sidecar(4), + focusedId = id(sidecar(4)), + ) + + assertTrue(presentation.rows.single { it.identity == sidecar(3) }.checked) + assertFalse(presentation.rows.single { it.identity == sidecar(4) }.checked) + assertEquals("Applying…", presentation.rows.single { it.identity == sidecar(4) }.status) + } + + @Test + fun `transaction failure removes Applying and keeps committed row checked`() { + val presentation = present( + committed = sidecar(3), + pending = null, + focusedId = id(sidecar(4)), + ) + + assertTrue(presentation.rows.single { it.identity == sidecar(3) }.checked) + assertFalse(presentation.rows.any { it.applying }) + } + + @Test + fun `cancellation never flashes the pending row as committed`() { + val applying = present(sidecar(3), sidecar(4), id(sidecar(4))) + val cancelled = present(sidecar(3), null, applying.focusedStableId) + + assertFalse(applying.rows.single { it.identity == sidecar(4) }.checked) + assertFalse(cancelled.rows.single { it.identity == sidecar(4) }.checked) + assertTrue(cancelled.rows.single { it.identity == sidecar(3) }.checked) + } + + @Test + fun `stale completion cannot change HUD selection`() { + val afterNewerIntent = present(sidecar(3), sidecar(5), id(sidecar(5))) + val afterStaleCompletion = present(sidecar(3), sidecar(5), afterNewerIntent.focusedStableId) + + assertTrue(afterStaleCompletion.rows.single { it.identity == sidecar(3) }.checked) + assertTrue(afterStaleCompletion.rows.single { it.identity == sidecar(5) }.applying) + assertFalse(afterStaleCompletion.rows.single { it.identity == sidecar(4) }.checked) + } + + @Test + fun `content reset removes prior pending HUD state`() { + val reset = present( + committed = SubtitleIdentity.Off, + pending = null, + focusedId = id(sidecar(4)), + ) + + assertTrue(reset.rows.single { it.identity == SubtitleIdentity.Off }.checked) + assertFalse(reset.rows.any { it.applying }) + } + + @Test + fun `exit clears pending HUD state`() { + val presentation = buildTvSubtitleHudPresentation( + options = options(), + committedIdentity = sidecar(3), + pendingIdentity = null, + hudOpen = false, + focusedStableId = id(sidecar(4)), + ) + + assertFalse(presentation.hudOpen) + assertFalse(presentation.rows.any { it.applying }) + } + + @Test + fun `selecting a catalog row while HUD is open keeps the HUD open`() { + val presentation = buildTvSubtitleHudPresentation( + options = options(), + committedIdentity = sidecar(3), + pendingIdentity = sidecar(4), + hudOpen = true, + focusedStableId = id(sidecar(4)), + ) + + assertTrue(presentation.hudOpen) + assertTrue(presentation.rows.single { it.identity == sidecar(4) }.applying) + } + + @Test + fun `picker focus is independent from the checked committed row`() { + val presentation = present(sidecar(3), null, id(sidecar(5))) + + assertTrue(presentation.rows.single { it.identity == sidecar(3) }.checked) + assertTrue(presentation.rows.single { it.identity == sidecar(5) }.focused) + assertFalse(presentation.rows.single { it.identity == sidecar(5) }.checked) + } + + @Test + fun `failed selection keeps focus on the activated pending row`() { + val pending = present(sidecar(3), sidecar(4), id(sidecar(4))) + val failed = present(sidecar(3), null, pending.focusedStableId) + + assertEquals(id(sidecar(4)), failed.focusedStableId) + assertTrue(failed.rows.single { it.identity == sidecar(4) }.focused) + } + + @Test + fun `cancelled selection keeps the picker focus trap active`() { + val cancelled = buildTvSubtitleHudPresentation( + options = options(), + committedIdentity = sidecar(3), + pendingIdentity = null, + hudOpen = true, + focusedStableId = id(sidecar(4)), + ) + + assertTrue(cancelled.focusTrapActive) + assertEquals(id(sidecar(4)), cancelled.focusedStableId) + } + + @Test + fun `stale completion cannot move focus`() { + val before = present(sidecar(3), sidecar(5), id(sidecar(5))) + val after = present(sidecar(3), sidecar(5), before.focusedStableId) + + assertEquals(id(sidecar(5)), after.focusedStableId) + } + + @Test + fun `refresh and remount preserve the focused stable option`() { + val before = present(sidecar(3), sidecar(4), id(sidecar(4))) + val refreshed = buildTvSubtitleHudPresentation( + options = listOf( + option(SubtitleIdentity.Off, "Off"), + option(sidecar(5), "Spanish"), + option(sidecar(4), "English"), + option(sidecar(3), "French"), + ), + committedIdentity = sidecar(3), + pendingIdentity = sidecar(4), + hudOpen = true, + focusedStableId = before.focusedStableId, + ) + + assertEquals(id(sidecar(4)), refreshed.focusedStableId) + assertTrue(refreshed.rows.single { it.identity == sidecar(4) }.focused) + } + + @Test + fun `duplicate labels use typed option IDs and never collapse focus targets`() { + val forced = SubtitleIdentity.LocalMedia3( + media(trackId = "forced", forced = true), + ) + val full = SubtitleIdentity.LocalMedia3( + media(trackId = "full", forced = false), + ) + val choices = listOf(option(forced, "English"), option(full, "English")) + + assertNotEquals(choices[0].stableId, choices[1].stableId) + val presentation = buildTvSubtitleHudPresentation( + options = choices, + committedIdentity = forced, + pendingIdentity = full, + hudOpen = true, + focusedStableId = choices[1].stableId, + ) + assertEquals(2, presentation.rows.map { it.stableId }.distinct().size) + assertTrue(presentation.rows.single { it.identity == full }.focused) + } + + private fun present( + committed: SubtitleIdentity, + pending: SubtitleIdentity?, + focusedId: String?, + ): TvSubtitleHudPresentation = buildTvSubtitleHudPresentation( + options = options(), + committedIdentity = committed, + pendingIdentity = pending, + hudOpen = true, + focusedStableId = focusedId, + ) + + private fun options(): List = listOf( + option(SubtitleIdentity.Off, "Off"), + option(sidecar(3), "French"), + option(sidecar(4), "English"), + option(sidecar(5), "Spanish"), + ) + + private fun option(identity: SubtitleIdentity, label: String): TvSubtitleHudOption = + TvSubtitleHudOption( + stableId = id(identity), + identity = identity, + label = label, + ) + + private fun id(identity: SubtitleIdentity): String = tvSubtitleOptionStableId(identity) + + private fun sidecar(index: Int): SubtitleIdentity = SubtitleIdentity.ServerSidecar(index) + + private fun media( + trackId: String, + forced: Boolean, + ): SubtitleMediaIdentity = SubtitleMediaIdentity( + trackId = trackId, + label = "English", + language = "en", + codecFamily = "pgs", + forced = forced, + hearingImpaired = false, + ) +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleMountDeadlineTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleMountDeadlineTest.kt new file mode 100644 index 000000000..0456f5437 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleMountDeadlineTest.kt @@ -0,0 +1,185 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.playback.CommittedSubtitle +import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.network.ApiResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * A pre-playback subtitle choice is applied by replanning the instant playback + * starts, and a fresh stream reports READY seconds before it publishes its text + * tracks. The mount deadline must not expire during that gap, or the choice is + * failed against a player that had nothing to mount — which surfaced as + * subtitles refusing to turn on when enabled before playback. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TvSubtitleMountDeadlineTest { + + @Test + fun `deadline does not fail the mount while the player has no text tracks`() = runTest { + var tracksPublished = false + val port = DeadlineStagedPort() + val adapter = TvSubtitleTransactionAdapter( + scope = backgroundScope, + stagedPort = port, + persistencePort = DeadlinePersistence(), + durablePersistenceScope = backgroundScope, + onCommittedPlayback = { TvSubtitleAdoptionResult.Adopted }, + hasMountableTracks = { tracksPublished }, + ) + adapter.resetContent(deadlineContext(), committedIdentity = SubtitleIdentity.Off) + + val target = SubtitleIdentity.ServerSidecar(serverIndex = 3) + adapter.select(target) + runCurrent() + port.completeStage(deadlineCandidate(index = 3)) + runCurrent() + assertEquals(target, adapter.snapshot.localMountIdentity) + + // Well past the 5s deadline, but the stream still has no text tracks. + advanceTimeBy(20_000) + runCurrent() + assertNotNull( + adapter.snapshot.localMountIdentity, + "mount was failed while the player had nothing to mount", + ) + + // Tracks finally publish and the mount resolves normally. + tracksPublished = true + adapter.reportMountedSelection( + identity = target, + selected = true, + snapshotKey = "tracks-arrived", + settled = true, + ) + runCurrent() + assertNull(adapter.snapshot.localMountIdentity) + } + + @Test + fun `deadline still fails a stream that never publishes text tracks`() = runTest { + val port = DeadlineStagedPort() + val adapter = TvSubtitleTransactionAdapter( + scope = backgroundScope, + stagedPort = port, + persistencePort = DeadlinePersistence(), + durablePersistenceScope = backgroundScope, + onCommittedPlayback = { TvSubtitleAdoptionResult.Adopted }, + hasMountableTracks = { false }, + ) + adapter.resetContent(deadlineContext(), committedIdentity = SubtitleIdentity.Off) + + adapter.select(SubtitleIdentity.ServerSidecar(serverIndex = 3)) + runCurrent() + port.completeStage(deadlineCandidate(index = 3)) + runCurrent() + + // The overall cap still bounds the wait, so the HUD cannot sit in + // "Applying" forever on a stream with no text tracks at all. + advanceTimeBy(60_000) // beyond the adapter's overall mount-wait cap + runCurrent() + + assertNull(adapter.snapshot.localMountIdentity) + } + + @Test + fun `a new plan for the same file does not reset an in-flight selection`() = runTest { + // versionId is ":", and every replan mints a new planId. + // The subtitle transaction's own replan therefore changes versionId -- + // if that counts as a content change, the transaction resets itself and + // the selection is lost the instant its mount matches. + val port = DeadlineStagedPort() + val adapter = TvSubtitleTransactionAdapter( + scope = backgroundScope, + stagedPort = port, + persistencePort = DeadlinePersistence(), + durablePersistenceScope = backgroundScope, + onCommittedPlayback = { TvSubtitleAdoptionResult.Adopted }, + hasMountableTracks = { true }, + ) + adapter.resetContent(deadlineContext(), committedIdentity = SubtitleIdentity.Off) + + val target = SubtitleIdentity.ServerSidecar(serverIndex = 3) + adapter.select(target) + runCurrent() + assertEquals(target, adapter.snapshot.pendingIdentity) + + // Same episode, same file — only the plan hash moved on. + adapter.updatePlaybackContext( + deadlineContext().copy(versionId = "11:plan:a-completely-new-plan"), + ) + runCurrent() + + assertEquals( + target, + adapter.snapshot.pendingIdentity, + "a replan of the same file must not discard the selection in flight", + ) + } + + private fun deadlineContext(): TvSubtitlePlaybackContext = TvSubtitlePlaybackContext( + contentId = "movie", + mediaFileId = 11, + versionId = "version-11", + sessionId = "session-a", + positionSeconds = 42.0, + audioTrackIndex = 1, + qualityPreference = "auto", + subtitleTracks = emptyList(), + writeScope = tvTestPlaybackWriteScope, + ) + + private fun deadlineCandidate(index: Int): TvStagedSubtitleCandidate = + TvStagedSubtitleCandidate( + id = "candidate-$index", + sessionId = "session-candidate-$index", + selectedAudioIndex = 1, + selectedSubtitleIndex = index, + subtitleMode = PlaybackSubtitleModeV3.RENDER, + hasSidecar = true, + subtitleTracks = emptyList(), + qualityPreference = "auto", + ) +} + +private class DeadlineStagedPort : TvSubtitleStagedReplanPort { + private val outcomes = Channel(Channel.UNLIMITED) + + override suspend fun stage( + request: TvSubtitleStageRequest, + ): ApiResult = ApiResult.Success(outcomes.receive()) + + override suspend fun commit( + candidate: TvStagedSubtitleCandidate, + ): ApiResult = ApiResult.Success( + TvSubtitleCommittedPlayback( + sessionId = candidate.sessionId, + subtitleTracks = candidate.subtitleTracks, + outputRouteGeneration = candidate.outputRouteGeneration, + ), + ) + + override suspend fun discard(candidate: TvStagedSubtitleCandidate) = Unit + + override suspend fun abandonCommitted(playback: TvSubtitleCommittedPlayback) = Unit + + suspend fun completeStage(candidate: TvStagedSubtitleCandidate) { + outcomes.send(candidate) + } +} + +private class DeadlinePersistence : TvSubtitlePersistencePort { + override suspend fun persist( + committed: CommittedSubtitle, + context: TvSubtitlePlaybackContext, + ) = true +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleMountPriorityTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleMountPriorityTest.kt new file mode 100644 index 000000000..612202d4c --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleMountPriorityTest.kt @@ -0,0 +1,228 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.common.player.subtitleArtifactTrackId +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * TV drives subtitle mounting from two pipelines at once: the subtitle + * transaction, and the legacy restore/auto machinery that reacts to track + * changes. They share a single remount latch, and a transaction's own replan + * republishes the track list — so the legacy pipeline reliably fires *after* + * the transaction and used to overwrite the selection the user just made. + * + * Authority, not timing, decides who owns the mount. + */ +class TvSubtitleMountPriorityTest { + + private fun track(index: Int, trackId: String?) = PlayerTrackEntry( + index = index, + label = "English", + language = "en", + isSelected = false, + codecOrMime = "subrip", + isForced = false, + trackId = trackId, + ) + + @Test + fun `a transport restore cannot evict an in-flight user selection`() { + val latch = SubtitleRemountReselection() + val chosen = SubtitleIdentity.ServerSidecar(serverIndex = 0) + latch.arm(chosen, generation = 1, priority = TvSubtitleMountPriority.UserTransaction) + + // The legacy transport remount arms from the PRE-transaction committed + // state — Off — exactly as it did on device. + latch.arm(SubtitleIdentity.Off, generation = 2, priority = TvSubtitleMountPriority.Restore) + + assertEquals(TvSubtitleMountPriority.UserTransaction, latch.pendingPriority) + val event = assertIs( + latch.consume( + subtitleTracks = listOf(track(20, "1:" + subtitleArtifactTrackId(0))), + snapshotKey = "tracks", + settled = true, + ), + ) + assertEquals(20, event.trackIndex, "the user's track must still be the one mounted") + } + + @Test + fun `auto selection cannot evict an in-flight user selection`() { + val latch = SubtitleRemountReselection() + latch.arm( + SubtitleIdentity.ServerSidecar(serverIndex = 0), + generation = 1, + priority = TvSubtitleMountPriority.UserTransaction, + ) + + latch.arm(SubtitleIdentity.Off, generation = 2, priority = TvSubtitleMountPriority.Auto) + + assertEquals(TvSubtitleMountPriority.UserTransaction, latch.pendingPriority) + } + + @Test + fun `a user selection may always take the latch from a restore`() { + val latch = SubtitleRemountReselection() + latch.arm(SubtitleIdentity.Off, generation = 1, priority = TvSubtitleMountPriority.Restore) + + val chosen = SubtitleIdentity.ServerSidecar(serverIndex = 2) + latch.arm(chosen, generation = 2, priority = TvSubtitleMountPriority.UserTransaction) + + assertEquals(TvSubtitleMountPriority.UserTransaction, latch.pendingPriority) + val event = assertIs( + latch.consume( + subtitleTracks = listOf(track(7, "1:" + subtitleArtifactTrackId(2))), + snapshotKey = "tracks", + settled = true, + ), + ) + assertEquals(7, event.trackIndex) + } + + @Test + fun `equal authority still replaces so a newer pick wins`() { + val latch = SubtitleRemountReselection() + latch.arm( + SubtitleIdentity.ServerSidecar(serverIndex = 0), + generation = 1, + priority = TvSubtitleMountPriority.UserTransaction, + ) + latch.arm( + SubtitleIdentity.ServerSidecar(serverIndex = 5), + generation = 2, + priority = TvSubtitleMountPriority.UserTransaction, + ) + + val event = assertIs( + latch.consume( + subtitleTracks = listOf( + track(3, "1:" + subtitleArtifactTrackId(0)), + track(9, "1:" + subtitleArtifactTrackId(5)), + ), + snapshotKey = "tracks", + settled = true, + ), + ) + assertEquals(9, event.trackIndex, "the most recent user pick owns the mount") + } + + @Test + fun `a rollback-armed Off cannot evict a pick between its match and its ack`() { + // The device sequence: the user's sidecar matched at 09.038, a rollback + // armed Off at 09.057, the sidecar mounted at 09.113 — and at 09.735 the + // Off fired and switched it off. The latch was unowned between match and + // acknowledgement, so the lower-authority Off walked straight in. + val latch = SubtitleRemountReselection() + val chosen = SubtitleIdentity.ServerSidecar(serverIndex = 0) + latch.arm(chosen, generation = 1, priority = TvSubtitleMountPriority.UserTransaction) + + val matched = assertIs( + latch.consume( + listOf(track(20, "1:" + subtitleArtifactTrackId(0))), + snapshotKey = "tracks", + settled = true, + ), + ) + assertEquals(20, matched.trackIndex) + + // Rollback arms the predecessor identity while the mount is in flight. + latch.arm(SubtitleIdentity.Off, generation = 3, priority = TvSubtitleMountPriority.Restore) + assertEquals( + TvSubtitleMountPriority.UserTransaction, + latch.pendingPriority, + "the resolved pick must keep defending its mount until acknowledged", + ) + assertNull( + latch.consume(emptyList(), snapshotKey = null, settled = true), + "the rejected Off must not produce a disable", + ) + + // Once acknowledged, ordinary restores work again. + latch.acknowledgeResolved(generation = 1) + latch.arm(SubtitleIdentity.Off, generation = 4, priority = TvSubtitleMountPriority.Restore) + assertEquals(TvSubtitleMountPriority.Restore, latch.pendingPriority) + } + + @Test + fun `the same sidecar merged twice still resolves`() { + // Observed on device: Media3 reported the one sidecar under two merge + // child indices, "1:prairie-subtitle:3" and "2:prairie-subtitle:3". Both + // denote the same authored artifact, so refusing them as ambiguous left + // the mount unresolved until the deadline blew and the whole + // transaction rolled back to Off. + val latch = SubtitleRemountReselection() + latch.arm( + SubtitleIdentity.ServerSidecar(serverIndex = 3), + generation = 1, + priority = TvSubtitleMountPriority.UserTransaction, + ) + + val event = assertIs( + latch.consume( + listOf( + track(11, "1:" + subtitleArtifactTrackId(3)), + track(12, "2:" + subtitleArtifactTrackId(3)), + ), + snapshotKey = "duplicated", + settled = true, + ), + ) + assertEquals(11, event.trackIndex, "resolve deterministically to the first merge") + } + + @Test + fun `a lost acknowledgement is released so mounting cannot wedge`() { + // The ack travels over a replay-0 flow collected in a backend-scoped + // effect; a backend swap drops it. Without an explicit release the + // resolved claim would defend a mount that can never be confirmed. + val latch = SubtitleRemountReselection() + latch.arm( + SubtitleIdentity.ServerSidecar(serverIndex = 0), + generation = 1, + priority = TvSubtitleMountPriority.UserTransaction, + ) + latch.consume( + listOf(track(20, "1:" + subtitleArtifactTrackId(0))), + snapshotKey = "tracks", + settled = true, + ) + + latch.releaseResolved() + + latch.arm(SubtitleIdentity.Off, generation = 2, priority = TvSubtitleMountPriority.Restore) + assertEquals(TvSubtitleMountPriority.Restore, latch.pendingPriority) + } + + @Test + fun `a restore owns the latch when no transaction is in flight`() { + val latch = SubtitleRemountReselection() + latch.arm( + SubtitleIdentity.ServerSidecar(serverIndex = 1), + generation = 1, + priority = TvSubtitleMountPriority.Restore, + ) + + assertEquals(TvSubtitleMountPriority.Restore, latch.pendingPriority) + assertTrue(latch.hasPendingOwner) + } + + @Test + fun `releasing the latch lets a lower authority arm again`() { + val latch = SubtitleRemountReselection() + latch.arm( + SubtitleIdentity.ServerSidecar(serverIndex = 0), + generation = 1, + priority = TvSubtitleMountPriority.UserTransaction, + ) + latch.clear() + assertNull(latch.pendingPriority) + + latch.arm(SubtitleIdentity.Off, generation = 2, priority = TvSubtitleMountPriority.Restore) + + assertEquals(TvSubtitleMountPriority.Restore, latch.pendingPriority) + } +} 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 new file mode 100644 index 000000000..6d342feb0 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRefreshOwnershipTest.kt @@ -0,0 +1,313 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runCurrent +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 +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class TvSubtitleRefreshOwnershipTest { + @Test + fun `download refresh merges and selects only the returned downloadId`() = runTest { + val harness = harness(backgroundScope) + val owner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + + assertTrue( + harness.adapter.applyRefresh( + owner = owner, + subtitleTracks = listOf(downloaded(90), downloaded(91)), + autoSelectDownloadId = 91, + ), + ) + runCurrent() + + assertEquals(listOf(90, 91), harness.adapter.snapshot.subtitleTracks.mapNotNull { it.downloadId }) + assertEquals(downloadedIdentity(91), harness.adapter.snapshot.pendingIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `AI completion refresh merges and selects only the returned downloadId`() = runTest { + val harness = harness(backgroundScope) + val owner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.AiCompletion) + + assertTrue( + harness.adapter.applyRefresh( + owner = owner, + subtitleTracks = listOf(downloaded(40), downloaded(41)), + autoSelectDownloadId = 40, + ), + ) + runCurrent() + + assertEquals(downloadedIdentity(40), harness.adapter.snapshot.pendingIdentity) + assertEquals(downloadedIdentity(40), harness.adapter.snapshot.localMountIdentity) + } + + @Test + fun `realtime refresh preserves committed identity without optimistic selection`() = runTest { + val harness = harness(backgroundScope) + val owner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Realtime) + + assertTrue( + harness.adapter.applyRefresh( + owner = owner, + subtitleTracks = listOf(downloaded(91)), + autoSelectDownloadId = null, + ), + ) + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertEquals(1L, harness.adapter.snapshot.subtitleRefreshNonce) + } + + @Test + fun `refresh failure leaves rows nonce and committed identity unchanged`() = runTest { + val harness = harness(backgroundScope) + val before = harness.adapter.snapshot + val owner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + + assertTrue(harness.adapter.completeRefreshFailure(owner, "network failed")) + + assertEquals(before.subtitleTracks, harness.adapter.snapshot.subtitleTracks) + assertEquals(before.subtitleRefreshNonce, harness.adapter.snapshot.subtitleRefreshNonce) + assertEquals(before.committedIdentity, harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `operation-local refresh cancellation leaves the worker available`() = runTest { + val harness = harness(backgroundScope) + val cancelled = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + harness.adapter.cancelRefresh(cancelled) + val next = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Realtime) + + assertFalse(harness.adapter.ownsRefresh(cancelled)) + assertTrue(harness.adapter.applyRefresh(next, listOf(downloaded(91)), null)) + assertEquals(listOf(91), harness.adapter.snapshot.subtitleTracks.mapNotNull { it.downloadId }) + } + + @Test + fun `older download refresh cannot overwrite newer manual intent`() = runTest { + val harness = harness(backgroundScope) + val old = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + + harness.adapter.select(sidecar(4)) + + assertFalse(harness.adapter.applyRefresh(old, listOf(downloaded(91)), 91)) + assertEquals(sidecar(4), harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.adapter.snapshot.subtitleTracks.none { it.downloadId == 91 }) + } + + @Test + fun `older AI refresh cannot overwrite newer download refresh`() = runTest { + val harness = harness(backgroundScope) + val ai = harness.adapter.beginRefresh(TvSubtitleRefreshSource.AiCompletion) + val download = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + + assertFalse(harness.adapter.applyRefresh(ai, listOf(downloaded(40)), 40)) + assertTrue(harness.adapter.applyRefresh(download, listOf(downloaded(91)), 91)) + assertEquals(listOf(91), harness.adapter.snapshot.subtitleTracks.mapNotNull { it.downloadId }) + } + + @Test + fun `realtime refresh for an old session cannot publish`() = runTest { + val harness = harness(backgroundScope) + val old = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Realtime) + + harness.adapter.replaceSession("s2") + + assertFalse(harness.adapter.applyRefresh(old, listOf(downloaded(91, sessionId = "s1")), null)) + assertTrue(harness.adapter.snapshot.subtitleTracks.none { it.downloadId == 91 }) + } + + @Test + fun `content reset while refresh is suspended invalidates the response`() = runTest { + val harness = harness(backgroundScope) + val old = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + + harness.adapter.resetContent( + context(contentId = "content-2", versionId = "version-2", sessionId = "s2"), + committedIdentity = sidecar(8), + ) + + assertFalse(harness.adapter.applyRefresh(old, listOf(downloaded(91)), 91)) + assertEquals(sidecar(8), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `exit invalidates every refresh source`() = runTest { + TvSubtitleRefreshSource.entries.forEach { source -> + val harness = harness(backgroundScope) + val owner = harness.adapter.beginRefresh(source) + + harness.adapter.invalidate() + + assertFalse(harness.adapter.applyRefresh(owner, listOf(downloaded(91)), 91)) + } + } + + @Test + fun `duplicate downloaded IDs safely miss during refresh auto-selection`() = runTest { + val harness = harness(backgroundScope) + val owner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + + assertTrue( + harness.adapter.applyRefresh( + owner, + listOf( + downloaded(91, label = "English"), + downloaded(91, label = "English forced").copy(forced = true), + ), + autoSelectDownloadId = 91, + ), + ) + + assertNull(harness.adapter.snapshot.pendingIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `newest authoritative empty refresh removes stale downloaded rows`() = 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(1L, harness.adapter.snapshot.subtitleRefreshNonce) + } + + @Test + fun `accepted refresh rebases downloaded URLs to the owned session`() = runTest { + val harness = harness(backgroundScope) + val owner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + + assertTrue( + harness.adapter.applyRefresh( + owner, + listOf(downloaded(91, sessionId = "stale")), + autoSelectDownloadId = null, + ), + ) + + assertEquals( + "https://prairie.test/api/v1/stream/s1/subtitles/91.vtt?token=stale", + harness.adapter.snapshot.subtitleTracks.single { it.downloadId == 91 }.url, + ) + } + + @Test + fun `quality intent during refresh invalidates the older refresh owner`() = runTest { + val harness = harness(backgroundScope) + val owner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) + + harness.adapter.selectQuality("720p") + + assertFalse(harness.adapter.applyRefresh(owner, listOf(downloaded(91)), 91)) + assertEquals("720p", harness.adapter.snapshot.transition.pending?.qualityPreference) + } + + private fun harness( + scope: CoroutineScope, + tracks: List = listOf(server(3)), + ): Harness { + val adapter = TvSubtitleTransactionAdapter( + scope = scope, + stagedPort = SuspendedPort(), + persistencePort = NoopPersistence(), + ) + adapter.resetContent(context(tracks = tracks), committedIdentity = sidecar(3)) + return Harness(adapter) + } + + private fun context( + contentId: String = "content-1", + versionId: String = "version-1", + sessionId: String? = "s1", + tracks: List = listOf(server(3)), + ): TvSubtitlePlaybackContext = TvSubtitlePlaybackContext( + contentId = contentId, + mediaFileId = 11, + versionId = versionId, + sessionId = sessionId, + positionSeconds = 42.0, + audioTrackIndex = 2, + qualityPreference = "auto", + subtitleTracks = tracks, + outputRouteGeneration = 0, + writeScope = tvTestPlaybackWriteScope, + ) + + private fun server(index: Int): PlayerSubtitleInfo = PlayerSubtitleInfo( + index = index, + language = "en", + codec = "webvtt", + label = "Server $index", + source = "server_artifact", + url = "https://prairie.test/api/v1/stream/s1/subtitles/$index.vtt", + ) + + private fun downloaded( + id: Int, + label: String = "English", + sessionId: String = "s1", + ): PlayerSubtitleInfo = PlayerSubtitleInfo( + index = id, + language = "en", + codec = "webvtt", + label = label, + source = "downloaded", + url = "https://prairie.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, + ), + ) + + private fun sidecar(index: Int): SubtitleIdentity = SubtitleIdentity.ServerSidecar(index) + + private data class Harness(val adapter: TvSubtitleTransactionAdapter) + + private class SuspendedPort : TvSubtitleStagedReplanPort { + override suspend fun stage( + request: TvSubtitleStageRequest, + ): ApiResult = awaitCancellation() + + override suspend fun commit( + candidate: TvStagedSubtitleCandidate, + ): ApiResult = awaitCancellation() + + override suspend fun discard(candidate: TvStagedSubtitleCandidate) = Unit + + override suspend fun abandonCommitted(playback: TvSubtitleCommittedPlayback) = Unit + } + + private class NoopPersistence : TvSubtitlePersistencePort { + override suspend fun persist( + committed: CommittedSubtitle, + context: TvSubtitlePlaybackContext, + ) = true + } +} 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 new file mode 100644 index 000000000..61a4dd030 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt @@ -0,0 +1,1232 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.playback.CommittedSubtitle +import org.prairieserver.prairie.model.playback.PlaybackOutputContext +import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.network.ApiResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class TvSubtitleSettlementOwnershipTest { + @Test + fun `new subtitle rolls back unpublished B before C stages and stale B ack is inert`() = + runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + + harness.adapter.select(sidecarC) + runCurrent() + + harness.assertRollbackBeforeStage( + nextClassification = "subtitle_track_changed", + nextSubtitleIndex = 5, + ) + val beforeStaleAck = harness.events.toList() + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = true, + snapshotKey = "stale-b", + settled = true, + ) + runCurrent() + + assertEquals(beforeStaleAck, harness.events) + assertFalse(harness.events.any { it == "manager-confirm:session-1" }) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `output route replan rolls back unpublished B before the new route stages`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + harness.adapter.updatePlaybackContext( + context( + sessionId = "session-1", + outputRouteGeneration = 9, + ), + ) + + harness.adapter.updateOutputRouteGeneration(9) + runCurrent() + + harness.assertRollbackBeforeStage( + nextClassification = "output_route_changed", + nextSubtitleIndex = 4, + outputRouteGeneration = 9, + ) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `route updated while rollback is suspended survives settlement replay`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + harness.port.suspendNextRollback() + + harness.adapter.select(sidecarC) + runCurrent() + harness.port.awaitRollbackStarted() + + harness.adapter.updatePlaybackContext( + context( + sessionId = "session-1", + outputRouteGeneration = 9, + ), + ) + harness.adapter.updateOutputRouteGeneration(9) + harness.port.releaseRollback() + runCurrent() + + harness.assertRollbackBeforeStage( + nextClassification = "output_route_changed", + nextSubtitleIndex = 5, + outputRouteGeneration = 9, + ) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `route updated while failed mount compensation is suspended survives restore`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + harness.port.suspendNextRollback() + + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = false, + snapshotKey = "failed-b", + settled = true, + ) + runCurrent() + harness.port.awaitRollbackStarted() + + harness.adapter.updatePlaybackContext( + context( + sessionId = "session-1", + outputRouteGeneration = 9, + ), + ) + harness.port.releaseRollback() + runCurrent() + + harness.assertRollbackBeforeStage( + nextClassification = "output_route_changed", + nextSubtitleIndex = 3, + outputRouteGeneration = 9, + ) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `newer replan during blocked load invalidation revokes obsolete discard`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + harness.port.suspendNextRollback() + + val invalidation = backgroundScope.async { + harness.adapter.invalidateAndSettle() + } + runCurrent() + harness.port.awaitRollbackStarted() + + harness.adapter.updatePlaybackContext( + context( + sessionId = "session-1", + outputRouteGeneration = 9, + ), + ) + harness.adapter.select(sidecarC) + harness.adapter.selectQuality("720p") + harness.adapter.updateOutputRouteGeneration(9) + harness.port.releaseRollback() + runCurrent() + + assertTrue(invalidation.await()) + harness.assertRollbackBeforeStage( + nextClassification = "output_route_changed", + nextSubtitleIndex = 5, + outputRouteGeneration = 9, + qualityPreference = "720p", + ) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `mutation during suspended confirm stages from confirmed B`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + harness.port.suspendNextConfirm() + harness.port.suspendNextStage() + + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = true, + snapshotKey = "mounted-b", + settled = true, + ) + runCurrent() + harness.port.awaitConfirmStarted() + + harness.adapter.selectQuality("720p") + harness.port.releaseConfirm() + runCurrent() + harness.port.awaitStageStarted() + + assertEquals(sidecarB, harness.adapter.snapshot.committedIdentity) + assertEquals(sidecarB, harness.adapter.snapshot.pendingIdentity) + harness.port.failNextStage = true + harness.port.releaseStage() + runCurrent() + assertEquals(sidecarB, harness.adapter.snapshot.committedIdentity) + assertEquals(listOf(sidecarB), harness.persistence.map { it.identity }) + assertEquals(listOf("session-1"), harness.persistedSessionIds) + } + + @Test + fun `ownerless invalidation clears latent discard before B confirms`() = runTest { + val harness = harness(backgroundScope) + + assertTrue(harness.adapter.invalidateAndSettle()) + harness.mountUnpublished(sidecarB) + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = true, + snapshotKey = "mounted-b", + settled = true, + ) + runCurrent() + + assertEquals(sidecarB, harness.adapter.snapshot.committedIdentity) + assertEquals(listOf(sidecarB), harness.persistence.map { it.identity }) + } + + @Test + fun `reset during suspended burn in confirm cannot persist stale content`() = runTest { + val harness = harness(backgroundScope) + val burnIn = SubtitleIdentity.ServerBurnIn(8) + harness.port.nextSubtitleMode = PlaybackSubtitleModeV3.BURN_IN + harness.port.suspendNextConfirm() + + harness.adapter.select(burnIn) + runCurrent() + harness.port.awaitConfirmStarted() + + harness.adapter.resetContent( + context = context(sessionId = "session-new"), + committedIdentity = sidecarA, + ) + harness.port.releaseConfirm() + runCurrent() + + assertEquals(sidecarA, harness.adapter.snapshot.committedIdentity) + assertTrue(harness.persistence.none { it.identity == burnIn }) + harness.adapter.select(sidecarC) + runCurrent() + assertTrue(harness.port.requests.any { it.subtitleTrackIndex == 5 }) + } + + @Test + fun `invalidation and new selection during burn in confirm cannot deadlock or stale persist`() = + runTest { + val harness = harness(backgroundScope) + val burnIn = SubtitleIdentity.ServerBurnIn(8) + harness.port.nextSubtitleMode = PlaybackSubtitleModeV3.BURN_IN + harness.port.suspendNextConfirm() + + harness.adapter.select(burnIn) + runCurrent() + harness.port.awaitConfirmStarted() + + val invalidation = backgroundScope.async { + harness.adapter.invalidateAndSettle() + } + runCurrent() + harness.adapter.select(sidecarC) + harness.port.releaseConfirm() + runCurrent() + + assertTrue(invalidation.await()) + assertTrue(harness.persistence.any { it.identity == burnIn }) + assertTrue(harness.port.requests.any { it.subtitleTrackIndex == 5 }) + } + + @Test + fun `burn in confirm failure drains reset only after authoritative rollback`() = runTest { + val harness = harness(backgroundScope) + val burnIn = SubtitleIdentity.ServerBurnIn(8) + harness.port.nextSubtitleMode = PlaybackSubtitleModeV3.BURN_IN + harness.port.failConfirm = true + harness.port.suspendNextConfirm() + harness.port.suspendNextRollback() + + harness.adapter.select(burnIn) + runCurrent() + harness.port.awaitConfirmStarted() + harness.adapter.resetContent( + context = context(sessionId = "session-new"), + committedIdentity = sidecarA, + ) + + harness.port.releaseConfirm() + runCurrent() + harness.port.awaitRollbackStarted() + harness.port.releaseRollback() + runCurrent() + + assertEquals(sidecarA, harness.adapter.snapshot.committedIdentity) + assertTrue(harness.persistence.none { it.identity == burnIn }) + assertEquals(1, harness.events.count { it == "manager-rollback:session-1" }) + } + + @Test + fun `session removal during suspended confirm resets to B and consumes flags`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + harness.port.suspendNextConfirm() + + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = true, + snapshotKey = "mounted-b", + settled = true, + ) + runCurrent() + harness.port.awaitConfirmStarted() + harness.adapter.updatePlaybackContext(context(sessionId = null)) + harness.port.releaseConfirm() + runCurrent() + + assertEquals(sidecarB, harness.adapter.snapshot.committedIdentity) + val fresh = SubtitleIdentity.ServerSidecar(6) + harness.adapter.select(fresh) + runCurrent() + assertEquals(fresh, harness.adapter.snapshot.committedIdentity) + assertTrue(harness.persistence.any { it.identity == fresh }) + } + + @Test + fun `failed mount compensation owns one rollback and preserves queued intent`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + harness.port.suspendNextRollback() + + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = false, + snapshotKey = "failed-b", + settled = true, + ) + runCurrent() + harness.port.awaitRollbackStarted() + harness.adapter.selectQuality("720p") + harness.port.releaseRollback() + runCurrent() + + assertEquals( + 1, + harness.events.count { it == "manager-rollback:session-1" }, + ) + assertTrue( + harness.port.requests.any { + it.subtitleTrackIndex == 4 && it.qualityPreference == "720p" + }, + ) + } + + @Test + fun `failed mount rollback failure retains B owner and never rewrites A`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + harness.port.failRollback = true + + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = false, + snapshotKey = "failed-b", + settled = true, + ) + runCurrent() + + assertEquals(sidecarB, harness.adapter.snapshot.localMountIdentity) + assertTrue( + harness.adapter.snapshot.failureMessage + ?.contains("roll", ignoreCase = true) == true, + ) + assertEquals(1, harness.port.requests.size) + } + + @Test + fun `session replacement during suspended rollback feeds the replay and consumes flags`() = + runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + harness.port.suspendNextRollback() + + harness.adapter.select(sidecarC) + runCurrent() + harness.port.awaitRollbackStarted() + harness.adapter.updatePlaybackContext(context(sessionId = "session-s2")) + harness.port.releaseRollback() + runCurrent() + + assertFalse(harness.port.requests.any { it.subtitleTrackIndex == 5 }) + harness.adapter.select(SubtitleIdentity.ServerSidecar(6)) + runCurrent() + assertTrue( + harness.port.requests.any { + it.sessionId == "session-s2" && it.subtitleTrackIndex == 6 + }, + ) + } + + @Test + fun `confirm failure completion waits for compensation and drains invalidation`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + harness.port.failConfirm = true + harness.port.suspendNextConfirm() + harness.port.suspendNextRollback() + + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = true, + snapshotKey = "mounted-b", + settled = true, + ) + runCurrent() + harness.port.awaitConfirmStarted() + val invalidation = backgroundScope.async { + harness.adapter.invalidateAndSettle() + } + runCurrent() + + harness.port.releaseConfirm() + runCurrent() + harness.port.awaitRollbackStarted() + assertFalse(invalidation.isCompleted) + harness.port.releaseRollback() + runCurrent() + + assertTrue(invalidation.await()) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(1, harness.events.count { it == "manager-rollback:session-1" }) + } + + @Test + fun `superseded adoption rollback failure retains B until a successful retry`() = runTest { + val harness = harness( + adapterScope = backgroundScope, + forceSupersededAfterAdoption = true, + ) + harness.port.failRollback = true + + harness.adapter.select(sidecarB) + runCurrent() + + assertEquals(sidecarB, harness.adapter.snapshot.localMountIdentity) + assertEquals(1, harness.port.requests.size) + harness.adapter.select(sidecarC) + runCurrent() + assertTrue(harness.port.requests.any { it.subtitleTrackIndex == 5 }) + } + + @Test + fun `failed adoption rollback failure retains B and blocks stale progression`() = runTest { + val harness = harness( + adapterScope = backgroundScope, + adoptionFailure = IllegalStateException("adoption failed"), + ) + harness.port.failRollback = true + + harness.adapter.select(sidecarB) + runCurrent() + + assertEquals(sidecarB, harness.adapter.snapshot.localMountIdentity) + assertEquals(1, harness.port.requests.size) + assertTrue( + harness.adapter.snapshot.failureMessage + ?.contains("roll", ignoreCase = true) == true, + ) + harness.adapter.reportMountedSelection( + identity = sidecarB, + selected = true, + snapshotKey = "recovered-b", + settled = true, + ) + runCurrent() + assertEquals(listOf(sidecarB), harness.persistence.map { it.identity }) + } + + @Test + fun `reset during suspended commit retains B when manager rollback fails then retries`() = + runTest { + val harness = harness(backgroundScope) + harness.port.suspendNextCommit() + harness.port.failRollback = true + + harness.adapter.select(sidecarB) + runCurrent() + harness.port.awaitCommitStarted() + harness.adapter.resetContent( + context = context(sessionId = "session-reset"), + committedIdentity = sidecarA, + ) + harness.port.releaseCommit() + runCurrent() + + assertEquals(sidecarB, harness.adapter.snapshot.pendingIdentity) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(1, harness.port.requests.size) + assertTrue(harness.adapter.invalidateAndSettle()) + val fresh = SubtitleIdentity.ServerSidecar(6) + harness.adapter.select(fresh) + runCurrent() + assertTrue( + harness.port.requests.any { + it.sessionId == "session-reset" && it.subtitleTrackIndex == 6 + }, + ) + } + + @Test + fun `context null during suspended commit retries failed rollback before fresh intent`() = + runTest { + val harness = harness(backgroundScope) + harness.port.suspendNextCommit() + harness.port.failRollback = true + + harness.adapter.select(sidecarB) + runCurrent() + harness.port.awaitCommitStarted() + harness.adapter.updatePlaybackContext(context(sessionId = null)) + harness.port.releaseCommit() + runCurrent() + + assertEquals(sidecarB, harness.adapter.snapshot.pendingIdentity) + assertEquals(1, harness.port.requests.size) + assertTrue(harness.adapter.invalidateAndSettle()) + val fresh = SubtitleIdentity.ServerSidecar(6) + harness.adapter.select(fresh) + runCurrent() + assertEquals(fresh, harness.adapter.snapshot.committedIdentity) + assertTrue(harness.persistence.any { it.identity == fresh }) + } + + @Test + fun `quality replan rolls back unpublished Off before the new quality stages`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(SubtitleIdentity.Off) + + harness.adapter.selectQuality("720p") + runCurrent() + + harness.assertRollbackBeforeStage( + nextClassification = "quality_changed", + nextSubtitleIndex = -1, + qualityPreference = "720p", + ) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `subtitle queued during adoption rolls B back before replay can stage`() = runTest { + val adoptionGate = CompletableDeferred() + val harness = harness(backgroundScope, adoptionGate = adoptionGate) + + harness.adapter.select(sidecarB) + runCurrent() + assertEquals("session-1", harness.lifecycle.currentSessionId) + + harness.adapter.select(sidecarC) + adoptionGate.complete(Unit) + runCurrent() + + harness.assertRollbackBeforeStage( + nextClassification = "subtitle_track_changed", + nextSubtitleIndex = 5, + ) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `quality queued during adoption rolls B back before replay can stage`() = runTest { + val adoptionGate = CompletableDeferred() + val harness = harness(backgroundScope, adoptionGate = adoptionGate) + + harness.adapter.select(sidecarB) + runCurrent() + harness.adapter.selectQuality("720p") + adoptionGate.complete(Unit) + runCurrent() + + harness.assertRollbackBeforeStage( + nextClassification = "quality_changed", + nextSubtitleIndex = 4, + qualityPreference = "720p", + ) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `subtitle then quality queued during adoption preserves explicit C and quality`() = runTest { + verifyQueuedSubtitleAndQuality( + mutate = { adapter -> + adapter.select(sidecarC) + adapter.selectQuality("720p") + }, + ) + } + + @Test + fun `quality then subtitle queued during adoption preserves explicit C and quality`() = runTest { + verifyQueuedSubtitleAndQuality( + mutate = { adapter -> + adapter.selectQuality("720p") + adapter.select(sidecarC) + }, + ) + } + + @Test + fun `route queued during adoption rolls B back before replay can stage`() = runTest { + val adoptionGate = CompletableDeferred() + val harness = harness(backgroundScope, adoptionGate = adoptionGate) + + harness.adapter.select(sidecarB) + runCurrent() + harness.adapter.updatePlaybackContext( + context(sessionId = "session-a", outputRouteGeneration = 9), + ) + harness.adapter.updateOutputRouteGeneration(9) + adoptionGate.complete(Unit) + runCurrent() + + harness.assertRollbackBeforeStage( + nextClassification = "output_route_changed", + nextSubtitleIndex = 4, + outputRouteGeneration = 9, + ) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `content reset jointly rolls back unpublished B before clearing its owner`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + + harness.adapter.resetContent( + context = context(sessionId = "session-new-content"), + committedIdentity = sidecarA, + ) + runCurrent() + + assertEquals( + listOf( + "stage:subtitle_track_changed:4:0:auto", + "commit:session-1", + "lifecycle-adopt:session-1", + "manager-rollback:session-1", + "lifecycle-rollback:session-1", + ), + harness.events, + ) + assertEquals("session-a", harness.lifecycle.currentSessionId) + assertNull(harness.port.pendingPlayback) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `new load invalidation settles B and lets the next content request proceed`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + + harness.adapter.invalidate() + runCurrent() + harness.adapter.resetContent( + context = context(sessionId = "session-a"), + committedIdentity = sidecarA, + ) + harness.adapter.select(sidecarC) + runCurrent() + + harness.assertRollbackBeforeStage( + nextClassification = "subtitle_track_changed", + nextSubtitleIndex = 5, + ) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `explicit exit restores A before lifecycle stop and never persists B`() = runTest { + val harness = harness(backgroundScope) + harness.mountUnpublished(sidecarB) + + harness.adapter.invalidate() + runCurrent() + harness.lifecycle.stopCurrent() + + assertEquals( + listOf( + "stage:subtitle_track_changed:4:0:auto", + "commit:session-1", + "lifecycle-adopt:session-1", + "manager-rollback:session-1", + "lifecycle-rollback:session-1", + "lifecycle-stop:session-a", + ), + harness.events, + ) + assertEquals(listOf("session-a"), harness.lifecycle.stoppedSessions) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `clear contains settlement outside the cancelled adapter scope then stops A`() = runTest { + val adapterJob = SupervisorJob() + val adapterScope = CoroutineScope(StandardTestDispatcher(testScheduler) + adapterJob) + val harness = harness( + adapterScope = adapterScope, + durableScope = backgroundScope, + ) + harness.mountUnpublished(sidecarB) + + harness.adapter.invalidate() + adapterScope.cancel() + runCurrent() + harness.lifecycle.stopCurrent() + + assertEquals( + listOf( + "stage:subtitle_track_changed:4:0:auto", + "commit:session-1", + "lifecycle-adopt:session-1", + "manager-rollback:session-1", + "lifecycle-rollback:session-1", + "lifecycle-stop:session-a", + ), + harness.events, + ) + assertEquals(listOf("session-a"), harness.lifecycle.stoppedSessions) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `parent cancellation during commit still settles and invokes clear callback once`() = + runTest { + val adapterJob = SupervisorJob() + val adapterScope = CoroutineScope(StandardTestDispatcher(testScheduler) + adapterJob) + val harness = harness( + adapterScope = adapterScope, + durableScope = backgroundScope, + ) + harness.port.suspendNextCommit() + var afterSettlementCalls = 0 + + harness.adapter.select(sidecarB) + runCurrent() + harness.port.awaitCommitStarted() + harness.adapter.invalidateAndSettleAsync(restoreUi = false) { + afterSettlementCalls += 1 + } + runCurrent() + + adapterScope.cancel() + harness.port.releaseCommit() + runCurrent() + + assertEquals(1, afterSettlementCalls) + assertEquals( + 1, + harness.events.count { it == "manager-rollback:session-1" }, + ) + assertNull(harness.port.pendingPlayback) + } + + @Test + fun `owner loss after lifecycle adoption jointly rolls manager and lifecycle back to A`() = + runTest { + val harness = harness( + adapterScope = backgroundScope, + forceSupersededAfterAdoption = true, + ) + + harness.adapter.select(sidecarB) + runCurrent() + + assertEquals( + listOf( + "stage:subtitle_track_changed:4:0:auto", + "commit:session-1", + "lifecycle-adopt:session-1", + "manager-rollback:session-1", + "lifecycle-rollback:session-1", + ), + harness.events, + ) + assertEquals("session-a", harness.lifecycle.currentSessionId) + assertNull(harness.port.pendingPlayback) + assertTrue(harness.persistence.isEmpty()) + } + + @Test + fun `real exit and clear wire settlement before invalidation and lifecycle stop`() { + val source = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt", + ).readText() + val exitBody = source + .substringAfter("suspend fun stopSessionForExit()") + .substringBefore("fun onExit()") + val clearBody = source + .substringAfter("override fun onCleared()") + .substringBefore("\n }\n\n}") + + assertBefore( + exitBody, + "subtitleTransactions.invalidateAndAwaitSettlement()", + "playbackMutationFence.invalidateAll()", + ) + assertBefore( + exitBody, + "subtitleTransactions.invalidateAndAwaitSettlement()", + "sessionLifecycle.stop(", + ) + // Every exit path runs prepareSessionExit, which blanks uiState.sessionId. + // If the id is not latched BEFORE that write, each of the three stops + // below passes null and the ownership guard they exist for never engages + // — which is exactly how this fix shipped inert the first time. + val prepareBody = source + .substringAfter("private fun prepareSessionExit()") + .substringBefore("\n private ") + assertBefore( + prepareBody, + "lastAdoptedSessionId = it", + "sessionId = null", + ) + assertTrue(exitBody.contains("sessionLifecycle.stop(expectedSessionId = exitSessionId)")) + assertBefore( + clearBody, + "subtitleTransactions.reserveDurableFinalPersistence()", + "subtitleTransactions.invalidateAndSettleAsync", + ) + assertBefore( + clearBody, + "subtitleTransactions.invalidateAndSettleAsync", + "playbackMutationFence.invalidateAll()", + ) + assertBefore( + clearBody, + "subtitleTransactions::requestDurableFinalPersistence", + "playbackMutationFence.invalidateAll()", + ) + assertBefore( + clearBody, + "subtitleTransactions::requestDurableFinalPersistence", + "sessionLifecycle.stop(", + ) + // 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()")) + val adoptionBody = source + .substringAfter("private suspend fun adoptSubtitlePlayback(") + .substringBefore("private suspend fun confirmSubtitlePlaybackPublication(") + assertFalse(adoptionBody.contains("rollbackUnpublishedActiveSession(")) + } + + private suspend fun TestScope.mountUnpublished( + harness: Harness, + identity: SubtitleIdentity, + ) { + harness.adapter.select(identity) + runCurrent() + assertEquals(identity, harness.adapter.snapshot.localMountIdentity) + assertEquals("session-1", harness.lifecycle.currentSessionId) + assertEquals("session-1", harness.port.pendingPlayback?.sessionId) + } + + private suspend fun Harness.mountUnpublished(identity: SubtitleIdentity) { + testScope.mountUnpublished(this, identity) + } + + private suspend fun TestScope.verifyQueuedSubtitleAndQuality( + mutate: (TvSubtitleTransactionAdapter) -> Unit, + ) { + val adoptionGate = CompletableDeferred() + val harness = harness(backgroundScope, adoptionGate = adoptionGate) + harness.adapter.select(sidecarB) + runCurrent() + + mutate(harness.adapter) + adoptionGate.complete(Unit) + runCurrent() + + harness.assertRollbackBeforeStage( + nextClassification = "quality_changed", + nextSubtitleIndex = 5, + qualityPreference = "720p", + ) + assertTrue(harness.persistence.isEmpty()) + } + + private fun TestScope.harness( + adapterScope: CoroutineScope, + durableScope: CoroutineScope = adapterScope, + forceSupersededAfterAdoption: Boolean = false, + adoptionGate: CompletableDeferred? = null, + adoptionFailure: Throwable? = null, + ): Harness { + val events = mutableListOf() + val port = PendingPublicationPort(events) + val lifecycle = LifecycleSettlementProbe(events) + val persistence = mutableListOf() + val persistedSessionIds = mutableListOf() + val adapter = TvSubtitleTransactionAdapter( + scope = adapterScope, + stagedPort = port, + persistencePort = object : TvSubtitlePersistencePort { + override suspend fun persist( + committed: CommittedSubtitle, + context: TvSubtitlePlaybackContext, + ): Boolean { + events += "persist:${committed.identity}" + persistence += committed + persistedSessionIds += context.sessionId + return true + } + }, + durablePersistenceScope = durableScope, + settlementScope = durableScope, + onCommittedPlayback = { adoption -> + lifecycle.adopt(adoption.playback.sessionId) + adoptionGate?.await() + adoptionFailure?.let { throw it } + if (forceSupersededAfterAdoption) { + TvSubtitleAdoptionResult.Superseded + } else { + TvSubtitleAdoptionResult.Adopted + } + }, + onCommittedPlaybackConfirmed = { playback -> + lifecycle.confirm(playback.sessionId) + }, + onCommittedPlaybackRollback = { playback, _ -> + lifecycle.rollback(playback.sessionId) + }, + ) + adapter.resetContent(context(), sidecarA) + return Harness( + testScope = this, + adapter = adapter, + port = port, + lifecycle = lifecycle, + persistence = persistence, + persistedSessionIds = persistedSessionIds, + events = events, + ) + } + + private fun context( + sessionId: String? = "session-a", + outputRouteGeneration: Long = 0, + ): TvSubtitlePlaybackContext { + val playbackContext = org.prairieserver.prairie.model.playback.ClientPlaybackContext( + formFactor = "tv", + appVersion = "test", + output = PlaybackOutputContext( + outputRouteGeneration = outputRouteGeneration, + ), + ) + return TvSubtitlePlaybackContext( + contentId = "movie", + mediaFileId = 11, + versionId = "version-11", + sessionId = sessionId, + positionSeconds = 42.0, + audioTrackIndex = 1, + qualityPreference = "auto", + subtitleTracks = emptyList(), + outputRouteGeneration = outputRouteGeneration, + clientPlaybackContext = playbackContext, + writeScope = tvTestPlaybackWriteScope, + ) + } + + private data class Harness( + val testScope: TestScope, + val adapter: TvSubtitleTransactionAdapter, + val port: PendingPublicationPort, + val lifecycle: LifecycleSettlementProbe, + val persistence: List, + val persistedSessionIds: List, + val events: MutableList, + ) { + fun assertRollbackBeforeStage( + nextClassification: String, + nextSubtitleIndex: Int, + outputRouteGeneration: Long = 0, + qualityPreference: String = "auto", + ) { + val rollbackManager = events.indexOf("manager-rollback:session-1") + val rollbackLifecycle = events.indexOf("lifecycle-rollback:session-1") + val nextStage = events.indexOf( + "stage:$nextClassification:$nextSubtitleIndex:" + + "$outputRouteGeneration:$qualityPreference", + ) + + assertTrue(rollbackManager >= 0, "The manager publication must be rolled back.") + assertTrue( + rollbackLifecycle > rollbackManager, + "Lifecycle rollback must follow manager rollback.", + ) + assertTrue( + nextStage > rollbackLifecycle, + "The next request must stage only after joint rollback. Events: $events", + ) + assertEquals(2, port.requests.size) + } + } + + private class PendingPublicationPort( + private val events: MutableList, + ) : TvSubtitleStagedReplanPort { + val requests = mutableListOf() + var pendingPlayback: TvSubtitleCommittedPlayback? = null + private set + private var settlement = CompletableDeferred().apply { complete(Unit) } + private var sessionSequence = 0 + private var stageStarted: CompletableDeferred? = null + private var stageRelease: CompletableDeferred? = null + private var commitStarted: CompletableDeferred? = null + private var commitRelease: CompletableDeferred? = null + private var confirmStarted: CompletableDeferred? = null + private var confirmRelease: CompletableDeferred? = null + private var rollbackStarted: CompletableDeferred? = null + private var rollbackRelease: CompletableDeferred? = null + var nextSubtitleMode: PlaybackSubtitleModeV3? = null + var failRollback = false + var failConfirm = false + var failNextStage = false + + override suspend fun stage( + request: TvSubtitleStageRequest, + ): ApiResult { + settlement.await() + stageStarted?.complete(Unit) + stageRelease?.await() + stageStarted = null + stageRelease = null + when (val validated = request.toManagerStageInput()) { + is ApiResult.Error -> return validated + is ApiResult.NetworkError -> return validated + is ApiResult.Success -> Unit + } + if (failNextStage) { + failNextStage = false + return ApiResult.Error( + code = 500, + error = "stage_failed", + message = "stage failed", + ) + } + requests += request + events += "stage:${request.classification}:${request.subtitleTrackIndex}:" + + "${request.outputRouteGeneration}:${request.qualityPreference}" + val sessionId = "session-${++sessionSequence}" + val off = request.subtitleTrackIndex < 0 + val subtitleMode = nextSubtitleMode ?: if (off) { + PlaybackSubtitleModeV3.OFF + } else { + PlaybackSubtitleModeV3.RENDER + } + nextSubtitleMode = null + return ApiResult.Success( + TvStagedSubtitleCandidate( + id = sessionId, + sessionId = sessionId, + selectedAudioIndex = request.audioTrackIndex, + selectedSubtitleIndex = request.subtitleTrackIndex, + subtitleMode = subtitleMode, + hasSidecar = subtitleMode != PlaybackSubtitleModeV3.OFF && + subtitleMode != PlaybackSubtitleModeV3.BURN_IN, + subtitleTracks = emptyList(), + qualityPreference = request.qualityPreference, + outputRouteGeneration = request.outputRouteGeneration, + ), + ) + } + + override suspend fun commit( + candidate: TvStagedSubtitleCandidate, + ): ApiResult { + commitStarted?.complete(Unit) + commitRelease?.await() + commitStarted = null + commitRelease = null + events += "commit:${candidate.sessionId}" + val playback = TvSubtitleCommittedPlayback( + sessionId = candidate.sessionId, + subtitleTracks = candidate.subtitleTracks, + outputRouteGeneration = candidate.outputRouteGeneration, + ) + pendingPlayback = playback + settlement = CompletableDeferred() + return ApiResult.Success(playback) + } + + override suspend fun discard(candidate: TvStagedSubtitleCandidate) { + events += "discard:${candidate.sessionId}" + } + + override suspend fun confirmCommitted(playback: TvSubtitleCommittedPlayback) { + events += "manager-confirm:${playback.sessionId}" + confirmStarted?.complete(Unit) + confirmRelease?.await() + confirmStarted = null + confirmRelease = null + if (failConfirm) { + failConfirm = false + throw IllegalStateException("confirm failed") + } + if (pendingPlayback?.sessionId == playback.sessionId) { + pendingPlayback = null + settlement.complete(Unit) + } + } + + override suspend fun abandonCommitted(playback: TvSubtitleCommittedPlayback) { + events += "manager-rollback:${playback.sessionId}" + rollbackStarted?.complete(Unit) + rollbackRelease?.await() + rollbackStarted = null + rollbackRelease = null + if (failRollback) { + failRollback = false + throw IllegalStateException("rollback failed") + } + if (pendingPlayback?.sessionId == playback.sessionId) { + pendingPlayback = null + settlement.complete(Unit) + } + } + + fun suspendNextRollback() { + rollbackStarted = CompletableDeferred() + rollbackRelease = CompletableDeferred() + } + + suspend fun awaitRollbackStarted() { + requireNotNull(rollbackStarted).await() + } + + fun releaseRollback() { + requireNotNull(rollbackRelease).complete(Unit) + } + + fun suspendNextStage() { + stageStarted = CompletableDeferred() + stageRelease = CompletableDeferred() + } + + suspend fun awaitStageStarted() { + requireNotNull(stageStarted).await() + } + + fun releaseStage() { + requireNotNull(stageRelease).complete(Unit) + } + + fun suspendNextConfirm() { + confirmStarted = CompletableDeferred() + confirmRelease = CompletableDeferred() + } + + suspend fun awaitConfirmStarted() { + requireNotNull(confirmStarted).await() + } + + fun releaseConfirm() { + requireNotNull(confirmRelease).complete(Unit) + } + + fun suspendNextCommit() { + commitStarted = CompletableDeferred() + commitRelease = CompletableDeferred() + } + + suspend fun awaitCommitStarted() { + requireNotNull(commitStarted).await() + } + + fun releaseCommit() { + requireNotNull(commitRelease).complete(Unit) + } + } + + private class LifecycleSettlementProbe( + private val events: MutableList, + ) { + var currentSessionId: String? = "session-a" + private set + private var predecessorSessionId: String? = null + val stoppedSessions = mutableListOf() + + fun adopt(sessionId: String) { + predecessorSessionId = currentSessionId + currentSessionId = sessionId + events += "lifecycle-adopt:$sessionId" + } + + fun confirm(sessionId: String): Boolean { + if (currentSessionId != sessionId) return false + events += "lifecycle-confirm:$sessionId" + predecessorSessionId = null + return true + } + + fun rollback(sessionId: String): Boolean { + if (currentSessionId != sessionId) return false + events += "lifecycle-rollback:$sessionId" + currentSessionId = predecessorSessionId + predecessorSessionId = null + return true + } + + fun stopCurrent() { + val sessionId = currentSessionId ?: return + events += "lifecycle-stop:$sessionId" + stoppedSessions += sessionId + currentSessionId = null + } + } + + private companion object { + val sidecarA: SubtitleIdentity = SubtitleIdentity.ServerSidecar(3) + val sidecarB: SubtitleIdentity = SubtitleIdentity.ServerSidecar(4) + val sidecarC: SubtitleIdentity = SubtitleIdentity.ServerSidecar(5) + + fun assertBefore(source: String, first: String, second: String) { + val firstIndex = source.indexOf(first) + val secondIndex = source.indexOf(second) + assertTrue(firstIndex >= 0, "Missing `$first`.") + assertTrue(secondIndex >= 0, "Missing `$second`.") + assertTrue(firstIndex < secondIndex, "`$first` must precede `$second`.") + } + } +} 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 new file mode 100644 index 000000000..0aab780f0 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapterTest.kt @@ -0,0 +1,2410 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.TestScope +import org.prairieserver.prairie.model.playback.CommittedSubtitle +import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 +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 org.prairieserver.prairie.common.player.PlaybackTrackSelectionWriteCoordinator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class TvSubtitleTransactionAdapterTest { + @Test + fun `pre-playback server selection commits without staging`() = runTest { + val harness = harness(backgroundScope, sessionId = null) + + harness.adapter.select(sidecar(4)) + runCurrent() + + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.port.requests.isEmpty()) + assertEquals(listOf(sidecar(4)), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `an embedded pick the player cannot mount is staged to the server`() = runTest { + // Catalog-only embedded rows carry a blank URL, so on a remuxed or + // transcoded route they never become Media3 tracks. Committing one + // locally could only ever end at the mount deadline and roll back to the + // previous subtitle — which is what "switching to Dutch does nothing" + // looked like. Route it to the staged replan that materialises the + // artifact instead. + val embedded = SubtitleIdentity.Embedded( + serverIndex = 13, + media = media(label = "SUBRIP", language = "nl", codec = "subrip"), + ) + val harness = harness(backgroundScope, isLocallyMountable = { false }) + + harness.adapter.select(embedded) + runCurrent() + + assertEquals( + listOf(13), + harness.port.requests.map { it.subtitleTrackIndex }, + "an unmountable identity must reach the staged replan port", + ) + } + + @Test + fun `an embedded pick the player already exposes stays local`() = runTest { + val embedded = SubtitleIdentity.Embedded( + serverIndex = 13, + media = media(trackId = "decoder-subrip-13", label = "SUBRIP", language = "nl"), + ) + val harness = harness(backgroundScope, isLocallyMountable = { true }) + + harness.adapter.select(embedded) + runCurrent() + + assertTrue( + harness.port.requests.isEmpty(), + "a locally mountable identity must not ask the server to replan", + ) + } + + @Test + fun `slow older preference write cannot overwrite newer commit`() = runTest { + val harness = harness(backgroundScope, sessionId = null) + harness.persistence.suspendFirst = true + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.persistence.awaitFirstStarted() + harness.adapter.select(sidecar(5)) + runCurrent() + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.persistence.releaseFirst() + runCurrent() + + assertEquals( + listOf(sidecar(4), sidecar(5)), + harness.persistence.persisted.map { it.identity }, + ) + } + + @Test + fun `A remains committed while B stages and commits`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(sidecar(4), harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.adapter.snapshot.subtitleApplying) + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.port.completeStage(candidate("b", 4)) + runCurrent() + confirmPendingPlayerBoundary(harness, "b-mounted") + runCurrent() + + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertEquals(listOf("b"), harness.port.committed) + assertEquals(listOf(sidecar(4)), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `A to B to C discards B and commits only latest C`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.adapter.select(sidecar(5)) + runCurrent() + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(sidecar(5), harness.adapter.snapshot.pendingIdentity) + + harness.port.completeStage(candidate("b", 4)) + runCurrent() + assertEquals(listOf("b"), harness.port.discarded) + assertEquals(listOf(4, 5), harness.port.requests.map { it.subtitleTrackIndex }) + + harness.port.completeStage(candidate("c", 5)) + runCurrent() + confirmPendingPlayerBoundary(harness, "c-mounted") + runCurrent() + assertEquals(sidecar(5), harness.adapter.snapshot.committedIdentity) + assertEquals(listOf("c"), harness.port.committed) + assertEquals(listOf(sidecar(5)), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `subtitle then audio merge into one latest reducer transaction`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.adapter.selectAudio(7) + harness.port.completeStage(candidate("subtitle-only", 4, selectedAudioIndex = 2)) + runCurrent() + + assertEquals(listOf(2, 7), harness.port.requests.map { it.audioTrackIndex }) + assertEquals(listOf(4, 4), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(listOf("subtitle-only"), harness.port.discarded) + + harness.port.completeStage(candidate("combined", 4, selectedAudioIndex = 7)) + runCurrent() + confirmPendingPlayerBoundary(harness, "combined-mounted") + runCurrent() + + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + assertEquals(7, harness.adapter.snapshot.transition.committed.audioTrackIndex) + assertEquals(listOf(7), harness.persistence.persisted.map { it.audioTrackIndex }) + } + + @Test + fun `audio then subtitle merge into one latest reducer transaction`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.selectAudio(7) + runCurrent() + harness.adapter.select(sidecar(4)) + harness.port.completeStage(candidate("audio-only", 3, selectedAudioIndex = 7)) + runCurrent() + + assertEquals(listOf(7, 7), harness.port.requests.map { it.audioTrackIndex }) + assertEquals(listOf(3, 4), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(listOf("audio-only"), harness.port.discarded) + + harness.port.completeStage(candidate("combined", 4, selectedAudioIndex = 7)) + runCurrent() + confirmPendingPlayerBoundary(harness, "audio-subtitle-mounted") + runCurrent() + + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + assertEquals(7, harness.adapter.snapshot.transition.committed.audioTrackIndex) + } + + @Test + fun `local then audio before mount keeps one client-owned transaction`() = runTest { + val downloaded = downloadedIdentity() + val row = downloadedTrack( + index = 9, + downloadId = downloaded.downloadId, + url = "https://prairie.test/api/v1/stream/s1/subtitles/9.vtt", + ) + val harness = harness(backgroundScope, tracks = listOf(row)) + + harness.adapter.select(downloaded) + harness.adapter.selectAudio(7) + runCurrent() + + assertEquals(downloaded, harness.adapter.snapshot.pendingIdentity) + assertEquals(downloaded, harness.adapter.snapshot.localMountIdentity) + assertEquals(-1, harness.port.requests.single().subtitleTrackIndex) + assertEquals(7, harness.port.requests.single().audioTrackIndex) + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.adapter.reportMountedSelection( + identity = downloaded, + selected = true, + snapshotKey = "pre-adoption-download", + settled = true, + ) + runCurrent() + assertEquals(downloaded, harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.port.completeStage(clientOwnedCandidate("local-audio", audioIndex = 7)) + runCurrent() + assertEquals(downloaded, harness.adapter.snapshot.localMountIdentity) + harness.adapter.reportMountedSelection( + identity = downloaded, + selected = true, + snapshotKey = "post-adoption-download", + settled = true, + ) + runCurrent() + + assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) + assertEquals(7, harness.adapter.snapshot.transition.committed.audioTrackIndex) + assertEquals( + listOf(CommittedSubtitle(downloaded, audioTrackIndex = 7, qualityPreference = "auto")), + harness.persistence.persisted, + ) + } + + @Test + fun `stage failure after early local mount clears applying owner`() = runTest { + val downloaded = downloadedIdentity() + val harness = harness(backgroundScope) + prepareEarlyMountedLocalAudioTransaction(harness, downloaded) + + harness.port.failStage(ApiResult.NetworkError(IllegalStateException("stage failed"))) + runCurrent() + + assertFalse(harness.adapter.snapshot.subtitleApplying) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `validation failure after early local mount clears applying owner`() = runTest { + val downloaded = downloadedIdentity() + val harness = harness(backgroundScope) + prepareEarlyMountedLocalAudioTransaction(harness, downloaded) + + harness.port.completeStage(clientOwnedCandidate("invalid", audioIndex = 2)) + runCurrent() + + assertFalse(harness.adapter.snapshot.subtitleApplying) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `validation discard exception cannot skip rollback or kill worker`() = runTest { + val downloaded = downloadedIdentity() + val harness = harness(backgroundScope) + prepareEarlyMountedLocalAudioTransaction(harness, downloaded) + harness.port.discardThrowable = IllegalStateException("discard failed") + + harness.port.completeStage(clientOwnedCandidate("invalid", audioIndex = 2)) + runCurrent() + + assertFalse(harness.adapter.snapshot.subtitleApplying) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + + harness.adapter.select(sidecar(5)) + runCurrent() + assertEquals(listOf(-1, 5), harness.port.requests.map { it.subtitleTrackIndex }) + harness.port.completeStage(candidate("next", selectedIndex = 5)) + runCurrent() + confirmPendingPlayerBoundary(harness, "validation-recovery-mounted") + runCurrent() + assertEquals(sidecar(5), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `operation local stale discard cancellation is contained and worker survives`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.adapter.select(sidecar(5)) + harness.port.discardThrowable = CancellationException("discard cancelled locally") + harness.port.completeStage(candidate("stale", selectedIndex = 4)) + runCurrent() + + assertEquals(listOf(4, 5), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(sidecar(5), harness.adapter.snapshot.pendingIdentity) + + harness.port.completeStage(candidate("next", selectedIndex = 5)) + runCurrent() + confirmPendingPlayerBoundary(harness, "discard-recovery-mounted") + runCurrent() + assertEquals(sidecar(5), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `commit failure after early local mount clears applying owner`() = runTest { + val downloaded = downloadedIdentity() + val harness = harness(backgroundScope) + prepareEarlyMountedLocalAudioTransaction(harness, downloaded) + harness.port.commitFailure = ApiResult.Error( + code = 503, + error = "commit_failed", + message = "commit failed", + ) + + harness.port.completeStage(clientOwnedCandidate("commit-failure", audioIndex = 7)) + runCurrent() + + assertFalse(harness.adapter.snapshot.subtitleApplying) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `adoption failure after early local mount remounts prior committed local identity`() = runTest { + val oldDownloaded = downloadedIdentity() + val newDownloaded = SubtitleIdentity.Downloaded( + downloadId = 913, + media = media( + trackId = "prairie-downloaded-subtitle:913", + label = "French", + language = "fr", + codec = "webvtt", + ), + ) + val harness = harness( + backgroundScope, + adoption = AdoptionControl(failure = IllegalStateException("adoption failed")), + ) + harness.adapter.resetContent( + context(sessionId = "s1"), + committedIdentity = oldDownloaded, + ) + prepareEarlyMountedLocalAudioTransaction(harness, newDownloaded) + + harness.port.completeStage(clientOwnedCandidate("adoption-failure", audioIndex = 7)) + runCurrent() + + assertEquals(oldDownloaded, harness.adapter.snapshot.committedIdentity) + assertEquals(oldDownloaded, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertTrue(harness.adapter.snapshot.subtitleApplying) + + harness.adapter.reportMountedSelection( + identity = oldDownloaded, + selected = true, + snapshotKey = "prior-identity-restored", + settled = true, + ) + runCurrent() + + assertFalse(harness.adapter.snapshot.subtitleApplying) + assertNull(harness.adapter.snapshot.localMountIdentity) + } + + @Test + fun `operation local stage cancellation rolls back exact local owner and keeps worker alive`() = runTest { + val downloaded = downloadedIdentity() + val harness = harness(backgroundScope) + prepareEarlyMountedLocalAudioTransaction(harness, downloaded) + + harness.port.cancelStage("stage request cancelled locally") + runCurrent() + + assertFalse(harness.adapter.snapshot.subtitleApplying) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + + harness.adapter.select(sidecar(5)) + runCurrent() + assertEquals(listOf(-1, 5), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(sidecar(5), harness.adapter.snapshot.pendingIdentity) + } + + @Test + fun `operation local commit cancellation rolls back exact local owner and keeps worker alive`() = runTest { + val downloaded = downloadedIdentity() + val harness = harness(backgroundScope) + prepareEarlyMountedLocalAudioTransaction(harness, downloaded) + harness.port.commitThrowable = CancellationException("commit request cancelled locally") + + harness.port.completeStage(clientOwnedCandidate("cancelled-commit", audioIndex = 7)) + runCurrent() + + assertFalse(harness.adapter.snapshot.subtitleApplying) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + + harness.adapter.select(sidecar(5)) + runCurrent() + assertEquals(listOf(-1, 5), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(sidecar(5), harness.adapter.snapshot.pendingIdentity) + } + + @Test + fun `parent cancellation stops stage worker without converting teardown into transaction failure`() = runTest { + val parent = Job() + val harness = harness(CoroutineScope(coroutineContext + parent)) + + harness.adapter.select(sidecar(4)) + runCurrent() + parent.cancel(CancellationException("adapter owner stopped")) + runCurrent() + + assertEquals(sidecar(4), harness.adapter.snapshot.pendingIdentity) + assertNull(harness.adapter.snapshot.failureMessage) + + harness.adapter.select(sidecar(5)) + runCurrent() + assertEquals(listOf(4), harness.port.requests.map { it.subtitleTrackIndex }) + } + + @Test + fun `audio then local while staging restages combined client-owned transaction`() = runTest { + val downloaded = downloadedIdentity() + val harness = harness(backgroundScope) + + harness.adapter.selectAudio(7) + runCurrent() + harness.adapter.select(downloaded) + runCurrent() + harness.port.completeStage(candidate("audio-only", 3, selectedAudioIndex = 7)) + runCurrent() + + assertEquals(listOf(3, -1), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(listOf(7, 7), harness.port.requests.map { it.audioTrackIndex }) + assertEquals(listOf("audio-only"), harness.port.discarded) + + harness.port.completeStage(clientOwnedCandidate("audio-local", audioIndex = 7)) + runCurrent() + assertEquals(downloaded, harness.adapter.snapshot.localMountIdentity) + harness.adapter.reportMountedSelection( + identity = downloaded, + selected = true, + snapshotKey = "audio-local-mounted", + settled = true, + ) + runCurrent() + + assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) + assertEquals(7, harness.adapter.snapshot.transition.committed.audioTrackIndex) + assertEquals(downloaded, harness.persistence.persisted.single().identity) + assertEquals(7, harness.persistence.persisted.single().audioTrackIndex) + } + + @Test + fun `modern downloaded row without source keeps server subtitles off during audio replan`() = runTest { + val row = downloadedTrack( + index = 9, + downloadId = 312, + url = "https://prairie.test/api/v1/stream/s1/subtitles/9.vtt", + ).copy(source = null, catalogSource = null) + val identity = tvSubtitleIdentity(row) + val harness = harness(backgroundScope, tracks = listOf(row)) + + assertTrue(identity is SubtitleIdentity.Downloaded) + harness.adapter.selectAudio(7) + runCurrent() + harness.adapter.select(identity) + runCurrent() + harness.port.completeStage(candidate("stale-audio", 3, selectedAudioIndex = 7)) + runCurrent() + + assertEquals(-1, harness.port.requests.last().subtitleTrackIndex) + assertEquals(7, harness.port.requests.last().audioTrackIndex) + harness.port.completeStage(clientOwnedCandidate("modern-download", audioIndex = 7)) + runCurrent() + assertEquals(identity, harness.adapter.snapshot.localMountIdentity) + } + + @Test + fun `local then audio while server subtitle stages retains local identity`() = runTest { + val downloaded = downloadedIdentity() + val harness = harness(backgroundScope) + harness.adapter.select(sidecar(4)) + runCurrent() + + harness.adapter.select(downloaded) + harness.adapter.selectAudio(7) + runCurrent() + harness.port.completeStage(candidate("server-subtitle", 4, selectedAudioIndex = 2)) + runCurrent() + + assertEquals(listOf(4, -1), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(listOf(2, 7), harness.port.requests.map { it.audioTrackIndex }) + assertEquals(downloaded, harness.adapter.snapshot.pendingIdentity) + assertEquals(listOf("server-subtitle"), harness.port.discarded) + } + + @Test + fun `queued local then audio during adoption preserves both intents`() = runTest { + verifyQueuedClientOwnedOrderDuringAdoption( + scope = backgroundScope, + mutate = { adapter, downloaded -> + adapter.select(downloaded) + adapter.selectAudio(7) + }, + ) + } + + @Test + fun `queued audio then local during adoption preserves both intents`() = runTest { + verifyQueuedClientOwnedOrderDuringAdoption( + scope = backgroundScope, + mutate = { adapter, downloaded -> + adapter.selectAudio(7) + adapter.select(downloaded) + }, + ) + } + + @Test + fun `audio change remounts committed downloaded subtitle without sending client index to server`() = runTest { + val row = downloadedTrack( + index = 9, + downloadId = 312, + url = "https://prairie.test/api/v1/stream/s1/subtitles/9.vtt", + ) + val downloaded = SubtitleIdentity.Downloaded( + downloadId = 312, + media = media( + trackId = "prairie-downloaded-subtitle:312", + language = "en", + codec = "webvtt", + ), + ) + val harness = harness(backgroundScope, tracks = listOf(row)) + harness.adapter.resetContent( + context(sessionId = "s1", tracks = listOf(row)), + committedIdentity = downloaded, + ) + + harness.adapter.selectAudio(7) + runCurrent() + + assertEquals(-1, harness.port.requests.single().subtitleTrackIndex) + harness.port.completeStage( + candidate( + id = "downloaded-audio", + selectedIndex = null, + selectedAudioIndex = 7, + mode = PlaybackSubtitleModeV3.OFF, + hasSidecar = false, + ), + ) + runCurrent() + + assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) + assertEquals(2, harness.adapter.snapshot.transition.committed.audioTrackIndex) + assertEquals(downloaded, harness.adapter.snapshot.localMountIdentity) + assertEquals( + 312, + harness.committedPlaybacks.single().subtitleTracks.single().downloadId, + ) + assertTrue( + harness.committedPlaybacks.single().subtitleTracks.single().url + .contains("/stream/s-downloaded-audio/"), + ) + val persistedBeforeRestoreConfirmation = harness.persistence.persisted.size + harness.adapter.reportMountedSelection( + identity = downloaded, + selected = true, + snapshotKey = "downloaded-restored", + settled = true, + ) + runCurrent() + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) + assertEquals(persistedBeforeRestoreConfirmation + 1, harness.persistence.persisted.size) + assertEquals(7, harness.persistence.persisted.last().audioTrackIndex) + assertEquals(downloaded, harness.persistence.persisted.last().identity) + } + + @Test + fun `audio change remounts committed local Media3 subtitle with server subtitles off`() = runTest { + val row = PlayerSubtitleInfo( + index = 6, + language = "fr", + codec = "vtt", + label = "Legacy local French", + source = "downloaded", + forced = false, + url = "https://prairie.test/api/v1/stream/s1/subtitles/6.vtt", + mediaTrackId = "decoder-text-6", + ) + val local = SubtitleIdentity.LocalMedia3( + media( + trackId = "decoder-text-6", + label = "Legacy local French", + language = "fr", + codec = "webvtt", + ), + ) + val harness = harness(backgroundScope, tracks = listOf(row)) + harness.adapter.resetContent( + context(sessionId = "s1", tracks = listOf(row)), + committedIdentity = local, + ) + + harness.adapter.selectAudio(7) + runCurrent() + + assertEquals(-1, harness.port.requests.single().subtitleTrackIndex) + harness.port.completeStage( + candidate( + id = "local-audio", + selectedIndex = null, + selectedAudioIndex = 7, + mode = PlaybackSubtitleModeV3.OFF, + hasSidecar = false, + ), + ) + runCurrent() + + assertEquals(local, harness.adapter.snapshot.committedIdentity) + assertEquals(local, harness.adapter.snapshot.localMountIdentity) + assertEquals("decoder-text-6", harness.committedPlaybacks.single().subtitleTracks.single().mediaTrackId) + assertTrue( + harness.committedPlaybacks.single().subtitleTracks.single().url + .contains("/stream/s-local-audio/"), + ) + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "ready-local-restore-miss", + settled = true, + ) + runCurrent() + assertEquals(local, harness.adapter.snapshot.committedIdentity) + assertEquals(local, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.adapter.snapshot.failureMessage?.contains("mount", ignoreCase = true) == true) + harness.adapter.reportMountedSelection( + identity = local, + selected = true, + snapshotKey = "prior-local-restored", + settled = true, + ) + runCurrent() + assertNull(harness.adapter.snapshot.localMountIdentity) + } + + @Test + fun `post-adoption local restore timeout keeps committed preference`() = runTest { + val row = downloadedTrack( + index = 9, + downloadId = 312, + url = "https://prairie.test/api/v1/stream/s1/subtitles/9.vtt", + ) + val downloaded = SubtitleIdentity.Downloaded( + downloadId = 312, + media = media( + trackId = "prairie-downloaded-subtitle:312", + language = "en", + codec = "webvtt", + ), + ) + val harness = harness(backgroundScope, tracks = listOf(row)) + harness.adapter.resetContent( + context(sessionId = "s1", tracks = listOf(row)), + committedIdentity = downloaded, + ) + harness.adapter.selectAudio(7) + runCurrent() + harness.port.completeStage( + candidate( + id = "restore-timeout", + selectedIndex = null, + selectedAudioIndex = 7, + mode = PlaybackSubtitleModeV3.OFF, + hasSidecar = false, + ), + ) + runCurrent() + assertEquals(downloaded, harness.adapter.snapshot.localMountIdentity) + + advanceTimeBy(5_000) + runCurrent() + + assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) + assertEquals(downloaded, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.adapter.snapshot.failureMessage?.contains("mount", ignoreCase = true) == true) + harness.adapter.reportMountedSelection( + identity = downloaded, + selected = true, + snapshotKey = "prior-downloaded-restored", + settled = true, + ) + runCurrent() + assertNull(harness.adapter.snapshot.localMountIdentity) + } + + @Test + fun `A to Off keeps A mounted until Off candidate commits`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(SubtitleIdentity.Off) + runCurrent() + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.pendingIdentity) + assertEquals(-1, harness.port.requests.single().subtitleTrackIndex) + + harness.port.completeStage( + candidate( + id = "off", + selectedIndex = null, + mode = PlaybackSubtitleModeV3.OFF, + ), + ) + runCurrent() + confirmPendingPlayerBoundary(harness, "off-applied") + runCurrent() + + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.committedIdentity) + assertEquals(listOf(SubtitleIdentity.Off), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `missing sidecar and network failure retain committed selection and preference`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage( + candidate( + id = "missing-sidecar", + selectedIndex = 4, + mode = PlaybackSubtitleModeV3.RENDER, + hasSidecar = false, + ), + ) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertEquals(listOf("missing-sidecar"), harness.port.discarded) + assertTrue(harness.persistence.persisted.isEmpty()) + assertTrue(harness.adapter.snapshot.failureMessage?.contains("sidecar", ignoreCase = true) == true) + + harness.adapter.select(sidecar(5)) + runCurrent() + harness.port.failStage(ApiResult.NetworkError(IllegalStateException("offline"))) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `burn-in candidate commits without a sidecar`() = runTest { + val harness = harness(backgroundScope) + val burnIn = SubtitleIdentity.ServerBurnIn(8) + + harness.adapter.select(burnIn) + runCurrent() + harness.port.completeStage( + candidate( + id = "burn-in", + selectedIndex = 8, + mode = PlaybackSubtitleModeV3.BURN_IN, + hasSidecar = false, + ), + ) + runCurrent() + + assertEquals(burnIn, harness.adapter.snapshot.committedIdentity) + assertEquals(listOf(burnIn), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `downloaded and embedded choices persist only after mounted resolver confirms`() = runTest { + val harness = harness(backgroundScope) + val downloaded = SubtitleIdentity.Downloaded( + downloadId = 312, + media = media( + trackId = "prairie-downloaded-subtitle:312", + label = "English", + language = "en", + codec = "webvtt", + ), + ) + val embedded = SubtitleIdentity.Embedded( + serverIndex = 7, + media = media( + trackId = "decoder-pgs-7", + label = "English Forced", + language = "en", + codec = "pgs", + forced = true, + ), + ) + + harness.adapter.select(downloaded) + runCurrent() + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(downloaded, harness.adapter.snapshot.pendingIdentity) + assertEquals(downloaded, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + harness.adapter.reportMountedSelection( + identity = downloaded, + selected = true, + snapshotKey = "downloaded-mounted", + ) + runCurrent() + assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) + + harness.adapter.select(embedded) + runCurrent() + assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) + assertEquals(embedded, harness.adapter.snapshot.localMountIdentity) + harness.adapter.reportMountedSelection( + identity = embedded, + selected = true, + snapshotKey = "embedded-mounted", + ) + runCurrent() + assertEquals(embedded, harness.adapter.snapshot.committedIdentity) + assertTrue(harness.port.requests.isEmpty()) + assertEquals( + listOf(downloaded, embedded), + harness.persistence.persisted.map { it.identity }, + ) + } + + @Test + fun `settled local mount miss rolls back immediately without persistence`() = 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 = "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 `repeated and empty local mount snapshots do not exhaust retry bound`() = runTest { + val harness = harness(backgroundScope) + val local = SubtitleIdentity.LocalMedia3( + media(label = "English", language = "en", codec = "webvtt"), + ) + + harness.adapter.select(local) + repeat(5) { + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = if (it == 0) null else "same-mounted-catalog", + ) + } + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(local, harness.adapter.snapshot.pendingIdentity) + assertEquals(local, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `local mount rolls back after bounded timeout when tracks never settle`() = runTest { + val harness = harness(backgroundScope) + val local = SubtitleIdentity.LocalMedia3( + media(label = "English", language = "en", codec = "webvtt"), + ) + + harness.adapter.select(local) + repeat(5) { + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = if (it == 0) null else "same-transient-catalog", + ) + } + advanceTimeBy(4_999) + runCurrent() + assertEquals(local, harness.adapter.snapshot.pendingIdentity) + + advanceTimeBy(1) + 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 `committed session replacement rebases downloaded rows to real session identity`() = runTest { + val downloaded = downloadedTrack( + index = 9, + downloadId = 312, + url = "https://prairie.test/api/v1/stream/s1/subtitles/9.vtt?token=s1", + ) + val harness = harness(backgroundScope, tracks = listOf(downloaded)) + harness.adapter.select(sidecar(4)) + runCurrent() + + harness.port.completeStage( + candidate( + id = "b", + selectedIndex = 4, + sessionId = "s2", + tracks = listOf(serverTrack(4, "/stream/s2/subtitles/4.vtt")), + ), + ) + runCurrent() + + val committed = harness.committedPlaybacks.single() + assertEquals("s2", committed.sessionId) + assertEquals( + "https://prairie.test/api/v1/stream/s2/subtitles/9.vtt?token=s1", + committed.subtitleTracks.single { it.downloadId == 312 }.url, + ) + } + + @Test + fun `content file version and session reset invalidates staged response`() = runTest { + val harness = harness(backgroundScope) + harness.adapter.select(sidecar(4)) + runCurrent() + + harness.adapter.resetContent( + context( + contentId = "content-2", + mediaFileId = 22, + versionId = "version-2", + sessionId = "s9", + ), + committedIdentity = SubtitleIdentity.Off, + ) + harness.port.completeStage(candidate("old", 4)) + runCurrent() + + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.committedIdentity) + assertEquals(listOf("old"), harness.port.discarded) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `new selection during suspended commit rolls B publication back before replaying explicit C`() = runTest { + val harness = harness(backgroundScope) + harness.port.suspendCommits = true + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("b", 4, sessionId = "s2")) + runCurrent() + assertEquals(listOf("b"), harness.port.commitStarted) + + harness.adapter.select(sidecar(5)) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(sidecar(5), harness.adapter.snapshot.pendingIdentity) + + harness.port.completeCommit("b") + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(sidecar(5), harness.adapter.snapshot.pendingIdentity) + assertEquals(listOf(4, 5), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(listOf("s2"), harness.committedPlaybacks.map { it.sessionId }) + assertEquals(listOf("s2"), harness.port.abandoned) + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.port.completeStage(candidate("c", 5, sessionId = "s3")) + harness.port.completeCommit("c") + runCurrent() + assertEquals( + sidecar(5), + harness.adapter.snapshot.localMountIdentity, + "Explicit C must own the player boundary after its replacement commits.", + ) + confirmPendingPlayerBoundary(harness, "queued-c-mounted") + runCurrent() + assertEquals(sidecar(5), harness.adapter.snapshot.committedIdentity) + assertEquals(listOf(sidecar(5)), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `an embedded pick replayed from the queue mounts locally instead of replanning`() = runTest { + // A direct press on an embedded track switches it locally, because the + // stream already carries it. The same press arriving while another + // mutation was committing went down a different path and replanned -- + // tearing the stream down and rebuilding it for the same picture, so the + // user saw a black flash, a rebuffer and a re-seek. + val harness = harness(backgroundScope) + val embedded = SubtitleIdentity.Embedded( + serverIndex = 7, + media = media( + trackId = "decoder-pgs-7", + label = "English", + language = "en", + codec = "pgs", + ), + ) + harness.port.suspendCommits = true + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("b", 4, sessionId = "s2")) + runCurrent() + assertEquals(listOf("b"), harness.port.commitStarted) + + // Pressed while the sidecar commit is still in flight, so it is folded + // into queuedMutations and replayed once that commit lands. + harness.adapter.select(embedded) + harness.port.completeCommit("b") + runCurrent() + + assertEquals( + listOf(4), + harness.port.requests.map { it.subtitleTrackIndex }, + "the embedded track is already in the stream — replanning for it is pure loss", + ) + assertEquals(embedded, harness.adapter.snapshot.localMountIdentity) + } + + @Test + fun `a queued embedded pick still replans when it carries an audio change`() = runTest { + // The other half of the contract: the local shortcut skips the server, + // so it must decline whenever the folded pending also carries an audio, + // quality or output-route preference — only the server can apply those, + // and silently dropping them is worse than the rebuffer. + val harness = harness(backgroundScope) + val embedded = SubtitleIdentity.Embedded( + serverIndex = 7, + media = media( + trackId = "decoder-pgs-7", + label = "English", + language = "en", + codec = "pgs", + ), + ) + harness.port.suspendCommits = true + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("b", 4, sessionId = "s2")) + runCurrent() + + harness.adapter.selectAudio(7) + harness.adapter.select(embedded) + harness.port.completeCommit("b") + runCurrent() + + assertEquals( + 7, + harness.port.requests.last().audioTrackIndex, + "the audio change must still reach the server", + ) + } + + @Test + fun `reset during suspended commit prevents old playback adoption and persistence`() = runTest { + val harness = harness(backgroundScope) + harness.port.suspendCommits = true + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("b", 4, sessionId = "s2")) + runCurrent() + + harness.adapter.resetContent( + context(contentId = "content-2", mediaFileId = 22, versionId = "v2", sessionId = "s9"), + committedIdentity = SubtitleIdentity.Off, + ) + harness.port.completeCommit("b") + runCurrent() + + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.committedIdentity) + assertTrue(harness.committedPlaybacks.isEmpty()) + assertTrue(harness.persistence.persisted.isEmpty()) + assertEquals(listOf("s2"), harness.port.abandoned) + } + + @Test + fun `failed old commit after reset cannot poison next content commit`() = runTest { + val harness = harness(backgroundScope) + harness.port.suspendCommits = true + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("old", 4, sessionId = "s2")) + runCurrent() + harness.port.commitFailure = ApiResult.Error( + code = 503, + error = "commit_failed", + message = "old commit failed", + ) + + harness.adapter.resetContent( + context( + contentId = "content-2", + mediaFileId = 22, + versionId = "v2", + sessionId = "s9", + ), + committedIdentity = SubtitleIdentity.Off, + ) + harness.port.completeCommit("old") + runCurrent() + assertFalse(harness.adapter.snapshot.subtitleApplying) + + harness.port.commitFailure = null + harness.adapter.select(sidecar(5)) + runCurrent() + harness.port.completeStage(candidate("new", 5, sessionId = "s10")) + harness.port.completeCommit("new") + runCurrent() + confirmPendingPlayerBoundary(harness, "new-content-mounted") + runCurrent() + + assertEquals(sidecar(5), harness.adapter.snapshot.committedIdentity) + assertFalse(harness.adapter.snapshot.subtitleApplying) + assertEquals(listOf("s10"), harness.committedPlaybacks.map { it.sessionId }) + assertEquals(listOf(sidecar(5)), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `new selection waits for adoption then abandons B before replaying explicit C`() = runTest { + val adoption = AdoptionControl(suspendAdoption = true) + val harness = harness(backgroundScope, adoption = adoption) + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("b", 4, sessionId = "s2")) + runCurrent() + assertEquals(1, adoption.started) + + harness.adapter.select(sidecar(5)) + runCurrent() + + assertEquals( + listOf(4), + harness.port.requests.map { it.subtitleTrackIndex }, + "A newer intent must not stage from the manager-committed base before lifecycle adoption finishes.", + ) + assertEquals(sidecar(5), harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertTrue(harness.port.abandoned.isEmpty()) + + adoption.complete() + runCurrent() + + assertEquals(listOf(4, 5), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(sidecar(5), harness.adapter.snapshot.pendingIdentity) + assertEquals(listOf("s2"), harness.port.abandoned) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `audio change during adoption waits and stages from validated B intent`() = runTest { + val adoption = AdoptionControl(suspendAdoption = true) + val harness = harness(backgroundScope, adoption = adoption) + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("subtitle", 4, selectedAudioIndex = 2, sessionId = "s2")) + runCurrent() + + harness.adapter.selectAudio(7) + runCurrent() + assertEquals(listOf(4), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(sidecar(4), harness.adapter.snapshot.pendingIdentity) + + adoption.complete() + runCurrent() + + assertEquals(listOf(4, 4), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(listOf(2, 7), harness.port.requests.map { it.audioTrackIndex }) + assertEquals(listOf("s2"), harness.port.abandoned) + assertTrue(harness.persistence.persisted.isEmpty()) + harness.port.completeStage(candidate("audio", 4, selectedAudioIndex = 7, sessionId = "s3")) + runCurrent() + adoption.complete() + runCurrent() + confirmPendingPlayerBoundary(harness, "audio-replan-mounted") + runCurrent() + + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + assertEquals(7, harness.adapter.snapshot.transition.committed.audioTrackIndex) + } + + @Test + fun `reset during suspended playback adoption invalidates stale callback and persistence`() = runTest { + val adoption = AdoptionControl(suspendAdoption = true) + val harness = harness(backgroundScope, adoption = adoption) + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("b", 4, sessionId = "s2")) + runCurrent() + assertEquals(1, adoption.started) + + harness.adapter.resetContent( + context(contentId = "content-2", mediaFileId = 22, versionId = "v2", sessionId = "s9"), + committedIdentity = SubtitleIdentity.Off, + ) + adoption.complete() + runCurrent() + + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.committedIdentity) + assertTrue(harness.committedPlaybacks.isEmpty()) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `playback adoption exception is contained and worker remains available`() = runTest { + val adoption = AdoptionControl( + failure = IllegalStateException("lifecycle adoption failed"), + ) + val harness = harness(backgroundScope, adoption = adoption) + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("b", 4, sessionId = "s2")) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue( + harness.adapter.snapshot.failureMessage + ?.contains("adoption", ignoreCase = true) == true, + ) + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.adapter.select(sidecar(5)) + runCurrent() + assertEquals(listOf(4, 5), harness.port.requests.map { it.subtitleTrackIndex }) + } + + @Test + fun `first preference write exception is retried and later write remains FIFO`() = runTest { + val harness = harness(backgroundScope, sessionId = null) + harness.persistence.throwFirst = true + + harness.adapter.select(sidecar(4)) + harness.adapter.select(sidecar(5)) + runCurrent() + + assertEquals( + listOf(sidecar(4), sidecar(5)), + harness.persistence.persisted.map { it.identity }, + ) + } + + @Test + fun `operation local persistence cancellation retries without killing consumer or flush`() = runTest { + val harness = harness( + scope = backgroundScope, + sessionId = null, + durablePersistenceScope = backgroundScope, + ) + harness.persistence.cancelFirst = true + + val flushed = async { harness.adapter.persistCommittedSelectionAndFlush() } + runCurrent() + + assertTrue(flushed.await()) + assertEquals(listOf(sidecar(3)), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `flush reports failure only after bounded primary and durable attempts then later succeeds`() = runTest { + val harness = harness( + scope = backgroundScope, + sessionId = null, + durablePersistenceScope = backgroundScope, + ) + harness.persistence.failuresRemaining = 4 + + val first = async { harness.adapter.persistCommittedSelectionAndFlush() } + runCurrent() + assertFalse(first.await()) + assertTrue(harness.persistence.persisted.isEmpty()) + + val second = async { harness.adapter.persistCommittedSelectionAndFlush() } + runCurrent() + assertTrue(second.await()) + assertEquals(listOf(sidecar(3)), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `false persistence result is not reported durable after bounded flush attempts`() = runTest { + val harness = harness( + scope = backgroundScope, + sessionId = null, + durablePersistenceScope = backgroundScope, + ) + harness.persistence.rejectionsRemaining = 4 + + val flushed = async { harness.adapter.persistCommittedSelectionAndFlush() } + runCurrent() + + assertFalse(flushed.await()) + assertEquals(4, harness.persistence.attempts) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `false persistence result retries until an accepted durable write`() = runTest { + val harness = harness( + scope = backgroundScope, + sessionId = null, + durablePersistenceScope = backgroundScope, + ) + harness.persistence.rejectionsRemaining = 3 + + val flushed = async { harness.adapter.persistCommittedSelectionAndFlush() } + runCurrent() + + assertTrue(flushed.await()) + assertEquals(4, harness.persistence.attempts) + assertEquals(listOf(sidecar(3)), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `retirement ticket reserved before settlement cannot overwrite replacement adapter`() = runTest { + val coordinator = PlaybackTrackSelectionWriteCoordinator() + val persistence = RecordingPersistence() + val old = harness( + scope = backgroundScope, + sessionId = null, + durablePersistenceScope = backgroundScope, + persistenceCoordinator = coordinator, + persistence = persistence, + ) + old.adapter.select(sidecar(4)) + runCurrent() + persistence.persisted.clear() + persistence.persistedContexts.clear() + + val reservation = requireNotNull(old.adapter.reserveDurableFinalPersistence()) + val releaseSettlement = CompletableDeferred() + old.adapter.invalidateAndSettleAsync(restoreUi = false) { + releaseSettlement.await() + old.adapter.requestDurableFinalPersistence(reservation) + } + runCurrent() + + val replacement = harness( + scope = backgroundScope, + sessionId = null, + durablePersistenceScope = backgroundScope, + persistenceCoordinator = coordinator, + persistence = persistence, + ) + replacement.adapter.select(sidecar(5)) + runCurrent() + releaseSettlement.complete(Unit) + runCurrent() + + assertEquals(listOf(sidecar(5)), persistence.persisted.map { it.identity }) + } + + @Test + fun `durable write leapfrogging another content key does not suppress older valid write`() = runTest { + val durableJob = Job() + val durableScope = CoroutineScope( + durableJob + UnconfinedTestDispatcher(testScheduler), + ) + val harness = harness( + scope = backgroundScope, + sessionId = null, + durablePersistenceScope = durableScope, + ) + + harness.adapter.select(sidecar(4)) + harness.adapter.resetContent( + context = context( + contentId = "content-2", + mediaFileId = 22, + sessionId = null, + ), + committedIdentity = sidecar(8), + ) + harness.adapter.requestDurableFinalPersistence() + runCurrent() + + assertEquals( + listOf("content-2" to 22, "content-1" to 11), + harness.persistence.persistedContexts, + ) + assertEquals( + listOf(sidecar(8), sidecar(4)), + harness.persistence.persisted.map { it.identity }, + ) + durableJob.cancel() + } + + @Test + fun `durable final write is bounded when persistence never completes`() = runTest { + val harness = harness( + scope = backgroundScope, + sessionId = null, + durablePersistenceScope = backgroundScope, + ) + harness.persistence.suspendEveryWrite = true + + harness.adapter.requestDurableFinalPersistence() + runCurrent() + advanceTimeBy(6_000L) + runCurrent() + + assertEquals(1, harness.persistence.cancelledWrites) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `consumer shutdown fails pending ack and flush uses bounded durable fallback`() = runTest { + val owner = Job() + val harness = harness( + scope = CoroutineScope(coroutineContext + owner), + sessionId = null, + durablePersistenceScope = backgroundScope, + ) + harness.persistence.suspendEveryWrite = true + + val flushed = async { harness.adapter.persistCommittedSelectionAndFlush() } + runCurrent() + owner.cancel(CancellationException("adapter owner stopped")) + runCurrent() + advanceTimeBy(6_000L) + runCurrent() + + assertFalse(flushed.await()) + assertTrue(harness.persistence.cancelledWrites >= 2) + } + + @Test + fun `flush is bounded when active consumer persistence never completes`() = runTest { + val harness = harness( + scope = backgroundScope, + sessionId = null, + durablePersistenceScope = backgroundScope, + ) + harness.persistence.suspendEveryWrite = true + + val flushed = async { harness.adapter.persistCommittedSelectionAndFlush() } + runCurrent() + advanceTimeBy(11_000L) + runCurrent() + + assertFalse(flushed.await()) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `refresh owner rejects stale response after intent and session changes`() = runTest { + val harness = harness(backgroundScope) + val first = harness.adapter.beginRefresh() + assertTrue(harness.adapter.ownsRefresh(first)) + + harness.adapter.select(sidecar(4)) + runCurrent() + assertFalse(harness.adapter.ownsRefresh(first)) + + val second = harness.adapter.beginRefresh() + assertTrue(harness.adapter.ownsRefresh(second)) + harness.adapter.replaceSession("s2") + assertFalse(harness.adapter.ownsRefresh(second)) + } + + @Test + fun `auto selection enters reducer only for current refresh owner`() = runTest { + val harness = harness(backgroundScope) + val stale = harness.adapter.beginRefresh() + val current = harness.adapter.beginRefresh() + val downloaded = SubtitleIdentity.Downloaded( + downloadId = 91, + media = media(trackId = "prairie-downloaded-subtitle:91", language = "en", codec = "webvtt"), + ) + + assertFalse(harness.adapter.selectFromRefresh(stale, downloaded)) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertTrue(harness.adapter.selectFromRefresh(current, downloaded)) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(downloaded, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + harness.adapter.reportMountedSelection( + identity = downloaded, + selected = true, + snapshotKey = "auto-downloaded-mounted", + ) + runCurrent() + + assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) + assertEquals(listOf(downloaded), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `HUD catalog selection while controls are open enters one transaction`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + + assertEquals(listOf(4), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(sidecar(4), harness.adapter.snapshot.pendingIdentity) + } + + @Test + fun `subtitle then quality merges into one latest staged request`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.adapter.selectQuality("720p") + harness.port.completeStage(candidate("subtitle-only", 4, qualityPreference = "auto")) + runCurrent() + + assertEquals(listOf("auto", "720p"), harness.port.requests.map { it.qualityPreference }) + assertEquals(listOf(4, 4), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(listOf("subtitle-only"), harness.port.discarded) + + harness.port.completeStage(candidate("combined", 4, qualityPreference = "720p")) + runCurrent() + confirmPendingPlayerBoundary(harness, "subtitle-quality-mounted") + runCurrent() + + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + assertEquals("720p", harness.adapter.snapshot.transition.committed.qualityPreference) + } + + @Test + fun `quality then subtitle merges into one latest staged request`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.selectQuality("720p") + runCurrent() + harness.adapter.select(sidecar(4)) + harness.port.completeStage(candidate("quality-only", 3, qualityPreference = "720p")) + runCurrent() + + assertEquals(listOf("720p", "720p"), harness.port.requests.map { it.qualityPreference }) + assertEquals(listOf(3, 4), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(listOf("quality-only"), harness.port.discarded) + + harness.port.completeStage(candidate("combined", 4, qualityPreference = "720p")) + runCurrent() + confirmPendingPlayerBoundary(harness, "quality-subtitle-mounted") + runCurrent() + + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + assertEquals("720p", harness.adapter.snapshot.transition.committed.qualityPreference) + } + + @Test + fun `failed combined quality subtitle replan retains committed HUD and quality`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + harness.adapter.selectQuality("720p") + runCurrent() + harness.port.failStage(ApiResult.NetworkError(IllegalStateException("quality failed"))) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals("auto", harness.adapter.snapshot.transition.committed.qualityPreference) + assertNull(harness.adapter.snapshot.pendingIdentity) + } + + @Test + fun `operation-local combined quality cancellation rolls back and worker survives`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + harness.adapter.selectQuality("720p") + runCurrent() + harness.port.cancelStage("quality cancelled locally") + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals("auto", harness.adapter.snapshot.transition.committed.qualityPreference) + harness.adapter.select(sidecar(5)) + runCurrent() + assertEquals(listOf(4, 5), harness.port.requests.map { it.subtitleTrackIndex }) + } + + @Test + fun `stale quality subtitle candidate cannot publish`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + harness.adapter.selectQuality("720p") + runCurrent() + harness.adapter.selectQuality("1080p") + harness.port.completeStage(candidate("stale", 4, qualityPreference = "720p")) + runCurrent() + + assertEquals(listOf("stale"), harness.port.discarded) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals("auto", harness.adapter.snapshot.transition.committed.qualityPreference) + } + + @Test + fun `reset while combined quality subtitle replan is suspended invalidates it`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + harness.adapter.selectQuality("720p") + runCurrent() + harness.adapter.resetContent( + context(contentId = "content-2", versionId = "version-2", sessionId = "s2"), + committedIdentity = sidecar(8), + ) + harness.port.completeStage(candidate("old", 4, qualityPreference = "720p")) + runCurrent() + + assertEquals(listOf("old"), harness.port.discarded) + assertEquals(sidecar(8), harness.adapter.snapshot.committedIdentity) + assertEquals("auto", harness.adapter.snapshot.transition.committed.qualityPreference) + } + + @Test + fun `exit flush captures only the committed quality subtitle snapshot`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + harness.adapter.selectQuality("720p") + runCurrent() + harness.port.completeStage(candidate("committed", 4, qualityPreference = "720p")) + runCurrent() + confirmPendingPlayerBoundary(harness, "flush-base-mounted") + runCurrent() + harness.adapter.select(sidecar(5)) + assertTrue(harness.adapter.persistCommittedSelectionAndFlush()) + runCurrent() + + assertEquals( + CommittedSubtitle(sidecar(4), audioTrackIndex = 2, qualityPreference = "720p"), + harness.persistence.persisted.last(), + ) + } + + @Test + fun `subtitle then route generation merges into one latest staged request`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.adapter.updateOutputRouteGeneration(7) + harness.port.completeStage(candidate("subtitle-only", 4, outputRouteGeneration = 0)) + runCurrent() + + assertEquals(listOf(0L, 7L), harness.port.requests.map { it.outputRouteGeneration }) + assertEquals(listOf("subtitle-only"), harness.port.discarded) + + harness.port.completeStage(candidate("combined", 4, outputRouteGeneration = 7)) + runCurrent() + confirmPendingPlayerBoundary(harness, "route-subtitle-mounted") + runCurrent() + assertEquals(7L, harness.adapter.snapshot.committedOutputRouteGeneration) + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `route generation then subtitle merges into one latest staged request`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.updateOutputRouteGeneration(7) + runCurrent() + harness.adapter.select(sidecar(4)) + harness.port.completeStage(candidate("route-only", 3, outputRouteGeneration = 7)) + runCurrent() + + assertEquals(listOf(7L, 7L), harness.port.requests.map { it.outputRouteGeneration }) + assertEquals(listOf(3, 4), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(listOf("route-only"), harness.port.discarded) + } + + @Test + fun `failed route subtitle replan retains committed playback`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + harness.adapter.updateOutputRouteGeneration(7) + runCurrent() + harness.port.failStage(ApiResult.NetworkError(IllegalStateException("route failed"))) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(0L, harness.adapter.snapshot.committedOutputRouteGeneration) + assertNull(harness.adapter.snapshot.pendingIdentity) + } + + @Test + fun `operation-local route cancellation rolls back and worker survives`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.updateOutputRouteGeneration(7) + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.cancelStage("route cancelled locally") + runCurrent() + harness.adapter.select(sidecar(5)) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(0L, harness.adapter.snapshot.committedOutputRouteGeneration) + assertEquals(listOf(4, 5), harness.port.requests.map { it.subtitleTrackIndex }) + } + + @Test + fun `stale route subtitle candidate cannot publish`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + harness.adapter.updateOutputRouteGeneration(7) + runCurrent() + harness.adapter.updateOutputRouteGeneration(8) + harness.port.completeStage(candidate("stale", 4, outputRouteGeneration = 7)) + runCurrent() + + assertEquals(listOf("stale"), harness.port.discarded) + assertEquals(0L, harness.adapter.snapshot.committedOutputRouteGeneration) + } + + @Test + fun `content reset while route subtitle replan is suspended invalidates it`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + harness.adapter.updateOutputRouteGeneration(7) + runCurrent() + harness.adapter.resetContent( + context(contentId = "content-2", versionId = "version-2", sessionId = "s2"), + committedIdentity = sidecar(8), + ) + harness.port.completeStage(candidate("old", 4, outputRouteGeneration = 7)) + runCurrent() + + assertEquals(listOf("old"), harness.port.discarded) + assertEquals(sidecar(8), harness.adapter.snapshot.committedIdentity) + assertEquals(0L, harness.adapter.snapshot.committedOutputRouteGeneration) + } + + @Test + fun `exit invalidates route subtitle work`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.updateOutputRouteGeneration(7) + runCurrent() + harness.adapter.invalidate() + harness.port.completeStage(candidate("route", 3, outputRouteGeneration = 7)) + runCurrent() + + assertEquals(listOf("route"), harness.port.discarded) + assertEquals(0L, harness.adapter.snapshot.committedOutputRouteGeneration) + } + + @Test + fun `reset between manager commit and lifecycle publication abandons the committed session`() = runTest { + val adoption = AdoptionControl(suspendAdoption = true) + val harness = harness(backgroundScope, adoption = adoption) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("committed", 4, sessionId = "replacement")) + runCurrent() + harness.adapter.resetContent( + context(contentId = "content-2", versionId = "version-2", sessionId = "s2"), + committedIdentity = sidecar(8), + ) + adoption.complete() + runCurrent() + + assertEquals(listOf("replacement"), harness.port.abandoned) + assertEquals(sidecar(8), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `adoption exception abandons replacement and the worker survives`() = runTest { + val adoption = AdoptionControl(failure = IllegalStateException("adoption failed")) + val harness = harness(backgroundScope, adoption = adoption) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("failed-adoption", 4, sessionId = "replacement")) + runCurrent() + + assertEquals(listOf("replacement"), harness.port.abandoned) + adoption.failure = null + harness.adapter.select(sidecar(5)) + runCurrent() + harness.port.completeStage(candidate("next", 5, sessionId = "replacement-2")) + runCurrent() + confirmPendingPlayerBoundary(harness, "adoption-recovery-mounted") + runCurrent() + assertEquals(sidecar(5), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `superseded committed Ready never changes the lifecycle active session`() = runTest { + val adoption = AdoptionControl(suspendAdoption = true, forceSuperseded = true) + val harness = harness(backgroundScope, adoption = adoption) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("b", 4, sessionId = "replacement-b")) + runCurrent() + harness.adapter.select(sidecar(5)) + adoption.complete() + runCurrent() + + assertEquals(listOf("replacement-b"), harness.port.abandoned) + assertTrue(harness.committedPlaybacks.isEmpty()) + assertEquals(sidecar(5), harness.adapter.snapshot.pendingIdentity) + } + + @Test + fun `version switch during stage invalidates and discards the candidate`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.adapter.resetContent( + context(versionId = "version-2", sessionId = "s2"), + committedIdentity = sidecar(8), + ) + harness.port.completeStage(candidate("version-1", 4)) + runCurrent() + + assertEquals(listOf("version-1"), harness.port.discarded) + assertEquals(sidecar(8), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `version switch during commit abandons a committed unpublished session`() = runTest { + val harness = harness(backgroundScope) + harness.port.suspendCommits = true + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("version-1", 4, sessionId = "replacement")) + runCurrent() + harness.adapter.resetContent( + context(versionId = "version-2", sessionId = "s2"), + committedIdentity = sidecar(8), + ) + harness.port.completeCommit("version-1") + runCurrent() + + assertEquals(listOf("replacement"), harness.port.abandoned) + assertEquals(sidecar(8), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `version switch during adoption cannot publish the older playback`() = runTest { + val adoption = AdoptionControl(suspendAdoption = true) + val harness = harness(backgroundScope, adoption = adoption) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("version-1", 4, sessionId = "replacement")) + runCurrent() + harness.adapter.resetContent( + context(versionId = "version-2", sessionId = "s2"), + committedIdentity = sidecar(8), + ) + adoption.complete() + runCurrent() + + assertEquals(listOf("replacement"), harness.port.abandoned) + assertEquals(sidecar(8), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `fresh typed sidecar remains pending until staged playback is mounted`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.restoreFreshPreference(sidecar(4)) + runCurrent() + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(sidecar(4), harness.adapter.snapshot.pendingIdentity) + + harness.port.completeStage(candidate("fresh-sidecar", 4)) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(sidecar(4), harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.adapter.reportMountedSelection( + identity = sidecar(4), + selected = true, + snapshotKey = "fresh-sidecar-mounted", + settled = true, + ) + runCurrent() + + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + assertEquals(listOf(sidecar(4)), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `fresh typed burn in commits without waiting for a mounted track`() = runTest { + val harness = harness(backgroundScope) + val burnIn = SubtitleIdentity.ServerBurnIn(4) + + harness.adapter.restoreFreshPreference(burnIn) + runCurrent() + harness.port.completeStage( + candidate( + id = "fresh-burn-in", + selectedIndex = 4, + mode = PlaybackSubtitleModeV3.BURN_IN, + hasSidecar = false, + ), + ) + runCurrent() + + assertEquals(burnIn, harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(listOf(burnIn), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `fresh typed Off emits one owned disable and persists only after backend accepts`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.restoreFreshPreference(SubtitleIdentity.Off) + runCurrent() + harness.port.completeStage( + candidate( + id = "fresh-off", + selectedIndex = -1, + mode = PlaybackSubtitleModeV3.OFF, + hasSidecar = false, + ), + ) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.adapter.reportMountedSelection( + identity = SubtitleIdentity.Off, + selected = true, + snapshotKey = "fresh-off-disabled", + settled = true, + ) + runCurrent() + + assertEquals(SubtitleIdentity.Off, harness.adapter.snapshot.committedIdentity) + assertEquals(listOf(SubtitleIdentity.Off), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `backend rejection rolls fresh restore back without committed or persisted false success`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.restoreFreshPreference(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("fresh-rejected", 4)) + runCurrent() + harness.adapter.reportMountedSelection( + identity = sidecar(4), + selected = false, + snapshotKey = "fresh-sidecar-rejected", + settled = true, + ) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertEquals("The selected subtitle could not be mounted.", harness.adapter.snapshot.failureMessage) + + harness.port.completeStage(candidate("fresh-restore-a", 3)) + runCurrent() + assertEquals(sidecar(3), harness.adapter.snapshot.localMountIdentity) + harness.adapter.reportMountedSelection( + identity = sidecar(3), + selected = true, + snapshotKey = "fresh-prior-a-restored", + settled = true, + ) + runCurrent() + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + } + + @Test + fun `legacy migration of already planned sidecar persists only after exact mount`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.resetContent(context(), committedIdentity = sidecar(4)) + harness.adapter.restoreFreshPreference(sidecar(4), migrationRequired = true) + runCurrent() + + assertEquals(sidecar(4), harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + + harness.adapter.reportMountedSelection( + identity = sidecar(4), + selected = true, + snapshotKey = "fresh-legacy-sidecar-mounted", + settled = true, + ) + runCurrent() + + assertEquals(listOf(sidecar(4)), harness.persistence.persisted.map { it.identity }) + } + + private fun harness( + scope: CoroutineScope, + sessionId: String? = "s1", + tracks: List = emptyList(), + adoption: AdoptionControl = AdoptionControl(), + durablePersistenceScope: CoroutineScope = scope, + isLocallyMountable: (SubtitleIdentity) -> Boolean = { true }, + persistenceCoordinator: PlaybackTrackSelectionWriteCoordinator = + PlaybackTrackSelectionWriteCoordinator(), + persistence: RecordingPersistence = RecordingPersistence(), + ): Harness { + val port = FakeStagedPort() + val committedPlaybacks = mutableListOf() + val adapter = TvSubtitleTransactionAdapter( + scope = scope, + stagedPort = port, + persistencePort = persistence, + durablePersistenceScope = durablePersistenceScope, + persistenceCoordinator = persistenceCoordinator, + onCommittedPlayback = { adoptionRequest -> + adoption.started += 1 + if (adoption.suspendAdoption) adoption.completions.receive() + adoption.failure?.let { throw it } + if (adoption.forceSuperseded || !adoptionRequest.isCurrent()) { + TvSubtitleAdoptionResult.Superseded + } else { + committedPlaybacks += adoptionRequest.playback + TvSubtitleAdoptionResult.Adopted + } + }, + isLocallyMountable = isLocallyMountable, + ) + adapter.resetContent( + context(sessionId = sessionId, tracks = tracks), + committedIdentity = sidecar(3), + ) + return Harness(adapter, port, persistence, committedPlaybacks) + } + + private suspend fun TestScope.verifyQueuedClientOwnedOrderDuringAdoption( + scope: CoroutineScope, + mutate: (TvSubtitleTransactionAdapter, SubtitleIdentity.Downloaded) -> Unit, + ) { + val adoption = AdoptionControl(suspendAdoption = true) + val harness = harness(scope, adoption = adoption) + val downloaded = downloadedIdentity() + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage(candidate("first", 4, selectedAudioIndex = 2, sessionId = "s2")) + runCurrent() + + mutate(harness.adapter, downloaded) + runCurrent() + adoption.complete() + runCurrent() + + assertEquals(listOf(4, -1), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(listOf(2, 7), harness.port.requests.map { it.audioTrackIndex }) + harness.port.completeStage(clientOwnedCandidate("combined", audioIndex = 7)) + runCurrent() + adoption.complete() + runCurrent() + assertEquals(downloaded, harness.adapter.snapshot.localMountIdentity) + harness.adapter.reportMountedSelection( + identity = downloaded, + selected = true, + snapshotKey = "queued-combined-mounted", + settled = true, + ) + runCurrent() + + assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) + assertEquals(7, harness.adapter.snapshot.transition.committed.audioTrackIndex) + assertEquals(downloaded, harness.persistence.persisted.single().identity) + assertEquals(7, harness.persistence.persisted.single().audioTrackIndex) + } + + private suspend fun TestScope.prepareEarlyMountedLocalAudioTransaction( + harness: Harness, + identity: SubtitleIdentity.Downloaded, + ) { + harness.adapter.select(identity) + harness.adapter.selectAudio(7) + runCurrent() + harness.adapter.reportMountedSelection( + identity = identity, + selected = true, + snapshotKey = "mounted-before-adoption", + settled = true, + ) + runCurrent() + assertEquals(identity, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.adapter.snapshot.subtitleApplying) + } + + private class AdoptionControl( + val suspendAdoption: Boolean = false, + var failure: Throwable? = null, + var forceSuperseded: Boolean = false, + ) { + var started: Int = 0 + val completions = Channel(Channel.UNLIMITED) + + suspend fun complete() { + completions.send(Unit) + } + } + + private fun context( + contentId: String = "content-1", + mediaFileId: Int = 11, + versionId: String = "version-1", + sessionId: String? = "s1", + tracks: List = emptyList(), + outputRouteGeneration: Long = 0L, + ): TvSubtitlePlaybackContext = TvSubtitlePlaybackContext( + contentId = contentId, + mediaFileId = mediaFileId, + versionId = versionId, + sessionId = sessionId, + positionSeconds = 42.0, + audioTrackIndex = 2, + qualityPreference = "auto", + subtitleTracks = tracks, + outputRouteGeneration = outputRouteGeneration, + writeScope = tvTestPlaybackWriteScope, + ) + + private fun candidate( + id: String, + selectedIndex: Int?, + selectedAudioIndex: Int? = null, + mode: PlaybackSubtitleModeV3 = PlaybackSubtitleModeV3.RENDER, + hasSidecar: Boolean = mode == PlaybackSubtitleModeV3.RENDER || + mode == PlaybackSubtitleModeV3.CONVERT, + sessionId: String = "s-$id", + tracks: List = emptyList(), + qualityPreference: String = "auto", + outputRouteGeneration: Long = 0L, + ): TvStagedSubtitleCandidate = TvStagedSubtitleCandidate( + id = id, + sessionId = sessionId, + selectedSubtitleIndex = selectedIndex, + selectedAudioIndex = selectedAudioIndex, + subtitleMode = mode, + hasSidecar = hasSidecar, + subtitleTracks = tracks, + qualityPreference = qualityPreference, + outputRouteGeneration = outputRouteGeneration, + ) + + private fun clientOwnedCandidate( + id: String, + audioIndex: Int, + ): TvStagedSubtitleCandidate = candidate( + id = id, + selectedIndex = null, + selectedAudioIndex = audioIndex, + mode = PlaybackSubtitleModeV3.OFF, + hasSidecar = false, + ) + + private fun sidecar(index: Int): SubtitleIdentity = SubtitleIdentity.ServerSidecar(index) + + private fun media( + trackId: String? = null, + label: String? = null, + language: String? = null, + codec: String? = null, + forced: Boolean? = null, + ): SubtitleMediaIdentity = SubtitleMediaIdentity( + trackId = trackId, + label = label, + language = language, + codecFamily = codec, + forced = forced, + hearingImpaired = false, + ) + + private fun serverTrack(index: Int, url: String): PlayerSubtitleInfo = PlayerSubtitleInfo( + index = index, + language = "en", + codec = "srt", + label = "Server subtitle", + source = "server_artifact", + forced = false, + url = url, + ) + + private fun downloadedTrack(index: Int, downloadId: Int, url: String): PlayerSubtitleInfo = + PlayerSubtitleInfo( + index = index, + language = "en", + codec = "vtt", + label = "English", + source = "downloaded", + forced = false, + url = url, + downloadId = downloadId, + ) + + private fun downloadedIdentity(): SubtitleIdentity.Downloaded = + SubtitleIdentity.Downloaded( + downloadId = 312, + media = media( + trackId = "prairie-downloaded-subtitle:312", + label = "English", + language = "en", + codec = "webvtt", + ), + ) + + private data class Harness( + val adapter: TvSubtitleTransactionAdapter, + val port: FakeStagedPort, + val persistence: RecordingPersistence, + val committedPlaybacks: MutableList, + ) + + private fun confirmPendingPlayerBoundary(harness: Harness, snapshotKey: String) { + val identity = requireNotNull(harness.adapter.snapshot.localMountIdentity) + harness.adapter.reportMountedSelection( + identity = identity, + selected = true, + snapshotKey = snapshotKey, + settled = true, + ) + } + + private class FakeStagedPort : TvSubtitleStagedReplanPort { + private sealed interface StageOutcome { + data class Result( + val value: ApiResult, + ) : StageOutcome + + data class Failure(val error: Throwable) : StageOutcome + } + + val requests = mutableListOf() + val committed = mutableListOf() + val commitStarted = mutableListOf() + val discarded = mutableListOf() + val abandoned = mutableListOf() + var suspendCommits = false + var commitFailure: ApiResult? = null + var commitThrowable: Throwable? = null + var discardThrowable: Throwable? = null + private val stageResults = Channel(Channel.UNLIMITED) + private val commitResults = Channel(Channel.UNLIMITED) + + override suspend fun stage(request: TvSubtitleStageRequest): ApiResult { + requests += request + return when (val outcome = stageResults.receive()) { + is StageOutcome.Result -> outcome.value + is StageOutcome.Failure -> throw outcome.error + } + } + + override suspend fun commit( + candidate: TvStagedSubtitleCandidate, + ): ApiResult { + commitStarted += candidate.id + if (suspendCommits) { + val committedId = commitResults.receive() + check(committedId == candidate.id) + } + commitThrowable?.let { + commitThrowable = null + throw it + } + commitFailure?.let { return it } + committed += candidate.id + return ApiResult.Success( + TvSubtitleCommittedPlayback( + sessionId = candidate.sessionId, + subtitleTracks = candidate.subtitleTracks, + outputRouteGeneration = candidate.outputRouteGeneration, + ), + ) + } + + override suspend fun discard(candidate: TvStagedSubtitleCandidate) { + discarded += candidate.id + discardThrowable?.let { + discardThrowable = null + throw it + } + } + + override suspend fun abandonCommitted(playback: TvSubtitleCommittedPlayback) { + abandoned += playback.sessionId + } + + suspend fun completeStage(candidate: TvStagedSubtitleCandidate) { + stageResults.send(StageOutcome.Result(ApiResult.Success(candidate))) + } + + suspend fun failStage(result: ApiResult) { + stageResults.send(StageOutcome.Result(result)) + } + + suspend fun cancelStage(message: String) { + stageResults.send(StageOutcome.Failure(CancellationException(message))) + } + + suspend fun completeCommit(id: String) { + commitResults.send(id) + } + } + + private class RecordingPersistence : TvSubtitlePersistencePort { + val persisted = mutableListOf() + val persistedContexts = mutableListOf>() + var suspendFirst = false + var suspendEveryWrite = false + var throwFirst = false + var cancelFirst = false + var failuresRemaining = 0 + var rejectionsRemaining = 0 + var cancelledWrites = 0 + var attempts = 0 + private set + private val firstStarted = Channel(Channel.CONFLATED) + private val firstRelease = Channel(Channel.CONFLATED) + + override suspend fun persist( + committed: CommittedSubtitle, + context: TvSubtitlePlaybackContext, + ): Boolean { + val call = attempts++ + if (cancelFirst && call == 0) { + throw CancellationException("persistence request cancelled locally") + } + if (throwFirst && call == 0) { + throw IllegalStateException("first persistence write failed") + } + if (failuresRemaining > 0) { + failuresRemaining -= 1 + throw IllegalStateException("persistence write failed") + } + if (rejectionsRemaining > 0) { + rejectionsRemaining -= 1 + return false + } + if (suspendFirst && call == 0) { + firstStarted.send(Unit) + firstRelease.receive() + } + if (suspendEveryWrite) { + try { + awaitCancellation() + } finally { + cancelledWrites += 1 + } + } + persisted += committed + persistedContexts += context.contentId to context.mediaFileId + return true + } + + suspend fun awaitFirstStarted() { + firstStarted.receive() + } + + suspend fun releaseFirst() { + firstRelease.send(Unit) + } + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt index 1610fe208..74eaec903 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.tv.ui.screens.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 @@ -54,4 +55,26 @@ class LobbyNavigationDecisionTest { fun nullSnapshotDoesNotEnter() { assertFalse(shouldEnterSyncedPlayer(null)) } + + @Test + fun hostAloneHoldsOnTheInviteCodeUntilSomeoneJoins() { + val alone = fixture(RoomPhase.Playing, "m1").copy( + selfRole = MemberRole.Host, + memberCount = 1, + ) + assertFalse(shouldEnterSyncedPlayer(alone)) + assertTrue(shouldEnterSyncedPlayer(alone.copy(memberCount = 2))) + } + + @Test + fun guestEntersAnAlreadyPlayingRoomImmediately() { + assertTrue( + shouldEnterSyncedPlayer( + fixture(RoomPhase.Playing, "m1").copy( + selfRole = MemberRole.Guest, + memberCount = 1, + ), + ), + ) + } } 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 new file mode 100644 index 000000000..d44653ce2 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt @@ -0,0 +1,89 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvWatchTogetherSurfaceSourceTest { + private val itemDetailScreen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + private val appNavigation = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt", + ).readText() + private val entryDialog = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt", + ).readText() + private val joinDialog = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt", + ).readText() + private val qrPanel = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/QrCodePanel.kt", + ).readText() + private val playerScreen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt", + ).readText() + + @Test + fun detailOverflowOpensTheEntryDialog() { + assertTrue(itemDetailScreen.contains("CLIENT_WATCH_TOGETHER_SURFACE_ENABLED")) + assertTrue(itemDetailScreen.contains("key = \"watch-together\"")) + assertTrue(itemDetailScreen.contains("watchTogetherOpen = true")) + assertTrue(itemDetailScreen.contains("TvWatchTogetherEntryDialog(")) + assertTrue(itemDetailScreen.contains("TvJoinCodeDialog(")) + } + + @Test + fun aResolvedRoomReachesTheNavigationCallback() { + assertTrue(itemDetailScreen.contains("onWatchTogether(room)")) + assertTrue(itemDetailScreen.contains("watchTogetherViewModel.consumeResult()")) + assertTrue(appNavigation.contains("TvRoute.WatchTogetherLobby(roomId = snapshot.roomId).route")) + } + + @Test + fun audiobooksDoNotOfferARoom() { + assertTrue( + itemDetailScreen.contains( + "CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && !isAudiobookItemType(detail.type)", + ), + ) + } + + @Test + fun activeRoomExposesSuggestionActionFromDetail() { + assertTrue(itemDetailScreen.contains("TvSuggestToRoomViewModel")) + assertTrue(itemDetailScreen.contains("key = \"suggest-to-room\"")) + assertTrue(itemDetailScreen.contains("Suggest to Watch Together")) + assertTrue(itemDetailScreen.contains("suggestViewModel.suggest(")) + } + + @Test + fun seriesSuggestionUsesItsPlayableEpisodeIdentity() { + assertTrue(itemDetailScreen.contains("contentType = playType")) + assertTrue(itemDetailScreen.contains("posterUrl = nextUp?.stillUrl ?: detail.posterUrl")) + } + + @Test + fun inFlightRoomMutationCannotBeDismissed() { + assertTrue(entryDialog.contains("canDismissRoomEntry(isBusy)")) + assertTrue(joinDialog.contains("canDismissRoomEntry(isBusy)")) + } + + @Test + fun qrQuietZoneIsModuleAware() { + assertTrue(qrPanel.contains("EncodeHintType.MARGIN to 4")) + assertTrue(!qrPanel.contains("quietZone: Dp")) + } + + @Test + fun roomCloseConfirmationTakesDpadFocusFromThePlayer() { + val dialog = playerScreen.substringAfter("private fun TvRoomCloseConfirmDialog(") + assertTrue(dialog.contains("rememberTvDialogInitialFocus(closeActionFocus)")) + assertTrue(dialog.contains(".focusRequester(closeActionFocus)")) + assertTrue(!dialog.contains("closeActionFocus.requestFocus()")) + assertTrue(dialog.contains("Popup(")) + assertTrue(dialog.contains("PopupProperties(")) + assertTrue(dialog.contains("focusable = true")) + assertTrue(dialog.contains("onDismissRequest = onCancel")) + } +} diff --git a/baselineprofile/gradle.lockfile b/baselineprofile/gradle.lockfile new file mode 100644 index 000000000..16705f7e0 --- /dev/null +++ b/baselineprofile/gradle.lockfile @@ -0,0 +1,512 @@ +# 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.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.mediarouter:mediarouter: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.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.webkit:webkit:1.16.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.datatransport:transport-api:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.android.datatransport:transport-backend-cct:3.1.9=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.android.datatransport:transport-runtime:3.1.9=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.android.gms:play-services-base:18.9.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.android.gms:play-services-basement:18.9.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.android.gms:play-services-cast-framework:21.5.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.android.gms:play-services-cast:21.5.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.android.gms:play-services-cloud-messaging:17.4.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.android.gms:play-services-flags:18.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.android.gms:play-services-stats:17.0.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.android.gms:play-services-tasks:18.4.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +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.firebase:firebase-annotations:17.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-common:22.0.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-components:19.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-datatransport:18.2.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-encoders-json:18.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-encoders-proto:16.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-encoders:17.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-iid-interop:17.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-installations-interop:17.3.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-installations:19.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-measurement-connector:19.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.firebase:firebase-messaging:25.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +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.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,benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +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-play-services: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.jsoup:jsoup:1.22.2=benchmarkReleaseTestedApks,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/build.gradle.kts b/build.gradle.kts index 1c089e2c5..97a1750ec 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,3 +18,22 @@ plugins { // is more reliable when CI invokes :shared:koverXmlReport / :shared:koverVerify. alias(libs.plugins.kover) apply false } + +allprojects { + dependencyLocking { + lockAllConfigurations() + } +} + +tasks.register("resolveAndLockAll") { + description = "Resolves every resolvable configuration so dependency locks can be written." + group = "build setup" + + doLast { + rootProject.allprojects.forEach { project -> + project.configurations + .filter { it.isCanBeResolved } + .forEach { it.incoming.resolutionResult.allComponents } + } + } +} diff --git a/docs/notes/2026-07-27-pr108-forward-split-traceability.md b/docs/notes/2026-07-27-pr108-forward-split-traceability.md new file mode 100644 index 000000000..852a45308 --- /dev/null +++ b/docs/notes/2026-07-27-pr108-forward-split-traceability.md @@ -0,0 +1,127 @@ +# PR 108 Forward Split Traceability + +## Slice A: Build, dependency verification, and native provenance + +Branch: `split/108-a-supply-chain` + +Upstream base: + +```text +da26f081efdd5d5ce069db937de105a30169ca16 +``` + +Archival integration reference: + +```text +PR 108 +445b5f39788657b1b8c58bb6b199f5c5404249a3 +``` + +This branch intentionally reproduces the **net behavior** of the build and +provenance portion of PR 108 rather than replaying its integration history. +The original range is not safely cherry-pickable as an isolated feature: its +lock and verification files contain dependencies introduced by later security, +reader, player, subtitle, and Watch Together changes. + +### Original commits mapped into this slice + +| PR 108 commit | Original purpose | Slice-A disposition | +| --- | --- | --- | +| `5fe404f2a197bf534ee794a7d02373ed7281e238` | Lock all Gradle configurations, add verification metadata, pin the wrapper and Actions | Policy/configuration ported; locks and metadata regenerated from this slice | +| `0b34912681ed4d672e6182f00c3be7806b96e5f2` | Harden and self-test the supply-chain policy | Ported, then extended with Linux AAPT2 coverage | +| `dd26399745ab1d7fdfe1abb20185e73fc0832d9a` | Refresh dependency state after integrating upstream | Not copied; replaced by fresh resolution from `da26f081` | +| `3b090b62fffe8f826d40be40d46c0a0e985b21ef` | Pin Dolby Vision native source, toolchains, locks, ABI outputs, and AAR | Ported as the native-provenance commit | +| `ccd223766fd4b37e29816973555f77eff62f92f3` | Record the native rebuild's hardware-validation caveat | Preserved here and in the draft PR verification notes | +| `f3d62d19b6d589da654489e2f67bb6774e4c669f` | Record audit of regenerated dependency metadata | Superseded: this slice has a smaller freshly generated graph | +| `c47912579c4bae2b7fc6adcb6fc0c8d8bcd1fd1f` | Add release configurations to verification metadata | Covered by `resolveAndLockAll` plus the full `test` and release build gates | +| `445b5f39788657b1b8c58bb6b199f5c5404249a3` | Add the missing Guava parent POM seen on PR 108 CI | Not needed: that dependency belongs to a later slice and is absent here | + +### Forward-split commits + +```text +74308e48 build(android): enforce verified dependency state +39379263 build(android): pin native playback provenance +c8a08e18 docs(android): map PR 108 supply-chain split +e4ec5880 build(android): verify release tool dependencies +``` + +The patch IDs are intentionally different from the original commits because +the first commit regenerates dependency state against a different, narrower +source tree. Reviewer traceability is therefore path- and purpose-based rather +than claiming false one-to-one patch identity. + +`e4ec5880` adds the 26 release/lint tool artifacts found by the first full +release build; each checksum matches the independently generated PR 108 +metadata. This documentation correction follows those four functional and +traceability commits and does not change their patch content. + +### Linux AAPT2 correction + +PR 108 recorded only the macOS classifier for +`com.android.tools.build:aapt2:8.10.1-12782657`. GitHub Actions uses the Linux +classifier, so dependency verification failed before tests could compile. + +Slice A adds: + +```text +aapt2-8.10.1-12782657-linux.jar +sha256 52f864b7fd20a9ff09fc3db96162537a63c5b38ecc1c2549db4b491c6a517ff0 +``` + +The artifact and checksum were fetched directly from Google's Maven repository: + +```text +https://dl.google.com/dl/android/maven2/com/android/tools/build/aapt2/8.10.1-12782657/aapt2-8.10.1-12782657-linux.jar +``` + +The policy self-test now proves that metadata with only the macOS classifier is +rejected. This catches the production failure at review time even when metadata +is generated on macOS. + +### Validation record + +- Upstream baseline: `testDebugUnitTest` passed before edits. +- TDD red: the policy self-test failed when the policy executable was absent. +- TDD green: the imported policy passed its original fixture suite. +- Regression red: metadata with only macOS AAPT2 was incorrectly accepted. +- Regression green: the policy rejected missing Linux AAPT2 and passed after + the official Linux artifact checksum was recorded. +- Slice dependency graph: `resolveAndLockAll --write-locks + --write-verification-metadata sha256` completed. +- Debug and release unit variants: `test --write-verification-metadata sha256` + completed successfully. +- CI-equivalent debug unit suite: `testDebugUnitTest` completed successfully + with verification enabled and no write flag. +- Release regression red: the first verification-enabled release build rejected + 26 Android lint/release-tool artifacts not traversed by `resolveAndLockAll` + or `test`. +- Release regression green: `androidApp:assembleRelease` and + `androidTvApp:assembleRelease` completed after recording those artifacts. + Every newly recorded checksum exactly matched the independently generated + metadata in archival PR 108. +- GitHub Actions regression red: the first clean Linux runner resolved three + build-classpath artifacts injected by `gradle/actions/setup-gradle` that are + absent from an uninjected local build: Guava parent `33.3.1-jre`, JUnit BOM + `5.10.2.module`, and coroutines BOM `1.8.0`. +- GitHub Actions correction: all three SHA-256 values were independently + downloaded from Maven Central and matched archival PR 108 before being + recorded. The rerun is the clean-runner GREEN gate. +- The rerun exposed the Android-flavor Guava parent on the libass compile + classpath. Its `33.3.1-android` POM was independently downloaded from Maven + Central, verified as SHA-256 + `6e11986ea7250b51f847157e2dc937f32a306804dfce0007a5e81ddb9b95c579`, + and recorded as the second clean-runner correction. +- The next clean runner reached `guava-parent:33.4.8-jre` only from the + Android shared test runtime. Its Maven Central SHA-256 + `a03c5199a1be14443df7fd40ba8673b1c6aae04deef6688ce7d573ea489e3a20` + was independently verified and recorded. +- Native provenance: `scripts/build-dovi-aar.sh --verify-provenance` verified + the pinned source archive, Rust and NDK toolchains, three ABI outputs, and + final AAR hash. + +### Remaining release gate + +The native artifact has not been played on an NVIDIA Shield in this slice. +Before a release, play a Dolby Vision title and verify Profile 7 conversion, +HDR fallback, seeking, and sustained playback. This is a hardware acceptance +gate, not a reason to weaken or defer the reproducibility checks. diff --git a/docs/notes/2026-07-27-pr108-slice-b-traceability.md b/docs/notes/2026-07-27-pr108-slice-b-traceability.md new file mode 100644 index 000000000..b63fd0675 --- /dev/null +++ b/docs/notes/2026-07-27-pr108-slice-b-traceability.md @@ -0,0 +1,89 @@ +# PR 108 Forward Split: Slice B Traceability + +Slice B is stacked on reviewed slice A: + +```text +base 5a34a95518047daa698acd4e80cde3571e6b0c8c +head split/108-b-auth-epub +``` + +PR 108 remains the archival integration reference. This branch contains only +the auth/origin/cleartext and EPUB-security vertical slice plus the dependency +state needed by that slice. + +## Original path/purpose mapping + +| PR 108 commit | Purpose in slice B | Forward-split disposition | +| --- | --- | --- | +| `9597eeee` | Auth refresh/session-safety prerequisite | Relevant `AuthInterceptorImpl` net state is carried by `aeead9d9` | +| `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` | +| `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` | +| `5db84af8` | Restrict SVG paint references to safe local forms | Cherry-picked with `-x` as `6b35e3c7` | +| `14b41b7f` | Bound downloads, ZIP entries, and extracted content | Cherry-picked with `-x` as `9dce6f10`; strict markup limits in `15278f95` | +| `5a040e0d` | Isolate EPUB resources behind a WebView asset-loader origin | Cherry-picked with `-x` as `1cc0cb79`; exact-book binding in `15278f95` | +| `fd545fab` | Remount reader content when the source changes | Cherry-picked with `-x` as `ffe24bf1` | +| `eda4a4a2` | Remove API-26-only hardened-path calls | Cherry-picked with `-x` as `d0d5eaee` | + +`6240c7fd` resolves the stacked slice's dependency state. The jsoup and +AndroidX WebKit additions produced seven new artifact checksums; all seven +exactly match the independently generated metadata in archival PR 108. + +## Conflict and net-diff decisions + +- The late auth commits assume credential-generation machinery introduced in + `9597eeee` and `65c4b316`. Rather than importing either broad commit, this + slice takes the final PR 108 state only for the affected auth/token paths. +- Current upstream already declares MockWebServer. The duplicate catalog alias + introduced by replaying `85890c6d` was removed; no dependency changed. +- `14b41b7f` predates `5d5f0562`. Replaying it after the auth work malformed + the combined fake data source, so the final PR 108 net state was used for + `AuthenticatedDataSourceFactory` and its test. +- `ReaderEngineHostSourceTest` did not yet exist on current upstream, although + `fd545fab` modifies it. Its complete prerequisite test contract was retained + so the source-remount regression remains executable; no unrelated production + path was imported. + +These decisions intentionally produce different patch IDs for the combined auth +commits while retaining `-x` ancestry for the independently applicable reader +commits. + +## TDD and verification record + +- Origin-policy RED: the adversarial common tests failed to compile without + `HttpOriginPolicy`; GREEN after the centralized parser/comparator was added. +- Credential-scope RED: final tests exposed missing scoped refresh and + generation APIs; GREEN after final auth/token path state was ported. +- Cleartext-consent RED: mobile/TV/store tests failed without an explicit + consent state machine; GREEN after the store, DI, view models, and UI were + added. +- EPUB RED: bounded-stream and reader security tests failed without the new + limits, sanitizer, and isolated resource handler. +- Integration RED: Gradle rejected a duplicate MockWebServer catalog alias + caused by upstream overlap; removal restored a single unchanged alias. +- Integration RED: reverse replay of the reader/auth commits malformed the + authenticated data-source test double; using the final two-commit net state + fixed the ordering conflict. +- Focused GREEN suites cover origin parsing, cross-origin redirects, scoped + refresh, credential replacement, mobile/TV cleartext persistence, stream and + ZIP limits, HTML/SVG sanitization, traversal/encoded traversal, resource + isolation, source remounting, and API-24-compatible paths. +- Independent security review identified five important gaps. `15278f95` + closes them with regression coverage: consent is enforced at shared Ktor, + media, startup, and pairing boundaries; live persistent credential epochs + never use the unstamped sentinel; media refresh pins the complete credential + scope and cannot fall through from a guest overlay to the owner; resource + loading is bound to one exact EPUB cache directory in JavaScript and native + code; and container/package/chapter markup has strict type-specific limits + while section metadata reads only a bounded prefix. +- Follow-up review found two additional cleartext destination gaps. The final + correction checks the actual resolved URL for absolute unauthenticated auth + POSTs, fails closed for every lexical `http:` form (including backslash + separators), and rejects an unapproved Media3 HTTP stream before + server-issued plan headers or query credentials reach the transport. A + network interceptor repeats that check for every redirect follow-up, so + HTTPS-to-HTTP and approved-to-unapproved redirects cannot bypass it. diff --git a/docs/notes/2026-07-27-pr108-slice-c-traceability.md b/docs/notes/2026-07-27-pr108-slice-c-traceability.md new file mode 100644 index 000000000..ddb112d37 --- /dev/null +++ b/docs/notes/2026-07-27-pr108-slice-c-traceability.md @@ -0,0 +1,84 @@ +# PR 108 Forward Split: Slice C Traceability + +Slice C is stacked on slice B: + +```text +base 06a775a32a66c8b602ab0068310e54cb11f655bf +head split/108-c-room-sync +``` + +Closed, unmerged PR 108 and its preserved `integrate/ship-everything` branch +remain the archival integration reference. + +## Local commits + +- `4fb21b89` — Room schema v8, exported schema, download-quality columns, + migration-test harness, locked/verified test dependencies +- `e5991a53` — identity-contained storage, race-safe staged writes, removed + server purge, download cancellation/sidecar cleanup, phone/TV lifecycle wiring +- `51d90f92` — profile-triggered drains and creation-ordered per-item outbox + +## PR 108 source mapping + +| PR 108 commit | Slice C area | +| --- | --- | +| `9597eeee` | Room v8 quality columns; download sidecar/cancel behavior; MediaStore root | +| `05df9c34` | dirty-operation order restoration before a new position write | +| `293e8207` | profile-scope drain trigger and removed-server purge | +| `34f72451` | complete per-server credential namespace purge | +| `d0b8a1b6` | per-item FIFO while an older operation is backing off | +| `e07946fd` | suppress destructive purge after registry decode failure | +| `1ba834a5` | contained identity path encoding | +| `ccc41c2a` | no-follow staged writes, collision/race hardening | +| `16f69ff4` | identity-scoped paths on current collection roots | + +This is a forward net-diff split, so rewritten commits do not have identical +patch IDs: unrelated player/subtitle/TV changes from the original commits are +intentionally excluded. The table above is the path-purpose mapping. + +## Review corrections beyond PR 108 + +- Added a real Room 7→8 migration regression. It creates a schema-7 database, + inserts an existing download, runs the generated auto-migration, validates + schema 8, and proves both new quality columns are nullable without losing the + row. +- Added a cancellation regression for removed-server cleanup. Once filesystem + deletion starts, each server purge completes its paired Room transaction in a + `NonCancellable` section so app-scope teardown cannot strand a half-purged + identity after the one removal event has been consumed. +- Made worker cancellation an acknowledged barrier. Cancellation or filesystem + failure now retains Room rows as durable retry evidence instead of deleting + the only index of an incompletely cleaned identity. A process-local worker + lease additionally waits for the actual CoroutineWorker body/finally path, + beyond WorkManager's interrupt acknowledgement. +- Subscribed to registry removals before the startup scan and added a + deterministic removal-during-startup regression, closing the event gap that + could permanently miss a server. +- Added no-follow discovery and deletion for JSON-only ebook/audiobook state, + including long hashed identity keys, root/child symlink escape regressions, + and a shared removal generation that prevents stale writers from recreating + purged identities. +- Moved detail-screen cancellation to the sidecar-cleaning path with a + synchronously captured identity scope, so a profile switch cannot redirect + cleanup to the next profile. +- Applied the FIFO predicate before the SQL page limit, fixed an immediate + profile-switch collector race, and serialised JSON bookmark read-modify-write + operations per target. +- Added Room's migration test harness with dependency locks and SHA-256 + verification independently reproduced from Google Maven and Maven Central. + +## Verification + +Focused red/green suites cover: + +- schema 7→8 migration and existing-row preservation; +- path traversal, encoded-namespace collisions, symlink/no-follow staged writes, + public collection roots, profile/server deletion scope; +- corrupt-registry suppression, active-playback deferral, idempotent purge, and + cancellation acknowledgement/failure retries during destructive cleanup; +- startup-removal event ordering, JSON-only orphan discovery, hashed identity + keys, and symlink-safe deletion; +- profile-scope drain triggers, per-item outbox FIFO, and retained retry state. + +The final full repository gate and independent review results are recorded on +the draft PR. diff --git a/docs/superpowers/plans/2026-07-27-pr108-hosted-ci-recovery.md b/docs/superpowers/plans/2026-07-27-pr108-hosted-ci-recovery.md new file mode 100644 index 000000000..85e9a7a2c --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pr108-hosted-ci-recovery.md @@ -0,0 +1,127 @@ +# PR 108 Hosted CI 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:** Make stacked PRs #116 and #117 deterministic under the hosted two-worker unit-test schedule without changing production behavior or the approved no-delta Slice G resolution. + +**Architecture:** Correct the omitted Slice-E test-harness controls at their source. Tests that deliberately inspect an unresolved publication inject `PlaybackSessionManager.NEVER_SELF_HEAL`, while a dedicated test injects the production timeout and proves abandoned publications recover. Committed predecessor cleanup keeps the manager-owned IO scope in production, while tests inject their structured scope so completion is awaitable without wider wall-clock guards. + +**Tech Stack:** Kotlin 2.1, kotlinx-coroutines-test, JUnit, Gradle 8.12, GitHub Actions. + +## Global Constraints + +- Make the correction in `split/108-e-subtitles`, then restack F and preserve local unpushed G. +- Do not change production timeout behavior. +- Do not merge any pull request. +- Run the GitHub Actions command locally with `testDebugUnitTest --max-workers=2`. + +--- + +### Task 1: Isolate publication timeout semantics in the manager harness + +**Files:** +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt` + +**Interfaces:** +- Consumes: `PlaybackSessionManager.NEVER_SELF_HEAL` and `PENDING_PUBLICATION_SETTLE_TIMEOUT_MS`. +- Produces: deterministic settlement-wait tests plus explicit production self-heal coverage. + +- [x] **Step 1: Preserve the hosted failure as RED evidence** + +Record the #116 artifact assertion: expected one stop each for `s3`/`s4`, observed `s3` twice under the two-worker suite. + +- [x] **Step 2: Add explicit self-heal coverage** + +Add a test that creates an unresolved deferred publication, injects +`PENDING_PUBLICATION_SETTLE_TIMEOUT_MS`, starts new content, and asserts the +abandoned replacement is stopped and the new session becomes active. + +- [x] **Step 3: Make waiting tests opt out of virtual-time self-healing** + +Add a nullable `pendingPublicationSettleTimeoutMs` harness argument defaulting +to `NEVER_SELF_HEAL`, and pass it to `PlaybackSessionManager`. + +- [x] **Step 4: Run the focused manager class** + +Run: +`./gradlew :android-shared:testDebugUnitTest --tests 'org.prairieserver.prairie.common.player.PlaybackSessionManagerStagedReplanTest' --max-workers=2 --rerun-tasks` + +Expected: PASS, including one-stop rollback and explicit self-heal tests. + +### Task 2: Make committed-session cleanup structurally awaitable + +**Files:** +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleTransactionIntegrationTest.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/PlaybackSessionManagerStagedReplanTest.kt` + +**Interfaces:** +- Consumes: manager-owned asynchronous predecessor cleanup. +- Produces: an optional caller-owned cleanup scope for deterministic tests while + retaining the manager's long-lived IO scope as the production default. + +- [x] **Step 1: Preserve the hosted failure as RED evidence** + +Record the second #117 artifact timeout in `awaitStopped("s1")` after the exact +typed Media3 mount. The failure persisted for 30 seconds while #116 with the +same Slice-E code passed, disproving ordinary five-second hosted load. + +- [x] **Step 2: Add a deterministic ownership regression** + +Inject the test `backgroundScope`, suspend cleanup once, prove confirmation +returns before cleanup, await entry through the test scheduler, then release it +and prove the predecessor stops exactly once. The exact TV integration harness +injects the same scope. + +- [x] **Step 3: Preserve production semantics** + +Keep the existing manager-owned IO scope as the default. Route only committed +predecessor cleanup through an optional injected scope; route telemetry +unchanged. Restore the integration deadlock guard to five seconds. + +- [x] **Step 4: Run the focused manager and integration classes** + +Run: +`./gradlew :android-shared:testDebugUnitTest --tests 'org.prairieserver.prairie.common.player.PlaybackSessionManagerStagedReplanTest' --max-workers=2 --rerun-tasks` + +and: +`./gradlew :androidTvApp:testDebugUnitTest --tests 'org.prairieserver.prairie.tv.ui.screens.player.SubtitleTransactionIntegrationTest' --max-workers=2 --rerun-tasks` + +Expected: PASS. + +### Task 3: Verify, review, restack, and publish + +**Files:** +- Modify through git ancestry only: `split/108-f-watch-together` +- Preserve locally: `split/108-g-tv-catalog` and `b653253f` + +**Interfaces:** +- Consumes: corrected Slice-E commit. +- Produces: updated #116/#117 heads with unchanged G resolution. + +- [x] **Step 1: Run the CI-equivalent gate** + +Run: +`./gradlew -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" testDebugUnitTest --max-workers=2 --rerun-tasks --no-daemon` + +Expected: BUILD SUCCESSFUL with zero failing tests. + +- [x] **Step 2: Request independent review** + +Review the Slice-E repair diff for timeout masking, lost production self-heal +coverage, coroutine scheduling mistakes, and stack ancestry. + +- [ ] **Step 3: Commit and restack** + +Commit the Slice-E correction, rebase `split/108-f-watch-together` onto it, and +rebase local `split/108-g-tv-catalog` onto the new F head while retaining its +traceability commit. + +- [ ] **Step 4: Push only E and F** + +Push `split/108-e-subtitles` and `split/108-f-watch-together`. Do not push G and +do not merge. + +- [ ] **Step 5: Confirm hosted green** + +Watch the new #116/#117 checks to completion and report exact conclusions. diff --git a/docs/superpowers/plans/2026-07-27-pr108-slice-a-supply-chain.md b/docs/superpowers/plans/2026-07-27-pr108-slice-a-supply-chain.md new file mode 100644 index 000000000..42071804a --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-a-supply-chain.md @@ -0,0 +1,238 @@ +# PR 108 Slice A Supply-Chain 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:** Rebuild PR 108's build, dependency-locking, and native-provenance work as a reviewable branch from current upstream, with Linux CI dependency verification covered explicitly. + +**Architecture:** The slice adds enforcement at the repository boundary: immutable GitHub Action SHAs, a pinned Gradle distribution, dependency locking for every resolvable configuration, strict verification metadata, and a self-testing policy script. Native Dolby Vision artifacts remain checked in, but their source, toolchains, lockfile, ABI hashes, and packaged AAR hash become reproducibly documented and verifiable. + +**Tech Stack:** Gradle 8.12, Kotlin/Android Gradle Plugin, Bash, Ruby standard-library YAML/XML parsers, GitHub Actions, Rust/Cargo and Android NDK provenance. + +## Global Constraints + +- Start from `origin/main` at `da26f081efdd5d5ce069db937de105a30169ca16`. +- Preserve PR 108 at `445b5f39788657b1b8c58bb6b199f5c5404249a3` as the archival integration reference. +- Do not merge or admin-merge PR 108. +- Keep this slice limited to build policy, dependency locks/verification, and native playback provenance. +- Record original commit references and a path-level mapping for reviewer traceability. +- Do not trust hashes generated only from the local cache; verify new artifacts against the official repository response. + +--- + +### Task 1: Port the Executable Supply-Chain Policy + +**Files:** +- Create: `scripts/test-check-build-supply-chain.sh` +- Create: `scripts/check-build-supply-chain.sh` +- Modify: `.github/workflows/android-build.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `gradle/wrapper/gradle-wrapper.properties` +- Modify: `build.gradle.kts` + +**Interfaces:** +- Consumes: GitHub workflow YAML, Gradle wrapper properties, and `gradle/verification-metadata.xml`. +- Produces: `scripts/check-build-supply-chain.sh`, which exits non-zero for mutable Actions, an unpinned wrapper, malformed/weak verification metadata, or missing CI platform artifacts. + +- [ ] **Step 1: Install only the policy self-test from PR 108** + +```bash +git show origin/pr/108:scripts/test-check-build-supply-chain.sh > /tmp/pr108-policy-test +``` + +Use `apply_patch` to add the exact file content at `scripts/test-check-build-supply-chain.sh`. + +- [ ] **Step 2: Run the policy self-test to verify RED** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +``` + +Expected: FAIL because `scripts/check-build-supply-chain.sh` does not exist on upstream. + +- [ ] **Step 3: Port the policy and build configuration** + +Apply the PR 108 net versions of: + +```text +scripts/check-build-supply-chain.sh +.github/workflows/android-build.yml +.github/workflows/release.yml +gradle/wrapper/gradle-wrapper.properties +build.gradle.kts +``` + +The root build must enable `lockAllConfigurations()` and expose `resolveAndLockAll`; workflows must run both policy scripts before Gradle and pin every external Action to a 40-character commit SHA. + +- [ ] **Step 4: Run the policy self-test to verify GREEN** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +``` + +Expected: PASS once verification metadata is installed in Task 2. + +### Task 2: Generate Locks and Verification Metadata for the Slice + +**Files:** +- Create: `android-shared/gradle.lockfile` +- Create: `androidApp/gradle.lockfile` +- Create: `androidTvApp/gradle.lockfile` +- Create: `baselineprofile/gradle.lockfile` +- Create: `libass-bridge/gradle.lockfile` +- Create: `shared/gradle.lockfile` +- Create: `settings-gradle.lockfile` +- Create: `gradle/verification-metadata.xml` +- Modify: `scripts/test-check-build-supply-chain.sh` +- Modify: `scripts/check-build-supply-chain.sh` + +**Interfaces:** +- Consumes: every resolvable Gradle configuration in this branch. +- Produces: strict SHA-256 verification metadata and per-project dependency lock state that allow both debug CI and release configurations to resolve. + +- [ ] **Step 1: Add a failing Linux AAPT2 policy fixture** + +Extend `scripts/test-check-build-supply-chain.sh` with a fixture containing a `com.android.tools.build:aapt2` component that has only `aapt2-8.10.1-12782657-osx.jar` and its POM. + +Expected assertion: + +```text +Missing Linux AAPT2 verification artifact +``` + +- [ ] **Step 2: Run the policy self-test to verify RED** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +``` + +Expected: FAIL because the policy currently accepts metadata without the Linux classifier. + +- [ ] **Step 3: Enforce CI platform coverage** + +Update `scripts/check-build-supply-chain.sh` so every `com.android.tools.build:aapt2` component containing an OS-specific executable artifact includes a `-linux.jar` entry with a SHA-256 checksum. + +- [ ] **Step 4: Run the policy self-test to verify GREEN** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +``` + +Expected: PASS for the complete fixture set. + +- [ ] **Step 5: Generate branch-specific locks and checksums** + +Run: + +```bash +./gradlew resolveAndLockAll --write-locks +./gradlew resolveAndLockAll --write-verification-metadata sha256 +./gradlew testDebugUnitTest --write-verification-metadata sha256 --max-workers=2 +./gradlew test --write-verification-metadata sha256 --max-workers=2 +./gradlew :androidApp:assembleRelease :androidTvApp:assembleRelease \ + --write-verification-metadata sha256 --max-workers=2 +``` + +Do not weaken verification or add broad trusted-artifact rules. + +- [ ] **Step 6: Verify the Linux AAPT2 checksum independently** + +Download the exact classifier from Google's Maven repository and compare: + +```bash +curl -fsSLO \ + https://dl.google.com/dl/android/maven2/com/android/tools/build/aapt2/8.10.1-12782657/aapt2-8.10.1-12782657-linux.jar +shasum -a 256 aapt2-8.10.1-12782657-linux.jar +``` + +The resulting literal must equal the value recorded in `gradle/verification-metadata.xml`. + +### Task 3: Port and Validate Native Playback Provenance + +**Files:** +- Modify: `THIRD_PARTY_NOTICES.md` +- Modify: `android-shared/libs/prairie-dovi-bridge-2.3.1.aar` +- Create: `android-shared/src/native/dovi/Cargo.lock` +- Modify: `android-shared/src/native/dovi/THIRD_PARTY_NOTICES.txt` +- Create: `android-shared/src/native/dovi/provenance.json` +- Modify: `scripts/build-dovi-aar.sh` + +**Interfaces:** +- Consumes: pinned `quietvoid/dovi_tool` source commit, source archive checksum, + Cargo lock checksums, Rust 1.85.0, Android NDK 26.3.11579264, and checked-in + native outputs. +- Produces: `scripts/build-dovi-aar.sh --verify-provenance`, which verifies source inputs, toolchains, three ABI shared libraries, and the final AAR hash. + +- [ ] **Step 1: Port the exact PR 108 native-provenance net diff** + +Use the archival paths from commits: + +```text +3b090b62fffe8f826d40be40d46c0a0e985b21ef +ccd223766fd4b37e29816973555f77eff62f92f3 +``` + +- [ ] **Step 2: Verify the checked-in artifact without rebuilding** + +Run: + +```bash +./scripts/build-dovi-aar.sh --verify-provenance +``` + +Expected: PASS after validating the pinned source archive, toolchains, ABI hashes, and AAR hash. If the exact NDK/Rust toolchain is unavailable, record that as a device/toolchain blocker without weakening the script. + +### Task 4: Traceability, Full Verification, Review, and Draft PR + +**Files:** +- Create: `docs/notes/2026-07-27-pr108-forward-split-traceability.md` +- Modify: `docs/superpowers/plans/2026-07-27-pr108-slice-a-supply-chain.md` + +**Interfaces:** +- Consumes: the final slice-A commit range and PR 108 archival commits. +- Produces: reviewer-facing path/commit mapping, verification evidence, and a draft PR that cannot merge automatically. + +- [ ] **Step 1: Record path and commit mapping** + +Document that slice A derives from: + +```text +5fe404f2a197bf534ee794a7d02373ed7281e238 +0b34912681ed4d672e6182f00c3be7806b96e5f2 +dd26399745ab1d7fdfe1abb20185e73fc0832d9a +3b090b62fffe8f826d40be40d46c0a0e985b21ef +c47912579c4bae2b7fc6adcb6fc0c8d8bcd1fd1f +445b5f39788657b1b8c58bb6b199f5c5404249a3 +``` + +Also explain that lock and verification files were regenerated for the slice rather than copied wholesale from PR 108, preventing dependencies from later slices being trusted early. + +- [ ] **Step 2: Run CI-equivalent verification** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + testDebugUnitTest --max-workers=2 +./gradlew -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + test --max-workers=2 +``` + +Then run release dependency resolution/build tasks needed to prove release locks are complete. + +- [ ] **Step 3: Review the committed diff** + +Request a code-review agent over `origin/main..HEAD`. Fix every Critical or Important finding and rerun the affected gates. + +- [ ] **Step 4: Push and open a draft PR** + +Push `split/108-a-supply-chain` and create a draft PR targeting `main`. The body must identify PR 108 as archival, list original commit mappings, include verification commands/results, and state that native Dolby Vision playback still requires Shield hardware validation before release. 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 new file mode 100644 index 000000000..66b497978 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md @@ -0,0 +1,82 @@ +# PR 108 Slice B: Auth, Origin, Cleartext Consent, and EPUB Security + +**Goal:** Forward-split PR 108's authenticated-origin and EPUB hardening onto +the reviewed slice-A head as one security-focused, compiling vertical slice. + +**Stack:** `split/108-b-auth-epub` targets +`split/108-a-supply-chain`; archival PR 108 remains unchanged. + +**Original commits:** + +- `9597eeee` — prerequisite auth refresh/session-safety state in the client audit +- `65c4b316` — prerequisite credential-generation scope state +- `85890c6d` — parse and sanitize EPUB markup with an allowlist +- `5db84af8` — validate SVG paint references +- `14b41b7f` — bound remote and archive content +- `5a040e0d` — isolate EPUB resources behind WebView asset loading +- `fd545fab` — remount the WebView when its source changes +- `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 +- `e18d092e` — reject authentication scopes whose credentials were replaced +- `c07d9f9f` — require explicit consent before cleartext login + +## Task 1: Authenticated Origin Policy + +1. Import the final origin-policy tests from `cbf398fa` without production + code and run the shared common tests to record RED. +2. Import the final origin-policy production implementation and run the same + tests to GREEN. +3. Confirm default-port normalization, IPv4/IPv6 handling, user-info + rejection, ambiguous-authority rejection, and HTTPS/HTTP separation. + +## Task 2: Credential Scoping and Replacement Races + +1. Import the tests changed by `5d5f0562` and `e18d092e` first, then run + focused shared and Android-shared tests to record RED. +2. Port only the related token-manager, auth-interceptor, media-auth session, + data-source, and HTTP-client net changes. +3. Run focused tests to GREEN, including redirects, host/port/scheme changes, + token-generation replacement, and reader-file authentication. + +## Task 3: Explicit Cleartext Consent + +1. Import cleartext consent and mobile/TV persistence tests first and run them + to record RED. +2. Port the consent store, dependency injection, setup view-model state + machines, and confirmation UI for mobile and TV. +3. Verify credentials are never persisted or sent before the user confirms + the exact cleartext origin, and that changing the entered origin invalidates + prior consent. + +## Task 4: EPUB Sanitization and Resource Isolation + +1. Import sanitizer/resource-path/WebView tests first and run focused reader + tests to record RED. +2. Port the parsed allowlist sanitizer, isolated asset-loader origin, + resource-path handler, reader HTML/JavaScript bridge changes, and API-24 + compatibility correction. +3. Verify scripts, event handlers, dangerous URLs, traversal, encoded + traversal, external origins, and cross-book paths are rejected while valid + EPUB-local resources remain readable. + +## Task 5: Dependency State, Traceability, and Verification + +1. Regenerate slice-B lockfiles and SHA-256 metadata after adding parser and + WebView dependencies; never copy later-slice dependency state wholesale. +2. Add a path/purpose traceability note mapping all eight original commits to + forward-split commits and document any conflict-resolution deviations. +3. Run: + + - focused security tests from Tasks 1–4 + - `./scripts/test-check-build-supply-chain.sh` + - `./scripts/check-build-supply-chain.sh` + - `./gradlew testDebugUnitTest` + - `./gradlew :androidApp:assembleRelease :androidTvApp:assembleRelease` + - `git diff --check` + +4. Obtain independent code/security review and fix all Critical or Important + findings. +5. Push the branch and open a draft stacked PR against + `split/108-a-supply-chain`. Do not merge either PR. diff --git a/docs/superpowers/plans/2026-07-27-pr108-slice-c-room-sync.md b/docs/superpowers/plans/2026-07-27-pr108-slice-c-room-sync.md new file mode 100644 index 000000000..fba236733 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-c-room-sync.md @@ -0,0 +1,53 @@ +# PR 108 Slice C: Room, Identity Storage, and Sync Plan + +## Goal + +Create a reviewable vertical slice stacked on slice B that upgrades Room to +schema v8, makes local/download storage identity-scoped and race-safe, purges +removed-server data only after a trustworthy registry load, and preserves +per-item outbox ordering across identity changes. + +## Archival source map + +Use the net behavior from PR 108 commits: + +- `9597eeee` — Room v8 download-quality fields and related download persistence +- `05df9c34` — dirty-operation selection/order and repository integration +- `293e8207` — profile-triggered drain and removed-server purge +- `d0b8a1b6` — per-item outbox head-of-line ordering +- `e07946fd` — fail-safe registry-load/purge contract +- `1ba834a5`, `ccc41c2a`, `16f69ff4` — contained, serialized, + identity-scoped storage paths +- `34f72451` — credential purge behavior only where required by the removed + server lifecycle + +Document path-level deviations and patch-ID/commit mappings in the slice +traceability note. + +## Implementation order + +1. Add failing Room 7→8 migration coverage that proves existing rows survive + and new nullable quality columns read correctly; then port the v8 entity, + schema, and auto-migration. +2. Add/port failing storage containment, identity-isolation, public-root + collision, and cancellation/concurrency regressions; then port the minimum + storage implementation required to pass them. +3. Add/port failing removed-server purge tests, including failed registry-load + behavior and database/filesystem cleanup; then wire the purger into both app + lifecycles. +4. Add/port failing profile-change drain and per-item outbox ordering tests; + then port the DAO/starter/engine/repository changes. +5. Run focused tests after each behavior, then all debug unit tests and phone/TV + release assemblies with the supply-chain gate. +6. Record original commit/path mapping and conflict decisions, request an + independent correctness/security review, resolve findings test-first, push, + and open a draft stacked PR based on `split/108-b-auth-epub`. + +## Guardrails + +- Do not import player, subtitle, Watch Together, or catalog/TV polish changes. +- Preserve slice B auth/origin changes when resolving shared files. +- Never purge when registry load trust is unknown or failed. +- Cancellation must not publish partial identity-scoped state or release a + serialization guard before the underlying write finishes. +- Do not merge any PR. diff --git a/docs/superpowers/plans/2026-07-27-pr108-slice-d-player-foundation.md b/docs/superpowers/plans/2026-07-27-pr108-slice-d-player-foundation.md new file mode 100644 index 000000000..868f9d990 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-d-player-foundation.md @@ -0,0 +1,95 @@ +# PR 108 Slice D: Player Foundation Plan + +## Goal + +Create a reviewable slice stacked on slice C that contains only player +lifecycle, performance, ownership, and durable final-write foundations from +closed PR 108. Transactional subtitle/PGS/libass/letterbox behavior remains in +slice E. + +## Source mapping + +The performance work entered PR 108 through squash `65c4b316`; its focused +source commits are: + +- TV clock/exit: `f4444698`, `582b554c`, `94cb09b1`, `49cae1ff` +- mobile clock/lifecycle/recreation: `177b5fe7`, `491eded9`, `93d7318d`, + `ee59fa77`, `94c60922` +- identity-bound final writes: `fade71e8` + +Later focused PR 108 corrections to port after that foundation: + +- `5811f0bf`, `cc5d2b2c`, `8403b8b8`, `c8481f1c`, `ac14e51a`, + `b64b7c04` +- evaluate `a36c7211` by behavior: include only the session-ownership cleanup, + leaving subtitle transaction semantics for slice E. + +## Steps + +1. Compare each focused source commit against current slice-C/upstream state; + omit changes already present and document path-purpose mapping. +2. Port TV/mobile clock isolation, non-blocking teardown, recreation ownership, + and identity-captured final writes in dependency order. +3. Add or preserve deterministic lifecycle tests for start-vs-exit, + publication settlement, final-write failure retention, identity switches, + and recreation. +4. Port the later lifecycle corrections without pulling subtitle rendering or + Watch Together behavior forward. +5. Run focused red/green suites, then all debug unit tests and phone/TV release + assemblies with the supply-chain gate. +6. Obtain independent code/lifecycle review, fix findings, rerun the full gate, + publish a draft stacked PR, and record commit/range-diff traceability. + +## Guardrails + +- No merge and no changes to closed PR 108. +- No subtitle renderer/PGS/libass/letterbox behavior in this slice. +- Final writes must use the identity captured when playback began. +- Screen recreation must not duplicate or tear down an adopted session. +- Teardown must not block the UI thread or publish stale terminal state. + +## Implemented traceability + +This branch is a forward port against slice C, so the local commit ids differ +from the archival PR 108 ids. The path/purpose mapping is: + +- TV clock, seek, duration, and non-blocking exit: + `f4444698`, `582b554c`, `94cb09b1`, `49cae1ff` -> + `072c61ab`, `676c6b87`, `d44a4f3b`, `7d401ac8`, `7b481d31`. +- Mobile clock isolation, recreation ownership, lifecycle-safe teardown, and + episode rollup: + `177b5fe7`, `491eded9`, `93d7318d`, `ee59fa77`, `94c60922` -> + `ec0f0dd2`, `63c5db6d`, `8ae2a4b1`, `5ca6a48a`, `7b481d31`. +- Identity-captured final writes and failure retention: + `fade71e8`, `c8481f1c` -> `cec1f7ed`, `c2a63d5f`. +- Start-versus-exit ownership, retry accounting, and authenticated PiP actions: + `5811f0bf`, `cc5d2b2c`, `b64b7c04` -> + `a1d9e491`, `c888efe3`, `78015de8`, with the D-only lifecycle reconstruction + in `e3e3b043`. + +The following fixes are intentionally deferred intact to slice E because their +contracts depend on transactional subtitle publication and staged replans: +`8403b8b8`, `a36c7211`, `ac14e51a`, and the staged-publication portion of +`c8481f1c`. Pulling `8403b8b8` into D would otherwise import roughly 1,300 +lines of the subtitle transaction implementation. + +## Verification record + +- Focused lifecycle race test observed failing before the epoch guard and + passing afterward. +- Queued-stop ownership acquisition, canceled post-allocation adoption cleanup, + and stale failure publication were each observed failing before their fixes + and passing afterward. +- Shared player, phone player/detail, and TV player focused unit suites pass. +- Final independent review: **Ready: YES**, with no remaining + Important/Critical findings. The proposed second Room staging queue was + withdrawn because the target write is already the atomic Room state/outbox + transaction; an asynchronous staging write has the same pre-persistence + process-death gap and cannot survive failure of that same store. +- Final gate: + `./scripts/test-check-build-supply-chain.sh`, + `./scripts/check-build-supply-chain.sh`, and + `./gradlew -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" + testDebugUnitTest :androidApp:assembleRelease + :androidTvApp:assembleRelease --max-workers=2`. + Result: **BUILD SUCCESSFUL**, 334 tasks, 2m50s. diff --git a/docs/superpowers/plans/2026-07-27-pr108-slice-e-transactional-subtitles.md b/docs/superpowers/plans/2026-07-27-pr108-slice-e-transactional-subtitles.md new file mode 100644 index 000000000..380825db9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-e-transactional-subtitles.md @@ -0,0 +1,217 @@ +# PR 108 Slice E: Transactional Subtitles 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:** Forward-port PR 108's transactional subtitle stack, client-side PGS +support, libass lifetime fixes, and safe letterbox/title-area placement onto +slice D without importing Watch Together or slice-G catalog/TV polish. + +**Architecture:** A shared typed transition coordinator owns committed versus +pending subtitle identity. Playback replans are staged without replacing the +working session, then committed or discarded atomically by thin mobile and TV +adapters. Media3 mounting resolves stable artifact identity; PGS extraction, +libass lifetime, sync offsets, and picture/title-safe insets sit below that +transaction boundary. + +**Tech Stack:** Kotlin, coroutines/Flow, Media3, Room-backed selection state, +Java/JNI libass bridge, Android local unit tests. + +## Global Constraints + +- Base is draft slice D (`split/108-d-player-foundation`). +- Preserve closed PR 108 and its branch as archival references; do not merge. +- Do not import Watch Together, TV request UX, catalog polish, or home/startup + performance work. +- The active session and committed subtitle remain visible until a candidate + is validated and atomically published. +- Failed, canceled, stale, or superseded candidates must be stopped without + stopping the working session. +- Persistence occurs only for the exact committed typed identity. +- Every behavioral correction follows a red/green test cycle. + +--- + +### Task 1: Port the shared typed identity and transition model + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackModels.kt` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt` +- Create: `shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTransition.kt` +- Create: `shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleCodecFamily.kt` +- Create: `shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt` +- Create: `shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprint.kt` +- Test: matching `shared/src/commonTest` model/playback suites + +**Interfaces:** +- Produces typed Off/server-sidecar/server-burn-in/embedded/downloaded/local + identity and latest-intent-wins transition effects. +- Consumed by shared mounting plus mobile/TV adapters. + +- [ ] Port only the subtitle model/fingerprint paths from squash `65c4b316`. +- [ ] Run the focused common tests and observe any missing-identity failures. +- [ ] Reconcile current slice-D model changes with the archival implementation. +- [ ] Run focused tests green and commit. + +### Task 2: Port staged session publication and settlement + +**Files:** +- Modify: `android-shared/.../player/PlaybackSessionManager.kt` +- Modify: `android-shared/.../player/PlaybackSessionLifecycle.kt` +- Create/modify: staged-replan and publication-settlement tests under + `android-shared/src/androidUnitTest/.../player` + +**Interfaces:** +- Produces `StagedVideoReplan`, one-use commit/discard, deferred publication, + confirmation, rollback, bounded orphan cleanup, and active-session abandon. +- Preserves slice-D external-start epoch/cancellation ownership rules. + +- [ ] Restore real staged-replan tests from `65c4b316`; run them red. +- [ ] Port the minimum shared manager/lifecycle foundation from `65c4b316`. +- [ ] Apply corrections `d7241141`, `8403b8b8`, the manager portion of + `c8481f1c`, and `ac14e51a` in order. +- [ ] Verify content reset cannot wedge, in-place rollback preserves the server + cursor, and cancellation cannot orphan either candidate or predecessor. +- [ ] Run shared player suites green and commit. + +### Task 3: Port shared subtitle mounting and Media3 contracts + +**Files:** +- Create/modify: `SubtitleMountResolver.kt`, `SubtitleManager.kt`, + `PrairiePlayerFactory.kt`, `VideoPlayerMediaSpec.kt`, + `VideoPlayerMediaMounter.kt`, backend interfaces, and their tests. + +**Interfaces:** +- Maps typed committed identity to exact Media3 tracks and planned artifacts. +- Rejects coincidental same-label matches when stable identity is known. + +- [ ] Restore resolver/track-selection tests and observe red failures. +- [ ] Port shared mount/parser paths from `65c4b316`. +- [ ] Apply `f609c431`, `4add279c`, `1ad970c9`, `274ffc61`, and `823ca359`. +- [ ] Verify Off omits `subtitle_track_index`, bitmap tracks choose burn-in + unless client-side PGS is enabled, and catalog-only rows remain selectable. +- [ ] Run focused shared tests green and commit. + +### Task 4: Port mobile and TV transaction adapters + +**Files:** +- Create/modify mobile `MobileSubtitleTransactionAdapter`, + `MobilePlayerLoadOwner`, restore/auto-selection helpers, ViewModel/screen. +- Create/modify TV `TvSubtitleTransactionAdapter`, load owner, identity, + policy, HUD state, remount reselection, ViewModel/screen/HUD. +- Restore the corresponding real integration/ownership/settlement tests. + +**Interfaces:** +- Consumes shared coordinator and staged session manager. +- Publishes committed selection only after mount and publication settlement. + +- [ ] Restore adapter/integration tests from `65c4b316`; run representative + latest-intent, failure rollback, and content-reset cases red. +- [ ] Port the adapter/view-model paths while preserving slice-D clock, + recreation, and exit ownership fixes. +- [ ] Apply `a36c7211`, `370c5a79`, `38584061`, and exact mobile/TV corrections + from later subtitle commits. +- [ ] Verify A→B→Off, stale refresh, downloaded rebasing, same-label tracks, + publication rollback, and fresh-load ownership on both platforms. +- [ ] Run mobile/TV subtitle suites green and commit. + +### Task 5: Add PGS extraction and libass lifetime/timeline fixes + +**Files:** +- Create: `android-shared/.../player/subtitle/PgsSupExtractor.kt` +- Modify: player factory/service/backend/settings and `libass-bridge` handler. +- Restore PGS binary fixture and focused parser/probe/lifetime tests. + +**Interfaces:** +- Provides framed PGS sidecars to Media3 when capability/policy permits. +- Gives each player a bounded libass handler lifetime and releases it at end. + +- [ ] Restore PGS tests/fixture; run red before extractor implementation. +- [ ] Apply `f0bc39fe`, `6a904254`, `eebe9dd3`, and `a02d5622`. +- [ ] Apply libass fixes `05df9c34`, `e080df48`, and `f3888bee`, retaining + slice-C FIFO outbox semantics. +- [ ] Verify PGS END sections, mount/select/reconcile, per-player font cleanup, + release-on-end, and source/player timeline offset. +- [ ] Run focused PGS/libass suites green and commit. + +### Task 6: Port appearance, sync, diagnostics gating, and safe insets + +**Files:** +- Modify subtitle appearance/sync models, manager/service, phone/TV screens. +- Add/restore `LetterboxInsetTest`, `TitleSafeInsetTest`, appearance and sync + tests. + +**Interfaces:** +- Applies per-item sync and web-parity appearance to committed subtitles. +- Clamps cues to server-provided picture bounds and TV title-safe bounds. + +- [ ] Restore appearance/sync/inset tests; run red. +- [ ] Apply `8e42f428`, `8cb8ed3f`, `a910f0d9`, and `4b87b24c`. +- [ ] Apply diagnostics changes `8c2339c4`, `dcfbece5`, and final gate/delete + behavior from `e22e3190`. +- [ ] Verify baked-in bars, title-safe clamping, selectable CC rows, and + diagnostics disabled by default. +- [ ] Run focused suites green and commit. + +### Task 7: Audit, review, verify, and publish + +**Files:** +- Update this plan with final source-to-local commit mapping and verification. + +- [x] Audit the net diff for Watch Together, request UX, home/startup, reader, + and catalog-only leakage; remove any unrelated paths. +- [x] Run `git diff --check` and all focused subtitle/player suites. +- [x] Run supply-chain policy, `testDebugUnitTest`, and phone/TV release + assemblies. +- [x] Obtain independent code/lifecycle/security review; fix every + Critical/Important finding test-first and repeat the full gate. +- [ ] Push `split/108-e-subtitles` and create a draft PR based on + `split/108-d-player-foundation`; do not merge. + +## Forward-port mapping + +| Local commit | PR 108 sources | +| --- | --- | +| `e1f7730f` | `65c4b316` typed subtitle identity/transition subset | +| `fbb001fd` | `65c4b316`, `d7241141`, `8403b8b8`, `c8481f1c`, `ac14e51a` | +| `5096cb7a` | `65c4b316`, `f609c431`, `4add279c`, `1ad970c9`, `274ffc61`, `823ca359` | +| `74907f1c` | `65c4b316`, `a36c7211`, `370c5a79`, `38584061`, corrected `4add279c` boundary | +| `23df27e4` | `f0bc39fe`, `6a904254`, `eebe9dd3`, `a02d5622`, `05df9c34`, `e080df48`, `f3888bee` | +| `f7f99645` | `8e42f428`, `8cb8ed3f`, `a910f0d9`, `4b87b24c`, `8c2339c4`, `dcfbece5`, `e22e3190` | +| `2cb70a4d` | Review correction: auth-scoped durable selection writes, cross-adapter ordering, bounded malformed PGS input | +| `789e5827` | Review correction: propagate Room acceptance, reserve TV retirement order, bounded coordinator state | +| `0fdfcc26` | Review correction: retain ordering state while abandoned writes remain active | + +## Review corrections + +- Durable selection writes carry the playback-captured server/profile/auth + generation scope through Room and fail closed after an identity switch. +- Phone and TV adapters share process-level latest-write ordering per captured + scope/content/file. TV reserves its retirement order before asynchronous + settlement while persisting the exact post-settlement committed identity. +- Repository rejection remains visible to adapter retry/flush handling. +- Coordinator state is released after all tickets and active/waiting writes + resolve; abandonment cannot create a second mutex while an old write runs. +- Malformed PGS display sets fail closed before payload allocation after + 16 MiB or 512 segments. +- The Dolby Vision/color-range paths in the net diff preserve slice D's + playback metadata and fallback behavior across the new adapter boundary; + they do not add slice-G catalog behavior. + +## Verification record + +- `./scripts/check-build-supply-chain.sh` (pass) +- `./gradlew --no-daemon testDebugUnitTest --max-workers=2 --rerun-tasks` + (pass; 127/127 tasks executed in 54s) +- One earlier full run exposed a pre-existing staged-replan scheduling flake + (`s3` cleanup observed twice). The exact test passed five forced isolated + reruns before the fresh full suite passed. +- `./gradlew --no-daemon :androidApp:assembleRelease :androidTvApp:assembleRelease --max-workers=2` + (pass; 207 tasks in 3m18s) +- `git diff --check` (pass) +- Independent final review verdict: Ready Yes; no remaining Critical, + Important, or Minor findings. +- Net-diff scope audit against `split/108-d-player-foundation`: no Watch + Together, home/startup, reader, or TV request paths. 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 new file mode 100644 index 000000000..3d50c4b82 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-f-watch-together.md @@ -0,0 +1,236 @@ +# PR 108 Slice F: Watch Together Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development or superpowers:executing-plans. +> Every behavioral change follows test-driven development. + +**Goal:** Forward-port PR 108's complete Watch Together feature onto Slice E, +correct its production transport/session/security defects, and validate a real +host/guest flow on two concurrent Android emulators. + +**Architecture:** `RoomSession` is the application-scoped owner of one +immutable room/auth lease and one reconnect job. The repository folds room +state only for the current generation. The realtime client distinguishes +protocol closure from transport termination, exposes a writable connection +epoch, and keeps credentials on same-origin approved transports. Phone and TV +controllers subscribe before attach and re-attach on every epoch. + +**Tech stack:** Kotlin Multiplatform, coroutines/Flow, Ktor WebSockets, +OkHttp/MockWebServer, Koin, Android Compose, Gradle, ADB/emulator tooling. + +## Constraints + +- Base is draft Slice E PR #116 (`split/108-e-subtitles`). +- Preserve closed PR #108 as archival reference; do not merge any PR. +- Do not import TV request UX, catalog/mixed-library polish, or unrelated + home/startup work; those belong to Slice G. +- Preserve the A-E auth-origin, Room, playback, subtitle, and lifecycle + contracts. +- Fully removing room/profile credentials from query parameters is blocked on + server protocol support; remove the redundant access JWT now and document + the residual contract. +- Do not use a physical device for destructive validation. Use at least two + concurrent emulators with separate app data/identities. + +--- + +### Task 1: Correct realtime transport semantics and credential boundaries + +**Files:** +- Modify `shared/.../network/WatchTogetherRealtimeEvent.kt` +- Modify `shared/.../network/WatchTogetherRealtimeClient.kt` +- Modify `shared/.../network/CleartextOriginConsent.kt` +- Modify focused common tests +- Add Android/JVM real-websocket tests under `android-shared/src/androidUnitTest` + +**Red tests:** +- Normal socket EOF and I/O failure are transient transport termination, not a + decoded `room_closed`. +- Cancellation never emits a terminal close. +- The handshake URL omits the access JWT while same-origin Authorization and + profile headers are present. +- `ws://` requires the exact `http://` origin's cleartext approval before the + engine sees any credential. +- Early server snapshot and outbound attach/ping ordering survive a real + MockWebServer upgrade. + +**Implementation:** +- Split explicit protocol room closure from physical connection termination. +- Re-throw cancellation and surface typed transport termination. +- Protect mutable session ownership by connection identity. +- Encode the room id as a path segment. +- Rely on the pinned/same-origin auth plugin for the access bearer; retain only + server-required query fields. + +- [ ] Establish RED. +- [ ] Implement minimum production changes. +- [ ] Run forced focused tests green and commit. + +### Task 2: Make repository reconnect and room leases production-safe + +**Files:** +- Modify `shared/.../repository/WatchTogetherRepository.kt` +- Modify `shared/.../network/api/WatchTogetherApi.kt` only where scoped room + binding requires it +- Modify `shared/.../repository/WatchTogetherRepositoryTest.kt` +- Add a deterministic per-attempt transport harness + +**Red tests:** +- EOF/I/O reconnect; decoded room close never reconnects. +- Every unstable failed attempt counts even after one healthy frame. +- Snapshot plus 30 seconds of stable connection resets the failure window. +- Cancellation/obsolete generation cannot clear or fold into a replacement. +- Room A's late REST/socket completion cannot use or mutate room B. +- Blank room tokens and mismatched room responses fail closed instead of + retaining an earlier room token. +- Every room-scoped REST completion revalidates its captured lease before + publishing state. +- Missing room fails fast. +- Attach/readiness/user transport sends report actual delivery. + +**Implementation:** +- Capture immutable room id/token/auth scope in a generation lease. +- Gate fold, close, send, and final cleanup on the current generation. +- Expose connection state/epoch. +- Keep capped 500/1000/2000/5000 ms backoff with the 30-second stability rule. +- Make voted-id state concurrency-safe and retain the PR 108 fail-fast room + behavior. + +- [ ] Establish RED. +- [ ] Implement minimum production changes. +- [ ] Run forced focused tests green and commit. + +### Task 3: Add the application-scoped RoomSession owner + +**Files:** +- Create `shared/.../watchtogether/RoomSession.kt` +- Modify shared Koin registration +- Add `RoomSessionTest.kt` + +**Red tests:** +- Same-room enter is idempotent. +- A-to-B replacement cancels and joins blocked A before B starts. +- Late A frames/finalizers cannot clear B. +- Concurrent enters serialize deterministically. +- Leave cancels and joins before reset. +- Server/profile/logout/credential-generation change ends the lease. +- `IdentityTransitionBarrier` WILL_CHANGE hides room state and cancels the + lease before the new identity is published; old WS/REST completions cannot + repopulate state after profile/server/sign-out/temporary-scope transitions. +- A terminal room close tombstones that room-binding generation. Recreation + cannot reconnect it; only a fresh successful create/join response can mint a + new live binding. + +**Implementation:** +- Port the intent of `864de27e`/`79b4278d`, replacing cancel-only behavior with + cancel-and-join and immutable generation ownership. +- Bind the session to `TokenManager.snapshotCurrentScope()` and the existing + `IdentityTransitionBarrier`, rather than relying on screen logout callbacks. +- Register one session per app process with an application coroutine scope. + +- [ ] Establish RED. +- [ ] Implement minimum production changes. +- [ ] Run forced focused tests green and commit. + +### Task 4: Port phone/TV controllers with reconnect-safe delivery + +**Files:** +- Modify mobile `RoomSyncController`, `PlayerScreen`, and lifecycle tests +- Modify TV `TvRoomSyncController`, `TvPlayerScreen`, and lifecycle tests +- Add shared/two-client transport-broker tests + +**Red tests:** +- Collectors are active before initial attach/ping. +- Every connection epoch re-attaches exactly once. +- Failed ready/buffering does not advance the local latch and retries. +- Failed user transport is surfaced/deferred rather than silently dropped. +- Player leave calls `RoomSession.leave`; screen disposal does not. +- `leave`/`closeAndLeave` is owned by the application session, completes close + at most once, joins the socket, and resets even if the initiating screen or + ViewModel scope is canceled immediately after navigation. +- Attach/readiness state is keyed by connection epoch plus playback session id, + including equal initial snapshots and unchanged buffering after reconnect. +- A command accepted for playback session A is revalidated after its + `execute_at` delay; publishing replacement session B prevents the stale + command from seeking/pausing B or sending `ready(A)`. +- Two participants receive the same revision/command and independently apply + host play/pause/seek, guest policy, drift correction, buffering, and ready. +- Host terminal close reaches both and stops reconnect. + +**Implementation:** +- Port `323510a2`, `c6ba32ef`, and the controller portions of `79b4278d`. +- Keep RoomSyncEngine scheduling/session/revision/dedupe behavior from the + current base. +- Use the connection epoch as the attach/ping/reconciliation trigger. + +- [ ] Establish RED. +- [ ] Implement minimum production changes. +- [ ] Run phone/TV controller and two-client tests green and commit. + +### Task 5: Port the complete reachable lobby, voting, and suggestion surface + +**Files:** +- Modify phone entry/lobby/detail/navigation/ViewModels +- Modify TV entry/lobby/detail/navigation/ViewModels/QR layout +- Add/restore destination, policy, navigation, source-surface, voting, and + focus tests + +**Behavior:** +- Phone and TV can host or join. +- Vote rooms open empty, accept suggestions, votes, promotion, and host + override. +- Invite code remains visible and TV controls remain reachable/focusable. +- Members can browse and suggest while the application-scoped room remains + connected. +- Lobby-to-player and back do not create a second socket. + +**Sources:** `9fe27a27`, `e171e011`, `acfac18a`, `fca6d7c6`, `bc58049b`, +`ea5756ec`, `8f36f7d2`, `530f9488`, `f91aa62e`, `923a1a7f`. + +- [ ] Restore/author RED tests for missing behavior. +- [ ] Forward-port the net UI behavior against the current stack. +- [ ] Run focused phone/TV suites green and commit. + +### Task 6: Validate two real emulator participants + +**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 + production data. + +**Flow:** +- [ ] Record AVD/API/app/backend versions and startup commands. +- [ ] Host, join, verify handshake and attach-session ordering. +- [ ] Exercise host and guest transport policy. +- [ ] Exercise suggestions, vote/unvote, promote, and host override. +- [ ] Exercise drift/seek/buffer/ready propagation. +- [ ] Interrupt/restore one emulator's network and verify reconnect/re-attach. +- [ ] Background/foreground both clients. +- [ ] Replace one room with another. +- [ ] Close/depart as host and verify terminal guest closure. +- [ ] Capture redacted ADB/backend logs and classify any limitation or defect. +- [ ] Commit the reproducible evidence document. + +### Task 7: Audit, review, verify, and publish + +- [ ] Audit net diff and remove Slice G/unrelated paths. +- [ ] Run all focused Watch Together, auth/origin, Room, and player suites. +- [ ] Run `./scripts/check-build-supply-chain.sh`. +- [ ] Run fresh `testDebugUnitTest` and phone/TV release assemblies. +- [ ] Obtain independent lifecycle/concurrency/security review. +- [ ] Fix every Critical/Important finding test-first and repeat full gates. +- [ ] Update source-to-local mapping and emulator evidence. +- [ ] Push `split/108-f-watch-together`. +- [ ] Create a draft PR based on `split/108-e-subtitles`; do not merge. + +## Initial source mapping + +| Area | PR 108 sources | +| --- | --- | +| Feature entry | `9fe27a27` | +| Send/open and room action fixes | `323510a2`, `c6ba32ef`, `e171e011` | +| Lobby/invite/TV focus | `acfac18a`, `fca6d7c6`, `bc58049b` | +| Voting and empty vote rooms | `ea5756ec`, `8f36f7d2`, `923a1a7f` | +| Browsing suggestions | `530f9488`, `f91aa62e` | +| App-scoped ownership intent | `864de27e`, `79b4278d` | 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 new file mode 100644 index 000000000..e6cc24e8c --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-pr108-slice-f-watch-together-design.md @@ -0,0 +1,173 @@ +# PR 108 Slice F: Watch Together Forward-Port Design + +**Date:** 2026-07-27 +**Status:** Approved by the existing Watch Together design plus the user's +explicit instruction to continue Slice F and validate with multiple emulators +**Base:** `split/108-e-subtitles` / draft PR #116 +**Source:** Closed archival PR #108 and the approved +`2026-06-12-watch-together-design.md` + +## Goal + +Forward-port the complete, user-reachable Watch Together vertical slice onto +the reviewed A-E stack, while correcting connection ownership, room +replacement races, credential exposure, and the gap between fake-flow tests +and real two-participant behavior. + +Success means phone and TV can create, join, vote, promote, enter playback, +exchange transport state, recover from transient disconnects, and terminate +cleanly when the room closes. One application-scoped owner must hold exactly +one room connection across lobby, browsing, and player screens. + +## Considered approaches + +1. **Forward-port the PR 108 net behavior behind corrected ownership + boundaries (selected).** Preserve the already-designed UI/protocol surface, + reconcile each source commit against the current A-E stack, and replace the + fragile lifecycle pieces before publication. This keeps traceability while + making review about the resulting behavior rather than old conflict + resolutions. +2. **Cherry-pick the original Watch Together commits.** This preserves commit + history but replays obsolete screen-owned connection logic and conflicts + with the current player/subtitle/auth stack. It would require corrective + commits whose review surface is harder to understand. +3. **Rewrite only a minimal host/join/player path.** This reduces immediate + code volume but drops the voting, suggestion, lobby, and TV behavior the + approved feature requires. It would not be a coherent replacement for the + PR 108 slice. + +## Ownership and concurrency + +`RoomSession` is the application-scoped authority for room membership and the +single reconnect job. Lobby and player components observe it and send through +the repository; they never start their own socket loops and never call +`repository.reset()` directly. + +`enter(roomId)` is serialized. Re-entering the active room is idempotent. +Replacing a room cancels and joins the previous connection before publishing +the replacement owner. `leave()` cancels and joins before clearing repository +state. A monotonically increasing connection generation prevents an obsolete +connection's cancellation/finally path from clearing the current client, +snapshot, token, or close reason. + +Create/join responses bind the room id, room token, and captured +`AuthScopeSnapshot` together. Room-scoped REST and websocket work captures that +immutable lease, so a late operation from room A cannot use room B's token or +mutate room B's state. Server/profile/logout or credential-generation changes +terminate the lease rather than silently continuing under a replacement +identity. + +## Realtime transport and reconnect + +The event model distinguishes: + +- an explicit server `room_closed` frame, which is terminal and never + reconnects; +- a transport EOF/exception, which is transient and participates in capped + exponential backoff; +- cancellation by the current owner, which is silent and cannot mutate a + replacement connection. + +Each physical socket attempt publishes an "open" readiness epoch. One-shot +frames such as `attach_session` and the initial ping are sent only after that +attempt is writable, and are re-sent after every successful reconnect. +Ordinary outbound calls return whether the frame reached the current attempt; +they do not silently succeed before a handshake. Attach/readiness/latest-state +messages are retained or coalesced until sent; user transport requests surface +failure instead of disappearing. + +The reconnect cap counts every failed or unexpectedly ended attempt, including +an attempt that emitted frames before dropping. A connection resets the +failure window only after receiving an authoritative snapshot and remaining +open for at least 30 seconds; a single frame cannot keep a flapping connection +alive forever. Backoff uses the protocol's 500 ms, 1 s, 2 s, and 5 s steps. A +terminal close, identity change, or explicit leave stops the loop immediately. + +Discrete commands and pong samples are not delivered through a replay-zero +flow before subscribers exist. Player controllers subscribe to commands, +pongs, close state, and the connection-epoch `StateFlow` before attaching. +Every new epoch triggers attach plus the initial ping, so no command can be +valid for that local playback session before its collector is ready. +Reconnection reconciles from the authoritative snapshot and never replays an +already-applied command. + +## Credentials + +The Silo 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. + +The current server contract still reads `room_token`, `profile_id`, and +`profile_token` from the query. Changing those requires coordinated server +work outside this Android slice, so the residual exposure is documented rather +than hidden. All websocket URLs remain same-origin and pass the existing +cleartext-consent/origin policy before credentials leave the client. The +cleartext policy must treat `ws://` as `http://` and `wss://` as `https://`; +tests prove that an unapproved cleartext websocket is blocked before the +engine observes any header or query credential. + +## UI and player integration + +Port the PR 108 phone/TV entry, lobby, suggestion/voting, host override, and +browsing-to-suggest behavior as one vertical feature. Screen ViewModels call +`RoomSession.enter` to adopt an already-created/joined room, but screen +destruction does not leave it. Explicit leave, host close, or a terminal server +close ends the session. + +Phone and TV room controllers attach the current playback session after +connection readiness and again after reconnect. They keep the existing +`RoomSyncEngine` authority checks, command de-duplication, selection/session +gates, execution scheduling, drift thresholds, ready/buffering barrier, and +state-report suppression window. + +## Automated verification + +Test-first coverage includes: + +- repository transport EOF versus explicit room-close behavior, reconnect cap, + stable-window reset, cancellation, generation ownership, identity changes, + and room-token scoping; +- `RoomSession` same-room idempotence, A-to-B replacement with a blocked old + connection, leave during connect, and concurrent enter calls; +- real Ktor/OkHttp websocket handshakes against `MockWebServer`, verifying + access JWT absence from the URL, Authorization/header presence, early + server-frame delivery, outbound frame ordering, cleartext consent, + disconnect, and reconnect; +- a two-participant transport harness exercising host and guest snapshots, + attach ordering, commands, votes, promotion/override, buffering/ready, drift + correction, terminal close, and replacement; +- phone/TV lifecycle/controller and source-surface tests. + +## Required two-emulator validation + +Run at least two Android emulator instances concurrently on this Mac with +separate app data and participant identities. Prefer the dedicated +`Silo_Phone` and `Silo_TV` AVDs; do not reset or repurpose unrelated physical +devices. + +Against a local or configured test backend: + +1. host a room and join from the second emulator; +2. verify websocket handshake and attach-session ordering; +3. exercise host play/pause/seek and permitted/blocked guest controls; +4. add/vote/unvote, promote, and use host override; +5. observe drift correction, seek, buffering, and ready propagation; +6. interrupt and restore networking to prove transient reconnect/re-attach; +7. background/foreground each participant without creating duplicate owners; +8. replace one held room with another; +9. close or depart as host and verify the guest receives terminal room close. + +Capture ADB logcat, emulator/device metadata, backend logs, timestamps, room +ids, and the exact commands in a reproducible evidence document. Redact +credentials and classify every unmet step as an app defect, backend defect, or +environment limitation. + +## Traceability and scope + +The source commits are `9fe27a27`, `323510a2`, `c6ba32ef`, `e171e011`, +`acfac18a`, `fca6d7c6`, `bc58049b`, `ea5756ec`, `8f36f7d2`, `530f9488`, +`864de27e`, `79b4278d`, `f91aa62e`, and `923a1a7f`. + +Slice F excludes TV request UX, catalog/mixed-library polish, and unrelated +home/startup work. Those remain Slice G. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a001062c5..d3e9ff5d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ robolectric = "4.16.1" ksp = "2.1.20-2.0.1" room = "2.8.4" androidx-test-core = "1.6.1" +androidx-webkit = "1.16.0" bouncycastle = "1.84" profileinstaller = "1.4.1" benchmark = "1.3.4" @@ -35,6 +36,7 @@ firebase-messaging = "25.1.0" google-services = "4.5.0" junit4 = "4.13.2" okhttp = "4.12.0" +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" @@ -51,6 +53,7 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" } # Ktor ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } @@ -126,6 +129,7 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "a androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebase-messaging" } junit4 = { module = "junit:junit", version.ref = "junit4" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } # Google Cast (Chromecast) — phone app only. Tier 2 casting starts a separate # cast-capability playback session; the raw phone stream is never cast. diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml new file mode 100644 index 000000000..8420b37d4 --- /dev/null +++ b/gradle/verification-metadata.xml @@ -0,0 +1,8611 @@ + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cea7a793a..c43ecd675 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +distributionSha256Sum=7a00d51fb93147819aab76024feece20b6b84e420694101f276be952e08bef03 networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/libass-bridge/gradle.lockfile b/libass-bridge/gradle.lockfile new file mode 100644 index 000000000..b60229b6f --- /dev/null +++ b/libass-bridge/gradle.lockfile @@ -0,0 +1,168 @@ +# 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.annotation:annotation-experimental:1.5.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation:1.9.1=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.arch.core:core-common:2.1.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.arch.core:core-runtime:2.1.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.core:core-ktx:1.5.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.core:core:1.7.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.customview:customview:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.exifinterface:exifinterface:1.3.6=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.3.1=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.3.1=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-common:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-container:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-database:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-datasource:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-decoder:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-effect:1.10.1=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-exoplayer:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-extractor:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.media3:media3-ui:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.recyclerview:recyclerview:1.3.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,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 +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,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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 +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,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,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=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,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,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,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 +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 +commons-io:commons-io:2.16.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control +io.github.peerless2012:ass-kt:0.4.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.github.peerless2012:ass-media:0.4.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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.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=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.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,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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-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-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 +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-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 +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: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.2.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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: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-bom:1.9.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,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.9.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +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-core:1.9.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.9.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,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,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAnnotationProcessorClasspath,debugUnitTestAnnotationProcessorClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseUnitTestAnnotationProcessorClasspath 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 72658d0b9..5849c6b0c 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 @@ -60,7 +60,13 @@ public final class LibassBridge { private final boolean renderingSupported; private final boolean embeddedFontsSupported; - private final AssHandler handler; + /** + * Recycled per player so embedded Matroska font attachments do not remain + * reachable for the lifetime of the process after their player is gone. + */ + private final AssRenderType renderType; + private volatile AssHandler handler; + private volatile AssSubtitleParserFactory assFactory; private final SubtitleParser.Factory parserFactory; private WeakReference overlayRef = new WeakReference<>(null); private ExoPlayer initializedPlayer; @@ -76,13 +82,15 @@ public LibassBridge(boolean preferOpenGl) { renderingSupported = probeNativeRuntime(); embeddedFontsSupported = renderingSupported && probeMatroskaIntegration(); if (renderingSupported) { - AssRenderType renderType = preferOpenGl + renderType = preferOpenGl ? AssRenderType.OVERLAY_OPEN_GL : AssRenderType.OVERLAY_CANVAS; - handler = new AssHandler(renderType, new AssHandlerConfig()); + newHandler(); parserFactory = buildParserFactory(); } else { + renderType = null; handler = null; + assFactory = null; parserFactory = new DefaultSubtitleParserFactory(); } } @@ -99,9 +107,34 @@ public SubtitleParser.Factory getParserFactory() { return parserFactory; } + private void newHandler() { + handler = new AssHandler(renderType, new AssHandlerConfig()); + assFactory = new AssSubtitleParserFactory(handler); + } + + /** + * The factory wrappers outlive individual players, so each call delegates + * to the factory belonging to the current handler rather than pinning the + * retired handler and its embedded fonts. + */ private SubtitleParser.Factory buildParserFactory() { - AssSubtitleParserFactory assFactory = new AssSubtitleParserFactory(handler); - if (embeddedFontsSupported) return assFactory; + SubtitleParser.Factory currentAssFactory = new SubtitleParser.Factory() { + @Override + public boolean supportsFormat(Format format) { + return LibassBridge.this.assFactory.supportsFormat(format); + } + + @Override + public int getCueReplacementBehavior(Format format) { + return LibassBridge.this.assFactory.getCueReplacementBehavior(format); + } + + @Override + public SubtitleParser create(Format format) { + return LibassBridge.this.assFactory.create(format); + } + }; + if (embeddedFontsSupported) return currentAssFactory; // The ass-media Matroska extractor supplies both timed dialogue packets // and font attachments. If its Media3 reflection probe fails, do not @@ -112,12 +145,12 @@ private SubtitleParser.Factory buildParserFactory() { return new SubtitleParser.Factory() { @Override public boolean supportsFormat(Format format) { - return assFactory.supportsFormat(format); + return currentAssFactory.supportsFormat(format); } @Override public int getCueReplacementBehavior(Format format) { - return assFactory.getCueReplacementBehavior(format); + return currentAssFactory.getCueReplacementBehavior(format); } @Override @@ -126,7 +159,7 @@ public SubtitleParser create(Format format) { && MimeTypes.VIDEO_MATROSKA.equals(format.containerMimeType); return unsupportedEmbeddedAss ? media3Factory.create(format) - : assFactory.create(format); + : currentAssFactory.create(format); } }; } @@ -213,6 +246,8 @@ public void initialize(ExoPlayer player) { if (initializedPlayer != null) { initializedPlayer.removeListener(handler); initializedPlayer.removeListener(frameSizeSyncListener); + retireOverlay(); + newHandler(); } initializedPlayer = player; handler.init(player); @@ -221,6 +256,19 @@ public void initialize(ExoPlayer player) { player.addListener(frameSizeSyncListener); } + /** + * Releases bridge-owned state for the adopted player. Safe for an unknown + * player or repeated calls. + */ + public void releasePlayer(ExoPlayer player) { + if (!renderingSupported || initializedPlayer == null || initializedPlayer != player) return; + initializedPlayer.removeListener(handler); + initializedPlayer.removeListener(frameSizeSyncListener); + initializedPlayer = null; + retireOverlay(); + newHandler(); + } + /** Adds one libass overlay beneath Media3's normal text cue layer. */ public void attachTo(SubtitleView host) { if (!renderingSupported) return; @@ -248,6 +296,17 @@ public void attachTo(SubtitleView host) { syncOverlayFrameSizeLater(); } + /** Removes the view that still points at the retiring handler. */ + private void retireOverlay() { + AssSubtitleView overlay = overlayRef.get(); + overlayRef = new WeakReference<>(null); + if (overlay == null) return; + ViewGroup parent = overlay.getParent() instanceof ViewGroup + ? (ViewGroup) overlay.getParent() + : null; + if (parent != null) parent.removeView(overlay); + } + private Extractor[] replaceMatroska( Extractor[] extractors, SubtitleParser.Factory combinedParserFactory diff --git a/scripts/build-dovi-aar.sh b/scripts/build-dovi-aar.sh index 32ed029bb..d417323e4 100755 --- a/scripts/build-dovi-aar.sh +++ b/scripts/build-dovi-aar.sh @@ -3,55 +3,310 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" VERSION="2.3.1" +SOURCE_REPOSITORY="https://github.com/quietvoid/dovi_tool" +SOURCE_COMMIT="b25558062e4a56973482ec70133bd7b891320e48" +SOURCE_ARCHIVE_URL="https://codeload.github.com/quietvoid/dovi_tool/tar.gz/$SOURCE_COMMIT" +SOURCE_ARCHIVE_SHA256="595b1c756c9ad4acd5c1ddd851726eb3f0bd1786627c3d374998efc2929209ad" +ROOT_CARGO_LOCK_SHA256="a2d6127f9e4bad347fc98602282eb1cd8f711fc46ddf28723abfedc47ef30eac" +UPSTREAM_CARGO_LOCK_SHA256="e415cb414098495667f07a732633b632393adc1de2e0fa23c38c675b4fa81286" +CARGO_LOCK_SHA256="d6e609177b9cf349734f3972b41dca11c05f42aea1d7fac62a6869cb48f7d276" +RUST_VERSION="1.85.0" +NDK_VERSION="26.3.11579264" +SOURCE_DATE_EPOCH="1755858563" + SDK="${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}" -NDK="${ANDROID_NDK_HOME:-$SDK/ndk/26.3.11579264}" -TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/darwin-x86_64" -WORK="$ROOT/android-shared/build/dovi-aar" -OUT="$ROOT/android-shared/libs/prairie-dovi-bridge-$VERSION.aar" -SOURCE="$ROOT/android-shared/src/native/dovi/prairie_dovi.cpp" -BASE="https://github.com/edde746/libdovi-builds/releases/download/v$VERSION" - -if [[ ! -x "$TOOLCHAIN/bin/clang++" ]]; then - echo "Android NDK toolchain not found: $TOOLCHAIN" >&2 +NDK="${ANDROID_NDK_HOME:-$SDK/ndk/$NDK_VERSION}" +HOST_TAG="${ANDROID_NDK_HOST_TAG:-darwin-x86_64}" +TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/$HOST_TAG" +WORK_ROOT="${DOVI_WORK_ROOT:-$ROOT/android-shared/build/dovi-aar}" +OUT="${DOVI_OUT:-$ROOT/android-shared/libs/prairie-dovi-bridge-$VERSION.aar}" +JNI_SOURCE="$ROOT/android-shared/src/native/dovi/prairie_dovi.cpp" +PROVENANCE="$ROOT/android-shared/src/native/dovi/provenance.json" +MANIFEST="$ROOT/android-shared/src/native/dovi/AndroidManifest.xml" +NOTICES="$ROOT/android-shared/src/native/dovi/THIRD_PARTY_NOTICES.txt" +BUILD_CARGO_LOCK="$ROOT/android-shared/src/native/dovi/Cargo.lock" +VERIFY_TMP="" + +cleanup_verify_tmp() { + if [[ -n "${VERIFY_TMP:-}" && -d "$VERIFY_TMP" ]]; then + rm -rf "$VERIFY_TMP" + fi +} + +sha256_file() { + shasum -a 256 "$1" | awk '{print $1}' +} + +fail() { + echo "Error: $*" >&2 exit 1 +} + +verify_toolchains() { + [[ -x "$TOOLCHAIN/bin/clang++" ]] || + fail "Android NDK toolchain not found: $TOOLCHAIN" + local installed_ndk + installed_ndk="$(awk -F ' = ' '/^Pkg\.Revision = / { print $2 }' "$NDK/source.properties")" + [[ "$installed_ndk" == "$NDK_VERSION" ]] || + fail "Android NDK version $installed_ndk does not match pinned $NDK_VERSION" + command -v rustc >/dev/null || fail "rustc $RUST_VERSION is required" + command -v cargo >/dev/null || fail "cargo $RUST_VERSION is required" + [[ "$(rustc --version)" == "rustc $RUST_VERSION "* ]] || + fail "rustc version does not match pinned $RUST_VERSION" + [[ "$(cargo --version)" == "cargo $RUST_VERSION "* ]] || + fail "cargo version does not match pinned $RUST_VERSION" +} + +download_and_verify_source() { + local archive="$1" + curl -fsSL "$SOURCE_ARCHIVE_URL" -o "$archive" + [[ "$(sha256_file "$archive")" == "$SOURCE_ARCHIVE_SHA256" ]] || + fail "dovi_tool source archive checksum mismatch" + + local root_lock_hash lock_hash + root_lock_hash="$( + tar -xOf "$archive" "dovi_tool-$SOURCE_COMMIT/Cargo.lock" | + shasum -a 256 | + awk '{print $1}' + )" + [[ "$root_lock_hash" == "$ROOT_CARGO_LOCK_SHA256" ]] || + fail "dovi_tool root Cargo.lock checksum mismatch" + lock_hash="$( + tar -xOf "$archive" "dovi_tool-$SOURCE_COMMIT/dolby_vision/Cargo.lock" | + shasum -a 256 | + awk '{print $1}' + )" + [[ "$lock_hash" == "$UPSTREAM_CARGO_LOCK_SHA256" ]] || + fail "dolby_vision Cargo.lock checksum mismatch" +} + +expected_abi_hash() { + node - "$PROVENANCE" "$1" <<'NODE' +const fs = require("node:fs"); +const provenance = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const hash = provenance.outputs?.abis?.[process.argv[3]]?.sha256; +if (typeof hash !== "string") process.exit(1); +process.stdout.write(hash); +NODE +} + +verify_provenance_document() { + node - \ + "$PROVENANCE" \ + "$SOURCE_REPOSITORY" \ + "$VERSION" \ + "$SOURCE_COMMIT" \ + "$SOURCE_ARCHIVE_URL" \ + "$SOURCE_ARCHIVE_SHA256" \ + "$ROOT_CARGO_LOCK_SHA256" \ + "$UPSTREAM_CARGO_LOCK_SHA256" \ + "$CARGO_LOCK_SHA256" \ + "$RUST_VERSION" \ + "$NDK_VERSION" \ + "$SOURCE_DATE_EPOCH" <<'NODE' +const fs = require("node:fs"); +const [ + file, + repository, + tag, + commit, + archiveUrl, + archiveSha256, + rootCargoLockSha256, + upstreamCargoLockSha256, + cargoLockSha256, + rust, + ndk, + sourceDateEpoch, +] = process.argv.slice(2); +const provenance = JSON.parse(fs.readFileSync(file, "utf8")); +const expected = { + "schemaVersion": 1, + "source.repository": repository, + "source.tag": tag, + "source.commit": commit, + "source.archive.url": archiveUrl, + "source.archive.sha256": archiveSha256, + "source.rootCargoLockSha256": rootCargoLockSha256, + "source.upstreamCargoLockSha256": upstreamCargoLockSha256, + "source.cargoLockSha256": cargoLockSha256, + "toolchains.rust": rust, + "toolchains.ndk": ndk, + "build.sourceDateEpoch": sourceDateEpoch, +}; +for (const [path, value] of Object.entries(expected)) { + const actual = path.split(".").reduce((object, key) => object?.[key], provenance); + if (actual !== value) { + throw new Error(`Provenance ${path} does not match the pinned build input`); + } +} +const expectedAbis = ["arm64-v8a", "armeabi-v7a", "x86_64"]; +if (JSON.stringify(Object.keys(provenance.outputs?.abis ?? {}).sort()) !== + JSON.stringify(expectedAbis)) { + throw new Error("Provenance must contain exactly the three supported Android ABIs"); +} +const hashes = [ + ...expectedAbis.map((abi) => provenance.outputs.abis[abi]?.sha256), + provenance.outputs?.aar?.sha256, +]; +if (hashes.some((hash) => typeof hash !== "string" || !/^[a-f0-9]{64}$/.test(hash))) { + throw new Error("Provenance output hashes must be lowercase SHA-256 values"); +} +NODE +} + +verify_provenance() { + verify_provenance_document + verify_toolchains + [[ "$(sha256_file "$BUILD_CARGO_LOCK")" == "$CARGO_LOCK_SHA256" ]] || + fail "checked-in dolby_vision build Cargo.lock checksum mismatch" + + VERIFY_TMP="$(mktemp -d "${TMPDIR:-/tmp}/prairie-dovi-verify.XXXXXX")" + trap cleanup_verify_tmp EXIT + download_and_verify_source "$VERIFY_TMP/dovi_tool.tar.gz" + + [[ -f "$OUT" ]] || fail "AAR not found: $OUT" + local abi expected actual + for abi in arm64-v8a armeabi-v7a x86_64; do + expected="$(expected_abi_hash "$abi")" + actual="$( + unzip -p "$OUT" "jni/$abi/libprairie_dovi.so" | + shasum -a 256 | + awk '{print $1}' + )" + [[ "$actual" == "$expected" ]] || + fail "$abi output hash does not match provenance" + done + + local expected_aar + expected_aar="$( + node -e \ + 'process.stdout.write(JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).outputs.aar.sha256)' \ + "$PROVENANCE" + )" + [[ "$(sha256_file "$OUT")" == "$expected_aar" ]] || + fail "AAR hash does not match provenance" + echo "Verified pinned libdovi source, toolchains, ABI outputs, and AAR provenance." +} + +if [[ "${1:-}" == "--verify-provenance" ]]; then + verify_provenance + exit +elif [[ $# -ne 0 ]]; then + fail "usage: $0 [--verify-provenance]" fi -rm -rf "$WORK" -mkdir -p "$WORK/aar/jni" -mkdir -p "$WORK/aar/META-INF" -printf '\n' > "$WORK/aar/AndroidManifest.xml" -cp "$ROOT/android-shared/src/native/dovi/THIRD_PARTY_NOTICES.txt" \ - "$WORK/aar/META-INF/PRAIRIE_DOVI_NOTICES.txt" +verify_toolchains +mkdir -p "$WORK_ROOT" +WORK="$(mktemp -d "$WORK_ROOT/run.XXXXXX")" +mkdir -p "$WORK/aar/jni" "$WORK/aar/META-INF" +cp "$MANIFEST" "$WORK/aar/AndroidManifest.xml" +cp "$NOTICES" "$WORK/aar/META-INF/PRAIRIE_DOVI_NOTICES.txt" + +ARCHIVE="$WORK/dovi_tool.tar.gz" +download_and_verify_source "$ARCHIVE" +tar -xzf "$ARCHIVE" -C "$WORK" +UPSTREAM="$WORK/dovi_tool-$SOURCE_COMMIT" +[[ "$(sha256_file "$UPSTREAM/Cargo.lock")" == "$ROOT_CARGO_LOCK_SHA256" ]] || + fail "extracted dovi_tool root Cargo.lock checksum mismatch" +[[ "$(sha256_file "$UPSTREAM/dolby_vision/Cargo.lock")" == "$UPSTREAM_CARGO_LOCK_SHA256" ]] || + fail "extracted dolby_vision Cargo.lock checksum mismatch" +[[ "$(sha256_file "$BUILD_CARGO_LOCK")" == "$CARGO_LOCK_SHA256" ]] || + fail "checked-in dolby_vision build Cargo.lock checksum mismatch" +# The checked-in lock is reproducibly derived from the verified upstream child +# lock with these exact commands, in order: +# cargo update -p bitvec_helpers --precise 4.0.2 +# cargo update -p anyhow --precise 1.0.103 +# cargo update -p crossbeam-epoch --precise 0.9.20 +cp "$BUILD_CARGO_LOCK" "$UPSTREAM/dolby_vision/Cargo.lock" + +node - "$UPSTREAM/dolby_vision/Cargo.toml" <<'NODE' +const fs = require("node:fs"); +const file = process.argv[2]; +const original = fs.readFileSync(file, "utf8"); +const marker = "[lib]\ndoctest = false"; +if (original.split(marker).length !== 2) { + throw new Error("Unexpected dolby_vision Cargo.toml [lib] configuration"); +} +fs.writeFileSync(file, original.replace(marker, `${marker}\ncrate-type = ["staticlib"]`)); +NODE build_abi() { - local abi="$1" triple="$2" target="$3" checksum="$4" - local archive="$WORK/libdovi-$triple.tar.gz" - local libdir="$WORK/$abi" - curl -fsSL "$BASE/libdovi-$triple.tar.gz" -o "$archive" - printf '%s %s\n' "$checksum" "$archive" | shasum -a 256 -c - - mkdir -p "$libdir" "$WORK/aar/jni/$abi" - tar -xzf "$archive" -C "$libdir" - "$TOOLCHAIN/bin/clang++" \ - --target="$target" --sysroot="$TOOLCHAIN/sysroot" \ + local abi="$1" rust_target="$2" clang_target="$3" cargo_linker_variable="$4" + local rust_linker="$TOOLCHAIN/bin/${clang_target}24-clang" + local cxx_linker="$TOOLCHAIN/bin/${clang_target}24-clang++" + [[ -x "$rust_linker" && -x "$cxx_linker" ]] || + fail "Android linker not found for $abi" + rustc --print target-libdir --target "$rust_target" >/dev/null || + fail "Rust target is not installed: $rust_target" + + ( + cd "$UPSTREAM/dolby_vision" + export CARGO_BUILD_JOBS=1 + export CARGO_INCREMENTAL=0 + export SOURCE_DATE_EPOCH + export RUSTFLAGS="--remap-path-prefix=$UPSTREAM=/usr/src/dovi_tool -C debuginfo=0 -C panic=abort" + export "$cargo_linker_variable=$rust_linker" + cargo fetch --locked --target "$rust_target" + cargo build \ + --locked \ + --offline \ + --release \ + --target "$rust_target" \ + --features capi \ + --lib + ) + + local libdir="$WORK/aar/jni/$abi" + mkdir -p "$libdir" + "$cxx_linker" \ + --sysroot="$TOOLCHAIN/sysroot" \ -std=c++17 -O2 -fPIC -fvisibility=hidden -shared -static-libstdc++ \ - -Wl,--no-undefined -Wl,--exclude-libs,ALL -Wl,-z,max-page-size=16384 \ - "$SOURCE" "$libdir/libdovi.a" -llog -ldl -lm \ - -o "$WORK/aar/jni/$abi/libprairie_dovi.so" - "$TOOLCHAIN/bin/llvm-strip" "$WORK/aar/jni/$abi/libprairie_dovi.so" - if "$TOOLCHAIN/bin/llvm-readelf" -d "$WORK/aar/jni/$abi/libprairie_dovi.so" | grep -q 'libc++_shared.so'; then - echo "Unexpected libc++_shared.so dependency for $abi" >&2 - exit 1 + -Wl,--build-id=none -Wl,--no-undefined -Wl,--exclude-libs,ALL \ + -Wl,-z,max-page-size=16384 \ + "$JNI_SOURCE" \ + "$UPSTREAM/dolby_vision/target/$rust_target/release/libdolby_vision.a" \ + -llog -ldl -lm \ + -o "$libdir/libprairie_dovi.so" + "$TOOLCHAIN/bin/llvm-strip" "$libdir/libprairie_dovi.so" + if "$TOOLCHAIN/bin/llvm-readelf" -d "$libdir/libprairie_dovi.so" | + grep -q 'libc++_shared.so'; then + fail "unexpected libc++_shared.so dependency for $abi" fi } -build_abi arm64-v8a aarch64-linux-android aarch64-linux-android24 \ - 9d2983fc86f2f9e6da54c3c84ba8ea3a528690619f312ff4620198071b84e9ae -build_abi armeabi-v7a armv7-linux-androideabi armv7a-linux-androideabi24 \ - ed6fec8bf744e41c661b97f5fc4bf1197ebe9b09a140cbde369728e790ee3a68 -build_abi x86_64 x86_64-linux-android x86_64-linux-android24 \ - eba59678f89b792f5c6f802962e237542fe8328f6aa03a0a90ee77353dac3194 +build_abi \ + arm64-v8a \ + aarch64-linux-android \ + aarch64-linux-android \ + CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER +build_abi \ + armeabi-v7a \ + armv7-linux-androideabi \ + armv7a-linux-androideabi \ + CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER +build_abi \ + x86_64 \ + x86_64-linux-android \ + x86_64-linux-android \ + CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER mkdir -p "$(dirname "$OUT")" rm -f "$OUT" -(cd "$WORK/aar" && zip -q -r "$OUT" AndroidManifest.xml jni META-INF) -echo "Built $OUT" +TZ=UTC touch -t 198001010000 \ + "$WORK/aar/AndroidManifest.xml" \ + "$WORK/aar/META-INF/PRAIRIE_DOVI_NOTICES.txt" \ + "$WORK/aar/jni/arm64-v8a/libprairie_dovi.so" \ + "$WORK/aar/jni/armeabi-v7a/libprairie_dovi.so" \ + "$WORK/aar/jni/x86_64/libprairie_dovi.so" +( + cd "$WORK/aar" + zip -X -q "$OUT" \ + AndroidManifest.xml \ + META-INF/PRAIRIE_DOVI_NOTICES.txt \ + jni/arm64-v8a/libprairie_dovi.so \ + jni/armeabi-v7a/libprairie_dovi.so \ + jni/x86_64/libprairie_dovi.so +) +echo "Built $OUT from quietvoid/dovi_tool@$SOURCE_COMMIT" +echo "Build work directory: $WORK" diff --git a/scripts/check-build-supply-chain.sh b/scripts/check-build-supply-chain.sh new file mode 100755 index 000000000..8a96e095b --- /dev/null +++ b/scripts/check-build-supply-chain.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +ruby - "${repo_root}" <<'RUBY' +require "psych" +require "rexml/document" + +repo_root = ARGV.fetch(0) +failures = [] + +relative_path = lambda do |path| + path.delete_prefix("#{repo_root}/") +end + +inspect_uses = lambda do |node, workflow_path| + case node + when Hash + node.each do |key, value| + if key.to_s == "uses" + reference = value.to_s + unless reference.start_with?("./") || + reference.match?(/\A[^@\s]+@[0-9a-fA-F]{40}\z/) + failures << "Unpinned GitHub Action in " \ + "#{relative_path.call(workflow_path)}: #{reference}" + end + end + inspect_uses.call(value, workflow_path) + end + when Array + node.each { |value| inspect_uses.call(value, workflow_path) } + end +end + +workflow_paths = Dir[ + File.join(repo_root, ".github", "workflows", "*.{yml,yaml}") +].sort +workflow_paths.each do |workflow_path| + begin + workflow = Psych.safe_load( + File.read(workflow_path), + permitted_classes: [], + permitted_symbols: [], + aliases: true, + filename: workflow_path + ) + inspect_uses.call(workflow, workflow_path) + rescue Psych::Exception => error + failures << "Invalid workflow YAML in " \ + "#{relative_path.call(workflow_path)}: #{error.message}" + end +end + +wrapper_properties = File.join( + repo_root, + "gradle", + "wrapper", + "gradle-wrapper.properties" +) +wrapper_contents = File.exist?(wrapper_properties) ? File.read(wrapper_properties) : "" +unless wrapper_contents.match?(/^distributionSha256Sum=[0-9a-fA-F]{64}$/) + failures << "Missing or invalid Gradle wrapper distributionSha256Sum in " \ + "#{relative_path.call(wrapper_properties)}" +end + +verification_metadata = File.join(repo_root, "gradle", "verification-metadata.xml") +metadata_errors = [] + +begin + raise Errno::ENOENT, verification_metadata unless File.file?(verification_metadata) + + document = REXML::Document.new(File.read(verification_metadata)) + root = document.root + metadata_errors << "unexpected root element" unless root&.name == "verification-metadata" + + elements = [] + walk_elements = lambda do |element| + elements << element + element.each_element { |child| walk_elements.call(child) } + end + walk_elements.call(root) if root + + configuration = root&.elements&.to_a&.find { |element| element.name == "configuration" } + verify_metadata = configuration&.elements&.to_a&.find do |element| + element.name == "verify-metadata" + end + unless verify_metadata&.text.to_s.strip == "true" + metadata_errors << "metadata verification is not enabled" + end + + components = elements.select { |element| element.name == "component" } + artifacts = components.flat_map do |component| + component.elements.to_a.select { |element| element.name == "artifact" } + end + checksums_by_artifact = artifacts.map do |artifact| + artifact.elements.to_a.select { |element| element.name == "sha256" } + end + checksums = checksums_by_artifact.flatten + metadata_errors << "no components" if components.empty? + metadata_errors << "no artifacts" if artifacts.empty? + metadata_errors << "no SHA-256 checksums" if checksums.empty? + if checksums_by_artifact.any?(&:empty?) + metadata_errors << "artifact without a SHA-256 checksum" + end + unless checksums.all? do |checksum| + checksum.attributes["value"].to_s.match?(/\A[0-9a-fA-F]{64}\z/) + end + metadata_errors << "invalid SHA-256 checksum" + end + + components + .select do |component| + component.attributes["group"] == "com.android.tools.build" && + component.attributes["name"] == "aapt2" + end + .each do |component| + version = component.attributes["version"].to_s + expected = "aapt2-#{version}-linux.jar" + names = component.elements + .to_a + .select { |element| element.name == "artifact" } + .map { |artifact| artifact.attributes["name"].to_s } + unless names.include?(expected) + metadata_errors << "Missing Linux AAPT2 verification artifact #{expected}" + end + end + + trust_all = elements.any? do |element| + next true if element.name == "trust-all" + next false unless element.name == "trust" + + selectors = %w[group name version file].map do |attribute| + element.attributes[attribute] + end.compact + next true if selectors.empty? + + universal = /\A(?:|\*|\.\*|\^\.\*\$)\z/ + element.attributes["regex"].to_s == "true" && + selectors.all? { |selector| selector.match?(universal) } + end + metadata_errors << "trust-all rule is not allowed" if trust_all +rescue Errno::ENOENT, REXML::ParseException => error + metadata_errors << error.message +end + +unless metadata_errors.empty? + failures << "Missing or invalid dependency verification metadata: " \ + "#{relative_path.call(verification_metadata)} " \ + "(#{metadata_errors.join('; ')})" +end + +unless failures.empty? + warn failures.join("\n") + exit 1 +end +RUBY diff --git a/scripts/test-check-build-supply-chain.sh b/scripts/test-check-build-supply-chain.sh new file mode 100755 index 000000000..52281282b --- /dev/null +++ b/scripts/test-check-build-supply-chain.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +policy_script="${script_dir}/check-build-supply-chain.sh" +test_root="$(mktemp -d)" +trap 'rm -rf "${test_root}"' EXIT + +fixture_root="" +failures=0 + +new_fixture() { + local name="$1" + fixture_root="${test_root}/${name}" + mkdir -p \ + "${fixture_root}/.github/workflows" \ + "${fixture_root}/gradle/wrapper" \ + "${fixture_root}/scripts" + cp "${policy_script}" "${fixture_root}/scripts/check-build-supply-chain.sh" + chmod +x "${fixture_root}/scripts/check-build-supply-chain.sh" + + printf '%s\n' \ + 'distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip' \ + 'distributionSha256Sum=7a00d51fb93147819aab76024feece20b6b84e420694101f276be952e08bef03' \ + > "${fixture_root}/gradle/wrapper/gradle-wrapper.properties" + + write_valid_metadata +} + +write_workflow() { + printf '%s\n' "$1" > "${fixture_root}/.github/workflows/test.yml" +} + +write_valid_metadata() { + printf '%s\n' \ + '' \ + '' \ + ' ' \ + ' true' \ + ' false' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + '' \ + > "${fixture_root}/gradle/verification-metadata.xml" +} + +expect_pass() { + local name="$1" + if ! "${fixture_root}/scripts/check-build-supply-chain.sh" \ + > "${fixture_root}/output.log" 2>&1; then + printf 'FAIL: %s should pass\n' "${name}" >&2 + cat "${fixture_root}/output.log" >&2 + failures=$((failures + 1)) + fi +} + +expect_fail() { + local name="$1" + local expected_message="$2" + if "${fixture_root}/scripts/check-build-supply-chain.sh" \ + > "${fixture_root}/output.log" 2>&1; then + printf 'FAIL: %s should fail\n' "${name}" >&2 + failures=$((failures + 1)) + elif ! grep -q "${expected_message}" "${fixture_root}/output.log"; then + printf 'FAIL: %s did not report %s\n' "${name}" "${expected_message}" >&2 + cat "${fixture_root}/output.log" >&2 + failures=$((failures + 1)) + fi +} + +new_fixture "pinned-action" +write_workflow 'jobs: + test: + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' +expect_pass "pinned action" + +new_fixture "flow-mapping" +write_workflow 'jobs: + test: + steps: + - { uses: actions/checkout@v6 }' +expect_fail "flow mapping" "Unpinned GitHub Action" + +new_fixture "spaced-key" +write_workflow 'jobs: + test: + steps: + - uses : actions/checkout@v6' +expect_fail "spaced uses key" "Unpinned GitHub Action" + +new_fixture "job-level" +write_workflow 'jobs: + reusable: + uses: example/project/.github/workflows/reusable.yml@main' +expect_fail "job-level reusable workflow" "Unpinned GitHub Action" + +new_fixture "nested-mutable" +write_workflow 'jobs: + test: + steps: + - name: Nested mutable action + uses: example/project/action@main' +expect_fail "nested mutable action" "Unpinned GitHub Action" + +new_fixture "local-action" +write_workflow 'jobs: + test: + steps: + - uses: ./github/actions/local' +expect_pass "local action" + +new_fixture "verification-disabled" +write_workflow 'jobs: {}' +sed -i.bak 's/true/false/' \ + "${fixture_root}/gradle/verification-metadata.xml" +expect_fail "metadata verification disabled" "dependency verification metadata" + +new_fixture "missing-checksum" +write_workflow 'jobs: {}' +sed -i.bak '/' \ + ' true' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + '' \ + > "${fixture_root}/gradle/verification-metadata.xml" +expect_fail "orphan artifact checksum" "dependency verification metadata" + +new_fixture "missing-linux-aapt2" +write_workflow 'jobs: {}' +printf '%s\n' \ + '' \ + ' true' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + '' \ + > "${fixture_root}/gradle/verification-metadata.xml" +expect_fail "missing Linux AAPT2" "Missing Linux AAPT2 verification artifact" + +new_fixture "trust-all" +write_workflow 'jobs: {}' +sed -i.bak '//a\ + ' \ + "${fixture_root}/gradle/verification-metadata.xml" +expect_fail "trust-all metadata" "dependency verification metadata" + +if ((failures > 0)); then + printf '%d supply-chain policy self-test(s) failed\n' "${failures}" >&2 + exit 1 +fi + +printf 'All supply-chain policy self-tests passed\n' diff --git a/settings-gradle.lockfile b/settings-gradle.lockfile new file mode 100644 index 000000000..709a43f74 --- /dev/null +++ b/settings-gradle.lockfile @@ -0,0 +1,4 @@ +# 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. +empty=incomingCatalogForLibs0 diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index cca2f2f2c..2fc26338c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -126,6 +126,12 @@ kover { // FramedJSONSession similarly). Exercise via integration tests. "org.prairieserver.prairie.network.DefaultWatchTogetherRealtimeClient", "org.prairieserver.prairie.network.DefaultWatchTogetherRealtimeClient$*", + // Private top-level JVM classes in WatchTogetherRealtimeClient.kt — + // not matched by DefaultWatchTogetherRealtimeClient$*. + "org.prairieserver.prairie.network.KtorWatchTogetherSocketConnection", + "org.prairieserver.prairie.network.KtorWatchTogetherSocketConnection$*", + "org.prairieserver.prairie.network.KtorWatchTogetherSocketConnector", + "org.prairieserver.prairie.network.KtorWatchTogetherSocketConnector$*", "org.prairieserver.prairie.network.DefaultPlaybackRealtimeClient", "org.prairieserver.prairie.network.DefaultPlaybackRealtimeClient$*", "org.prairieserver.prairie.network.DefaultHomeRealtimeClient", diff --git a/shared/gradle.lockfile b/shared/gradle.lockfile new file mode 100644 index 000000000..68eff0d9a --- /dev/null +++ b/shared/gradle.lockfile @@ -0,0 +1,244 @@ +# 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.annotation:annotation-jvm:1.9.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation:1.9.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection-jvm:1.4.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection:1.4.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.core:core-viewtree:1.0.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.9.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.security:security-crypto: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,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrency-jvm:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrency:2.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugApiDependenciesMetadata,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrent-collections-jvm:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-concurrent-collections:2.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugApiDependenciesMetadata,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-strict-jvm:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +co.touchlab:stately-strict:2.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugApiDependenciesMetadata,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseRuntimeClasspath,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 +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.android.tools:desugar_jdk_libs:2.1.5=coreLibraryDesugaring +com.android.tools:desugar_jdk_libs_configuration:2.1.5=coreLibraryDesugaring +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 +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,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.crypto.tink:tink-android:1.8.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,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_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 +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 +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-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 +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 +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.squareup.okhttp3:okhttp-sse:4.12.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.squareup.okio:okio-jvm:3.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.squareup.okio:okio:3.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +commons-io:commons-io:2.16.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control +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-core-jvm:4.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.insert-koin:koin-core:4.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-auth-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-auth:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-content-negotiation-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-content-negotiation:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-core-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-core:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-logging-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-logging:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-mock-jvm:3.1.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-mock:3.1.2=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 +io.ktor:ktor-client-okhttp-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-okhttp:3.1.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,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-websockets-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-client-websockets:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-events-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-events:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-cio-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-cio:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-http:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-io-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-io:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-network-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-network:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-serialization:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-sse-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-sse:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-utils-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-utils:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websocket-serialization-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websocket-serialization:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websockets-jvm:3.1.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.ktor:ktor-websockets:3.1.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,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.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 +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=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +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,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.1.20=kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain +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=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,metadataCompileClasspath +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 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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-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 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.21=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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: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=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-junit:2.1.20=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.1.20=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 +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.23.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugApiDependenciesMetadata,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,metadataCompileClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,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,debugAndroidTestRuntimeClasspath,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,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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-slf4j:1.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.10.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=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 +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.6.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.6.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.6.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core:0.6.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.8.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.8.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugApiDependenciesMetadata,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,metadataCompileClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugApiDependenciesMetadata,androidDebugCompileClasspath,androidDebugCompileOnlyDependenciesMetadata,androidDebugImplementationDependenciesMetadata,androidDebugResolvableDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseApiDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseCompileOnlyDependenciesMetadata,androidReleaseImplementationDependenciesMetadata,androidReleaseResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,metadataCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +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,debugAndroidTestRuntimeClasspath,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,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.slf4j:slf4j-api:2.0.16=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +empty=androidApis,androidDebugAndroidTestRuntimeClasspath,androidDebugIntransitiveDependenciesMetadata,androidInstrumentedTestDebugIntransitiveDependenciesMetadata,androidInstrumentedTestIntransitiveDependenciesMetadata,androidJdkImage,androidMainIntransitiveDependenciesMetadata,androidReleaseIntransitiveDependenciesMetadata,androidTestUtil,androidUnitTestDebugIntransitiveDependenciesMetadata,androidUnitTestIntransitiveDependenciesMetadata,androidUnitTestReleaseIntransitiveDependenciesMetadata,commonMainIntransitiveDependenciesMetadata,commonTestIntransitiveDependenciesMetadata,debugAndroidTestAnnotationProcessorClasspath,debugAnnotationProcessorClasspath,debugUnitTestAnnotationProcessorClasspath,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseUnitTestAnnotationProcessorClasspath 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 472800920..ddb110e71 100644 --- a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt @@ -48,6 +48,10 @@ class AndroidServerRegistry( private val _activeEntry = MutableStateFlow(null) override val activeEntry: StateFlow = _activeEntry.asStateFlow() + @Volatile + override var stateLoadFailed: Boolean = false + private set + init { // Synchronous load + migration so MainActivity.runBlocking can read state. runBlocking { @@ -118,15 +122,18 @@ class AndroidServerRegistry( override suspend fun remove(serverId: String) { identityTransitions.changing(IdentityTransitionKind.SERVER_REMOVE) { mutex.withLock { - // Wipe all per-server token keys before dropping the entry, otherwise - // they'd linger encrypted on disk indefinitely. - prefs.edit() - .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_PROFILE_ID)) - .remove(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_PROFILE_TOKEN)) - .apply() + // Wipe every key in this server's "." namespace before + // dropping the entry, otherwise they'd linger encrypted on disk + // indefinitely. Sweeping the prefix rather than a fixed key list + // keeps the purge correct as per-server slots are added: ids are + // base64url so they never contain the '.' separator, and no other + // server's key can match this prefix. + val scopePrefix = serverScopedKey(serverId, "") + val editor = prefs.edit() + 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) { @@ -182,7 +189,23 @@ class AndroidServerRegistry( private fun loadStateLocked(): RegistryState? { val raw = prefs.getString(KEY_REGISTRY_STATE, null) ?: return null - return runCatching { json.decodeFromString(raw) }.getOrNull() + val decoded = runCatching { json.decodeFromString(raw) }.getOrNull() + // A blob that exists but will not decode is NOT the same as no blob. + // Both used to collapse to an empty registry, which was survivable while + // "no servers" only meant "sign in again" — the rows and downloaded + // bytes stayed on disk under an id derived from the URL, so re-adding + // the server restored them. It stopped being survivable when + // OrphanedServerDataPurger started deleting every server present in the + // database but absent from the registry: one unparseable blob would + // permanently erase downloads, resume positions and queued outbox ops. + if (decoded == null) { + android.util.Log.e( + "AndroidServerRegistry", + "registry state failed to decode; suppressing destructive cleanup", + ) + stateLoadFailed = true + } + return decoded } private fun applyStateLocked(state: RegistryState) { 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 cea362e69..6b562d164 100644 --- a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerImpl.kt +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerImpl.kt @@ -50,6 +50,18 @@ class EncryptedTokenManagerImpl( private var profileToken: String? = null private var temporaryScope: TemporaryAuthScope? = null + /** + * Incremented whenever the persistent identity moves: writing or clearing + * persistent credentials, and switching the active server. + * Stamped onto snapshots so a scope captured before a sign-out cannot read or + * overwrite the credentials of the login that replaced it. Overlay begin/end + * deliberately does not move it — see [AuthScopeSnapshot.credentialEpoch]. + */ + // Zero is reserved by AuthScopeSnapshot for callers that did not capture + // a live generation. A manager constructed over credentials already on + // disk must therefore begin at a non-sentinel value. + private var persistentCredentialEpoch: Long = 1L + private val _sessionExpired = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, @@ -71,6 +83,7 @@ class EncryptedTokenManagerImpl( mutex.withLock { if (id != activeServerId) { activeServerId = id + persistentCredentialEpoch += 1 reloadCacheUnsynchronized() } } @@ -102,6 +115,7 @@ class EncryptedTokenManagerImpl( val liveId = registry.activeServerId.value if (liveId != activeServerId) { activeServerId = liveId + persistentCredentialEpoch += 1 reloadCacheUnsynchronized() } } @@ -138,6 +152,7 @@ class EncryptedTokenManagerImpl( this.refreshToken = refreshToken val expiryEpochMs = System.currentTimeMillis() + expiresIn * 1000L this.tokenExpiryEpochMs = expiryEpochMs + persistentCredentialEpoch += 1 prefs.edit() .putString(serverScopedKey(serverId, KEY_ACCESS_TOKEN), accessToken) .putString(serverScopedKey(serverId, KEY_REFRESH_TOKEN), refreshToken) @@ -175,6 +190,7 @@ class EncryptedTokenManagerImpl( } private fun clearPersistentTokensLocked() { + persistentCredentialEpoch += 1 val serverId = activeServerId accessToken = null refreshToken = null @@ -265,6 +281,7 @@ class EncryptedTokenManagerImpl( mutex.withLock { if (activeServerId == serverId) return@withLock activeServerId = serverId + persistentCredentialEpoch += 1 reloadCacheUnsynchronized() } } @@ -305,6 +322,7 @@ class EncryptedTokenManagerImpl( serverUrl = scope.serverUrl, profileToken = scope.profileToken, credentialGenerationId = scope.generationId, + identityGeneration = identityTransitions.generation.value, ) } val serverId = activeServerId ?: return@withLock null @@ -320,6 +338,8 @@ class EncryptedTokenManagerImpl( profileId = profileId, serverUrl = url, profileToken = profileToken, + identityGeneration = identityTransitions.generation.value, + credentialEpoch = persistentCredentialEpoch, ) } @@ -333,9 +353,27 @@ class EncryptedTokenManagerImpl( else prefs.getString(serverScopedKey(serverId, KEY_REFRESH_TOKEN), null) } + /** + * True when an identity transition has happened since [this] was captured. + * + * The persistent path is keyed by serverId alone, so a snapshot taken before + * a sign-out could still read — and overwrite — the credentials issued by the + * NEXT login on that same server. Comparing the captured identity generation + * closes that: [saveTokens] and [clearTokens]/[invalidateSession] both run + * inside `identityTransitions.changing`, which increments it. + * + * `0L` means "not captured from a live snapshot" — several call sites build a + * scope by hand (the interceptor's refresh fallback, companion pairing, + * remote-playback identity) and carry the default. Those keep the old + * behaviour rather than failing closed on a generation they never recorded. + */ + 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 @@ -347,6 +385,7 @@ class EncryptedTokenManagerImpl( 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 @@ -377,6 +416,9 @@ class EncryptedTokenManagerImpl( 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 } @@ -451,4 +493,55 @@ class EncryptedTokenManagerImpl( // strip the pre-multi-server unprefixed key off disk. const val KEY_SERVER_URL = "server_url" } + + private fun currentScopeMatchesLocked( + scope: AuthScopeSnapshot, + expectedGeneration: Long, + ): Boolean { + if (identityTransitions.generation.value != expectedGeneration) return false + val temporary = temporaryScope + val generationId = scope.credentialGenerationId + if (generationId != null) { + return temporary?.generationId == generationId && + temporary.serverId == scope.serverId && + temporary.serverUrl == scope.serverUrl + } + if (temporary != null || activeServerId != scope.serverId) return false + return registry.entries.value + .firstOrNull { it.id == scope.serverId } + ?.url == scope.serverUrl + } + + override suspend fun invalidateSessionForScope(scope: AuthScopeSnapshot): Boolean = + tokenWriteMutex.withLock { + val matchesBeforeTransition = mutex.withLock { + ensureCacheMatchesRegistryLocked() + currentScopeMatchesLocked( + scope = scope, + expectedGeneration = scope.identityGeneration, + ) + } + if (!matchesBeforeTransition) return@withLock false + + identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + mutex.withLock { + ensureCacheMatchesRegistryLocked() + // `changing` increments the generation before entering this + // block. Any intervening identity transition therefore + // makes this value larger and the mutation fails closed. + if ( + !currentScopeMatchesLocked( + scope = scope, + expectedGeneration = scope.identityGeneration + 1, + ) + ) { + return@withLock false + } + val wasTemporary = temporaryScope != null + clearCurrentScopeLocked() + if (!wasTemporary) _sessionExpired.tryEmit(Unit) + true + } + } + } } diff --git a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt new file mode 100644 index 000000000..13e4568cc --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt @@ -0,0 +1,150 @@ +package org.prairieserver.prairie.network + +import android.content.SharedPreferences +import java.lang.reflect.Proxy +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.assertNull + +class EncryptedTokenManagerScopeGenerationTest { + + @Test + fun staleSameServerScopeCannotReadOrRestoreReloggedCredentials() = runTest { + val registry = FakeServerRegistry() + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences(), + registry = registry, + ) + manager.saveTokens("old-access", "old-refresh", 3600) + val staleScope = checkNotNull(manager.snapshotCurrentScope()) + + manager.clearTokens() + manager.saveTokens("new-access", "new-refresh", 3600) + + assertNull(manager.getAccessTokenForScope(staleScope)) + assertNull(manager.getRefreshTokenForScope(staleScope)) + + manager.saveTokensForScope(staleScope, "stale-access", "stale-refresh", 3600) + + assertEquals("new-access", manager.getAccessToken()) + assertEquals("new-refresh", manager.getRefreshToken()) + } + + @Test + fun startupSnapshotOfPreloadedCredentialsCannotReadOrRestoreReloggedCredentials() = runTest { + val registry = FakeServerRegistry() + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences( + AndroidServerRegistry.serverScopedKey("server-a", EncryptedTokenManagerImpl.KEY_ACCESS_TOKEN) to + "old-access", + AndroidServerRegistry.serverScopedKey("server-a", EncryptedTokenManagerImpl.KEY_REFRESH_TOKEN) to + "old-refresh", + ), + registry = registry, + ) + val staleScope = checkNotNull(manager.snapshotCurrentScope()) + + manager.clearTokens() + manager.saveTokens("new-access", "new-refresh", 3600) + + assertNull(manager.getAccessTokenForScope(staleScope)) + assertNull(manager.getRefreshTokenForScope(staleScope)) + manager.saveTokensForScope(staleScope, "stale-access", "stale-refresh", 3600) + assertEquals("new-access", manager.getAccessToken()) + assertEquals("new-refresh", manager.getRefreshToken()) + } + + @Test + fun registryFirstSwitchInvalidatesSnapshotBeforeManagerSwitchCall() = runTest { + val registry = FakeServerRegistry() + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences(), + registry = registry, + ) + manager.saveTokens("server-a-access", "server-a-refresh", 3600) + val staleScope = checkNotNull(manager.snapshotCurrentScope()) + + registry.switchExternally("server-b") + manager.getAccessToken() + manager.switchActiveServer("server-b") + + assertNull(manager.getAccessTokenForScope(staleScope)) + manager.saveTokensForScope(staleScope, "stale-access", "stale-refresh", 3600) + assertNull(manager.getAccessTokenForScope(staleScope)) + } + + 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") + private val entriesFlow = MutableStateFlow(listOf(serverA, serverB)) + private val activeServerIdFlow = MutableStateFlow(serverA.id) + private val activeEntryFlow = MutableStateFlow(serverA) + override val entries: StateFlow> = entriesFlow + override val activeServerId: StateFlow = activeServerIdFlow + override val activeEntry: StateFlow = activeEntryFlow + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = serverA.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 + + fun switchExternally(serverId: String) { + activeServerIdFlow.value = serverId + activeEntryFlow.value = entriesFlow.value.first { it.id == serverId } + } + } + + private fun inMemoryPreferences(vararg initialValues: Pair): SharedPreferences { + val values = mutableMapOf(*initialValues) + lateinit var preferences: SharedPreferences + preferences = Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, args -> + when (method.name) { + "getString" -> values[args!![0]] as? String ?: args[1] + "getLong" -> values[args!![0]] as? Long ?: args[1] + "contains" -> values.containsKey(args!![0]) + "getAll" -> values.toMap() + "edit" -> editor(values) + "registerOnSharedPreferenceChangeListener", + "unregisterOnSharedPreferenceChangeListener" -> Unit + else -> method.defaultValue() + } + } as SharedPreferences + return preferences + } + + private fun editor(values: MutableMap): SharedPreferences.Editor { + lateinit var editor: SharedPreferences.Editor + editor = Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { _, method, args -> + when (method.name) { + "putString", "putLong" -> 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 + "commit" -> true + else -> editor + } + } as SharedPreferences.Editor + return editor + } + + private fun java.lang.reflect.Method.defaultValue(): Any? = when (returnType) { + java.lang.Boolean.TYPE -> false + java.lang.Integer.TYPE -> 0 + java.lang.Long.TYPE -> 0L + java.lang.Float.TYPE -> 0f + else -> null + } +} 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 d78f6525d..37378ae10 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt @@ -12,7 +12,7 @@ import org.koin.dsl.module val networkModule = module { single { DefaultIdentityTransitionBarrier() } single { TokenManagerImpl(get()) } - single { createPrairieClient(get(), getOrNull(), getOrNull()) } + single { createPrairieClient(get(), getOrNull(), getOrNull(), getOrNull()) } single { AuthApi(get()) } single { DefaultDeviceLoginApi(get()) } single { CatalogApi(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 a4cb241af..9a74ae010 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt @@ -5,8 +5,8 @@ import org.prairieserver.prairie.domain.ManagePlaybackUseCase 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.AdminRepository import org.prairieserver.prairie.repository.LiveTvRepository +import org.prairieserver.prairie.repository.AdminRepository import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.CalendarRepository import org.prairieserver.prairie.repository.DeviceLoginRepository @@ -26,7 +26,12 @@ import org.prairieserver.prairie.repository.RequestsRepository import org.prairieserver.prairie.repository.SectionRepository 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.koin.dsl.module +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob /** * Koin module providing all repository and domain use case instances. @@ -93,19 +98,33 @@ 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 - // (query-param auth). Lazy so a socket is only minted when connect() runs. + // 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 + // query fields are a residual server contract. Lazy so a socket is only minted + // when connect() runs. single { + val tokenManager: TokenManager = get() WatchTogetherRepository( api = get(), + authScopeProvider = { tokenManager.snapshotCurrentScope() }, realtimeFactory = { org.prairieserver.prairie.network.DefaultWatchTogetherRealtimeClient( client = get(), - tokenManager = get(), + tokenManager = tokenManager, ) }, ) } + // 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. + single(createdAtStart = true) { + RoomSession( + repository = get(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + identityTransitions = get(), + ) + } // Per-session playback control socket (admin remote control). Parallel to // the watch-together realtime client — same HttpClient + query-param auth. 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 d511c0992..924ec0b79 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 @@ -294,7 +294,9 @@ data class VideoTrack( val resolution: String? = null, val width: Int? = null, val height: Int? = null, - @SerialName("frame_rate") val frameRate: Double? = null, + @SerialName("frame_rate") + @Serializable(with = NullableFrameRateSerializer::class) + val frameRate: Double? = null, val bitrate: Int? = null, val hdr: Boolean = false, @SerialName("hdr_format") val hdrFormat: String? = null, @@ -304,6 +306,7 @@ data class VideoTrack( @SerialName("color_space") val colorSpace: String? = null, @SerialName("color_primaries") val colorPrimaries: String? = null, @SerialName("color_transfer") val colorTransfer: String? = null, + @SerialName("color_range") val colorRange: String? = null, val title: String? = null, val language: String? = null ) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/FrameRateSerializer.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/FrameRateSerializer.kt new file mode 100644 index 000000000..15b654b1d --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/FrameRateSerializer.kt @@ -0,0 +1,50 @@ +package org.prairieserver.prairie.model.catalog + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull + +@OptIn(ExperimentalSerializationApi::class) +internal object NullableFrameRateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("FrameRate", PrimitiveKind.DOUBLE).nullable + + override fun deserialize(decoder: Decoder): Double? { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("Frame rate requires JSON decoding") + val element = jsonDecoder.decodeJsonElement() + if (element is JsonNull) return null + + val primitive = element as? JsonPrimitive + ?: throw SerializationException("Frame rate must be a number or rational string") + primitive.doubleOrNull?.let { return it } + + val parts = primitive.content.split('/') + if (parts.size == 2) { + val numerator = parts[0].toDoubleOrNull() + val denominator = parts[1].toDoubleOrNull() + if (numerator != null && denominator != null && denominator != 0.0) { + return numerator / denominator + } + } + throw SerializationException("Invalid frame rate: ${primitive.content}") + } + + override fun serialize(encoder: Encoder, value: Double?) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeDouble(value) + } + } +} 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 5fc61e86b..94f96978a 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 = false +const val CLIENT_WATCH_TOGETHER_SURFACE_ENABLED: Boolean = 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 e86c097fe..cd094440e 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 @@ -62,6 +62,10 @@ data class PlayerSubtitleInfo( @SerialName("catalog_source") val catalogSource: String? = null, /** Catalog default marker; playback-session artifact rows do not carry it themselves. */ @SerialName("default") val isDefault: Boolean? = null, + /** Persistent provider download identity; distinct from the mutable combined artifact [index]. */ + @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, ) /** @@ -254,8 +258,11 @@ data class PlaybackSourceMetadata( @SerialName("audio_codec") val audioCodec: String? = null, val resolution: String? = null, @SerialName("hdr_format") val hdrFormat: String? = null, + @SerialName("color_range") val colorRange: String? = null, @SerialName("dolby_vision_profile") val dolbyVisionProfile: Int? = null, @SerialName("subtitle_codec") val subtitleCodec: String? = null, + @SerialName("letterbox_top_fraction") val letterboxTopFraction: Double = 0.0, + @SerialName("letterbox_bottom_fraction") val letterboxBottomFraction: Double = 0.0, ) @Serializable 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 e079c32ff..d27f58e47 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 @@ -158,6 +158,9 @@ data class PlaybackSourceDescriptorV3( * reports the window produced so far, not the runtime. */ @SerialName("duration_seconds") val durationSeconds: Double? = null, + @SerialName("color_range") val colorRange: String? = null, + @SerialName("letterbox_top_fraction") val letterboxTopFraction: Double = 0.0, + @SerialName("letterbox_bottom_fraction") val letterboxBottomFraction: Double = 0.0, ) @Serializable 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 3382bf7c9..afb273bd5 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 @@ -86,3 +86,25 @@ fun buildPlaybackSubtitleChoices( return (catalogChoices + plannedTracks.filterNot { it.index in covered }) .distinctBy(PlayerSubtitleInfo::index) } + +private val DOWNLOADED_SUBTITLE_SESSION_PATH = + Regex("""(/stream/)([^/]+)(/subtitles/[0-9]+\.[^/]+)$""") + +/** Retargets a downloaded subtitle artifact to a replacement playback session. */ +fun rebaseDownloadedSubtitleUrl(url: String, targetSessionId: String): String { + if ( + targetSessionId.isEmpty() || + targetSessionId.any { it == '/' || it == '?' || it == '#' } + ) { + return url + } + val pathEnd = listOf(url.indexOf('?'), url.indexOf('#')) + .filter { it >= 0 } + .minOrNull() + ?: url.length + val resource = url.substring(0, pathEnd) + val suffix = url.substring(pathEnd) + val match = DOWNLOADED_SUBTITLE_SESSION_PATH.find(resource) ?: return url + val sessionRange = match.groups[2]?.range ?: return url + return resource.replaceRange(sessionRange, targetSessionId) + suffix +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMerge.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMerge.kt index 4a57f8757..dce7b794c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMerge.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMerge.kt @@ -54,6 +54,7 @@ fun mergeDownloadedSubtitles( source = SUBTITLE_SOURCE_DOWNLOADED, forced = null, url = "/stream/$sessionId/subtitles/$index${subtitleUrlExtension(dl.format)}", + downloadId = dl.id, ) } 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 new file mode 100644 index 000000000..2d3613cdc --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTransition.kt @@ -0,0 +1,272 @@ +package org.prairieserver.prairie.model.playback + +sealed interface SubtitleIdentity { + data object Off : SubtitleIdentity + + data class ServerSidecar( + val serverIndex: Int, + val media: SubtitleMediaIdentity? = null, + ) : SubtitleIdentity + + data class ServerBurnIn( + val serverIndex: Int, + val media: SubtitleMediaIdentity? = null, + ) : SubtitleIdentity + + data class Embedded( + val serverIndex: Int, + val media: SubtitleMediaIdentity, + ) : SubtitleIdentity + + data class Downloaded( + val downloadId: Int, + val media: SubtitleMediaIdentity, + ) : SubtitleIdentity + + data class LocalMedia3(val media: SubtitleMediaIdentity) : SubtitleIdentity +} + +data class SubtitleMediaIdentity( + val trackId: String? = null, + val label: String? = null, + val language: String? = null, + val codecFamily: String? = null, + val forced: Boolean? = null, + val hearingImpaired: Boolean? = null, +) + +data class CommittedSubtitle( + val identity: SubtitleIdentity, + val audioTrackIndex: Int? = null, + val qualityPreference: String? = null, +) + +data class PendingSubtitle( + val identity: SubtitleIdentity, + val generation: Long, + val audioTrackIndex: Int?, + val qualityPreference: String?, + val audioPreferenceSpecified: Boolean = false, + val qualityPreferenceSpecified: Boolean = false, +) + +data class SubtitleTransitionState( + val committed: CommittedSubtitle, + val pending: PendingSubtitle?, + val nextGeneration: Long, +) { + companion object { + fun committed( + identity: SubtitleIdentity, + audioTrackIndex: Int? = null, + qualityPreference: String? = null, + ): SubtitleTransitionState = SubtitleTransitionState( + committed = CommittedSubtitle( + identity = identity, + audioTrackIndex = audioTrackIndex, + qualityPreference = qualityPreference, + ), + pending = null, + nextGeneration = 1L, + ) + } +} + +data class StagedSubtitleCandidate(val id: String) + +sealed interface SubtitleTransitionEvent + +data class SelectSubtitle(val identity: SubtitleIdentity) : SubtitleTransitionEvent + +data class UpdateAudioPreference(val audioTrackIndex: Int?) : SubtitleTransitionEvent + +data class UpdateQualityPreference(val qualityPreference: String?) : SubtitleTransitionEvent + +data class StagedSubtitleValidated( + val generation: Long, + val candidate: StagedSubtitleCandidate, +) : SubtitleTransitionEvent + +data class StagedSubtitleFailed( + val generation: Long, + val message: String, +) : SubtitleTransitionEvent + +data class SubtitleContentReset( + val committedIdentity: SubtitleIdentity = SubtitleIdentity.Off, +) : SubtitleTransitionEvent + +sealed interface SubtitleTransitionEffect + +data class StageSubtitleReplan( + val pending: PendingSubtitle, +) : SubtitleTransitionEffect + +data class CommitStagedSubtitleReplan( + val generation: Long, + val candidate: StagedSubtitleCandidate, + val committed: CommittedSubtitle, +) : SubtitleTransitionEffect + +data class DiscardStagedSubtitleReplan( + val candidate: StagedSubtitleCandidate, +) : SubtitleTransitionEffect + +data class ApplyLocalSubtitleSelection( + val committed: CommittedSubtitle, +) : SubtitleTransitionEffect + +data class PersistSubtitleSelection( + val committed: CommittedSubtitle, +) : SubtitleTransitionEffect + +data class ReportSubtitleTransitionFailure( + val message: String, +) : SubtitleTransitionEffect + +data class SubtitleTransitionResult( + val state: SubtitleTransitionState, + val effects: List, +) + +fun reduceSubtitleTransition( + state: SubtitleTransitionState, + event: SubtitleTransitionEvent, +): SubtitleTransitionResult = when (event) { + is SelectSubtitle -> state.select(event.identity) + is UpdateAudioPreference -> state.stageLatest( + identity = state.pending?.identity ?: state.committed.identity, + audioTrackIndex = event.audioTrackIndex, + qualityPreference = state.effectiveQualityPreference(), + audioPreferenceSpecified = true, + qualityPreferenceSpecified = state.pending?.qualityPreferenceSpecified ?: false, + ) + is UpdateQualityPreference -> state.stageLatest( + identity = state.pending?.identity ?: state.committed.identity, + audioTrackIndex = state.effectiveAudioTrackIndex(), + qualityPreference = event.qualityPreference, + audioPreferenceSpecified = state.pending?.audioPreferenceSpecified ?: false, + qualityPreferenceSpecified = true, + ) + is StagedSubtitleValidated -> state.validate(event) + is StagedSubtitleFailed -> state.fail(event) + is SubtitleContentReset -> SubtitleTransitionResult( + state = SubtitleTransitionState.committed(event.committedIdentity).copy( + nextGeneration = state.nextGeneration + 1, + ), + effects = emptyList(), + ) +} + +private fun SubtitleTransitionState.select(identity: SubtitleIdentity): SubtitleTransitionResult { + val audioTrackIndex = effectiveAudioTrackIndex() + val qualityPreference = effectiveQualityPreference() + if (identity.requiresClientMount() && pending.hasServerPreferenceMutation()) { + return stageLatest( + identity = identity, + audioTrackIndex = audioTrackIndex, + qualityPreference = qualityPreference, + audioPreferenceSpecified = pending?.audioPreferenceSpecified == true, + qualityPreferenceSpecified = pending?.qualityPreferenceSpecified == true, + ) + } + if (identity.requiresClientMount()) { + val updated = CommittedSubtitle(identity, audioTrackIndex, qualityPreference) + return SubtitleTransitionResult( + state = copy( + committed = updated, + pending = null, + nextGeneration = nextGeneration + 1, + ), + effects = listOf( + ApplyLocalSubtitleSelection(updated), + PersistSubtitleSelection(updated), + ), + ) + } + return stageLatest( + identity = identity, + audioTrackIndex = audioTrackIndex, + qualityPreference = qualityPreference, + audioPreferenceSpecified = pending?.audioPreferenceSpecified ?: false, + qualityPreferenceSpecified = pending?.qualityPreferenceSpecified ?: false, + ) +} + +private fun SubtitleIdentity.requiresClientMount(): Boolean = + this is SubtitleIdentity.LocalMedia3 || this is SubtitleIdentity.Downloaded + +private fun PendingSubtitle?.hasServerPreferenceMutation(): Boolean = + this?.audioPreferenceSpecified == true || this?.qualityPreferenceSpecified == true + +private fun SubtitleTransitionState.stageLatest( + identity: SubtitleIdentity, + audioTrackIndex: Int?, + qualityPreference: String?, + audioPreferenceSpecified: Boolean, + qualityPreferenceSpecified: Boolean, +): SubtitleTransitionResult { + val latest = PendingSubtitle( + identity = identity, + generation = nextGeneration, + audioTrackIndex = audioTrackIndex, + qualityPreference = qualityPreference, + audioPreferenceSpecified = audioPreferenceSpecified, + qualityPreferenceSpecified = qualityPreferenceSpecified, + ) + return SubtitleTransitionResult( + state = copy( + pending = latest, + nextGeneration = nextGeneration + 1, + ), + effects = listOf(StageSubtitleReplan(latest)), + ) +} + +private fun SubtitleTransitionState.effectiveAudioTrackIndex(): Int? = + if (pending != null) pending.audioTrackIndex else committed.audioTrackIndex + +private fun SubtitleTransitionState.effectiveQualityPreference(): String? = + if (pending != null) pending.qualityPreference else committed.qualityPreference + +private fun SubtitleTransitionState.validate( + event: StagedSubtitleValidated, +): SubtitleTransitionResult { + val latest = pending + if (latest == null || latest.generation != event.generation) { + return SubtitleTransitionResult( + state = this, + effects = listOf(DiscardStagedSubtitleReplan(event.candidate)), + ) + } + + val updated = CommittedSubtitle( + identity = latest.identity, + audioTrackIndex = latest.audioTrackIndex, + qualityPreference = latest.qualityPreference, + ) + return SubtitleTransitionResult( + state = copy(committed = updated, pending = null), + effects = listOf( + CommitStagedSubtitleReplan( + generation = latest.generation, + candidate = event.candidate, + committed = updated, + ), + PersistSubtitleSelection(updated), + ), + ) +} + +private fun SubtitleTransitionState.fail( + event: StagedSubtitleFailed, +): SubtitleTransitionResult { + val latest = pending + if (latest == null || latest.generation != event.generation) { + return SubtitleTransitionResult(this, emptyList()) + } + return SubtitleTransitionResult( + state = copy(pending = null), + effects = listOf(ReportSubtitleTransitionFailure(event.message)), + ) +} 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 2c5f4e984..e255adc65 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 @@ -11,6 +11,14 @@ object PlaybackSettingsKeys { 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" 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 3060a5242..ef01bdd07 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 @@ -51,9 +51,9 @@ data class SubtitleAppearance( val fontFamily: String = SANS_SERIF, val fontColor: String = "#ffffff", val backgroundColor: String = "#000000", - val backgroundStyle: SubtitleBackgroundStylePreset = SubtitleBackgroundStylePreset.None, + val backgroundStyle: SubtitleBackgroundStylePreset = SubtitleBackgroundStylePreset.Shadow, val backgroundOpacity: Int = 75, - val textOutline: Boolean = true, + val textOutline: Boolean = false, val textOutlineColor: String = "#000000", val position: SubtitlePositionPreset = SubtitlePositionPreset.Bottom, ) { 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 f59c3a725..1f4f41c37 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt @@ -6,6 +6,8 @@ import io.ktor.client.plugins.api.* import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.http.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.prairieserver.prairie.model.auth.RefreshRequest @@ -22,6 +24,7 @@ class PrairieAuthConfig { var tokenManager: TokenManager? = null var deviceMetadataProvider: DeviceMetadataProvider? = null var diagnosticsObserver: NetworkDiagnosticsObserver? = null + var cleartextOriginConsent: CleartextOriginConsent? = null } /** @@ -44,35 +47,76 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon ?: error("TokenManager must be provided to PrairieAuthPlugin") val deviceMetadataProvider = pluginConfig.deviceMetadataProvider val diagnosticsObserver = pluginConfig.diagnosticsObserver + val cleartextOriginConsent = pluginConfig.cleartextOriginConsent val refreshMutex = Mutex() + // Temporary credential generations (remote-playback overlays) whose refresh + // the server has definitively rejected. Such an overlay is deliberately left + // INSTALLED — clearing it would make every later token read fall through to + // the saved owner's account, so the guest session would silently continue as + // the owner. Flagging the generation here is what stops the plugin from + // refreshing dead credentials again on every subsequent 401. + val deadCredentialGenerations = MutableStateFlow>(emptySet()) + onRequest { request, _ -> val skipAuth = request.attributes.getOrNull(SkipPrairieAuthAttributeKey) == true + val requireAuth = request.attributes.getOrNull(RequirePrairieAuthAttributeKey) == true val diagnosticsScope = request.attributes.getOrNull(DiagnosticsRequestScopeKey) + val pinned = request.attributes.getOrNull(AuthScopeAttributeKey) + val activeServerIdBefore = if (pinned == null) tokenManager.getCurrentServerId() else null + val trustedServerUrl = 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. + if ( + request.url.encodedPath.startsWith("/api/") && + (request.url.host.isBlank() || request.url.host == "localhost") && + trustedServerUrl.isNotBlank() + ) { + request.url.rebaseRelativeApiUrl(trustedServerUrl) + } + + if ( + // skipPrairieAuth is also used by login/refresh/device-login POSTs: + // those requests omit headers but still carry credentials in the + // body. Only read-only candidate probes may bypass consent. + (!skipAuth || request.method != HttpMethod.Get) && + cleartextOriginConsent?.requiresApproval(request.url.toString()) == true + ) { + request.removePrairieCredentialHeaders() + throw CleartextOriginNotApprovedException(request.url.toString()) + } + + val sameOrigin = isSamePrairieHttpOrigin(trustedServerUrl, request.url) + if (skipAuth) { + request.removePrairieCredentialHeaders() + if (sameOrigin) { + request.attachPrairieDeviceMetadataHeaders(deviceMetadataProvider) + } + return@onRequest + } + + if (!sameOrigin) { + request.removePrairieCredentialHeaders() + return@onRequest + } // Pinned (Track B outbox replay): bind this request to a captured scope // regardless of the globally-active server/profile, so a mid-drain switch // can't send it to the wrong account. Uses the snapshot's URL/profile and // the *live* per-server access token (handles rotation). - val pinned = request.attributes.getOrNull(AuthScopeAttributeKey) - if (pinned != null && !skipAuth) { - if (request.url.encodedPath.startsWith("/api/") && pinned.serverUrl.isNotBlank()) { - val originalPath = request.url.encodedPath - val originalParameters = request.url.parameters.build() - val originalFragment = request.url.fragment - val originalProtocol = request.url.protocol - request.url.takeFrom(pinned.serverUrl) - request.url.restoreWebSocketProtocol(originalProtocol) - request.url.encodedPath = originalPath - request.url.parameters.clear() - request.url.parameters.appendAll(originalParameters) - request.url.fragment = originalFragment - } + if (pinned != null) { // Replace (never append) the scoped headers; clear the profile token // header when the snapshot has none. request.headers.remove(HttpHeaders.Authorization) - tokenManager.getAccessTokenForScope(pinned)?.let { token -> + val scopedAccessToken = tokenManager.getAccessTokenForScope(pinned) + if (requireAuth && scopedAccessToken.isNullOrBlank()) { + request.removePrairieCredentialHeaders() + throw IllegalStateException("required_prairie_auth_unavailable") + } + scopedAccessToken?.let { token -> request.header(HttpHeaders.Authorization, "Bearer $token") } request.headers.remove("X-Profile-Id") @@ -86,65 +130,83 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon return@onRequest } - // Shared API calls use relative paths; bind them to the configured server URL - // before the request is sent so Ktor doesn't fall back to localhost on iOS. - if (request.url.encodedPath.startsWith("/api/") && request.url.host == "localhost") { - val serverUrl = tokenManager.getServerUrl() - if (serverUrl.isNotBlank()) { - val originalPath = request.url.encodedPath - val originalParameters = request.url.parameters.build() - val originalFragment = request.url.fragment - val originalProtocol = request.url.protocol - - request.url.takeFrom(serverUrl) - request.url.restoreWebSocketProtocol(originalProtocol) - request.url.encodedPath = originalPath - request.url.parameters.clear() - request.url.parameters.appendAll(originalParameters) - request.url.fragment = originalFragment - } - } - - if (skipAuth) { - request.headers.remove(HttpHeaders.Authorization) - request.headers.remove("X-Profile-Id") - request.headers.remove("X-Profile-Token") - request.attachPrairieDeviceMetadataHeaders(deviceMetadataProvider) - return@onRequest - } - // Skip auth headers for the refresh endpoint itself to avoid recursion val isRefreshRequest = request.url.encodedPath.endsWith("/auth/refresh") if (isRefreshRequest) return@onRequest - tokenManager.getAccessToken()?.let { token -> + val accessToken = tokenManager.getAccessToken() + val profileId = tokenManager.getProfileId() + val profileToken = tokenManager.getProfileToken() + val activeServerIdAfter = tokenManager.getCurrentServerId() + val activeServerUrlAfter = tokenManager.getServerUrl() + if ( + activeServerIdBefore != activeServerIdAfter || + !isSameHttpOrigin(trustedServerUrl, activeServerUrlAfter) + ) { + request.removePrairieCredentialHeaders() + return@onRequest + } + + accessToken?.let { token -> request.header(HttpHeaders.Authorization, "Bearer $token") } request.applyProfileHeaders( diagnosticsScope = diagnosticsScope, - activeProfileId = tokenManager.getProfileId(), - activeProfileToken = tokenManager.getProfileToken(), + activeProfileId = profileId, + activeProfileToken = profileToken, ) request.attachPrairieDeviceMetadataHeaders(deviceMetadataProvider) } on(Send) { request -> - if (request.attributes.getOrNull(SkipPrairieAuthAttributeKey) == true) { - 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. val pinnedScope = request.attributes.getOrNull(AuthScopeAttributeKey) + val normalScope = + if (pinnedScope == null) tokenManager.snapshotCurrentScope() else null + val activeServerIdBeforeUrl = + if (pinnedScope == null) normalScope?.serverId ?: tokenManager.getCurrentServerId() else null + val trustedServerUrl = + pinnedScope?.serverUrl ?: normalScope?.serverUrl ?: tokenManager.getServerUrl() + val activeServerIdBeforeRequest = + if (pinnedScope == null) tokenManager.getCurrentServerId() else null + if ( + pinnedScope == null && + activeServerIdBeforeUrl != activeServerIdBeforeRequest + ) { + request.removePrairieCredentialHeaders() + return@on proceed(request) + } + if (request.attributes.getOrNull(SkipPrairieAuthAttributeKey) == true) { + if (!isSamePrairieHttpOrigin(trustedServerUrl, request.url)) { + request.removePrairieCredentialHeaders() + } + return@on proceed(request) + } + if (!isSamePrairieHttpOrigin(trustedServerUrl, request.url)) { + request.removePrairieCredentialHeaders() + return@on proceed(request) + } if (pinnedScope != null) { val sentAuth = request.headers[HttpHeaders.Authorization] val originalCall = proceed(request) if (originalCall.response.status != HttpStatusCode.Unauthorized) { return@on originalCall } + val pinnedGeneration = pinnedScope.credentialGenerationId + if (pinnedGeneration != null && pinnedGeneration in deadCredentialGenerations.value) { + // Already-rejected temporary credentials: surface the 401 instead of + // 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 + // then would hand this scope's credentials to whatever answered. + if (!isSamePrairieHttpOrigin(pinnedScope.serverUrl, originalCall.request.url)) { + return@on originalCall + } diagnosticsObserver.safeAuthRefresh("required") val refreshed = refreshMutex.withLock { // Another path may have refreshed this scope while we waited. @@ -180,6 +242,11 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon after != null && after != sentAuth } else { diagnosticsObserver.safeAuthRefresh("failed") + if (pinnedGeneration != null && + refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure() + ) { + deadCredentialGenerations.update { it + pinnedGeneration } + } // Don't invalidate the active session for a background scope. // Re-check in case a concurrent path refreshed it in flight. val after = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } @@ -201,11 +268,19 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon } } - // Capture the access token we will actually SEND with this request. - // If the response comes back 401, we compare against this snapshot + val refreshScope = normalScope ?: AuthScopeSnapshot( + serverId = activeServerIdBeforeRequest.orEmpty(), + profileId = null, + serverUrl = trustedServerUrl, + profileToken = null, + ) + + // Capture the authorization value we will actually SEND, together with + // the server identity and origin that own it. If the response comes back + // 401, we compare against this snapshot // inside the refresh mutex to detect a concurrent refresh that // already happened — so N parallel 401s collapse into ONE refresh. - val tokenBeforeRequest = tokenManager.getAccessToken() + val authorizationBeforeRequest = request.headers[HttpHeaders.Authorization] val originalCall = proceed(request) @@ -213,6 +288,15 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon if (originalCall.response.status != HttpStatusCode.Unauthorized) { return@on originalCall } + if (!isSamePrairieHttpOrigin(trustedServerUrl, originalCall.request.url)) { + return@on originalCall + } + if ( + tokenManager.getCurrentServerId() != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, tokenManager.getServerUrl()) + ) { + return@on originalCall + } diagnosticsObserver.safeAuthRefresh("required") val requestPath = originalCall.request.url.encodedPath @@ -220,6 +304,18 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon return@on originalCall } + // Identity of the temporary overlay (remote playback) this request ran + // under, if any. Null means the request ran on a saved account. + val temporaryGeneration = tokenManager.temporaryGenerationId() + if (temporaryGeneration != null && + temporaryGeneration in deadCredentialGenerations.value + ) { + // The server already rejected these credentials. Refreshing again would + // storm it once per request for the rest of the handoff; the overlay stays + // installed so the guest cannot fall back onto the owner's account. + return@on originalCall + } + // Capture the server id as well so we can detect a mid-refresh server // switch — without this, a 401-refresh kicked off against server A // could land after the user has switched to server B and write A's @@ -237,30 +333,50 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon // "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() - if (serverIdNow != serverIdBeforeRequest) { + val serverUrlNow = tokenManager.getServerUrl() + if ( + serverIdNow != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, serverUrlNow) + ) { return@withLock false } - val tokenNow = tokenManager.getAccessToken() - if (tokenNow != null && tokenNow != tokenBeforeRequest) { + 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 } - val refreshToken = tokenManager.getRefreshToken() + 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") - val serverUrl = tokenManager.getServerUrl() - if (serverUrl.isBlank()) { + if (trustedServerUrl.isBlank()) { return@withLock false } - val refreshResponse = client.post("$serverUrl/api/v1/auth/refresh") { + val refreshResponse = client.post("$trustedServerUrl/api/v1/auth/refresh") { contentType(ContentType.Application.Json) setBody(RefreshRequest(refreshToken)) } @@ -271,7 +387,11 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon // save time, so a mismatch here means we'd write to the wrong // slot. val serverIdAfterCall = tokenManager.getCurrentServerId() - if (serverIdAfterCall != serverIdBeforeRequest) { + val serverUrlAfterCall = tokenManager.getServerUrl() + if ( + serverIdAfterCall != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, serverUrlAfterCall) + ) { return@withLock false } @@ -281,32 +401,53 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon // 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.getRefreshToken().isNullOrBlank()) { + if (tokenManager.getRefreshTokenForScope(refreshScope).isNullOrBlank()) { return@withLock false } if (refreshResponse.status.isSuccess()) { diagnosticsObserver.safeAuthRefresh("succeeded") val tokens = refreshResponse.body() - tokenManager.saveTokens( + tokenManager.saveTokensForScope( + scope = refreshScope, accessToken = tokens.accessToken, refreshToken = tokens.refreshToken, - expiresIn = tokens.expiresIn + expiresIn = tokens.expiresIn, ) - true + 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()) { - // 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. - tokenManager.invalidateSession() + 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 } @@ -323,11 +464,23 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon // NOT run a second time, so if we don't update the header here the // retry gets sent with the expired Bearer token and the server // returns another 401. - val newAccessToken = tokenManager.getAccessToken() - if (newAccessToken != null) { - request.headers.remove(HttpHeaders.Authorization) - request.header(HttpHeaders.Authorization, "Bearer $newAccessToken") + val retryServerIdBeforeToken = tokenManager.getCurrentServerId() + val retryServerUrlBeforeToken = tokenManager.getServerUrl() + val newAccessToken = tokenManager.getAccessTokenForScope(refreshScope) + val retryServerIdAfterToken = tokenManager.getCurrentServerId() + val retryServerUrlAfterToken = tokenManager.getServerUrl() + if ( + retryServerIdBeforeToken != activeServerIdBeforeRequest || + retryServerIdAfterToken != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, retryServerUrlBeforeToken) || + !isSameHttpOrigin(trustedServerUrl, retryServerUrlAfterToken) || + !isSamePrairieHttpOrigin(trustedServerUrl, request.url) || + newAccessToken == null + ) { + return@on originalCall } + request.headers.remove(HttpHeaders.Authorization) + request.header(HttpHeaders.Authorization, "Bearer $newAccessToken") proceed(request) } else { originalCall @@ -362,6 +515,14 @@ private fun HttpRequestBuilder.applyProfileHeaders( } } +/** + * Generation id of the temporary credential overlay currently installed (remote + * playback), or null when the active identity is a saved account. Managers that + * don't model overlays report null, which keeps the saved-account behaviour. + */ +private suspend fun TokenManager.temporaryGenerationId(): String? = + snapshotCurrentScope()?.credentialGenerationId + private fun HttpStatusCode.shouldInvalidateSessionAfterRefreshFailure(): Boolean = this == HttpStatusCode.BadRequest || this == HttpStatusCode.Unauthorized || @@ -383,6 +544,50 @@ private suspend fun HttpRequestBuilder.attachPrairieDeviceMetadataHeaders( device.clientVersion?.takeIf { it.isNotBlank() }?.let { header("X-Prairie-Client-Version", it) } } +private fun URLBuilder.rebaseRelativeApiUrl(serverUrl: String) { + val originalPath = encodedPath + val originalParameters = parameters.build() + val originalFragment = fragment + val originalProtocol = protocol + + takeFrom(serverUrl) + restoreWebSocketProtocol(originalProtocol) + encodedPath = originalPath + parameters.clear() + parameters.appendAll(originalParameters) + fragment = originalFragment +} + +private fun HttpRequestBuilder.removePrairieCredentialHeaders() { + headers.remove(HttpHeaders.Authorization) + headers.remove("X-Profile-Id") + headers.remove("X-Profile-Token") + headers.names() + .filter { name -> name.startsWith("X-Prairie-", ignoreCase = true) } + .forEach(headers::remove) +} + +private fun isSamePrairieHttpOrigin(serverUrl: String, requestUrl: URLBuilder): Boolean { + val httpRequestUrl = when (requestUrl.protocol) { + URLProtocol.WS -> requestUrl.toString().replaceSchemeForOriginCheck("http") + URLProtocol.WSS -> requestUrl.toString().replaceSchemeForOriginCheck("https") + else -> requestUrl.toString() + } + return isSameHttpOrigin(serverUrl, httpRequestUrl) +} + +private fun isSamePrairieHttpOrigin(serverUrl: String, requestUrl: Url): Boolean { + val httpRequestUrl = when (requestUrl.protocol) { + URLProtocol.WS -> requestUrl.toString().replaceSchemeForOriginCheck("http") + URLProtocol.WSS -> requestUrl.toString().replaceSchemeForOriginCheck("https") + else -> requestUrl.toString() + } + return isSameHttpOrigin(serverUrl, httpRequestUrl) +} + +private fun String.replaceSchemeForOriginCheck(scheme: String): String = + "$scheme://${substringAfter("://")}" + /** * Re-applies the websocket protocol after a `takeFrom(serverUrl)` rebase. * 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 d3cd2160a..457f892c0 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthScopeSnapshot.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthScopeSnapshot.kt @@ -20,6 +20,11 @@ import io.ktor.util.AttributeKey * refresh tokens are read live from the captured credential slot at send time * because they rotate on refresh; freezing their values would break the second * op in a drain after the first triggers a token rotation. + * + * [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. */ data class AuthScopeSnapshot( val serverId: String, @@ -27,7 +32,30 @@ data class AuthScopeSnapshot( val serverUrl: String, val profileToken: String?, val credentialGenerationId: String? = null, -) + val identityGeneration: Long = 0L, + /** + * 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 + * remote-playback overlay beginning or ending. + * + * Persistent scopes are keyed by serverId alone, so without this a snapshot + * captured before a sign-out would still read — and overwrite — whatever the + * next login stored for that same server. [identityGeneration] cannot serve: + * it moves for overlays too, and an overlay must leave a pinned persistent + * scope working. + * + * `0L` means "not stamped from a live snapshot"; hand-built scopes keep the + * pre-existing behaviour rather than failing closed on a value they never + * recorded. + */ + val credentialEpoch: Long = 0L, +) { + override fun toString(): String = + "AuthScopeSnapshot(" + + "serverId=, profileId=, serverUrl=, " + + "profileToken=, credentialGenerationId=, " + + "identityGeneration=, credentialEpoch=)" +} /** Attribute carrying the [AuthScopeSnapshot] that [PrairieAuthPlugin] honors. */ val AuthScopeAttributeKey: AttributeKey = AttributeKey("PrairieAuthScope") @@ -41,11 +69,24 @@ val AuthScopeAttributeKey: AttributeKey = AttributeKey("Prair */ val SkipPrairieAuthAttributeKey: AttributeKey = AttributeKey("PrairieSkipAuth") +/** + * Marks a same-origin request as unsafe to send without a Prairie bearer. + * + * This is intentionally opt-in: public endpoints and [skipPrairieAuth] requests + * retain their existing behavior. + */ +internal val RequirePrairieAuthAttributeKey: AttributeKey = AttributeKey("PrairieRequireAuth") + /** Pin this request to [snapshot]'s scope; [PrairieAuthPlugin] uses it verbatim. */ fun HttpRequestBuilder.authScope(snapshot: AuthScopeSnapshot) { attributes.put(AuthScopeAttributeKey, snapshot) } +/** Abort before the engine if the request's exact auth scope has no access token. */ +internal fun HttpRequestBuilder.requirePrairieAuth() { + attributes.put(RequirePrairieAuthAttributeKey, true) +} + /** Opt this request out of Prairie auth/profile headers and auth-refresh handling. */ fun HttpRequestBuilder.skipPrairieAuth() { attributes.put(SkipPrairieAuthAttributeKey, true) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/CleartextOriginConsent.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/CleartextOriginConsent.kt new file mode 100644 index 000000000..3a4f29738 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/CleartextOriginConsent.kt @@ -0,0 +1,33 @@ +package org.prairieserver.prairie.network + +/** + * Exact-origin approval boundary for credential-bearing cleartext traffic. + * + * Implementations persist only a digest of the canonical origin. HTTPS never + * requires an approval entry. + */ +interface CleartextOriginConsent { + suspend fun isApproved(origin: String): Boolean +} + +class CleartextOriginNotApprovedException(origin: String) : + IllegalStateException("Cleartext HTTP origin has not been approved: ${canonicalHttpOrigin(origin) ?: ""}") + +suspend fun CleartextOriginConsent.requiresApproval(url: String): Boolean { + val origin = httpOrigin(url) + return when { + origin?.scheme == "https" -> false + origin?.scheme == "http" -> !isApproved(checkNotNull(canonicalHttpOrigin(url))) + url.trim().startsWith("http:", ignoreCase = true) || + url.trim().startsWith("ws:", ignoreCase = true) -> true + else -> false + } +} + +fun canonicalHttpOrigin(raw: String): String? { + val origin = httpOrigin(raw) ?: return null + val host = if (':' in origin.host) "[${origin.host.trim('[', ']')}]" else origin.host + val defaultPort = if (origin.scheme == "https") 443 else 80 + val port = if (origin.port == defaultPort) "" else ":${origin.port}" + return "${origin.scheme}://$host$port" +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/HttpOriginPolicy.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/HttpOriginPolicy.kt new file mode 100644 index 000000000..22ee8c441 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/HttpOriginPolicy.kt @@ -0,0 +1,153 @@ +package org.prairieserver.prairie.network + +import io.ktor.http.Url + +data class HttpOrigin( + val scheme: String, + val host: String, + val port: Int, +) + +fun httpOrigin(raw: String): HttpOrigin? = runCatching { + val normalized = raw.normalizedHttpOriginUrl() + val scheme = normalized.httpSchemeOrNull() ?: return null + val authorityHost = normalized.authorityHostOrNull(scheme) ?: return null + + val url = Url(normalized) + if (url.user != null || url.password != null) return null + if (url.protocol.name.lowercase() != scheme) return null + + val parsedHost = url.host + if (!parsedHost.isValidHttpHost()) return null + if (!authorityHost.equals(parsedHost, ignoreCase = true)) return null + + HttpOrigin( + scheme = scheme, + host = parsedHost.lowercase(), + port = url.port, + ) +}.getOrNull() + +private fun String.normalizedHttpOriginUrl(): String = when { + startsWith("wss://", ignoreCase = true) -> "https://${drop(6)}" + startsWith("ws://", ignoreCase = true) -> "http://${drop(5)}" + else -> this +} + +fun isSameHttpOrigin(serverUrl: String, requestUrl: String): Boolean { + val serverOrigin = httpOrigin(serverUrl) ?: return false + val requestOrigin = httpOrigin(requestUrl) ?: return false + return serverOrigin == requestOrigin +} + +private fun String.httpSchemeOrNull(): String? = when { + startsWith("https://", ignoreCase = true) -> "https" + startsWith("http://", ignoreCase = true) -> "http" + else -> null +} + +private fun String.authorityHostOrNull(scheme: String): String? { + val prefix = "$scheme://" + val authority = drop(prefix.length).takeWhile { it != '/' && it != '?' && it != '#' } + if (authority.isBlank() || authority.any(Char::isUnsafeAuthorityCharacter)) return null + if ('@' in authority) return null + + return if (authority.startsWith('[')) { + authority.bracketedIpv6HostOrNull() + } else { + authority.unbracketedHostOrNull() + } +} + +private fun String.bracketedIpv6HostOrNull(): String? { + val closingBracket = indexOf(']') + if (closingBracket <= 1) return null + if (indexOf('[', startIndex = 1) >= 0) return null + if (indexOf(']', startIndex = closingBracket + 1) >= 0) return null + + val literal = substring(1, closingBracket) + if (!literal.isValidIpv6Literal()) return null + + val suffix = substring(closingBracket + 1) + if (suffix.isNotEmpty()) { + if (!suffix.startsWith(':')) return null + val port = suffix.drop(1) + if (port.isEmpty() || !port.all(Char::isAsciiDigit)) return null + } + return substring(0, closingBracket + 1) +} + +private fun String.unbracketedHostOrNull(): String? { + if ('[' in this || ']' in this) return null + + val firstColon = indexOf(':') + val lastColon = lastIndexOf(':') + if (firstColon != lastColon) return null + + val host = if (lastColon >= 0) substring(0, lastColon) else this + if (!host.isValidRawHttpHost()) return null + + if (lastColon >= 0) { + val port = substring(lastColon + 1) + if (port.isEmpty() || !port.all(Char::isAsciiDigit)) return null + } + return host +} + +private fun String.isValidRawHttpHost(): Boolean = + isNotBlank() && + !startsWith('.') && + !contains("..") && + all { character -> + character.isLetterOrDigit() || + character == '.' || + character == '-' || + character == '_' + } + +private fun String.isValidIpv6Literal(): Boolean { + if (isEmpty() || any { !it.isAsciiHexDigit() && it != ':' }) return false + + val compressionIndex = indexOf("::") + if (compressionIndex != lastIndexOf("::")) return false + + if (compressionIndex < 0) { + val groups = split(':') + return groups.size == IPV6_GROUP_COUNT && groups.all(String::isValidIpv6Group) + } + + val leftGroups = substring(0, compressionIndex).ipv6GroupsOrNull() ?: return false + val rightGroups = substring(compressionIndex + 2).ipv6GroupsOrNull() ?: return false + return leftGroups.size + rightGroups.size < IPV6_GROUP_COUNT +} + +private fun String.ipv6GroupsOrNull(): List? { + if (isEmpty()) return emptyList() + return split(':').takeIf { groups -> groups.all(String::isValidIpv6Group) } +} + +private fun String.isValidIpv6Group(): Boolean = + length in 1..IPV6_GROUP_HEX_LENGTH && all(Char::isAsciiHexDigit) + +private fun Char.isUnsafeAuthorityCharacter(): Boolean = + isWhitespace() || + code <= 0x1f || + code == 0x7f || + this == '\\' + +private fun Char.isAsciiDigit(): Boolean = this in '0'..'9' + +private fun Char.isAsciiHexDigit(): Boolean = + isAsciiDigit() || this in 'a'..'f' || this in 'A'..'F' + +private fun String.isValidHttpHost(): Boolean = + isNotBlank() && + none { character -> + character.isWhitespace() || + character.code <= 0x1f || + character.code == 0x7f || + character in "/?#@\\" + } + +private const val IPV6_GROUP_COUNT = 8 +private const val IPV6_GROUP_HEX_LENGTH = 4 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 a2dfd5b3e..2e689ece0 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrier.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrier.kt @@ -32,8 +32,8 @@ interface IdentityTransitionBarrier { val transitions: SharedFlow val generation: StateFlow - /** Installs the synchronous privacy gate invoked before any identity mutation. */ - fun installGate(listener: (IdentityTransition) -> Unit) + /** 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 } @@ -48,7 +48,7 @@ class DefaultIdentityTransitionBarrier : IdentityTransitionBarrier { private val _generation = MutableStateFlow(0L) @Volatile - private var gate: ((IdentityTransition) -> Unit)? = null + private var gates: List Unit> = emptyList() @Volatile private var testObserver: ((IdentityTransition) -> Unit)? = null @@ -56,8 +56,11 @@ class DefaultIdentityTransitionBarrier : IdentityTransitionBarrier { override val transitions: SharedFlow = _transitions.asSharedFlow() override val generation: StateFlow = _generation.asStateFlow() - override fun installGate(listener: (IdentityTransition) -> Unit) { - gate = listener + override fun installGate(listener: suspend (IdentityTransition) -> Unit) { + // Gates are installed during sequential application startup. Publish an + // immutable snapshot so mutations can iterate safely without holding a + // lock across suspending privacy cleanup. + gates = gates + listener } override suspend fun changing(kind: IdentityTransitionKind, block: suspend () -> T): T = @@ -69,7 +72,7 @@ class DefaultIdentityTransitionBarrier : IdentityTransitionBarrier { generation = nextGeneration, ) // This callback is the privacy boundary. It runs inline before new identity is visible. - gate?.invoke(willChange) + gates.forEach { gate -> gate(willChange) } _generation.value = nextGeneration publish(willChange) try { 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 e7d2e5a29..03abb43a8 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PlaybackRealtimeClient.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PlaybackRealtimeClient.kt @@ -52,7 +52,8 @@ fun decodePlaybackFrame(json: Json, raw: String): PlaybackRealtimeEvent? { /** * Per-session control socket. One [connect] = one connection to * `/api/v1/playback/sessions/{session_id}/control/ws`, authenticated by query - * string (token + profile, matching [DefaultWatchTogetherRealtimeClient]). The + * string (token + profile; unlike [DefaultWatchTogetherRealtimeClient], which + * keeps the access bearer in the same-origin Authorization header). The * returned flow emits [PlaybackRealtimeEvent.Opened] once the socket is live, * then decoded frames, and ends with [PlaybackRealtimeEvent.Closed]; reconnect * with backoff is the controller's job. [sendHello]/[sendAck]/[sendResult] diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClientImpl.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClientImpl.kt index 367f7e9f5..facce4e74 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClientImpl.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieHttpClientImpl.kt @@ -39,6 +39,7 @@ fun createPrairieClient( tokenManager: TokenManager, deviceMetadataProvider: DeviceMetadataProvider? = null, diagnosticsObserver: NetworkDiagnosticsObserver? = null, + cleartextOriginConsent: CleartextOriginConsent? = null, ): HttpClient { val platformClient = createPlatformHttpClient() @@ -76,6 +77,7 @@ fun createPrairieClient( this.tokenManager = tokenManager this.deviceMetadataProvider = deviceMetadataProvider this.diagnosticsObserver = diagnosticsObserver + this.cleartextOriginConsent = cleartextOriginConsent } install(HttpTimeout) { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ServerRegistry.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ServerRegistry.kt index d0b6b4d5e..863f1fa8d 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ServerRegistry.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ServerRegistry.kt @@ -23,6 +23,17 @@ interface ServerRegistry { /** Convenience: the [ServerEntry] for [activeServerId], or null. */ val activeEntry: StateFlow + /** + * True when persisted state existed but could not be read back, so + * [entries] is empty for a reason other than "the user has no servers". + * + * Callers that delete data derived from registry absence — see + * `OrphanedServerDataPurger` — must treat an empty registry as + * untrustworthy while this is set, or a single unreadable blob erases + * every download and resume position on the device. + */ + val stateLoadFailed: Boolean get() = false + /** Insert-or-update a server by url. Returns the id of the resulting entry. */ suspend fun addOrUpdate( url: String, 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 a728fe71b..4b8e6681a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManager.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManager.kt @@ -43,6 +43,22 @@ interface TokenManager { */ suspend fun invalidateSession() + /** + * Invalidates [scope] without ever clearing a different active identity. + * + * Single-scope implementations may use this default. Implementations that + * support server switching must override it with an atomic scope check and + * mutation. + * + * @return true only when the captured scope was invalidated. + */ + suspend fun invalidateSessionForScope(scope: AuthScopeSnapshot): Boolean { + val current = snapshotCurrentScope() + if (current != null && current != scope) return false + invalidateSession() + return true + } + /** * Emits [Unit] each time [invalidateSession] runs. Does NOT fire for * plain [clearTokens] calls — manual signout flows own their own nav. diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt index c828bed20..072053fb3 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt @@ -11,13 +11,21 @@ import org.prairieserver.prairie.model.watchtogether.WsStateReport import org.prairieserver.prairie.model.watchtogether.WsTransportRequest import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession -import io.ktor.client.plugins.websocket.webSocket +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.url import io.ktor.http.encodeURLParameter +import io.ktor.http.encodeURLPathPart import io.ktor.websocket.Frame +import io.ktor.websocket.close 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.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -26,87 +34,203 @@ import kotlinx.serialization.json.JsonPrimitive /** * Per-room websocket. One [connect] = one connection to - * `/api/v1/watch-together/rooms/{id}/ws`, authenticated by query string only: - * `?token=&room_token=&profile_id=&profile_token=` - * (a separate socket from `/events/ws`). Every server frame is mapped through - * the pure [decodeRoomFrame] into the returned [Flow]; the flow completes - * (emitting [RoomRealtimeEvent.Closed]) when the socket ends — reconnect with - * capped backoff is the repository's job. + * `/api/v1/watch-together/rooms/{id}/ws`. The same-origin Prairie auth plugin + * supplies the access bearer and profile headers. The server still requires + * `room_token`, `profile_id`, and (when present) `profile_token` in the query; + * these residual URL credentials remain exposed to infrastructure that logs + * request targets until the server protocol can be changed. * - * [send*] methods write client frames on an open session; the repository holds - * the session and the ping loop. Auth values are read from [TokenManager] at - * connect time. Behind an interface so the repository's tests use a fake flow - * — the only logic worth unit-testing is the pure [decodeRoomFrame]. + * [RoomRealtimeEvent.Closed] is reserved for an explicit decoded + * `room_closed` frame. Physical EOF and socket failures surface as + * [RoomRealtimeEvent.TransportTerminated], while owner cancellation is silent. */ interface WatchTogetherRealtimeClient { - /** Open the room socket. The returned flow ends with [RoomRealtimeEvent.Closed]. */ - fun connect(roomId: String, roomToken: String): Flow - - /** Client→server sends. No-op when no session is open (repository gates on connection). */ - suspend fun attachSession(sessionId: String) - suspend fun transportRequest(action: String, positionSeconds: Double?, isPaused: Boolean) - suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean) - suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean) - suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean) - suspend fun ping(clientSentAt: String) + /** Open one physical room socket and publish [RoomRealtimeEvent.Opened] once writable. */ + fun connect( + roomId: String, + roomToken: String, + authScope: AuthScopeSnapshot? = null, + ): Flow + + /** Client→server sends report whether a frame reached the current writable socket. */ + suspend fun attachSession(sessionId: String): Boolean + suspend fun transportRequest(action: String, positionSeconds: Double?, isPaused: Boolean): Boolean + suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean + suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean + suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean + suspend fun ping(clientSentAt: String): Boolean + + /** Opaque identity of the currently writable physical socket, if any. */ + suspend fun currentConnectionId(): Long? = null + + /** + * Send only on the physical socket identified by [connectionId]. + * A replacement socket must never receive an old room's delayed command. + */ + suspend fun transportRequestOnConnection( + connectionId: Long, + action: String, + positionSeconds: Double?, + isPaused: Boolean, + ): Boolean = false +} + +internal interface WatchTogetherSocketConnection { + suspend fun receiveText(): String? + suspend fun sendText(text: String) + suspend fun close() +} + +internal data class WatchTogetherSocketRequest( + val url: String, + val authScope: AuthScopeSnapshot, +) { + override fun toString(): String = + "WatchTogetherSocketRequest(url=, authScope=$authScope)" +} + +internal fun interface WatchTogetherSocketConnector { + suspend fun open(request: WatchTogetherSocketRequest): WatchTogetherSocketConnection } -class DefaultWatchTogetherRealtimeClient( +private class KtorWatchTogetherSocketConnector( private val client: HttpClient, +) : WatchTogetherSocketConnector { + override suspend fun open(request: WatchTogetherSocketRequest): WatchTogetherSocketConnection = + KtorWatchTogetherSocketConnection( + client.webSocketSession { + url(request.url) + authScope(request.authScope) + requirePrairieAuth() + }, + ) +} + +private class KtorWatchTogetherSocketConnection( + private val session: DefaultClientWebSocketSession, +) : WatchTogetherSocketConnection { + override suspend fun receiveText(): String? { + while (true) { + val result = session.incoming.receiveCatching() + result.exceptionOrNull()?.let { throw it } + val frame = result.getOrNull() ?: return null + if (frame is Frame.Text) return frame.readText() + } + } + + override suspend fun sendText(text: String) { + session.send(Frame.Text(text)) + } + + override suspend fun close() { + session.close() + } +} + +class DefaultWatchTogetherRealtimeClient private constructor( private val tokenManager: TokenManager, - private val json: Json = PrairieJson, + private val json: Json, + private val socketConnector: WatchTogetherSocketConnector, ) : WatchTogetherRealtimeClient { - // The live session for the current connect(); send* methods write to it. - // Single-connection-at-a-time (the repository owns one room). - private var session: DefaultClientWebSocketSession? = null + constructor( + client: HttpClient, + tokenManager: TokenManager, + json: Json = PrairieJson, + ) : this(tokenManager, json, KtorWatchTogetherSocketConnector(client)) + + internal constructor( + tokenManager: TokenManager, + socketConnector: WatchTogetherSocketConnector, + json: Json = PrairieJson, + ) : this(tokenManager, json, socketConnector) + + private val sessionMutex = Mutex() + private var session: WatchTogetherSocketConnection? = null + private var sessionId = 0L + private var activeSessionId: Long? = null - override fun connect(roomId: String, roomToken: String): Flow = callbackFlow { - val token = tokenManager.getAccessToken() - val profileId = tokenManager.getProfileId() - val profileToken = tokenManager.getProfileToken() - if (token.isNullOrBlank() || profileId.isNullOrBlank()) { - trySend(RoomRealtimeEvent.Closed("missing_auth")) + override fun connect( + roomId: String, + roomToken: String, + authScope: AuthScopeSnapshot?, + ): Flow = callbackFlow { + val scope = authScope ?: tokenManager.snapshotCurrentScope() + val profileId = scope?.profileId + val scopedAccessToken = scope?.let { tokenManager.getAccessTokenForScope(it) } + if (scope == null || scopedAccessToken.isNullOrBlank() || profileId.isNullOrBlank()) { + trySend( + RoomRealtimeEvent.TransportTerminated( + IllegalStateException("missing_auth_scope"), + ), + ) close() return@callbackFlow } val url = buildString { append("/api/v1/watch-together/rooms/") - append(roomId.encodeURLParameter()) - append("/ws?token=").append(token.encodeURLParameter()) - append("&room_token=").append(roomToken.encodeURLParameter()) + append(roomId.encodeURLPathPart()) + append("/ws?room_token=").append(roomToken.encodeURLParameter()) append("&profile_id=").append(profileId.encodeURLParameter()) - if (!profileToken.isNullOrBlank()) { - append("&profile_token=").append(profileToken.encodeURLParameter()) + if (!scope.profileToken.isNullOrBlank()) { + append("&profile_token=").append(scope.profileToken.encodeURLParameter()) } } + var connection: WatchTogetherSocketConnection? = null try { - client.webSocket(urlString = url) { - session = this - try { - for (frame in incoming) { - if (frame !is Frame.Text) continue - decodeRoomFrame(json, frame.readText())?.let { trySend(it) } + connection = socketConnector.open( + WatchTogetherSocketRequest( + url = url, + authScope = scope, + ), + ) + sessionMutex.withLock { + session = connection + activeSessionId = ++sessionId + } + trySend(RoomRealtimeEvent.Opened) + while (true) { + val raw = connection.receiveText() ?: break + decodeRoomFrame(json, raw)?.let { trySend(it) } + } + trySend(RoomRealtimeEvent.TransportTerminated()) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + trySend(RoomRealtimeEvent.TransportTerminated(failure)) + } finally { + connection?.let { completed -> + // Cancellation is the privacy boundary for an identity/room + // replacement. Explicitly finish clearing and closing the + // physical socket before cancelAndJoin is allowed to return. + withContext(NonCancellable) { + sessionMutex.withLock { + if (session === completed) { + session = null + activeSessionId = null + } } - } finally { - session = null + runCatching { completed.close() } } } - trySend(RoomRealtimeEvent.Closed()) - } catch (e: Throwable) { - session = null - trySend(RoomRealtimeEvent.Closed(e.message)) - } finally { close() } - awaitClose { /* socket closes when the collector is cancelled */ } + awaitClose { } } - private suspend fun sendText(text: String) { - session?.send(Frame.Text(text)) + private suspend fun sendText(text: String): Boolean { + val writable = sessionMutex.withLock { session } ?: return false + return try { + writable.sendText(text) + true + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Throwable) { + false + } } override suspend fun attachSession(sessionId: String) = @@ -120,6 +244,32 @@ class DefaultWatchTogetherRealtimeClient( ), ) + override suspend fun currentConnectionId(): Long? = + sessionMutex.withLock { activeSessionId } + + override suspend fun transportRequestOnConnection( + connectionId: Long, + action: String, + positionSeconds: Double?, + isPaused: Boolean, + ): Boolean { + val writable = sessionMutex.withLock { + session.takeIf { activeSessionId == connectionId } + } ?: return false + val payload = json.encodeToString( + WsTransportRequest.serializer(), + WsTransportRequest(action = action, positionSeconds = positionSeconds, isPaused = isPaused), + ) + return try { + writable.sendText(payload) + true + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Throwable) { + false + } + } + override suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean) = sendText( json.encodeToString( diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeEvent.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeEvent.kt index 813696493..f104fa2c7 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeEvent.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeEvent.kt @@ -16,11 +16,12 @@ object WatchTogetherRealtime { /** * A decoded room realtime event. The repository folds these into its - * StateFlows / feeds them to the RoomSyncEngine. [Closed] - * is emitted by the client when the socket itself ends (distinct from a server - * [Closed]-with-reason `room_closed`, surfaced as the same event). + * StateFlows / feeds them to the RoomSyncEngine. */ sealed class RoomRealtimeEvent { + /** A physical connection completed its handshake and is writable. */ + data object Opened : RoomRealtimeEvent() + data class SnapshotEvent(val room: RoomSnapshot) : RoomRealtimeEvent() data class TransportCommandEvent(val command: TransportCommand) : RoomRealtimeEvent() data class SuggestionsEvent(val suggestions: List) : RoomRealtimeEvent() @@ -30,9 +31,12 @@ sealed class RoomRealtimeEvent { val serverSentAt: String, ) : RoomRealtimeEvent() - /** Server `room_closed{reason}`. The repository stops reconnecting on this. */ + /** Explicit server `room_closed{reason}`. Terminal at the protocol layer. */ data class Closed(val reason: String? = null) : RoomRealtimeEvent() + /** Physical EOF, handshake failure, or socket I/O failure. Transient. */ + data class TransportTerminated(val cause: Throwable? = null) : RoomRealtimeEvent() + /** Server `error{code,message}`. */ data class Error(val code: String, val message: String) : RoomRealtimeEvent() } 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 fae4526f0..25bff0f9d 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 @@ -9,6 +9,9 @@ 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.authScope +import org.prairieserver.prairie.network.requirePrairieAuth import io.ktor.client.HttpClient import io.ktor.client.request.delete import io.ktor.client.request.get @@ -19,6 +22,7 @@ import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType +import io.ktor.http.encodeURLPathPart /** * Watch Together REST surface (`/api/v1/watch-together`). Create/join return a @@ -34,19 +38,20 @@ import io.ktor.http.contentType interface WatchTogetherApi { /** POST /rooms — caller becomes host; 201 with room + room_access_token. */ - suspend fun createRoom(request: CreateRoomRequest): ApiResult + suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult /** POST /join — resolves a code or join token; 200 with room + room_access_token. */ - suspend fun joinRoom(request: JoinRoomRequest): ApiResult + suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot): ApiResult /** GET /rooms/{id}?room_token= — current room snapshot. */ - suspend fun getRoom(roomId: String, roomToken: String): ApiResult + suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult /** PUT /rooms/{id}/selection?room_token= (host-only). */ suspend fun setSelection( roomId: String, roomToken: String, request: SetSelectionRequest, + scope: AuthScopeSnapshot, ): ApiResult /** PATCH /rooms/{id}/policy?room_token= (host-only). */ @@ -54,19 +59,21 @@ interface WatchTogetherApi { roomId: String, roomToken: String, request: UpdatePolicyRequest, + scope: AuthScopeSnapshot, ): ApiResult /** DELETE /rooms/{id}?room_token= (host-only) — 204 → Unit. */ - suspend fun closeRoom(roomId: String, roomToken: String): ApiResult + suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult /** GET /rooms/{id}/suggestions?room_token=. */ - suspend fun listSuggestions(roomId: String, roomToken: String): ApiResult + suspend fun listSuggestions(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult /** POST /rooms/{id}/suggestions?room_token=. */ suspend fun addSuggestion( roomId: String, roomToken: String, request: AddSuggestionRequest, + scope: AuthScopeSnapshot, ): ApiResult /** DELETE /rooms/{id}/suggestions/{sid}?room_token= (host or suggester). */ @@ -74,6 +81,7 @@ interface WatchTogetherApi { roomId: String, roomToken: String, suggestionId: String, + scope: AuthScopeSnapshot, ): ApiResult /** POST /rooms/{id}/suggestions/{sid}/vote?room_token= — 409 on dup. */ @@ -81,6 +89,7 @@ interface WatchTogetherApi { roomId: String, roomToken: String, suggestionId: String, + scope: AuthScopeSnapshot, ): ApiResult /** DELETE /rooms/{id}/suggestions/{sid}/vote?room_token= — 409 if not voted. */ @@ -88,6 +97,7 @@ interface WatchTogetherApi { roomId: String, roomToken: String, suggestionId: String, + scope: AuthScopeSnapshot, ): ApiResult /** POST /rooms/{id}/suggestions/promote?room_token= (host-only) → room. */ @@ -95,38 +105,46 @@ interface WatchTogetherApi { roomId: String, roomToken: String, request: PromoteSuggestionRequest, + scope: AuthScopeSnapshot, ): ApiResult } class DefaultWatchTogetherApi(private val client: HttpClient) : WatchTogetherApi { - override suspend fun createRoom(request: CreateRoomRequest): ApiResult = + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult = safeApiCall { client.post("$BASE/rooms") { + pin(scope) contentType(ContentType.Application.Json) setBody(request) } } - override suspend fun joinRoom(request: JoinRoomRequest): ApiResult = + override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot): ApiResult = safeApiCall { client.post("$BASE/join") { + pin(scope) contentType(ContentType.Application.Json) setBody(request) } } - override suspend fun getRoom(roomId: String, roomToken: String): ApiResult = + override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult = safeApiCall { - client.get("$BASE/rooms/$roomId") { parameter("room_token", roomToken) } + client.get("$BASE/rooms/${roomId.encodeURLPathPart()}") { + pin(scope) + parameter("room_token", roomToken) + } } override suspend fun setSelection( roomId: String, roomToken: String, request: SetSelectionRequest, + scope: AuthScopeSnapshot, ): ApiResult = safeApiCall { - client.put("$BASE/rooms/$roomId/selection") { + client.put("$BASE/rooms/${roomId.encodeURLPathPart()}/selection") { + pin(scope) parameter("room_token", roomToken) contentType(ContentType.Application.Json) setBody(request) @@ -137,32 +155,47 @@ class DefaultWatchTogetherApi(private val client: HttpClient) : WatchTogetherApi roomId: String, roomToken: String, request: UpdatePolicyRequest, + scope: AuthScopeSnapshot, ): ApiResult = safeApiCall { - client.patch("$BASE/rooms/$roomId/policy") { + client.patch("$BASE/rooms/${roomId.encodeURLPathPart()}/policy") { + pin(scope) parameter("room_token", roomToken) contentType(ContentType.Application.Json) setBody(request) } } - override suspend fun closeRoom(roomId: String, roomToken: String): ApiResult = + override suspend fun closeRoom( + roomId: String, + roomToken: String, + scope: AuthScopeSnapshot, + ): ApiResult = safeApiCall { - client.delete("$BASE/rooms/$roomId") { parameter("room_token", roomToken) } + client.delete("$BASE/rooms/${roomId.encodeURLPathPart()}") { + pin(scope) + parameter("room_token", roomToken) + } } override suspend fun listSuggestions( roomId: String, roomToken: String, + scope: AuthScopeSnapshot, ): ApiResult = safeApiCall { - client.get("$BASE/rooms/$roomId/suggestions") { parameter("room_token", roomToken) } + client.get("$BASE/rooms/${roomId.encodeURLPathPart()}/suggestions") { + pin(scope) + parameter("room_token", roomToken) + } } override suspend fun addSuggestion( roomId: String, roomToken: String, request: AddSuggestionRequest, + scope: AuthScopeSnapshot, ): ApiResult = safeApiCall { - client.post("$BASE/rooms/$roomId/suggestions") { + client.post("$BASE/rooms/${roomId.encodeURLPathPart()}/suggestions") { + pin(scope) parameter("room_token", roomToken) contentType(ContentType.Application.Json) setBody(request) @@ -173,8 +206,12 @@ class DefaultWatchTogetherApi(private val client: HttpClient) : WatchTogetherApi roomId: String, roomToken: String, suggestionId: String, + scope: AuthScopeSnapshot, ): ApiResult = safeApiCall { - client.delete("$BASE/rooms/$roomId/suggestions/$suggestionId") { + client.delete( + "$BASE/rooms/${roomId.encodeURLPathPart()}/suggestions/${suggestionId.encodeURLPathPart()}", + ) { + pin(scope) parameter("room_token", roomToken) } } @@ -183,8 +220,12 @@ class DefaultWatchTogetherApi(private val client: HttpClient) : WatchTogetherApi roomId: String, roomToken: String, suggestionId: String, + scope: AuthScopeSnapshot, ): ApiResult = safeApiCall { - client.post("$BASE/rooms/$roomId/suggestions/$suggestionId/vote") { + client.post( + "$BASE/rooms/${roomId.encodeURLPathPart()}/suggestions/${suggestionId.encodeURLPathPart()}/vote", + ) { + pin(scope) parameter("room_token", roomToken) } } @@ -193,8 +234,12 @@ class DefaultWatchTogetherApi(private val client: HttpClient) : WatchTogetherApi roomId: String, roomToken: String, suggestionId: String, + scope: AuthScopeSnapshot, ): ApiResult = safeApiCall { - client.delete("$BASE/rooms/$roomId/suggestions/$suggestionId/vote") { + client.delete( + "$BASE/rooms/${roomId.encodeURLPathPart()}/suggestions/${suggestionId.encodeURLPathPart()}/vote", + ) { + pin(scope) parameter("room_token", roomToken) } } @@ -203,8 +248,10 @@ class DefaultWatchTogetherApi(private val client: HttpClient) : WatchTogetherApi roomId: String, roomToken: String, request: PromoteSuggestionRequest, + scope: AuthScopeSnapshot, ): ApiResult = safeApiCall { - client.post("$BASE/rooms/$roomId/suggestions/promote") { + client.post("$BASE/rooms/${roomId.encodeURLPathPart()}/suggestions/promote") { + pin(scope) parameter("room_token", roomToken) contentType(ContentType.Application.Json) setBody(request) @@ -214,4 +261,9 @@ class DefaultWatchTogetherApi(private val client: HttpClient) : WatchTogetherApi private companion object { const val BASE = "/api/v1/watch-together" } + + private fun io.ktor.client.request.HttpRequestBuilder.pin(scope: AuthScopeSnapshot) { + authScope(scope) + requirePrairieAuth() + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleCodecFamily.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleCodecFamily.kt new file mode 100644 index 000000000..6fd7d0996 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleCodecFamily.kt @@ -0,0 +1,53 @@ +package org.prairieserver.prairie.playback + +/** + * Canonical codec family shared by catalog persistence and mounted-player + * identity matching. Raw catalog names and decoder/MIME aliases must serialize + * to the same value or a player-written preference cannot survive a restart. + */ +fun canonicalSubtitleCodecFamily(codecOrMime: String?): String? { + val normalized = codecOrMime + ?.trim() + ?.filter(Char::isLetterOrDigit) + ?.lowercase() + ?.takeIf(String::isNotEmpty) + ?: return null + return when { + normalized.contains("pgs") -> "pgs" + normalized.contains("vobsub") || normalized.contains("dvdsubtitle") -> "vobsub" + normalized.contains("dvbsub") -> "dvbsub" + normalized.contains("subrip") || normalized.endsWith("srt") -> "subrip" + normalized.contains("webvtt") || + normalized == "textvtt" || + normalized.endsWith("vtt") -> "webvtt" + normalized.contains("tx3g") || normalized.contains("movtext") -> "tx3g" + normalized.contains("ssa") || normalized == "ass" -> "ssa" + normalized.contains("ttml") -> "ttml" + normalized.contains("cea608") || normalized.contains("eia608") -> "cea608" + normalized.contains("cea708") -> "cea708" + else -> normalized + } +} + +/** + * Whether [family] is a text subtitle family. Server-materialized text + * artifacts are served as WebVTT regardless of their catalog source format; + * bitmap families remain exact-match only. + */ +fun isTextSubtitleCodecFamily(family: String?): Boolean = + when (canonicalSubtitleCodecFamily(family)) { + "subrip", "webvtt", "ssa", "ttml", "tx3g" -> true + else -> false + } + +/** + * Whether the server can hand this bitmap family to the client as a sidecar + * instead of burning it into the picture. + * + * Mirrors `ResolveSubtitlePolicyV3`: the stream handler raw-serves exactly one + * bitmap shape — an embedded PGS track as `.sup`. VobSub and DVB have no + * sidecar route and always burn in, so a pick on those is a burn-in identity + * from the start rather than a client-mounted one. + */ +fun isClientMountableBitmapCodecFamily(family: String?): Boolean = + canonicalSubtitleCodecFamily(family) == "pgs" diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt new file mode 100644 index 000000000..9b8228dfc --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt @@ -0,0 +1,29 @@ +package org.prairieserver.prairie.playback + +/** + * Canonical primary subtitle language shared by catalog persistence and + * mounted-player identity matching. + * + * Servers commonly expose ISO 639-2 aliases while Android decoders expose + * ISO 639-1 tags. Region/script suffixes do not identify a different subtitle + * artifact for the selection fallback, so matching uses the primary language. + */ +fun canonicalSubtitleLanguage(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 + } +} 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 0531213a9..05cb33ef3 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprint.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprint.kt @@ -1,10 +1,189 @@ package org.prairieserver.prairie.playback +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json 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.model.playback.combinedSubtitleSelectionIndexes const val SUBTITLE_OFF_FINGERPRINT = "off" +private const val TYPED_SUBTITLE_PREFERENCE_PREFIX = "prairie-subtitle-v2:" + +private val typedSubtitlePreferenceJson = Json { + encodeDefaults = false + explicitNulls = false + ignoreUnknownKeys = true +} + +@Serializable +private data class PersistedSubtitleIdentityV2( + val kind: String, + val serverIndex: Int? = null, + val downloadId: Int? = null, + val media: PersistedSubtitleMediaIdentityV2? = null, +) + +@Serializable +private data class PersistedSubtitleMediaIdentityV2( + val trackId: String? = null, + val label: String? = null, + val language: String? = null, + val codecFamily: String? = null, + val forced: Boolean? = null, + val hearingImpaired: Boolean? = null, +) + +fun encodeSubtitleIdentityPreference(identity: SubtitleIdentity): String = + TYPED_SUBTITLE_PREFERENCE_PREFIX + typedSubtitlePreferenceJson.encodeToString( + identity.toPersisted(), + ) + +fun decodeSubtitleIdentityPreference(value: String?): SubtitleIdentity? { + val encoded = value + ?.trim() + ?.takeIf { it.startsWith(TYPED_SUBTITLE_PREFERENCE_PREFIX) } + ?.removePrefix(TYPED_SUBTITLE_PREFERENCE_PREFIX) + ?: return null + return runCatching { + typedSubtitlePreferenceJson + .decodeFromString(encoded) + .toIdentity() + }.getOrNull() +} + +fun encodeCatalogSubtitlePreference( + tracks: List, + selectedOrdinal: Int, +): String? { + if (selectedOrdinal == -1) { + return encodeSubtitleIdentityPreference(SubtitleIdentity.Off) + } + val track = tracks.getOrNull(selectedOrdinal) ?: return null + val serverIndex = combinedSubtitleSelectionIndexes(tracks).getOrNull(selectedOrdinal) + ?: return null + val media = track.catalogMediaIdentity() + val identity = when { + // VobSub/DVB have no sidecar route and are burn-in wherever they live; + // PGS keeps the normal embedded/external split because the server does + // sidecar it as `.sup`. + track.codec.isCatalogBitmapSubtitle() && + !isClientMountableBitmapCodecFamily(track.codec) -> + SubtitleIdentity.ServerBurnIn(serverIndex, media) + !track.external -> SubtitleIdentity.Embedded( + serverIndex = serverIndex, + media = media, + ) + track.codec.isCatalogBitmapSubtitle() -> + SubtitleIdentity.ServerBurnIn(serverIndex, media) + else -> SubtitleIdentity.ServerSidecar(serverIndex, media) + } + return encodeSubtitleIdentityPreference(identity) +} + +fun resolveCatalogSubtitlePreferenceOrdinal( + tracks: List, + preference: String?, +): Int? { + val typed = decodeSubtitleIdentityPreference(preference) + if (typed != null) { + if (typed == SubtitleIdentity.Off) return -1 + val stableMedia = typed.catalogMediaIdentityOrNull() + if (stableMedia != null) { + if (!stableMedia.hasPositiveCatalogDiscriminator()) return null + return tracks.indices + .filter { ordinal -> + tracks[ordinal].matchesTypedCatalogIdentity(typed, stableMedia) + } + .singleOrNull() + } + val serverIndex = when (typed) { + SubtitleIdentity.Off -> return -1 + is SubtitleIdentity.ServerSidecar -> typed.serverIndex + is SubtitleIdentity.ServerBurnIn -> typed.serverIndex + is SubtitleIdentity.Embedded -> typed.serverIndex + is SubtitleIdentity.Downloaded, + is SubtitleIdentity.LocalMedia3, + -> return null + } + return combinedSubtitleSelectionIndexes(tracks) + .indexOf(serverIndex) + .takeIf { it >= 0 } + } + return resolveSubtitleTrackOrdinal(tracks, preference) +} + +private fun SubtitleIdentity.toPersisted(): PersistedSubtitleIdentityV2 = when (this) { + SubtitleIdentity.Off -> PersistedSubtitleIdentityV2(kind = "off") + is SubtitleIdentity.ServerSidecar -> PersistedSubtitleIdentityV2( + kind = "server_sidecar", + serverIndex = serverIndex, + media = media?.toPersisted(), + ) + is SubtitleIdentity.ServerBurnIn -> PersistedSubtitleIdentityV2( + kind = "server_burn_in", + serverIndex = serverIndex, + media = media?.toPersisted(), + ) + is SubtitleIdentity.Embedded -> PersistedSubtitleIdentityV2( + kind = "embedded", + serverIndex = serverIndex, + media = media.toPersisted(), + ) + is SubtitleIdentity.Downloaded -> PersistedSubtitleIdentityV2( + kind = "downloaded", + downloadId = downloadId, + media = media.toPersisted(), + ) + is SubtitleIdentity.LocalMedia3 -> PersistedSubtitleIdentityV2( + kind = "local_media3", + media = media.toPersisted(), + ) +} + +private fun SubtitleMediaIdentity.toPersisted(): PersistedSubtitleMediaIdentityV2 = + PersistedSubtitleMediaIdentityV2( + trackId = trackId, + label = label, + language = language, + codecFamily = codecFamily, + forced = forced, + hearingImpaired = hearingImpaired, + ) + +private fun PersistedSubtitleIdentityV2.toIdentity(): SubtitleIdentity? { + val mediaIdentity = media?.toIdentity() + return when (kind) { + "off" -> SubtitleIdentity.Off + "server_sidecar" -> serverIndex?.let { index -> + SubtitleIdentity.ServerSidecar(index, mediaIdentity) + } + "server_burn_in" -> serverIndex?.let { index -> + SubtitleIdentity.ServerBurnIn(index, mediaIdentity) + } + "embedded" -> serverIndex?.let { index -> + mediaIdentity?.let { SubtitleIdentity.Embedded(index, it) } + } + "downloaded" -> downloadId?.let { id -> + mediaIdentity?.let { SubtitleIdentity.Downloaded(id, it) } + } + "local_media3" -> mediaIdentity?.let(SubtitleIdentity::LocalMedia3) + else -> null + } +} + +private fun PersistedSubtitleMediaIdentityV2.toIdentity(): SubtitleMediaIdentity = + SubtitleMediaIdentity( + trackId = trackId, + label = label, + language = language, + codecFamily = codecFamily, + forced = forced, + hearingImpaired = hearingImpaired, + ) fun audioTrackFingerprint(track: AudioTrack): String = trackSelectionFingerprint( @@ -95,3 +274,113 @@ private fun String?.normalizedTrackField(lowercase: Boolean): String { private fun String?.normalizedFingerprintOrNull(): String? = this?.trim()?.takeIf { it.isNotBlank() } + +private fun String?.isCatalogBitmapSubtitle(): Boolean { + val normalized = this + ?.filter(Char::isLetterOrDigit) + ?.lowercase() + ?.takeIf(String::isNotBlank) + ?: return false + return normalized.contains("pgs") || + normalized.contains("dvd") || + normalized.contains("dvbsub") || + 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, + language = canonicalSubtitleLanguage(language), + codecFamily = canonicalSubtitleCodecFamily(codec), + forced = forced, + hearingImpaired = title + ?.takeIf(::catalogLabelIndicatesHearingImpaired) + ?.let { true }, + ) + +private fun SubtitleIdentity.catalogMediaIdentityOrNull(): SubtitleMediaIdentity? = when (this) { + is SubtitleIdentity.ServerSidecar -> media + is SubtitleIdentity.ServerBurnIn -> media + is SubtitleIdentity.Embedded -> media + SubtitleIdentity.Off, + is SubtitleIdentity.Downloaded, + is SubtitleIdentity.LocalMedia3, + -> null +} + +private fun SubtitleTrack.matchesTypedCatalogIdentity( + identity: SubtitleIdentity, + expected: SubtitleMediaIdentity, +): Boolean { + val kindMatches = when (identity) { + is SubtitleIdentity.ServerSidecar -> + external && !codec.isCatalogBitmapSubtitle() + // Burn-in covers external bitmaps plus the embedded families with no + // sidecar route; embedded PGS stays on the Embedded side, so the two + // remain disjoint. + is SubtitleIdentity.ServerBurnIn -> + codec.isCatalogBitmapSubtitle() && + (external || !isClientMountableBitmapCodecFamily(codec)) + is SubtitleIdentity.Embedded -> + !external && + (!codec.isCatalogBitmapSubtitle() || isClientMountableBitmapCodecFamily(codec)) + SubtitleIdentity.Off, + is SubtitleIdentity.Downloaded, + is SubtitleIdentity.LocalMedia3, + -> false + } + if (!kindMatches) return false + val actual = catalogMediaIdentity() + if ( + expected.trackId.normalizedTrackField(lowercase = false).isNotEmpty() && + actual.trackId.normalizedTrackField(lowercase = false) != + expected.trackId.normalizedTrackField(lowercase = false) + ) { + return false + } + if ( + expected.label.normalizedTrackField(lowercase = true).isNotEmpty() && + actual.label.normalizedTrackField(lowercase = true) != + expected.label.normalizedTrackField(lowercase = true) + ) { + return false + } + val expectedLanguage = canonicalSubtitleLanguage(expected.language) + if ( + expectedLanguage != null && + canonicalSubtitleLanguage(actual.language) != expectedLanguage + ) { + return false + } + if ( + canonicalSubtitleCodecFamily(expected.codecFamily) != null && + canonicalSubtitleCodecFamily(actual.codecFamily) != + canonicalSubtitleCodecFamily(expected.codecFamily) + ) { + return false + } + if (expected.forced != null && actual.forced != expected.forced) return false + if ( + expected.hearingImpaired != null && + actual.hearingImpaired != expected.hearingImpaired + ) { + return false + } + return true +} + +private fun SubtitleMediaIdentity.hasPositiveCatalogDiscriminator(): Boolean = + !trackId.isNullOrBlank() || + !label.isNullOrBlank() || + canonicalSubtitleLanguage(language) != null || + !codecFamily.isNullOrBlank() || + forced == true || + hearingImpaired == true 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 d858d4b75..eeb3d49c2 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt @@ -13,10 +13,15 @@ import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse import org.prairieserver.prairie.model.watchtogether.TransportCommand import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot 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.RoomDeliveryLatch +import org.prairieserver.prairie.watchtogether.RoomSessionRepository +import org.prairieserver.prairie.watchtogether.RoomTransportIntent +import org.prairieserver.prairie.watchtogether.roomTransportAuthorized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay @@ -26,6 +31,9 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.TimeSource /** * A transport command paired with its `execute_at` already parsed to a @@ -36,6 +44,7 @@ import kotlinx.coroutines.flow.asStateFlow data class ScheduledTransportCommand( val command: TransportCommand, val executeAtMs: Long?, + val connection: WatchTogetherConnectionState, ) /** @@ -53,6 +62,21 @@ data class PongSample( val serverSentMs: Long?, ) +data class WatchTogetherConnectionState( + val generation: Long = 0L, + val epoch: Long = 0L, + val writable: Boolean = false, +) + +/** Immutable, atomically-published authority for one room + physical socket. */ +class RoomTransportAuthorization internal constructor( + val roomId: String, + val generation: Long, + val connectionOwner: Long?, + val realtimeConnectionId: Long?, + val snapshot: RoomSnapshot, +) + /** * Singleton owner of one Watch Together room's state and websocket lifecycle. * The per-room WS IS the feature (no REST fallback); the REST calls are for @@ -71,12 +95,40 @@ data class PongSample( class WatchTogetherRepository( private val api: WatchTogetherApi, private val realtimeFactory: () -> WatchTogetherRealtimeClient? = { null }, -) { + private val monotonicNowMs: () -> Long = { MONOTONIC_ORIGIN.elapsedNow().inWholeMilliseconds }, + private val authScopeProvider: suspend () -> AuthScopeSnapshot? = { null }, +) : RoomSessionRepository { + /** Successful delivery state follows the process connection, not a UI controller. */ + val roomDeliveryLatch = RoomDeliveryLatch() + + private data class RoomBinding( + val roomId: String, + val roomToken: String, + val authScope: AuthScopeSnapshot, + val generation: Long, + ) + + private val stateMutex = Mutex() + private var nextGeneration = 0L + private var latestRoomRequest = 0L + private var binding: RoomBinding? = null + private var terminalGeneration: Long? = null + private var realtimeGeneration: Long? = null + private var realtimeConnectionId: Long? = null + private var nextConnectionOwner = 0L + private var activeConnectionOwner: Long? = null private val _roomSnapshot = MutableStateFlow(null) private val _suggestions = MutableStateFlow>(emptyList()) - val roomSnapshot: StateFlow = _roomSnapshot.asStateFlow() + override val roomSnapshot: StateFlow = _roomSnapshot.asStateFlow() val suggestions: StateFlow> = _suggestions.asStateFlow() + private val _connectionState = MutableStateFlow(WatchTogetherConnectionState()) + val connectionState: StateFlow = _connectionState.asStateFlow() + @kotlin.concurrent.Volatile + private var transportAuthorization: RoomTransportAuthorization? = null + + /** One atomic read; never pair independently-read snapshot/generation values. */ + fun currentTransportAuthorization(): RoomTransportAuthorization? = transportAuthorization /** * Transport commands surfaced for the player binding to feed to its @@ -111,7 +163,7 @@ class WatchTogetherRepository( * this (they would eject the user) — see [errors]. */ private val _roomClosedReason = MutableStateFlow(null) - val roomClosedReason: StateFlow = _roomClosedReason.asStateFlow() + override val roomClosedReason: StateFlow = _roomClosedReason.asStateFlow() /** * Transient, non-terminal server `error` frames (e.g. a rejected @@ -126,106 +178,239 @@ class WatchTogetherRepository( ) val errors: SharedFlow = _errors.asSharedFlow() + /** Surface a local delivery failure through the same non-terminal UI path. */ + fun reportDeliveryFailure(message: String) { + if (message.isNotBlank()) _errors.tryEmit(message) + } + // Locally-tracked vote set: ids the local user has voted for. Used to // re-merge voted_by_me into broadcast suggestion lists (which force false). private val votedIds = mutableSetOf() - @kotlin.concurrent.Volatile - private var roomToken: String = "" - @kotlin.concurrent.Volatile private var realtime: WatchTogetherRealtimeClient? = null // ---- REST: create / join (store the room token) --------------------------- suspend fun createRoom(request: CreateRoomRequest): ApiResult { - val r = api.createRoom(request) - if (r is ApiResult.Success) onRoomResponse(r.data) - return r + val scope = authScopeProvider() ?: return missingAuthScope() + val requestGeneration = beginRoomRequest() + val r = api.createRoom(request, scope) + if (authScopeProvider() != scope) return obsoleteRoomRequest() + return if (r is ApiResult.Success) installRoomResponse(r.data, scope, requestGeneration) else r } suspend fun joinRoom(request: JoinRoomRequest): ApiResult { - val r = api.joinRoom(request) - if (r is ApiResult.Success) onRoomResponse(r.data) - return r + val scope = authScopeProvider() ?: return missingAuthScope() + val requestGeneration = beginRoomRequest() + val r = api.joinRoom(request, scope) + if (authScopeProvider() != scope) return obsoleteRoomRequest() + if (r !is ApiResult.Success) return r + val installed = installRoomResponse(r.data, scope, requestGeneration) + if (installed !is ApiResult.Success) return installed + + // The realtime handshake's initial event is a room snapshot; it does + // not replay suggestions that existed before this member connected. + // Hydrate them while the join lease is current so a re-entered lobby + // does not render an empty vote list until somebody mutates it. + val lease = stateMutex.withLock { + binding?.takeIf { current -> + latestRoomRequest == requestGeneration && + current.roomId == r.data.room.roomId && + isCurrentLocked(current) + } + } ?: return obsoleteRoomRequest() + publishSuggestionsResponse( + lease, + api.listSuggestions(lease.roomId, lease.roomToken, lease.authScope), + expectedRoomRequest = requestGeneration, + ) + val stillCurrent = stateMutex.withLock { + latestRoomRequest == requestGeneration && isCurrentLocked(lease) + } + if (!stillCurrent) return obsoleteRoomRequest() + return installed } - private fun onRoomResponse(data: RoomResponse) { - if (data.roomAccessToken.isNotBlank()) roomToken = data.roomAccessToken - _roomSnapshot.value = data.room + private suspend fun beginRoomRequest(): Long = stateMutex.withLock { ++latestRoomRequest } + + private suspend fun installRoomResponse( + data: RoomResponse, + scope: AuthScopeSnapshot, + requestGeneration: Long, + ): ApiResult { + if (data.room.roomId.isBlank() || data.roomAccessToken.isBlank()) { + return invalidRoomResponse() + } + stateMutex.withLock { + if (requestGeneration != latestRoomRequest) return obsoleteRoomRequest() + val installed = RoomBinding( + roomId = data.room.roomId, + roomToken = data.roomAccessToken, + authScope = scope, + generation = ++nextGeneration, + ) + binding = installed + terminalGeneration = null + votedIds.clear() + _suggestions.value = emptyList() + _roomClosedReason.value = null + _roomSnapshot.value = data.room + _connectionState.value = WatchTogetherConnectionState(generation = installed.generation) + realtimeConnectionId = null + refreshTransportAuthorizationLocked() + } + return ApiResult.Success(data) } // ---- REST: host management ------------------------------------------------ suspend fun setSelection(request: SetSelectionRequest): ApiResult { - val roomId = _roomSnapshot.value?.roomId ?: "" - val r = api.setSelection(roomId, roomToken, request) - if (r is ApiResult.Success) _roomSnapshot.value = r.data.room - return r + val lease = activeBinding() ?: return missingRoom() + val r = api.setSelection(lease.roomId, lease.roomToken, request, lease.authScope) + return publishRoomResponse(lease, r) } suspend fun updatePolicy(request: UpdatePolicyRequest): ApiResult { - val roomId = _roomSnapshot.value?.roomId ?: "" - val r = api.updatePolicy(roomId, roomToken, request) - if (r is ApiResult.Success) _roomSnapshot.value = r.data.room - return r + val lease = activeBinding() ?: return missingRoom() + val r = api.updatePolicy(lease.roomId, lease.roomToken, request, lease.authScope) + return publishRoomResponse(lease, r) } - suspend fun closeRoom(): ApiResult { - val roomId = _roomSnapshot.value?.roomId ?: "" - return api.closeRoom(roomId, roomToken) + override suspend fun closeRoom(): ApiResult { + val lease = activeBinding() ?: return missingRoom() + val result = api.closeRoom(lease.roomId, lease.roomToken, lease.authScope) + return if (isCurrent(lease)) result else obsoleteRoomRequest() } // ---- REST: suggestions ---------------------------------------------------- suspend fun refreshSuggestions(): ApiResult { - val roomId = _roomSnapshot.value?.roomId ?: "" - val r = api.listSuggestions(roomId, roomToken) - if (r is ApiResult.Success) applySuggestions(r.data.suggestions, fromBroadcast = false) - return r + val lease = activeBinding() ?: return missingRoom() + val r = api.listSuggestions(lease.roomId, lease.roomToken, lease.authScope) + return publishSuggestionsResponse(lease, r) } suspend fun addSuggestion(request: AddSuggestionRequest): ApiResult { - val roomId = _roomSnapshot.value?.roomId ?: "" - val r = api.addSuggestion(roomId, roomToken, request) - if (r is ApiResult.Success) applySuggestions(r.data.suggestions, fromBroadcast = false) - return r + val lease = activeBinding() ?: return missingRoom() + val r = api.addSuggestion(lease.roomId, lease.roomToken, request, lease.authScope) + return publishSuggestionsResponse(lease, r) } suspend fun deleteSuggestion(suggestionId: String): ApiResult { - val roomId = _roomSnapshot.value?.roomId ?: "" - val r = api.deleteSuggestion(roomId, roomToken, suggestionId) - if (r is ApiResult.Success) applySuggestions(r.data.suggestions, fromBroadcast = false) - return r + val lease = activeBinding() ?: return missingRoom() + val r = api.deleteSuggestion(lease.roomId, lease.roomToken, suggestionId, lease.authScope) + return publishSuggestionsResponse(lease, r) } suspend fun vote(suggestionId: String): ApiResult { - val roomId = _roomSnapshot.value?.roomId ?: "" - val r = api.vote(roomId, roomToken, suggestionId) - if (r is ApiResult.Success) { + val lease = activeBinding() ?: return missingRoom() + val r = api.vote(lease.roomId, lease.roomToken, suggestionId, lease.authScope) + if (r !is ApiResult.Success) return r + val published = stateMutex.withLock { + if (!isCurrentLocked(lease)) return@withLock false votedIds.add(suggestionId) applySuggestions(r.data.suggestions, fromBroadcast = false) + true } - return r + return if (published) r else obsoleteRoomRequest() } suspend fun unvote(suggestionId: String): ApiResult { - val roomId = _roomSnapshot.value?.roomId ?: "" - val r = api.unvote(roomId, roomToken, suggestionId) - if (r is ApiResult.Success) { + val lease = activeBinding() ?: return missingRoom() + val r = api.unvote(lease.roomId, lease.roomToken, suggestionId, lease.authScope) + if (r !is ApiResult.Success) return r + val published = stateMutex.withLock { + if (!isCurrentLocked(lease)) return@withLock false votedIds.remove(suggestionId) applySuggestions(r.data.suggestions, fromBroadcast = false) + true } - return r + return if (published) r else obsoleteRoomRequest() } suspend fun promoteSuggestion(request: PromoteSuggestionRequest): ApiResult { - val roomId = _roomSnapshot.value?.roomId ?: "" - val r = api.promoteSuggestion(roomId, roomToken, request) - if (r is ApiResult.Success) _roomSnapshot.value = r.data.room - return r + val lease = activeBinding() ?: return missingRoom() + val r = api.promoteSuggestion(lease.roomId, lease.roomToken, request, lease.authScope) + return publishRoomResponse(lease, r) + } + + private suspend fun activeBinding(): RoomBinding? = stateMutex.withLock { + binding?.takeIf { terminalGeneration != it.generation } } + private fun isCurrentLocked(lease: RoomBinding): Boolean = + binding == lease && terminalGeneration != lease.generation + + private fun refreshTransportAuthorizationLocked() { + val current = binding + val snapshot = _roomSnapshot.value + val owner = activeConnectionOwner + val connectionId = realtimeConnectionId + transportAuthorization = if ( + current != null && + snapshot != null && + isCurrentLocked(current) && + snapshot.roomId == current.roomId + ) { + RoomTransportAuthorization( + roomId = current.roomId, + generation = current.generation, + connectionOwner = owner, + realtimeConnectionId = connectionId, + snapshot = snapshot, + ) + } else { + null + } + } + + private suspend fun publishRoomResponse( + lease: RoomBinding, + result: ApiResult, + ): ApiResult { + if (result !is ApiResult.Success) return result + if (result.data.room.roomId != lease.roomId) return invalidRoomResponse() + val published = stateMutex.withLock { + if (!isCurrentLocked(lease)) return@withLock false + _roomSnapshot.value = result.data.room + refreshTransportAuthorizationLocked() + true + } + return if (published) result else obsoleteRoomRequest() + } + + private suspend fun publishSuggestionsResponse( + lease: RoomBinding, + result: ApiResult, + expectedRoomRequest: Long? = null, + ): ApiResult { + if (result !is ApiResult.Success) return result + val published = stateMutex.withLock { + if ( + !isCurrentLocked(lease) || + (expectedRoomRequest != null && latestRoomRequest != expectedRoomRequest) + ) { + return@withLock false + } + applySuggestions(result.data.suggestions, fromBroadcast = false) + true + } + return if (published) result else obsoleteRoomRequest() + } + + private fun invalidRoomResponse(): ApiResult.Error = + ApiResult.Error(502, "invalid_room_response", "The room response was missing or mismatched.") + + private fun missingRoom(): ApiResult.Error = + ApiResult.Error(409, "no_active_room", "No active Watch Together room.") + + private fun missingAuthScope(): ApiResult.Error = + ApiResult.Error(401, "missing_auth_scope", "No authenticated Watch Together scope.") + + private fun obsoleteRoomRequest(): ApiResult.Error = + ApiResult.Error(409, "obsolete_room_request", "The Watch Together identity changed.") + /** * Publish suggestions, re-merging `voted_by_me` from the local [votedIds] * set. For REST results we also seed [votedIds] from authoritative @@ -242,20 +427,90 @@ class WatchTogetherRepository( // ---- WS: client→server send passthroughs ---------------------------------- - suspend fun attachSession(sessionId: String) { realtime?.attachSession(sessionId) } - suspend fun transportRequest(action: String, positionSeconds: Double?, isPaused: Boolean) { - realtime?.transportRequest(action, positionSeconds, isPaused) - } - suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean) { - realtime?.stateReport(sessionId, positionSeconds, isPaused) - } - suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean) { - realtime?.ready(sessionId, positionSeconds, isPaused) - } - suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean) { - realtime?.buffering(sessionId, positionSeconds, isPaused) + private suspend fun currentWritableRealtime(): WatchTogetherRealtimeClient? = + stateMutex.withLock { + val current = binding + realtime.takeIf { + current != null && + terminalGeneration != current.generation && + realtimeGeneration == current.generation && + activeConnectionOwner != null && + _connectionState.value.writable + } + } + + suspend fun attachSession(sessionId: String): Boolean = + currentWritableRealtime()?.attachSession(sessionId) ?: false + + suspend fun transportRequest( + action: String, + positionSeconds: Double?, + isPaused: Boolean, + ): Boolean = currentWritableRealtime()?.transportRequest(action, positionSeconds, isPaused) ?: false + + /** + * Send a user transport intent only while the exact room generation that + * authorized it is still current. Authority is rechecked under the same + * lock that guards room replacement, so a delayed UI coroutine cannot send + * an old room's command through a replacement room or changed policy. + */ + suspend fun transportRequestForAuthorization( + authorization: RoomTransportAuthorization, + intent: RoomTransportIntent, + action: String, + positionSeconds: Double?, + isPaused: Boolean, + ): Boolean { + val target = stateMutex.withLock { + val current = binding ?: return@withLock null + val snapshot = _roomSnapshot.value ?: return@withLock null + val currentRealtime = realtime ?: return@withLock null + val connectionId = authorization.realtimeConnectionId ?: return@withLock null + val connectionOwner = authorization.connectionOwner ?: return@withLock null + if ( + transportAuthorization != authorization || + !isCurrentLocked(current) || + current.generation != authorization.generation || + current.roomId != authorization.roomId || + snapshot != authorization.snapshot || + !roomTransportAuthorized(snapshot, intent) || + realtimeGeneration != current.generation || + activeConnectionOwner != connectionOwner || + realtimeConnectionId != connectionId || + !_connectionState.value.writable + ) { + return@withLock null + } + currentRealtime to connectionId + } ?: return false + return target.first.transportRequestOnConnection( + connectionId = target.second, + action = action, + positionSeconds = positionSeconds, + isPaused = isPaused, + ) } - suspend fun ping(clientSentAt: String) { realtime?.ping(clientSentAt) } + + suspend fun stateReport( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + ): Boolean = currentWritableRealtime()?.stateReport(sessionId, positionSeconds, isPaused) ?: false + + suspend fun ready( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + ): Boolean = currentWritableRealtime()?.ready(sessionId, positionSeconds, isPaused) ?: false + + suspend fun buffering( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + ): Boolean = currentWritableRealtime()?.buffering(sessionId, positionSeconds, isPaused) ?: false + + suspend fun ping(clientSentAt: String): Boolean = + currentWritableRealtime()?.ping(clientSentAt) ?: false // ---- WS lifecycle: connect + reconnect-with-backoff ------------------------ @@ -265,97 +520,225 @@ class WatchTogetherRepository( * server `room_closed` arrives (which stops reconnecting). Backoff steps are * [BACKOFF_MS]; a healthy event resets the index. */ - suspend fun connect(roomId: String) { + override suspend fun connect(roomId: String) { + val lease = activeBinding()?.takeIf { it.roomId == roomId } ?: return val client = realtimeFactory() ?: return - realtime = client - _roomClosedReason.value = null // fresh connect: clear any stale close/error reason + val owner = stateMutex.withLock { + if (!isCurrentLocked(lease)) return + val newOwner = ++nextConnectionOwner + activeConnectionOwner = newOwner + realtime = client + realtimeGeneration = lease.generation + realtimeConnectionId = null + _connectionState.value = _connectionState.value.copy( + generation = lease.generation, + writable = false, + ) + _roomClosedReason.value = null + refreshTransportAuthorizationLocked() + newOwner + } var backoffIndex = 0 var failures = 0 while (true) { + if (!isCurrent(lease, owner)) break var closedByServer = false + var openedAtMs: Long? = null + var sawSnapshot = false try { - client.connect(roomId, roomToken).collect { event -> + client.connect(lease.roomId, lease.roomToken, lease.authScope).collect { event -> + if (!isCurrent(lease, owner)) throw ObsoleteBinding if (event is RoomRealtimeEvent.Closed) { // Any server-initiated close (with or without a reason) is terminal. // The event flow (a hot SharedFlow) never completes on its // own, so we stop collecting by throwing a private sentinel. closedByServer = true - _roomClosedReason.value = event.reason // surface "host left" etc. - _roomSnapshot.value = null + stateMutex.withLock { + if (isCurrentOwnerLocked(lease, owner)) { + terminalGeneration = lease.generation + _roomClosedReason.value = event.reason ?: "room_closed" + _roomSnapshot.value = null + refreshTransportAuthorizationLocked() + } + } throw ServerClosed - } else { - backoffIndex = 0 // healthy traffic resets backoff to short delays + } else if (event is RoomRealtimeEvent.TransportTerminated) { + markNotWritable(lease, owner) + throw TransportEnded + } else if (event is RoomRealtimeEvent.Opened) { + openedAtMs = monotonicNowMs() + markOpened(lease, owner, client.currentConnectionId()) + } else if ( + event is RoomRealtimeEvent.SnapshotEvent && + event.room.roomId == lease.roomId + ) { + sawSnapshot = true } - fold(event) + fold(lease, owner, event) } - // Flow completed without throwing — this was a clean connection end. - // Only reset the failure counter on a genuinely clean completion so a - // server that emits one event then drops every attempt cannot reset - // failures to 0 and bypass the cap. - failures = 0 + markNotWritable(lease, owner) + if (isStableAttempt(openedAtMs, sawSnapshot)) { + failures = 0 + backoffIndex = 0 + } + failures++ } catch (e: CancellationException) { - realtime = null + clearRealtimeIfCurrent(lease, client, owner) throw e + } catch (_: ObsoleteBinding) { + break } catch (_: ServerClosed) { // terminal — handled below via closedByServer } catch (_: Throwable) { // Any throw (including from a flapping server) counts as a failure, // regardless of whether a healthy event arrived in the same attempt. + markNotWritable(lease, owner) + if (isStableAttempt(openedAtMs, sawSnapshot)) { + failures = 0 + backoffIndex = 0 + } failures++ } if (closedByServer) break + if (!isCurrent(lease, owner)) break if (failures >= MAX_RECONNECT_ATTEMPTS) { - _roomClosedReason.value = "connection_lost" - _roomSnapshot.value = null + stateMutex.withLock { + if (isCurrentOwnerLocked(lease, owner)) { + terminalGeneration = lease.generation + _roomClosedReason.value = "connection_lost" + _roomSnapshot.value = null + refreshTransportAuthorizationLocked() + } + } break } delay(BACKOFF_MS[backoffIndex]) backoffIndex = (backoffIndex + 1).coerceAtMost(BACKOFF_MS.lastIndex) } - realtime = null + clearRealtimeIfCurrent(lease, client, owner) + } + + private suspend fun isCurrent(lease: RoomBinding): Boolean = + stateMutex.withLock { isCurrentLocked(lease) } + + private suspend fun isCurrent(lease: RoomBinding, owner: Long): Boolean = + stateMutex.withLock { isCurrentOwnerLocked(lease, owner) } + + private fun isCurrentOwnerLocked(lease: RoomBinding, owner: Long): Boolean = + isCurrentLocked(lease) && activeConnectionOwner == owner + + private suspend fun clearRealtimeIfCurrent( + lease: RoomBinding, + client: WatchTogetherRealtimeClient, + owner: Long, + ) { + stateMutex.withLock { + if ( + realtime === client && + realtimeGeneration == lease.generation && + activeConnectionOwner == owner + ) { + realtime = null + realtimeGeneration = null + realtimeConnectionId = null + activeConnectionOwner = null + _connectionState.value = _connectionState.value.copy(writable = false) + refreshTransportAuthorizationLocked() + } + } + } + + private suspend fun markOpened( + lease: RoomBinding, + owner: Long, + connectionId: Long?, + ) { + stateMutex.withLock { + if (isCurrentOwnerLocked(lease, owner)) { + realtimeConnectionId = connectionId + val previous = _connectionState.value + _connectionState.value = WatchTogetherConnectionState( + generation = lease.generation, + epoch = if (previous.generation == lease.generation) previous.epoch + 1 else 1, + writable = true, + ) + refreshTransportAuthorizationLocked() + } + } } + private suspend fun markNotWritable(lease: RoomBinding, owner: Long) { + stateMutex.withLock { + if (binding == lease && activeConnectionOwner == owner) { + _connectionState.value = _connectionState.value.copy(writable = false) + realtimeConnectionId = null + refreshTransportAuthorizationLocked() + } + } + } + + private fun isStableAttempt(openedAtMs: Long?, sawSnapshot: Boolean): Boolean = + sawSnapshot && openedAtMs != null && monotonicNowMs() - openedAtMs >= STABLE_CONNECTION_MS + /** Sentinel to unwind the [connect] collect loop on a server `room_closed`. */ private object ServerClosed : Throwable() + private object TransportEnded : Throwable() + private object ObsoleteBinding : Throwable() /** Pure-ish fold of one realtime event into the state flows + side streams. */ - private fun fold(event: RoomRealtimeEvent) { - when (event) { - is RoomRealtimeEvent.SnapshotEvent -> _roomSnapshot.value = event.room - is RoomRealtimeEvent.SuggestionsEvent -> applySuggestions(event.suggestions, fromBroadcast = true) - is RoomRealtimeEvent.TransportCommandEvent -> _transportCommands.tryEmit( - ScheduledTransportCommand( - command = event.command, - executeAtMs = parseRfc3339ToEpochMillis(event.command.executeAt), - ), - ) - is RoomRealtimeEvent.Pong -> _pongs.tryEmit( - PongSample( - clientSentMs = parseRfc3339ToEpochMillis(event.clientSentAt), - serverReceivedMs = parseRfc3339ToEpochMillis(event.serverReceivedAt), - serverSentMs = parseRfc3339ToEpochMillis(event.serverSentAt), - ), - ) - is RoomRealtimeEvent.Closed -> { /* lifecycle handled in connect() */ } - is RoomRealtimeEvent.Error -> - // Transient, NON-terminal: a server `error` frame (e.g. a rejected - // transport_request) must be "ignored gracefully" per the design. - // It must NOT feed roomClosedReason — that is reserved for the - // terminal room_closed/ServerClosed path, which the player observes - // to exit. Folding errors here would eject the user on any transient - // rejection. Surface to the transient [errors] stream instead. - _errors.tryEmit(event.message.ifBlank { event.code }) + private suspend fun fold(lease: RoomBinding, owner: Long, event: RoomRealtimeEvent) { + stateMutex.withLock { + if (!isCurrentOwnerLocked(lease, owner)) return + when (event) { + RoomRealtimeEvent.Opened -> Unit + is RoomRealtimeEvent.SnapshotEvent -> + if (event.room.roomId == lease.roomId) { + _roomSnapshot.value = event.room + refreshTransportAuthorizationLocked() + } + is RoomRealtimeEvent.SuggestionsEvent -> applySuggestions(event.suggestions, fromBroadcast = true) + is RoomRealtimeEvent.TransportCommandEvent -> _transportCommands.tryEmit( + ScheduledTransportCommand( + command = event.command, + executeAtMs = parseRfc3339ToEpochMillis(event.command.executeAt), + connection = _connectionState.value, + ), + ) + is RoomRealtimeEvent.Pong -> _pongs.tryEmit( + PongSample( + clientSentMs = parseRfc3339ToEpochMillis(event.clientSentAt), + serverReceivedMs = parseRfc3339ToEpochMillis(event.serverReceivedAt), + serverSentMs = parseRfc3339ToEpochMillis(event.serverSentAt), + ), + ) + is RoomRealtimeEvent.Closed -> { /* lifecycle handled in connect() */ } + is RoomRealtimeEvent.TransportTerminated -> Unit + is RoomRealtimeEvent.Error -> + // Transient, NON-terminal: a server `error` frame (e.g. a rejected + // transport_request) must NOT feed roomClosedReason. + _errors.tryEmit(event.message.ifBlank { event.code }) + } } } /** Clear all room state on leave. The connect() loop ends via scope cancellation. */ - fun reset() { - _roomSnapshot.value = null - _suggestions.value = emptyList() - _roomClosedReason.value = null - votedIds.clear() - roomToken = "" - realtime = null + override suspend fun reset() { + stateMutex.withLock { + nextGeneration++ + latestRoomRequest++ + binding = null + terminalGeneration = null + _roomSnapshot.value = null + _suggestions.value = emptyList() + _roomClosedReason.value = null + votedIds.clear() + realtime = null + realtimeGeneration = null + realtimeConnectionId = null + activeConnectionOwner = null + _connectionState.value = WatchTogetherConnectionState(generation = nextGeneration) + refreshTransportAuthorizationLocked() + } } companion object { @@ -368,5 +751,7 @@ class WatchTogetherRepository( * loop gives up. Reset to zero on any healthy server event. */ const val MAX_RECONNECT_ATTEMPTS = 6 + const val STABLE_CONNECTION_MS = 30_000L + private val MONOTONIC_ORIGIN = TimeSource.Monotonic.markNow() } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/UserItemStatePort.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/UserItemStatePort.kt index b2444b652..e57c3830f 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/UserItemStatePort.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/UserItemStatePort.kt @@ -2,6 +2,24 @@ package org.prairieserver.prairie.repository.port import org.prairieserver.prairie.network.AuthScopeSnapshot +data class PlaybackWriteScope( + val serverId: String, + val profileId: String, + val credentialGenerationId: String?, + val identityGeneration: Long, +) + +sealed interface TrackSelectionFingerprintUpdate { + data object Preserve : TrackSelectionFingerprintUpdate + data object Clear : TrackSelectionFingerprintUpdate + + data class Set(val fingerprint: String) : TrackSelectionFingerprintUpdate { + init { + require(fingerprint.isNotBlank()) { "Track-selection fingerprint must not be blank" } + } + } +} + /** * Local-first side-channel for **content-level** user-state mutations * (watched / favorite / rating). The strangler entry point for Track B: @@ -44,6 +62,18 @@ interface UserItemStatePort { ) { } + /** + * Records a final playback position only while the captured playback + * identity is still current. Returns true only when the write was accepted. + */ + suspend fun recordPosition( + scope: PlaybackWriteScope, + contentId: String, + fileId: Int, + positionSeconds: Double, + durationSeconds: Double?, + ): Boolean = false + /** * The locally-recorded resume position for an item, or null if none. Lets the * player resume from the last on-device position when offline (no server @@ -77,6 +107,54 @@ interface UserItemStatePort { suspend fun recordSubtitleTrackSelection(contentId: String, fileId: Int, subtitleFingerprint: String?) { } + suspend fun recordTrackSelection( + contentId: String, + fileId: Int, + audioFingerprint: String?, + subtitleFingerprint: String?, + ) { + recordTrackSelection( + contentId = contentId, + fileId = fileId, + audioUpdate = audioFingerprint.toTrackSelectionFingerprintUpdate(), + subtitleUpdate = subtitleFingerprint.toTrackSelectionFingerprintUpdate(), + ) + } + + suspend fun recordTrackSelection( + contentId: String, + fileId: Int, + audioUpdate: TrackSelectionFingerprintUpdate, + subtitleUpdate: TrackSelectionFingerprintUpdate, + ) { + when (audioUpdate) { + TrackSelectionFingerprintUpdate.Preserve -> Unit + TrackSelectionFingerprintUpdate.Clear -> + recordAudioTrackSelection(contentId, fileId, null) + is TrackSelectionFingerprintUpdate.Set -> + recordAudioTrackSelection(contentId, fileId, audioUpdate.fingerprint.trim()) + } + when (subtitleUpdate) { + TrackSelectionFingerprintUpdate.Preserve -> Unit + TrackSelectionFingerprintUpdate.Clear -> + recordSubtitleTrackSelection(contentId, fileId, null) + is TrackSelectionFingerprintUpdate.Set -> + recordSubtitleTrackSelection(contentId, fileId, subtitleUpdate.fingerprint.trim()) + } + } + + /** + * Records a final track selection only while the auth identity captured for + * this playback remains current. Returns true when the write was accepted. + */ + suspend fun recordTrackSelection( + scope: PlaybackWriteScope, + contentId: String, + fileId: Int, + audioUpdate: TrackSelectionFingerprintUpdate, + subtitleUpdate: TrackSelectionFingerprintUpdate, + ): Boolean = false + suspend fun localTrackSelection(contentId: String, fileId: Int): LocalTrackSelection? = null /** @@ -147,6 +225,13 @@ data class OutboxHandle(val opId: Long, val scope: AuthScopeSnapshot? = null) { */ enum class WriteOutcome { SYNCED, RETRIABLE, TERMINAL } +private fun String?.toTrackSelectionFingerprintUpdate(): TrackSelectionFingerprintUpdate = + this + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let(TrackSelectionFingerprintUpdate::Set) + ?: TrackSelectionFingerprintUpdate.Clear + /** Network-only behaviour: records nothing, resolves to nothing. */ object NoOpUserItemStatePort : UserItemStatePort { override suspend fun recordWatched(contentId: String, watched: Boolean) = OutboxHandle.NONE diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt new file mode 100644 index 000000000..fa4c97b58 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt @@ -0,0 +1,130 @@ +package org.prairieserver.prairie.watchtogether + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.TransportCommand +import org.prairieserver.prairie.repository.WatchTogetherConnectionState + +data class RoomDeliveryKey( + val connectionGeneration: Long, + val connectionEpoch: Long, + val playbackSessionId: String, +) + +/** + * Successful-delivery latches for one player controller. + * + * Failed sends never advance a latch, so a caller can retry. Reconnection + * changes the epoch and therefore re-arms attach/readiness even when the room + * snapshot and local buffering state are otherwise identical. + */ +class RoomDeliveryLatch { + private var attached: RoomDeliveryKey? = null + private var readiness: Pair? = null + + fun keyOrNull( + connection: WatchTogetherConnectionState, + playbackSessionId: String?, + ): RoomDeliveryKey? = + playbackSessionId + ?.takeIf { it.isNotBlank() && connection.writable } + ?.let { + RoomDeliveryKey( + connectionGeneration = connection.generation, + connectionEpoch = connection.epoch, + playbackSessionId = it, + ) + } + + fun needsAttach(key: RoomDeliveryKey): Boolean = attached != key + + fun isAttached(key: RoomDeliveryKey?): Boolean = key != null && attached == key + + fun recordAttach(key: RoomDeliveryKey, delivered: Boolean) { + if (delivered) attached = key + } + + fun needsReadiness(key: RoomDeliveryKey, buffering: Boolean): Boolean = + readiness != key to buffering + + fun recordReadiness(key: RoomDeliveryKey, buffering: Boolean, delivered: Boolean) { + if (delivered) readiness = key to buffering + } +} + +/** + * Starts each accepted command independently so an execute-at delay cannot + * prevent the transport collector from accepting a newer command. + */ +class RoomCommandDispatcher( + private val scope: CoroutineScope, +) { + fun dispatch(block: suspend () -> Unit) { + scope.launch { block() } + } +} + +class RoomPendingExecutionToken internal constructor() + +/** Per-command pending ownership; finishing one command cannot clear another. */ +class RoomPendingExecutions { + private val deadlines = MutableStateFlow>(emptyMap()) + + fun record(executeAtMs: Long): RoomPendingExecutionToken { + val token = RoomPendingExecutionToken() + deadlines.update { it + (token to executeAtMs) } + return token + } + + fun finish(token: RoomPendingExecutionToken) { + deadlines.update { it - token } + } + + fun nearestTo(nowMs: Long): Long? = + deadlines.value.values.minByOrNull { deadline -> + kotlin.math.abs(deadline - nowMs) + } +} + +/** + * A replaceable controller owns child collectors but never the process room + * lease. Disposing it cancels only these child jobs. + */ +class RoomControllerLifetime(parentScope: CoroutineScope) { + private val job = SupervisorJob(parentScope.coroutineContext[Job]) + val scope = CoroutineScope(parentScope.coroutineContext + job) + + fun dispose() { + job.cancel() + } +} + +/** + * Revalidate a delayed command immediately before touching the player. This + * prevents a command accepted for playback session A from seeking/pausing a + * replacement session B after its execute-at delay. + */ +fun scheduledCommandStillCurrent( + command: TransportCommand, + acceptedPlaybackSessionId: String?, + acceptedRoomId: String, + acceptedConnection: WatchTogetherConnectionState, + currentPlaybackSessionId: String?, + currentRoom: RoomSnapshot?, + currentConnection: WatchTogetherConnectionState, +): Boolean = + !acceptedPlaybackSessionId.isNullOrBlank() && + currentPlaybackSessionId == acceptedPlaybackSessionId && + (command.sessionId.isBlank() || command.sessionId == acceptedPlaybackSessionId) && + acceptedRoomId.isNotBlank() && + currentRoom?.roomId == acceptedRoomId && + currentRoom.selectionRevision == command.selectionRevision && + acceptedConnection.writable && + currentConnection.writable && + currentConnection.generation == acceptedConnection.generation && + currentConnection.epoch == acceptedConnection.epoch diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomEntryUiPolicy.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomEntryUiPolicy.kt new file mode 100644 index 000000000..5bfb563ab --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomEntryUiPolicy.kt @@ -0,0 +1,7 @@ +package org.prairieserver.prairie.watchtogether + +/** + * A create/join operation installs a process-scoped room binding on success. + * Keep its observer visible until that mutation resolves. + */ +fun canDismissRoomEntry(isBusy: Boolean): Boolean = !isBusy diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomSession.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomSession.kt new file mode 100644 index 000000000..08fe624ec --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomSession.kt @@ -0,0 +1,116 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionPhase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Narrow room lifecycle port so the application-scoped owner can be tested + * independently from REST and websocket implementations. + */ +interface RoomSessionRepository { + val roomSnapshot: StateFlow + val roomClosedReason: StateFlow + + suspend fun connect(roomId: String) + suspend fun reset() + suspend fun closeRoom(): ApiResult +} + +/** A Watch Together room, not a device-local episode queue, owns title changes. */ +fun shouldNavigateToLocalNext(inWatchTogetherRoom: Boolean): Boolean = + !inWatchTogetherRoom + +/** + * The single application-scoped owner of a Watch Together connection. + * + * Screen scopes adopt this session; they never own the socket. Replacement + * and leave are serialized, and the previous job is joined before repository + * state can be reused by another room. + */ +class RoomSession( + private val repository: RoomSessionRepository, + private val scope: CoroutineScope, + identityTransitions: IdentityTransitionBarrier, +) { + private val mutex = Mutex() + private var connectionJob: Job? = null + private var connectedRoomId: String? = null + + val room: StateFlow = repository.roomSnapshot + val closedReason: StateFlow = repository.roomClosedReason + + init { + identityTransitions.installGate { transition -> + if (transition.phase == IdentityTransitionPhase.WILL_CHANGE) { + leave() + } + } + } + + /** + * UI-facing entry point. The returned job is owned by the process scope, so + * navigation cannot cancel a queued replacement midway through teardown. + */ + fun adopt(roomId: String): Job = + scope.launch(start = CoroutineStart.UNDISPATCHED) { enter(roomId) } + + /** + * UI-facing durable departure. If requested, close is attempted while the + * current lease is still live; teardown then joins the socket and resets + * state even if the initiating screen disappears. + */ + fun depart(closeRoom: Boolean = false): Job = + scope.launch(start = CoroutineStart.UNDISPATCHED) { + mutex.withLock { + if (closeRoom && connectedRoomId != null) { + try { + repository.closeRoom() + } finally { + leaveLocked() + } + } else { + leaveLocked() + } + } + } + + suspend fun enter(roomId: String) { + if (roomId.isBlank()) return + mutex.withLock { + if (connectedRoomId == roomId && connectionJob?.isActive == true) return + connectionJob?.cancelAndJoin() + connectedRoomId = roomId + // Establish repository ownership before enter() returns. Without + // UNDISTPATCHED, a concurrent replacement can cancel a queued job + // before it ever installs its connection owner. + connectionJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + repository.connect(roomId) + } + } + } + + suspend fun leave() { + mutex.withLock { + leaveLocked() + } + } + + suspend fun isActive(): Boolean = mutex.withLock { connectionJob?.isActive == true } + + private suspend fun leaveLocked() { + connectionJob?.cancelAndJoin() + connectionJob = null + connectedRoomId = null + repository.reset() + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomTransportAuthority.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomTransportAuthority.kt index 3bfb49ba8..8867b282a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomTransportAuthority.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomTransportAuthority.kt @@ -7,6 +7,19 @@ import org.prairieserver.prairie.model.watchtogether.RoomSnapshot /** A local transport intent originating from the UI: a play/pause toggle or a seek. */ enum class RoomTransportIntent { PlayPause, Seek } +/** + * Reconciliation required when a platform media control changes the local + * player directly instead of entering through the in-app room controls. + * + * [restorePlayWhenReady] immediately returns the player to the server-owned + * room state. [requestIsPaused] is non-null only when the member is authorized + * to ask the room to adopt the attempted state. + */ +data class ExternalPlayPauseDecision( + val restorePlayWhenReady: Boolean, + val requestIsPaused: Boolean?, +) + /** * Single source of truth for Watch Together transport authority, shared across * mobile and TV. Mirrors prairie-server `internal/watchtogether/service.go`: @@ -26,3 +39,30 @@ fun roomTransportAuthorized(snapshot: RoomSnapshot?, intent: RoomTransportIntent RoomTransportIntent.PlayPause -> snapshot.selfCanControlTransport } } + +/** + * Keeps notification, headset, Bluetooth, and other MediaSession controls from + * creating a local-only fork from Watch Together playback. + * + * Every external toggle is first restored to the authoritative room state. An + * authorized toggle is then requested through the room and will be applied to + * every participant by the resulting scheduled transport command. + */ +fun externalPlayPauseDecision( + snapshot: RoomSnapshot?, + localPlayWhenReady: Boolean, +): ExternalPlayPauseDecision? { + if (snapshot == null || snapshot.phase != RoomPhase.Playing) return null + val authoritativePlayWhenReady = !snapshot.isPaused + if (localPlayWhenReady == authoritativePlayWhenReady) return null + return ExternalPlayPauseDecision( + restorePlayWhenReady = authoritativePlayWhenReady, + requestIsPaused = if ( + roomTransportAuthorized(snapshot, RoomTransportIntent.PlayPause) + ) { + !localPlayWhenReady + } else { + null + }, + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomVote.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomVote.kt new file mode 100644 index 000000000..680f5d185 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomVote.kt @@ -0,0 +1,18 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion + +/** + * Returns the winner from the server-ordered suggestion list. + * + * The server sorts by vote count and then age, so the first suggestion wins + * only after somebody has voted. An unvoted list intentionally has no winner. + */ +fun roomVoteWinner(suggestions: List): Suggestion? = + suggestions.firstOrNull()?.takeIf { it.voteCount > 0 } + +/** Whether this room decides its selection by vote. */ +fun RoomSnapshot?.isVoteRoom(): Boolean = + this != null && selectionMode == RoomSelectionMode.Vote diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/CatalogTrackSerializationTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/CatalogTrackSerializationTest.kt index 47fee3c57..10c73b17b 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/CatalogTrackSerializationTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/CatalogTrackSerializationTest.kt @@ -45,4 +45,31 @@ class CatalogTrackSerializationTest { assertEquals("Profile 8", track.dolbyVision) assertEquals(8, track.dolbyVisionProfile) } + + @Test + fun `VideoTrack decodes server color range`() { + val source = """{"codec":"hevc","color_range":"tv"}""" + + val track = json.decodeFromString(source) + + assertEquals("tv", track.colorRange) + } + + @Test + fun `VideoTrack decodes server rational frame rate`() { + val source = """{"codec":"h264","frame_rate":"30000/1001"}""" + + val track = json.decodeFromString(source) + + assertEquals(29.97002997002997, track.frameRate) + } + + @Test + fun `VideoTrack keeps accepting numeric frame rate`() { + val source = """{"codec":"h264","frame_rate":30.0}""" + + val track = json.decodeFromString(source) + + assertEquals(30.0, track.frameRate) + } } 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 766b69e02..b45225b19 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.assertFalse +import kotlin.test.assertTrue class ClientSurfacePolicyTest { @Test - fun watchTogetherCodeStaysPresentButHiddenFromUserMenus() { - assertFalse(CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) + fun watchTogetherIsExposedInTheDetailOverflows() { + assertTrue(CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) } } 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 index 50284d52e..7280c408b 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackModelsV2SerializationTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackModelsV2SerializationTest.kt @@ -6,6 +6,7 @@ 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 { @@ -91,6 +92,42 @@ class PlaybackModelsV2SerializationTest { 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 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 c2af7e913..bb6b7b958 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 @@ -79,6 +79,17 @@ class PlaybackProtocolV3Test { assertEquals(84, decoded.effectiveMediaFileId) } + @Test + fun sourceColorRangeRoundTrips() { + val encoded = PrairieJson.encodeToString( + plan.copy(source = PlaybackSourceDescriptorV3(colorRange = "pc")), + ) + + val decoded = PrairieJson.decodeFromString(encoded) + + assertEquals("pc", decoded.source.colorRange) + } + @Test fun adaptationUnavailableIsTerminal() { val result = PlaybackDecisionResponseV3( 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 58b6c1adb..1c92dedf7 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 @@ -64,15 +64,78 @@ class SubtitleTrackMergeTest { assertEquals("srt", first.codec) assertEquals("Dune.Part.Three.WEB-DL (opensubtitles)", first.label) // `${release_name} (${provider})` assertEquals("downloaded", first.source) + assertEquals(312, first.downloadId) assertNull(first.forced) assertEquals("/stream/sess-1/subtitles/2.vtt", first.url) val second = merged[3] assertEquals(3, second.index) + assertEquals(313, second.downloadId) assertEquals("Dune Part Three (subdl)", second.label) assertEquals("/stream/sess-1/subtitles/3.ass", second.url) } + @Test + fun `download identity survives provider result reordering`() { + val first = mergeDownloadedSubtitles( + existing = listOf(track(0, source = "embedded")), + downloaded = listOf(downloaded(id = 312), downloaded(id = 313)), + sessionId = "sess-1", + serverUrl = "https://silo.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", + ) + + assertEquals(1, first.single { it.downloadId == 312 }.index) + assertEquals(2, reordered.single { it.downloadId == 312 }.index) + assertEquals(312, reordered.single { it.label == "Release.312 (opensubtitles)" }.downloadId) + } + + @Test + fun `download identity survives deletion of an earlier provider result`() { + val beforeDeletion = mergeDownloadedSubtitles( + existing = listOf(track(0, source = "embedded")), + downloaded = listOf(downloaded(id = 312), downloaded(id = 313)), + sessionId = "sess-1", + serverUrl = "https://silo.example", + ) + val afterDeletion = mergeDownloadedSubtitles( + existing = listOf(track(0, source = "embedded")), + downloaded = listOf(downloaded(id = 313)), + sessionId = "sess-1", + serverUrl = "https://silo.example", + ) + + assertEquals(2, beforeDeletion.single { it.downloadId == 313 }.index) + assertEquals(1, afterDeletion.single { it.source == "downloaded" }.index) + assertEquals(313, afterDeletion.single { it.source == "downloaded" }.downloadId) + } + + @Test + fun `download identity survives catalog artifact count changes`() { + val shortCatalog = mergeDownloadedSubtitles( + existing = listOf(track(0, source = "embedded")), + downloaded = listOf(downloaded(id = 312)), + sessionId = "sess-1", + serverUrl = "https://silo.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", + ) + + assertEquals(1, shortCatalog.single { it.source == "downloaded" }.index) + assertEquals(8, expandedCatalog.single { it.source == "downloaded" }.index) + assertEquals(312, shortCatalog.single { it.source == "downloaded" }.downloadId) + assertEquals(312, expandedCatalog.single { it.source == "downloaded" }.downloadId) + } + @Test fun `empty downloaded list returns existing unchanged`() { val existing = listOf(track(0, source = "embedded")) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTransitionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTransitionTest.kt new file mode 100644 index 000000000..bc646e3b8 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTransitionTest.kt @@ -0,0 +1,356 @@ +package org.prairieserver.prairie.model.playback + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SubtitleTransitionTest { + @Test + fun `A remains committed while B applies then B commits`() { + val initial = SubtitleTransitionState.committed(serverSidecar(3)) + val applying = reduceSubtitleTransition(initial, SelectSubtitle(serverSidecar(4))) + assertEquals(serverSidecar(3), applying.state.committed.identity) + assertEquals(serverSidecar(4), applying.state.pending?.identity) + assertTrue(applying.effects.single() is StageSubtitleReplan) + + val committed = reduceSubtitleTransition( + applying.state, + StagedSubtitleValidated(applying.state.pending!!.generation, candidate("s2")), + ) + assertEquals(serverSidecar(4), committed.state.committed.identity) + assertNull(committed.state.pending) + assertEquals( + listOf(CommitStagedSubtitleReplan::class, PersistSubtitleSelection::class), + committed.effects.map { it::class }, + ) + } + + @Test + fun `A to B to C retains A and discards the superseded B response`() { + val initial = SubtitleTransitionState.committed(serverSidecar(1)) + val applyingB = reduceSubtitleTransition(initial, SelectSubtitle(serverSidecar(2))) + val applyingC = reduceSubtitleTransition(applyingB.state, SelectSubtitle(serverSidecar(3))) + + assertEquals(serverSidecar(1), applyingC.state.committed.identity) + assertEquals(serverSidecar(3), applyingC.state.pending?.identity) + assertTrue(applyingC.state.pending!!.generation > applyingB.state.pending!!.generation) + + val staleB = reduceSubtitleTransition( + applyingC.state, + StagedSubtitleValidated(applyingB.state.pending!!.generation, candidate("s2")), + ) + assertEquals(applyingC.state, staleB.state) + assertEquals(candidate("s2"), assertIs(staleB.effects.single()).candidate) + + val committedC = reduceSubtitleTransition( + staleB.state, + StagedSubtitleValidated(applyingC.state.pending!!.generation, candidate("s3")), + ) + assertEquals(serverSidecar(3), committedC.state.committed.identity) + assertNull(committedC.state.pending) + } + + @Test + fun `A remains committed while Off applies then Off commits`() { + val initial = SubtitleTransitionState.committed(serverSidecar(3)) + val applying = reduceSubtitleTransition(initial, SelectSubtitle(SubtitleIdentity.Off)) + + assertEquals(serverSidecar(3), applying.state.committed.identity) + assertEquals(SubtitleIdentity.Off, applying.state.pending?.identity) + assertIs(applying.effects.single()) + + val committed = reduceSubtitleTransition( + applying.state, + StagedSubtitleValidated(applying.state.pending!!.generation, candidate("off-session")), + ) + assertEquals(SubtitleIdentity.Off, committed.state.committed.identity) + assertNull(committed.state.pending) + } + + @Test + fun `matching staged failure clears pending and retains committed`() { + val initial = SubtitleTransitionState.committed(serverSidecar(3)) + val applying = reduceSubtitleTransition(initial, SelectSubtitle(serverSidecar(4))) + + val failed = reduceSubtitleTransition( + applying.state, + StagedSubtitleFailed(applying.state.pending!!.generation, "Subtitle unavailable"), + ) + + assertEquals(serverSidecar(3), failed.state.committed.identity) + assertNull(failed.state.pending) + assertEquals( + "Subtitle unavailable", + assertIs(failed.effects.single()).message, + ) + } + + @Test + fun `superseded failure cannot clear the latest pending selection`() { + val initial = SubtitleTransitionState.committed(serverSidecar(1)) + val applyingB = reduceSubtitleTransition(initial, SelectSubtitle(serverSidecar(2))) + val applyingC = reduceSubtitleTransition(applyingB.state, SelectSubtitle(serverSidecar(3))) + + val staleFailure = reduceSubtitleTransition( + applyingC.state, + StagedSubtitleFailed(applyingB.state.pending!!.generation, "B failed"), + ) + + assertEquals(applyingC.state, staleFailure.state) + assertTrue(staleFailure.effects.isEmpty()) + } + + @Test + fun `content reset invalidates pending generations and stale candidates`() { + val applying = reduceSubtitleTransition( + SubtitleTransitionState.committed(serverSidecar(3)), + SelectSubtitle(serverSidecar(4)), + ) + val oldGeneration = applying.state.pending!!.generation + + val reset = reduceSubtitleTransition(applying.state, SubtitleContentReset(SubtitleIdentity.Off)) + assertEquals(SubtitleIdentity.Off, reset.state.committed.identity) + assertNull(reset.state.pending) + assertTrue(reset.state.nextGeneration > applying.state.nextGeneration) + + val stale = reduceSubtitleTransition( + reset.state, + StagedSubtitleValidated(oldGeneration, candidate("old-content")), + ) + assertEquals(reset.state, stale.state) + assertEquals(candidate("old-content"), assertIs(stale.effects.single()).candidate) + } + + @Test + fun `local Media3 selection commits synchronously`() { + val local = SubtitleIdentity.LocalMedia3(media(trackId = "media3-text-7")) + + val selected = reduceSubtitleTransition( + SubtitleTransitionState.committed(serverSidecar(3)), + SelectSubtitle(local), + ) + + assertEquals(local, selected.state.committed.identity) + assertNull(selected.state.pending) + assertEquals( + listOf(ApplyLocalSubtitleSelection::class, PersistSubtitleSelection::class), + selected.effects.map { it::class }, + ) + } + + @Test + fun `burn-in remains pending until its matching staged validation`() { + val burnIn = SubtitleIdentity.ServerBurnIn(serverIndex = 8) + val applying = reduceSubtitleTransition( + SubtitleTransitionState.committed(serverSidecar(3)), + SelectSubtitle(burnIn), + ) + + assertEquals(serverSidecar(3), applying.state.committed.identity) + assertEquals(burnIn, applying.state.pending?.identity) + assertEquals(burnIn, assertIs(applying.effects.single()).pending.identity) + + val committed = reduceSubtitleTransition( + applying.state, + StagedSubtitleValidated(applying.state.pending!!.generation, candidate("burn-in-session")), + ) + assertEquals(burnIn, committed.state.committed.identity) + } + + @Test + fun `downloaded identity keeps its stable id and media fallback when committed locally`() { + val fallback = media( + trackId = "downloaded-42", + label = "English (OpenSubtitles)", + language = "en", + codecFamily = "text-vtt", + hearingImpaired = true, + ) + val downloaded = SubtitleIdentity.Downloaded(downloadId = 42, media = fallback) + + val selected = reduceSubtitleTransition( + SubtitleTransitionState.committed(SubtitleIdentity.Off), + SelectSubtitle(downloaded), + ) + + assertEquals(downloaded, selected.state.committed.identity) + assertEquals( + downloaded, + assertIs(selected.effects.last()).committed.identity, + ) + } + + @Test + fun `audio then downloaded remains one pending server and mount transaction`() { + val downloaded = SubtitleIdentity.Downloaded( + downloadId = 42, + media = media(trackId = "prairie-downloaded-subtitle:42"), + ) + val audio = reduceSubtitleTransition( + SubtitleTransitionState.committed(serverSidecar(3), audioTrackIndex = 2), + UpdateAudioPreference(audioTrackIndex = 7), + ) + + val selected = reduceSubtitleTransition(audio.state, SelectSubtitle(downloaded)) + + assertEquals(serverSidecar(3), selected.state.committed.identity) + assertEquals(downloaded, selected.state.pending?.identity) + assertEquals(7, selected.state.pending?.audioTrackIndex) + assertTrue(selected.state.pending?.audioPreferenceSpecified == true) + assertIs(selected.effects.single()) + } + + @Test + fun `audio then local Media3 remains one pending server and mount transaction`() { + val local = SubtitleIdentity.LocalMedia3(media(trackId = "decoder-text-7")) + val audio = reduceSubtitleTransition( + SubtitleTransitionState.committed(serverSidecar(3), audioTrackIndex = 2), + UpdateAudioPreference(audioTrackIndex = 7), + ) + + val selected = reduceSubtitleTransition(audio.state, SelectSubtitle(local)) + + assertEquals(serverSidecar(3), selected.state.committed.identity) + assertEquals(local, selected.state.pending?.identity) + assertEquals(7, selected.state.pending?.audioTrackIndex) + assertTrue(selected.state.pending?.audioPreferenceSpecified == true) + assertIs(selected.effects.single()) + } + + @Test + fun `audio and quality preferences merge independently in either order`() { + val subtitle = serverSidecar(4) + + fun merged(events: List): PendingSubtitle { + var state = SubtitleTransitionState.committed(serverSidecar(3)) + events.forEach { event -> state = reduceSubtitleTransition(state, event).state } + return state.pending!! + } + + val audioThenQuality = merged( + listOf( + SelectSubtitle(subtitle), + UpdateAudioPreference(audioTrackIndex = 2), + UpdateQualityPreference(qualityPreference = "1080p"), + ), + ) + val qualityThenAudio = merged( + listOf( + SelectSubtitle(subtitle), + UpdateQualityPreference(qualityPreference = "1080p"), + UpdateAudioPreference(audioTrackIndex = 2), + ), + ) + + assertEquals(subtitle, audioThenQuality.identity) + assertEquals(2, audioThenQuality.audioTrackIndex) + assertEquals("1080p", audioThenQuality.qualityPreference) + assertEquals( + audioThenQuality.copy(generation = qualityThenAudio.generation), + qualityThenAudio, + ) + } + + @Test + fun `explicit quality clear survives a later audio update`() { + val initial = stateWithPreferences() + val qualityCleared = reduceSubtitleTransition( + initial, + UpdateQualityPreference(qualityPreference = null), + ) + + val audioUpdated = reduceSubtitleTransition( + qualityCleared.state, + UpdateAudioPreference(audioTrackIndex = 2), + ) + + assertEquals(2, audioUpdated.state.pending?.audioTrackIndex) + assertNull(audioUpdated.state.pending?.qualityPreference) + assertTrue(audioUpdated.state.pending?.qualityPreferenceSpecified == true) + } + + @Test + fun `explicit quality clear survives a later subtitle selection`() { + val initial = stateWithPreferences() + val qualityCleared = reduceSubtitleTransition( + initial, + UpdateQualityPreference(qualityPreference = null), + ) + + val subtitleSelected = reduceSubtitleTransition( + qualityCleared.state, + SelectSubtitle(serverSidecar(4)), + ) + + assertEquals(serverSidecar(4), subtitleSelected.state.pending?.identity) + assertNull(subtitleSelected.state.pending?.qualityPreference) + assertTrue(subtitleSelected.state.pending?.qualityPreferenceSpecified == true) + } + + @Test + fun `explicit audio clear survives a later quality update`() { + val initial = stateWithPreferences() + val audioCleared = reduceSubtitleTransition( + initial, + UpdateAudioPreference(audioTrackIndex = null), + ) + + val qualityUpdated = reduceSubtitleTransition( + audioCleared.state, + UpdateQualityPreference(qualityPreference = "1080p"), + ) + + assertNull(qualityUpdated.state.pending?.audioTrackIndex) + assertEquals("1080p", qualityUpdated.state.pending?.qualityPreference) + assertTrue(qualityUpdated.state.pending?.audioPreferenceSpecified == true) + } + + @Test + fun `explicit audio clear survives a later subtitle selection`() { + val initial = stateWithPreferences() + val audioCleared = reduceSubtitleTransition( + initial, + UpdateAudioPreference(audioTrackIndex = null), + ) + + val subtitleSelected = reduceSubtitleTransition( + audioCleared.state, + SelectSubtitle(serverSidecar(4)), + ) + + assertEquals(serverSidecar(4), subtitleSelected.state.pending?.identity) + assertNull(subtitleSelected.state.pending?.audioTrackIndex) + assertTrue(subtitleSelected.state.pending?.audioPreferenceSpecified == true) + } + + private fun serverSidecar(index: Int): SubtitleIdentity = + SubtitleIdentity.ServerSidecar(serverIndex = index) + + private fun candidate(id: String): StagedSubtitleCandidate = + StagedSubtitleCandidate(id) + + private fun stateWithPreferences(): SubtitleTransitionState = + SubtitleTransitionState.committed( + identity = serverSidecar(3), + audioTrackIndex = 7, + qualityPreference = "4k", + ) + + private fun media( + trackId: String? = null, + label: String? = null, + language: String? = null, + codecFamily: String? = null, + forced: Boolean? = null, + hearingImpaired: Boolean? = null, + ): SubtitleMediaIdentity = SubtitleMediaIdentity( + trackId = trackId, + label = label, + language = language, + codecFamily = codecFamily, + forced = forced, + hearingImpaired = hearingImpaired, + ) +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceTest.kt index cae467606..ce60f1dc0 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceTest.kt @@ -24,14 +24,14 @@ class SubtitleAppearanceTest { fun defaultSubtitleAppearanceMatchesTheTvReferenceStyle() { assertEquals("#ffffff", SubtitleAppearance.DEFAULT.fontColor) assertEquals(SubtitleAppearance.SANS_SERIF, SubtitleAppearance.DEFAULT.fontFamily) - assertEquals(SubtitleBackgroundStylePreset.None, SubtitleAppearance.DEFAULT.backgroundStyle) - assertEquals(true, SubtitleAppearance.DEFAULT.textOutline) + assertEquals(SubtitleBackgroundStylePreset.Shadow, SubtitleAppearance.DEFAULT.backgroundStyle) + assertEquals(false, SubtitleAppearance.DEFAULT.textOutline) assertEquals("#000000", SubtitleAppearance.DEFAULT.textOutlineColor) assertEquals(SubtitlePositionPreset.Bottom, SubtitleAppearance.DEFAULT.position) } @Test - fun decodingMissingBackgroundStyleUsesNoBackgroundDefault() { + fun decodingMissingBackgroundStyleUsesTheDefaultStyle() { val decoded = SubtitleAppearance.decode( """ { @@ -47,6 +47,6 @@ class SubtitleAppearanceTest { """.trimIndent(), ) - assertEquals(SubtitleBackgroundStylePreset.None, decoded.backgroundStyle) + assertEquals(SubtitleBackgroundStylePreset.Shadow, decoded.backgroundStyle) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/HttpOriginPolicyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/HttpOriginPolicyTest.kt new file mode 100644 index 000000000..17fd1713a --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/HttpOriginPolicyTest.kt @@ -0,0 +1,177 @@ +package org.prairieserver.prairie.network + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class HttpOriginPolicyTest { + @Test + fun cleartextApprovalUsesCanonicalExactOriginAndHttpsNeedsNoApproval() = runTest { + val approved = setOf("http://prairie.example", "http://[2001:db8::1]:8090") + val consent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = + canonicalHttpOrigin(origin) in approved + } + + assertFalse(consent.requiresApproval("HTTP://PRAIRIE.EXAMPLE:80/path")) + assertFalse(consent.requiresApproval("http://[2001:db8::1]:8090/path")) + assertTrue(consent.requiresApproval("http://prairie.example:8090/path")) + assertTrue(consent.requiresApproval("http://user:secret@prairie.example/path")) + assertTrue(consent.requiresApproval("""http:\\prairie.example\private""")) + assertTrue(consent.requiresApproval("HtTp:/prairie.example/private")) + assertFalse(consent.requiresApproval("https://prairie.example")) + } + + @Test + fun webSocketSchemesUseTheirCorrespondingHttpOriginsForConsent() = runTest { + val consent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = + origin == "http://prairie.example" + } + + assertFalse(consent.requiresApproval("ws://PRAIRIE.EXAMPLE:80/rooms/r/ws?room_token=secret")) + assertTrue(consent.requiresApproval("ws://prairie.example:8090/rooms/r/ws")) + assertFalse(consent.requiresApproval("wss://prairie.example/rooms/r/ws")) + assertEquals("http://prairie.example", canonicalHttpOrigin("ws://PRAIRIE.EXAMPLE:80/rooms/r/ws")) + assertEquals("https://prairie.example", canonicalHttpOrigin("wss://PRAIRIE.EXAMPLE:443/rooms/r/ws")) + assertTrue(isSameHttpOrigin("http://prairie.example", "ws://prairie.example/rooms/r/ws")) + assertTrue(isSameHttpOrigin("https://prairie.example", "wss://prairie.example/rooms/r/ws")) + } + + @Test + fun defaultPortsAndCaseNormalize() { + assertTrue(isSameHttpOrigin("HTTPS://Prairie.Example", "https://prairie.example:443/a")) + assertTrue(isSameHttpOrigin("http://prairie.example", "http://PRAIRIE.EXAMPLE:80/a")) + assertEquals( + HttpOrigin(scheme = "https", host = "prairie.example", port = 443), + httpOrigin("HTTPS://Prairie.Example"), + ) + } + + @Test + fun pathQueryAndFragmentDoNotAffectOrigin() { + assertTrue( + isSameHttpOrigin( + "https://prairie.example/base?server=true#settings", + "https://PRAIRIE.EXAMPLE/library/items?offset=10#details", + ), + ) + } + + @Test + fun matchingNonDefaultPortsArePreserved() { + assertEquals( + HttpOrigin(scheme = "https", host = "prairie.example", port = 8443), + httpOrigin("https://prairie.example:8443/a"), + ) + assertTrue( + isSameHttpOrigin( + "https://prairie.example:8443", + "https://PRAIRIE.EXAMPLE:8443/a", + ), + ) + } + + @Test + fun schemeHostPortAndUserInfoMismatchesFailClosed() { + assertFalse(isSameHttpOrigin("https://prairie.example", "http://prairie.example/a")) + assertFalse(isSameHttpOrigin("https://prairie.example", "https://cdn.prairie.example/a")) + assertFalse(isSameHttpOrigin("https://prairie.example", "https://prairie.example:444/a")) + assertNull(httpOrigin("https://user@prairie.example/a")) + assertNull(httpOrigin("file:///tmp/a")) + } + + @Test + fun everyUserInfoFormIsRejected() { + assertNull(httpOrigin("https://user:password@prairie.example/a")) + assertNull(httpOrigin("https://:password@prairie.example/a")) + assertNull(httpOrigin("https://@prairie.example/a")) + assertNull(httpOrigin("https://user:@prairie.example/a")) + } + + @Test + fun relativeAndMissingAuthorityUrlsAreRejected() { + listOf( + "", + " ", + "/relative/path", + "prairie.example/path", + "https://", + "https:///path", + "https://?query", + "https://#fragment", + "https://:443/path", + ).forEach { raw -> + assertNull(httpOrigin(raw), "Expected a missing authority to be rejected: '$raw'") + } + } + + @Test + fun malformedHostsAndPortsAreRejected() { + assertNull(httpOrigin("https://prairie example/path")) + assertNull(httpOrigin("https://prairie.example:65536/path")) + assertNull(httpOrigin("https://prairie.example:-1/path")) + } + + @Test + fun backslashAuthorityConfusionFailsClosed() { + listOf( + "https://\\attacker.example", + "https://attacker.example\\path", + "https://localhost\\@attacker.example", + "https://attacker.example\u0001.evil", + ).forEach { raw -> + assertNull(httpOrigin(raw), "Expected a confused authority to be rejected: '$raw'") + } + assertFalse( + isSameHttpOrigin( + serverUrl = "https://localhost", + requestUrl = "https://\\attacker.example", + ), + ) + } + + @Test + fun malformedBracketedAuthoritiesFailClosed() { + listOf( + "https://[]/", + "https://[x]/", + "https://[::1/", + "https://[[::1]]/", + "https://[:::1]/", + "https://[::1]extra/", + ).forEach { raw -> + assertNull(httpOrigin(raw), "Expected an invalid bracketed host to be rejected: '$raw'") + } + } + + @Test + fun validatedIpv6AuthoritiesArePreserved() { + assertEquals( + HttpOrigin(scheme = "https", host = "[::1]", port = 443), + httpOrigin("HTTPS://[::1]"), + ) + assertTrue(isSameHttpOrigin("https://[::1]", "https://[::1]:443/a")) + assertTrue( + isSameHttpOrigin( + "http://[2001:db8:0:1::1]:8080", + "http://[2001:DB8:0:1::1]:8080/a", + ), + ) + } + + @Test + fun invalidOriginsNeverCompareEqual() { + assertFalse(isSameHttpOrigin("", "")) + assertFalse(isSameHttpOrigin("/server", "/request")) + assertFalse( + isSameHttpOrigin( + "https://user@prairie.example", + "https://user@prairie.example/path", + ), + ) + } +} 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 0997a389f..0d9ea3ea9 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrierTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrierTest.kt @@ -28,6 +28,20 @@ class IdentityTransitionBarrierTest { assertEquals(1, barrier.generation.value) } + @Test + fun allInstalledPrivacyGatesRunBeforeMutation() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val timeline = mutableListOf() + barrier.installGate { timeline += "diagnostics" } + barrier.installGate { timeline += "room" } + + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { + timeline += "mutation" + } + + assertEquals(listOf("diagnostics", "room", "mutation"), timeline) + } + @Test fun didChangeIsEmittedWhenMutationFails() = 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 a4009b289..643d02e81 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginPinTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginPinTest.kt @@ -3,13 +3,20 @@ 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.header import io.ktor.client.request.post import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertFailsWith /** * Verifies [PrairieAuthPlugin] honors an [AuthScopeSnapshot] pin: a request @@ -18,6 +25,120 @@ import kotlin.test.assertEquals * the network-layer guarantee the Track B outbox drain relies on. */ class PrairieAuthPluginPinTest { + @Test + fun unapprovedCleartextOriginIsBlockedBeforeCredentialsReachEngine() = runTest { + var engineCalled = false + val tokenManager = TokenManagerImpl().apply { + setServerUrl("http://prairie.lan") + saveTokens("access", "refresh", 3600) + } + val client = HttpClient( + MockEngine { + engineCalled = true + respond("{}", HttpStatusCode.OK) + }, + ) { + install(PrairieAuthPlugin) { + this.tokenManager = tokenManager + this.cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + } + } + } + + assertFailsWith { + client.get("/api/v1/items") + } + assertEquals(false, engineCalled) + client.close() + } + + @Test + fun unapprovedCleartextWebSocketIsBlockedBeforeCredentialsReachEngine() = runTest { + var engineCalled = false + val tokenManager = TokenManagerImpl().apply { + setServerUrl("http://prairie.lan") + saveTokens("access", "refresh", 3600) + setProfileId("profile") + setProfileToken("profile-token") + } + val client = HttpClient( + MockEngine { + engineCalled = true + respond("{}", HttpStatusCode.OK) + }, + ) { + install(PrairieAuthPlugin) { + this.tokenManager = tokenManager + this.cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + } + } + } + + assertFailsWith { + client.get("ws://prairie.lan/api/v1/watch-together/rooms/r/ws?room_token=secret") + } + assertEquals(false, engineCalled) + client.close() + } + + @Test + fun unauthenticatedLoginPostStillRequiresCleartextApproval() = runTest { + var engineCalled = false + val tokenManager = TokenManagerImpl().apply { setServerUrl("http://prairie.lan") } + val client = HttpClient( + MockEngine { + engineCalled = true + respond("{}", HttpStatusCode.OK) + }, + ) { + install(PrairieAuthPlugin) { + this.tokenManager = tokenManager + this.cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + } + } + } + + assertFailsWith { + client.post("/api/v1/auth/login") { skipPrairieAuth() } + } + assertEquals(false, engineCalled) + client.close() + } + + @Test + fun absoluteCandidateAuthPostsCheckTheirOwnCleartextOrigin() = runTest { + var engineCalled = false + val tokenManager = TokenManagerImpl().apply { setServerUrl("https://active.example") } + val client = HttpClient( + MockEngine { + engineCalled = true + respond("{}", HttpStatusCode.OK) + }, + ) { + install(PrairieAuthPlugin) { + this.tokenManager = tokenManager + this.cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = false + } + } + } + + listOf( + "/api/v1/auth/device/start", + "/api/v1/auth/device/poll", + "/api/v1/auth/remote-playback/start", + ).forEach { path -> + assertFailsWith(path) { + client.post("http://candidate.lan$path") { skipPrairieAuth() } + } + } + assertEquals(false, engineCalled) + client.close() + } + private class Captured { var url: String = "" @@ -114,4 +235,601 @@ class PrairieAuthPluginPinTest { assertEquals("Prairie Android TV", captured.prairieClient) assertEquals("0.2.3", captured.prairieClientVersion) } + + @Test + fun normalRequestsUseCredentialsOnlyOnConfiguredOrigin() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://prairie.example:8443") + setProfileId("profile-a") + setProfileToken("profile-token-a") + saveTokens(accessToken = "ACCESS-A", refreshToken = "REFRESH-A", expiresIn = 3600) + } + val captured = mutableListOf() + val client = capturingClient(tokenManager, captured) + + client.get("/api/v1/downloads/download-1/file") + client.get("https://prairie.example:8443/api/v1/items") + client.get("https://cdn.prairie.example:8443/video") + client.get("https://prairie.example:9443/video") + client.get("http://prairie.example:8443/video") + + assertEquals( + listOf( + "https://prairie.example:8443/api/v1/downloads/download-1/file", + "https://prairie.example:8443/api/v1/items", + "https://cdn.prairie.example:8443/video", + "https://prairie.example:9443/video", + "http://prairie.example:8443/video", + ), + captured.map(CapturedRequest::url), + ) + captured.take(2).forEach { request -> + assertEquals("Bearer ACCESS-A", request.authorization) + assertEquals("profile-a", request.profileId) + assertEquals("profile-token-a", request.profileToken) + } + captured.drop(2).forEach { request -> + assertNull(request.authorization, "Authorization leaked to ${request.url}") + assertNull(request.profileId, "X-Profile-Id leaked to ${request.url}") + assertNull(request.profileToken, "X-Profile-Token leaked to ${request.url}") + } + } + + @Test + fun pinnedForeignRequestRemovesSensitiveExplicitHeaders() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://active.example") + saveTokens(accessToken = "ACCESS-A", refreshToken = "REFRESH-A", expiresIn = 3600) + } + val captured = mutableListOf() + val snapshot = AuthScopeSnapshot( + serverId = "server-a", + profileId = "profile-a", + serverUrl = "https://prairie.example", + profileToken = "profile-token-a", + ) + + capturingClient(tokenManager, captured).get("https://cdn.example/video") { + authScope(snapshot) + header(HttpHeaders.Authorization, "Signed explicit-cdn-credential") + header("X-Profile-Id", "explicit-profile") + header("X-Profile-Token", "explicit-profile-token") + } + + val request = captured.single() + assertNull(request.authorization) + assertNull(request.profileId) + assertNull(request.profileToken) + } + + @Test + fun foreignUnauthorizedResponseDoesNotRefresh() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://prairie.example") + setProfileId("profile-a") + setProfileToken("profile-token-a") + saveTokens(accessToken = "ACCESS-A", refreshToken = "REFRESH-A", expiresIn = 3600) + } + val captured = mutableListOf() + val engine = MockEngine { request -> + captured += request.capture() + respond( + content = "{}", + status = if (request.url.host == "cdn.example") { + HttpStatusCode.Unauthorized + } else { + HttpStatusCode.InternalServerError + }, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val client = HttpClient(engine) { + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + client.get("https://cdn.example/video") + + assertEquals(listOf("https://cdn.example/video"), captured.map(CapturedRequest::url)) + assertNull(captured.single().authorization) + assertNull(captured.single().profileId) + assertNull(captured.single().profileToken) + } + + @Test + fun crossOriginRedirectDoesNotForwardPrairieOrExplicitAuthorizationHeaders() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://prairie.example") + setProfileId("profile-a") + setProfileToken("profile-token-a") + saveTokens(accessToken = "ACCESS-A", refreshToken = "REFRESH-A", expiresIn = 3600) + } + val captured = mutableListOf() + val provider = object : DeviceMetadataProvider { + override suspend fun current(): PrairieDeviceMetadata = + PrairieDeviceMetadata( + id = "device-1", + name = "Phone", + platform = "android", + clientName = "Prairie Android", + clientVersion = "1.0", + ) + } + val engine = MockEngine { request -> + captured += request.capture() + if (request.url.host == "prairie.example") { + respond( + content = "", + status = HttpStatusCode.Found, + headers = headersOf(HttpHeaders.Location, "https://cdn.example/video"), + ) + } else { + respond("ok", HttpStatusCode.OK) + } + } + val client = HttpClient(engine) { + followRedirects = true + install(PrairieAuthPlugin) { + this.tokenManager = tokenManager + deviceMetadataProvider = provider + } + } + + client.get("https://prairie.example/redirect") { + header(HttpHeaders.Authorization, "Signed explicit-target-credential") + } + + val downstream = captured.single { it.url == "https://cdn.example/video" } + assertNull(downstream.authorization) + assertNull(downstream.profileId) + assertNull(downstream.profileToken) + assertEquals(emptyMap(), downstream.prairieHeaders) + } + + @Test + fun activeServerSwitchDuringCredentialReadFailsClosed() = runTest { + val tokenManager = SwitchingTokenManager() + val captured = mutableListOf() + + capturingClient(tokenManager, captured).get("/api/v1/items") + + val request = captured.single() + assertEquals("https://server-a.example/api/v1/items", request.url) + assertNull(request.authorization) + assertNull(request.profileId) + assertNull(request.profileToken) + } + + @Test + fun foreignSkipAuthRequestDoesNotReceiveDeviceMetadata() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://prairie.example") + } + val captured = mutableListOf() + val provider = object : DeviceMetadataProvider { + override suspend fun current(): PrairieDeviceMetadata = + PrairieDeviceMetadata( + id = "device-1", + name = "Phone", + platform = "android", + clientName = "Prairie Android", + clientVersion = "1.0", + ) + } + + capturingClient( + tokenManager = tokenManager, + captured = captured, + deviceMetadataProvider = provider, + ).get("https://candidate.example/api/v1/auth/device/start") { + skipPrairieAuth() + } + + assertEquals(emptyMap(), captured.single().prairieHeaders) + } + + @Test + fun sameOriginSkipAuthRequestKeepsDeviceMetadata() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://prairie.example") + } + val captured = mutableListOf() + val provider = deviceMetadataProvider() + + capturingClient( + tokenManager = tokenManager, + captured = captured, + deviceMetadataProvider = provider, + ).get("https://prairie.example/api/v1/auth/login") { + skipPrairieAuth() + } + + assertEquals("device-1", captured.single().prairieHeaders["X-Prairie-Device-Id"]) + } + + @Test + fun crossOriginSkipAuthRedirectRemovesCopiedDeviceMetadata() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://prairie.example") + } + val captured = mutableListOf() + val engine = MockEngine { request -> + captured += request.capture() + if (request.url.host == "prairie.example") { + respond( + content = "", + status = HttpStatusCode.Found, + headers = headersOf(HttpHeaders.Location, "https://candidate.example/device"), + ) + } else { + respond("ok", HttpStatusCode.OK) + } + } + val client = HttpClient(engine) { + followRedirects = true + install(PrairieAuthPlugin) { + this.tokenManager = tokenManager + deviceMetadataProvider = deviceMetadataProvider() + } + } + + client.get("https://prairie.example/api/v1/auth/login") { + skipPrairieAuth() + } + + assertEquals( + emptyMap(), + captured.single { it.url == "https://candidate.example/device" }.prairieHeaders, + ) + } + + @Test + fun serverSwitchWhileRequestIsInFlightDoesNotRetryWithNewServerCredentials() = runTest { + val tokenManager = InFlightSwitchingTokenManager() + val captured = mutableListOf() + val engine = MockEngine { request -> + captured += request.capture() + if (captured.size == 1) { + tokenManager.switchToServerB() + respond("{}", HttpStatusCode.Unauthorized) + } else { + respond("{}", HttpStatusCode.OK) + } + } + val client = HttpClient(engine) { + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + client.get("https://server-a.example/api/v1/items") + + assertEquals(1, captured.size) + assertEquals("Bearer server-a-access", captured.single().authorization) + assertEquals(0, tokenManager.invalidations) + } + + @Test + fun normalUnauthorizedRefreshUsesOnlyCapturedScopeOperations() = runTest { + val tokenManager = ScopedRefreshTokenManager() + val paths = mutableListOf() + val client = HttpClient( + MockEngine { request -> + paths += request.url.encodedPath + when { + request.url.encodedPath.endsWith("/auth/refresh") -> + respond( + content = """ + { + "access_token": "server-a-fresh-access", + "refresh_token": "server-a-fresh-refresh", + "expires_in": 3600 + } + """.trimIndent(), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + paths.count { it.endsWith("/catalog/home") } == 1 -> + respond("{}", HttpStatusCode.Unauthorized) + else -> respond("{}", HttpStatusCode.OK) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + client.get("https://server-a.example/api/v1/catalog/home") + + assertEquals( + listOf( + "/api/v1/catalog/home", + "/api/v1/auth/refresh", + "/api/v1/catalog/home", + ), + paths, + ) + assertEquals(0, tokenManager.activeRefreshReads) + assertEquals(0, tokenManager.activeSaves) + assertEquals(2, tokenManager.scopedRefreshReads) + assertEquals(1, tokenManager.scopedSaves) + } + + @Test + fun switchBeforeRefreshTokenReadNeverReadsNewActiveServerToken() = runTest { + val tokenManager = ScopedRefreshTokenManager(switchBeforeRefreshTokenRead = true) + val paths = executeRefreshScenario(tokenManager, HttpStatusCode.OK) + + assertEquals(listOf("/api/v1/catalog/home", "/api/v1/auth/refresh"), paths) + assertEquals(0, tokenManager.activeRefreshReads) + assertEquals(1, tokenManager.scopedRefreshReads) + assertEquals(0, tokenManager.activeSaves) + } + + @Test + fun switchDuringPostResponseCredentialReadSavesOnlyCapturedScope() = runTest { + val tokenManager = ScopedRefreshTokenManager(switchDuringPostResponseRead = true) + executeRefreshScenario(tokenManager, HttpStatusCode.OK) + + assertEquals(0, tokenManager.activeSaves) + assertEquals(1, tokenManager.scopedSaves) + assertEquals("server-a-fresh-access", tokenManager.scopedAccessToken) + } + + @Test + fun switchAtRefreshRejectionInvalidatesOnlyCapturedScope() = runTest { + val tokenManager = ScopedRefreshTokenManager(switchDuringInvalidation = true) + executeRefreshScenario(tokenManager, HttpStatusCode.Unauthorized) + + assertEquals(0, tokenManager.activeInvalidations) + assertEquals(1, tokenManager.scopedInvalidations) + assertEquals("server-a", tokenManager.invalidatedScope?.serverId) + assertEquals("server-b-access", tokenManager.activeAccessToken) + } + + private suspend fun executeRefreshScenario( + tokenManager: ScopedRefreshTokenManager, + refreshStatus: HttpStatusCode, + ): List { + val paths = mutableListOf() + val client = HttpClient( + MockEngine { request -> + paths += request.url.encodedPath + when { + request.url.encodedPath.endsWith("/auth/refresh") -> + respond( + content = if (refreshStatus.isSuccess()) { + """ + { + "access_token": "server-a-fresh-access", + "refresh_token": "server-a-fresh-refresh", + "expires_in": 3600 + } + """.trimIndent() + } else { + """{"error":"unauthorized","message":"expired"}""" + }, + status = refreshStatus, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + paths.count { it.endsWith("/catalog/home") } == 1 -> + respond("{}", HttpStatusCode.Unauthorized) + else -> respond("{}", HttpStatusCode.OK) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + client.get("https://server-a.example/api/v1/catalog/home") + return paths + } + + private data class CapturedRequest( + val url: String, + val authorization: String?, + val profileId: String?, + val profileToken: String?, + val prairieHeaders: Map, + ) + + private fun io.ktor.client.request.HttpRequestData.capture(): CapturedRequest = + CapturedRequest( + url = url.toString(), + authorization = headers[HttpHeaders.Authorization], + profileId = headers["X-Profile-Id"], + profileToken = headers["X-Profile-Token"], + prairieHeaders = headers.entries() + .filter { (name, _) -> name.startsWith("X-Prairie-", ignoreCase = true) } + .associate { (name, values) -> name to values.joinToString(",") }, + ) + + private fun capturingClient( + tokenManager: TokenManager, + captured: MutableList, + deviceMetadataProvider: DeviceMetadataProvider? = null, + ): HttpClient = + HttpClient( + MockEngine { request -> + captured += request.capture() + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }, + ) { + install(PrairieAuthPlugin) { + this.tokenManager = tokenManager + this.deviceMetadataProvider = deviceMetadataProvider + } + } + + private fun deviceMetadataProvider(): DeviceMetadataProvider = + object : DeviceMetadataProvider { + override suspend fun current(): PrairieDeviceMetadata = + PrairieDeviceMetadata( + id = "device-1", + name = "Phone", + platform = "android", + clientName = "Prairie Android", + clientVersion = "1.0", + ) + } + + private class SwitchingTokenManager( + private val delegate: TokenManager = TokenManagerImpl(), + ) : TokenManager by delegate { + private var switched = false + private var serverUrlReads = 0 + + override suspend fun getServerUrl(): String { + serverUrlReads += 1 + if (serverUrlReads > 1) switched = false + return if (switched) "https://server-b.example" else "https://server-a.example" + } + + override suspend fun getCurrentServerId(): String = + if (switched) "server-b" else "server-a" + + override suspend fun getAccessToken(): String { + switched = true + return "server-b-access" + } + + override suspend fun getProfileId(): String = "server-b-profile" + + override suspend fun getProfileToken(): String = "server-b-profile-token" + } + + private class InFlightSwitchingTokenManager( + private val delegate: TokenManager = TokenManagerImpl(), + ) : TokenManager by delegate { + private var activeServer = "server-a" + var invalidations = 0 + private set + + fun switchToServerB() { + activeServer = "server-b" + } + + override suspend fun getCurrentServerId(): String = activeServer + + override suspend fun getServerUrl(): String = "https://$activeServer.example" + + override suspend fun getAccessToken(): String = "$activeServer-access" + + override suspend fun getRefreshToken(): String = "$activeServer-refresh" + + override suspend fun getProfileId(): String = "$activeServer-profile" + + override suspend fun getProfileToken(): String = "$activeServer-profile-token" + + override suspend fun invalidateSession() { + invalidations += 1 + } + } + + private class ScopedRefreshTokenManager( + private val switchBeforeRefreshTokenRead: Boolean = false, + private val switchDuringPostResponseRead: Boolean = false, + private val switchDuringInvalidation: Boolean = false, + private val delegate: TokenManager = TokenManagerImpl(), + ) : TokenManager by delegate { + private val scope = AuthScopeSnapshot( + serverId = "server-a", + profileId = null, + serverUrl = "https://server-a.example", + profileToken = null, + identityGeneration = 7L, + ) + private var activeServer = "server-a" + private var accessToken = "server-a-expired-access" + private var armSwitchAfterServerUrlRead = false + private var activeAccessReads = 0 + var activeRefreshReads = 0 + private set + var activeSaves = 0 + private set + var scopedRefreshReads = 0 + private set + var scopedSaves = 0 + private set + var activeInvalidations = 0 + private set + var scopedInvalidations = 0 + private set + var invalidatedScope: AuthScopeSnapshot? = null + private set + val scopedAccessToken: String get() = accessToken + val activeAccessToken: String + get() = if (activeServer == "server-a") accessToken else "server-b-access" + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = scope + + override suspend fun getCurrentServerId(): String = activeServer + + override suspend fun getServerUrl(): String { + val url = "https://$activeServer.example" + if (armSwitchAfterServerUrlRead) { + armSwitchAfterServerUrlRead = false + activeServer = "server-b" + } + return url + } + + override suspend fun getAccessToken(): String { + activeAccessReads += 1 + if (switchBeforeRefreshTokenRead && activeAccessReads > 1) { + armSwitchAfterServerUrlRead = true + } + return activeAccessToken + } + + override suspend fun getRefreshToken(): String { + activeRefreshReads += 1 + val token = "$activeServer-refresh" + if (switchDuringPostResponseRead && activeRefreshReads == 2) { + activeServer = "server-b" + } + return token + } + + override suspend fun saveTokens( + accessToken: String, + refreshToken: String, + expiresIn: Long, + ) { + activeSaves += 1 + this.accessToken = accessToken + } + + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String { + if (switchBeforeRefreshTokenRead) armSwitchAfterServerUrlRead = true + return accessToken + } + + override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot): String { + scopedRefreshReads += 1 + if (switchDuringPostResponseRead && scopedRefreshReads == 2) { + activeServer = "server-b" + } + return "server-a-refresh" + } + + override suspend fun saveTokensForScope( + scope: AuthScopeSnapshot, + accessToken: String, + refreshToken: String, + expiresIn: Long, + ) { + scopedSaves += 1 + this.accessToken = accessToken + } + + override suspend fun invalidateSession() { + if (switchDuringInvalidation) activeServer = "server-b" + activeInvalidations += 1 + } + + override suspend fun invalidateSessionForScope(scope: AuthScopeSnapshot): Boolean { + if (switchDuringInvalidation) activeServer = "server-b" + scopedInvalidations += 1 + invalidatedScope = scope + return true + } + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClientTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClientTest.kt new file mode 100644 index 000000000..ee50aa2c6 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClientTest.kt @@ -0,0 +1,419 @@ +package org.prairieserver.prairie.network + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class WatchTogetherRealtimeClientTest { + + @Test + fun `socket request string never exposes query credentials or profile token`() { + val request = WatchTogetherSocketRequest( + url = "/rooms/room-1/ws?room_token=room-secret&profile_token=profile-secret", + authScope = AuthScopeSnapshot( + serverId = "aHR0cHM6Ly9wcml2YXRlLXNpbG8uZXhhbXBsZQ", + profileId = "private-profile-id", + serverUrl = "https://user:url-secret@private-prairie.example/path?api_key=url-query-secret", + profileToken = "profile-secret", + credentialGenerationId = "credential-generation-secret", + identityGeneration = 987654321L, + credentialEpoch = 123456789L, + ), + ) + + val rendered = request.toString() + + assertFalse(rendered.contains("room-secret")) + assertFalse(rendered.contains("profile-secret")) + assertFalse(rendered.contains("aHR0cHM6Ly9wcml2YXRlLXNpbG8uZXhhbXBsZQ")) + assertFalse(rendered.contains("private-profile-id")) + assertFalse(rendered.contains("private-prairie.example")) + assertFalse(rendered.contains("url-secret")) + assertFalse(rendered.contains("url-query-secret")) + assertFalse(rendered.contains("credential-generation-secret")) + assertFalse(rendered.contains("987654321")) + assertFalse(rendered.contains("123456789")) + assertTrue(rendered.contains("")) + } + + @Test + fun `normal socket EOF is transport termination not protocol room close`() = runTest { + val connection = FakeConnection().apply { incoming.close() } + val client = client(FakeConnector(connection)) + + val events = mutableListOf() + client.connect("room-1", "room-token").collect(events::add) + + assertIs(events[0]) + assertIs(events[1]) + assertNull(events[1].let { it as RoomRealtimeEvent.TransportTerminated }.cause) + assertTrue(events.none { it is RoomRealtimeEvent.Closed }) + } + + @Test + fun `socket receive failure is typed transport termination not protocol room close`() = runTest { + val connection = FakeConnection().apply { + incoming.close(IllegalStateException("socket broke")) + } + val client = client(FakeConnector(connection)) + + val events = mutableListOf() + client.connect("room-1", "room-token").collect(events::add) + + val terminated = assertIs(events.last()) + assertEquals("socket broke", terminated.cause?.message) + assertTrue(events.none { it is RoomRealtimeEvent.Closed }) + } + + @Test + fun `handshake failure is typed transport termination not protocol room close`() = runTest { + val client = client( + object : WatchTogetherSocketConnector { + override suspend fun open(request: WatchTogetherSocketRequest): WatchTogetherSocketConnection { + throw IllegalStateException("handshake rejected") + } + }, + ) + + val events = mutableListOf() + client.connect("room-1", "room-token").collect(events::add) + + val terminated = assertIs(events.single()) + assertEquals("handshake rejected", terminated.cause?.message) + } + + @Test + fun `collector cancellation emits no transport or protocol close`() = runTest { + val connection = FakeConnection() + val opened = CompletableDeferred() + val events = mutableListOf() + val job = launch { + client(FakeConnector(connection)) + .connect("room-1", "room-token") + .collect { + events += it + if (it is RoomRealtimeEvent.Opened) opened.complete(Unit) + } + } + opened.await() + + job.cancelAndJoin() + + assertEquals(1, events.size) + assertIs(events.single()) + } + + @Test + fun `collector cancellation joins physical socket close before completing`() = runTest { + val connection = FakeConnection() + val closeGate = CompletableDeferred() + connection.closeGate = closeGate + val opened = CompletableDeferred() + val job = launch { + client(FakeConnector(connection)) + .connect("room-1", "room-token") + .collect { + if (it is RoomRealtimeEvent.Opened) opened.complete(Unit) + } + } + opened.await() + + job.cancel() + runCurrent() + + assertTrue(connection.closeStarted.isCompleted) + assertFalse(job.isCompleted) + closeGate.complete(Unit) + job.join() + assertTrue(connection.closed) + } + + @Test + fun `sends report delivery only while a writable connection is current`() = runTest { + val connection = FakeConnection() + val client = client(FakeConnector(connection)) + assertFalse(client.attachSession("session-before-open")) + + val opened = CompletableDeferred() + val job = launch { + client.connect("room-1", "room-token").collect { + if (it is RoomRealtimeEvent.Opened) opened.complete(Unit) + } + } + opened.await() + + assertTrue(client.attachSession("session-1")) + assertTrue(client.transportRequest("seek", 12.5, false)) + assertTrue(client.stateReport("session-1", 13.5, false)) + assertTrue(client.ready("session-1", 14.5, false)) + assertTrue(client.buffering("session-1", 15.5, true)) + assertTrue(client.ping("2026-07-27T10:00:00Z")) + assertEquals( + listOf( + "attach_session", + "transport_request", + "state_report", + "ready", + "buffering", + "ping", + ), + connection.sent.map { PrairieJson.parseToJsonElement(it).jsonObject.getValue("type").jsonPrimitive.content }, + ) + + connection.sendFailure = IllegalStateException("closed") + assertFalse(client.ping("2026-07-27T10:00:01Z")) + job.cancelAndJoin() + assertFalse(client.attachSession("session-after-close")) + } + + @Test + fun `connection scoped transport never crosses into a replacement socket`() = runTest { + val first = FakeConnection() + val second = FakeConnection() + val client = client(FakeConnector(first, second)) + val firstOpened = CompletableDeferred() + val firstJob = launch { + client.connect("room-1", "room-token-1").collect { + if (it is RoomRealtimeEvent.Opened) firstOpened.complete(Unit) + } + } + firstOpened.await() + val firstId = requireNotNull(client.currentConnectionId()) + + val secondOpened = CompletableDeferred() + val secondJob = launch { + client.connect("room-2", "room-token-2").collect { + if (it is RoomRealtimeEvent.Opened) secondOpened.complete(Unit) + } + } + secondOpened.await() + val secondId = requireNotNull(client.currentConnectionId()) + + assertFalse(client.transportRequestOnConnection(firstId, "pause", 12.0, true)) + assertTrue(client.transportRequestOnConnection(secondId, "pause", 12.0, true)) + assertTrue(first.sent.isEmpty()) + assertEquals(1, second.sent.size) + firstJob.cancelAndJoin() + secondJob.cancelAndJoin() + } + + @Test + fun `old connection finalizer cannot clear a writable replacement`() = runTest { + val first = FakeConnection() + val second = FakeConnection() + val connector = FakeConnector(first, second) + val client = client(connector) + val firstOpened = CompletableDeferred() + val secondOpened = CompletableDeferred() + val firstJob = launch { + client.connect("room-1", "room-token").collect { + if (it is RoomRealtimeEvent.Opened) firstOpened.complete(Unit) + } + } + firstOpened.await() + val secondJob = launch { + client.connect("room-1", "room-token").collect { + if (it is RoomRealtimeEvent.Opened) secondOpened.complete(Unit) + } + } + secondOpened.await() + + firstJob.cancelAndJoin() + assertTrue(client.ping("replacement-ping")) + assertTrue(first.sent.isEmpty()) + assertEquals(1, second.sent.size) + + secondJob.cancelAndJoin() + } + + @Test + fun `connection URL encodes room path and excludes access JWT`() = runTest { + val connection = FakeConnection() + val connector = FakeConnector(connection) + val client = client( + connector = connector, + accessToken = "ACCESS_SECRET", + profileId = "profile id", + profileToken = "profile&secret", + ) + val opened = CompletableDeferred() + val job = launch { + client.connect("room/with ?#", "room&secret").collect { + if (it is RoomRealtimeEvent.Opened) opened.complete(Unit) + } + } + opened.await() + + val request = connector.requests.single() + val url = request.url + assertTrue(url.startsWith("/api/v1/watch-together/rooms/room%2Fwith%20%3F%23/ws?")) + assertFalse(url.contains("ACCESS_SECRET")) + assertFalse(url.contains("token=ACCESS_SECRET")) + assertTrue(url.contains("room_token=room%26secret")) + assertTrue(url.contains("profile_id=profile%20id")) + assertTrue(url.contains("profile_token=profile%26secret")) + assertEquals("profile id", request.authScope.profileId) + assertEquals("profile&secret", request.authScope.profileToken) + + job.cancelAndJoin() + } + + @Test + fun `missing access token for captured scope fails before connector opens`() = runTest { + val scope = AuthScopeSnapshot( + serverId = "server-a", + profileId = "profile-a", + serverUrl = "https://a.example", + profileToken = "PROFILE_A", + ) + val tokens = SnapshotTokenManager(scope, accessToken = null) + val connector = FakeConnector(FakeConnection()) + val client = DefaultWatchTogetherRealtimeClient( + tokenManager = tokens, + socketConnector = connector, + ) + + val events = mutableListOf() + client.connect("room-a", "ROOM_A").collect(events::add) + + val terminated = assertIs(events.single()) + assertEquals("missing_auth_scope", terminated.cause?.message) + assertEquals(1, tokens.snapshotReads) + assertTrue(connector.requests.isEmpty()) + } + + @Test + fun `explicit room scope wins over a different live identity without snapshot reads`() = runTest { + val scopeA = AuthScopeSnapshot( + serverId = "server-a", + profileId = "profile-a", + serverUrl = "https://a.example", + profileToken = "PROFILE_A", + identityGeneration = 1L, + ) + val scopeB = AuthScopeSnapshot( + serverId = "server-b", + profileId = "profile-b", + serverUrl = "https://b.example", + profileToken = "PROFILE_B", + identityGeneration = 2L, + ) + var snapshotReads = 0 + val delegate = TokenManagerImpl() + val tokens = object : TokenManager by delegate { + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot { + snapshotReads++ + return scopeB + } + + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = + "ACCESS_A".takeIf { scope == scopeA } + } + val connection = FakeConnection().apply { incoming.close() } + val connector = FakeConnector(connection) + val client = DefaultWatchTogetherRealtimeClient( + tokenManager = tokens, + socketConnector = connector, + ) + + client.connect("room-a", "ROOM_A", scopeA).collect { } + + assertEquals(0, snapshotReads) + val request = connector.requests.single() + assertEquals(scopeA, request.authScope) + assertTrue(request.url.contains("profile_id=profile-a")) + assertTrue(request.url.contains("profile_token=PROFILE_A")) + assertFalse(request.url.contains("profile-b")) + assertFalse(request.url.contains("PROFILE_B")) + } + + private suspend fun client( + connector: WatchTogetherSocketConnector, + accessToken: String = "access", + profileId: String = "profile", + profileToken: String = "profile-token", + ): DefaultWatchTogetherRealtimeClient { + val scope = AuthScopeSnapshot( + serverId = "server", + profileId = profileId, + serverUrl = "http://localhost:8090", + profileToken = profileToken, + ) + val tokens = SnapshotTokenManager(scope, accessToken) + return DefaultWatchTogetherRealtimeClient( + tokenManager = tokens, + socketConnector = connector, + ) + } + + private class FakeConnector( + vararg connections: FakeConnection, + ) : WatchTogetherSocketConnector { + private val connections = Channel(Channel.UNLIMITED).apply { + connections.forEach { trySend(it) } + } + val requests = mutableListOf() + + override suspend fun open(request: WatchTogetherSocketRequest): WatchTogetherSocketConnection { + requests += request + return connections.receive() + } + } + + private class SnapshotTokenManager( + private val scope: AuthScopeSnapshot, + private val accessToken: String?, + private val delegate: TokenManager = TokenManagerImpl(), + ) : TokenManager by delegate { + var snapshotReads = 0 + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot { + snapshotReads += 1 + return scope + } + + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = + accessToken?.takeIf { scope == this.scope } + } + + private class FakeConnection : WatchTogetherSocketConnection { + val incoming = Channel(Channel.UNLIMITED) + val sent = mutableListOf() + var sendFailure: Throwable? = null + val closeStarted = CompletableDeferred() + var closeGate: CompletableDeferred? = null + var closed = false + + override suspend fun receiveText(): String? { + val result = incoming.receiveCatching() + result.exceptionOrNull()?.let { throw it } + return result.getOrNull() + } + + override suspend fun sendText(text: String) { + sendFailure?.let { throw it } + sent += text + } + + override suspend fun close() { + closeStarted.complete(Unit) + closeGate?.await() + incoming.cancel(CancellationException("test connection closed")) + closed = true + } + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApiTest.kt index d0c65724a..42948778a 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApiTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApiTest.kt @@ -7,6 +7,7 @@ import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.PrairieJson import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine @@ -25,6 +26,12 @@ import kotlin.test.assertEquals import kotlin.test.assertIs class WatchTogetherApiTest { + private val scope = AuthScopeSnapshot( + serverId = "server-1", + profileId = "profile-1", + serverUrl = "https://prairie.example", + profileToken = "profile-token", + ) private class Captured { var method: HttpMethod? = null @@ -71,7 +78,7 @@ class WatchTogetherApiTest { status = HttpStatusCode.Created, responseBody = """{"room":$roomJson,"room_access_token":"jwt-1"}""", ) - val r = api.createRoom(CreateRoomRequest(selectionMode = "vote")) + val r = api.createRoom(CreateRoomRequest(selectionMode = "vote"), scope) assertEquals(HttpMethod.Post, captured.method) assertEquals("/api/v1/watch-together/rooms", captured.path) assertEquals(setOf("selection_mode"), PrairieJson.parseToJsonElement(captured.body).jsonObject.keys) @@ -82,7 +89,7 @@ class WatchTogetherApiTest { @Test fun `joinRoom posts code and decodes room`() = runTest { val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-2"}""") - val r = api.joinRoom(JoinRoomRequest(code = "ABCD1234")) + val r = api.joinRoom(JoinRoomRequest(code = "ABCD1234"), scope) assertEquals(HttpMethod.Post, captured.method) assertEquals("/api/v1/watch-together/join", captured.path) assertIs>(r) @@ -92,7 +99,7 @@ class WatchTogetherApiTest { @Test fun `getRoom passes room_token query`() = runTest { val (api, captured) = api(responseBody = """{"room":$roomJson}""") - api.getRoom("room-1", "jwt-room") + api.getRoom("room-1", "jwt-room", scope) assertEquals(HttpMethod.Get, captured.method) assertEquals("/api/v1/watch-together/rooms/room-1", captured.path) assertEquals("jwt-room", captured.query["room_token"]) @@ -101,7 +108,7 @@ class WatchTogetherApiTest { @Test fun `setSelection puts content_id with room_token query`() = runTest { val (api, captured) = api(responseBody = """{"room":$roomJson}""") - api.setSelection("room-1", "jwt-room", SetSelectionRequest(contentId = "tt-9", fileId = 3)) + api.setSelection("room-1", "jwt-room", SetSelectionRequest(contentId = "tt-9", fileId = 3), scope) assertEquals(HttpMethod.Put, captured.method) assertEquals("/api/v1/watch-together/rooms/room-1/selection", captured.path) assertEquals("jwt-room", captured.query["room_token"]) @@ -112,7 +119,12 @@ class WatchTogetherApiTest { @Test fun `updatePolicy patches policy with room_token query`() = runTest { val (api, captured) = api(responseBody = """{"room":$roomJson}""") - api.updatePolicy("room-1", "jwt-room", UpdatePolicyRequest(guestControlPolicy = "guest_play_pause")) + api.updatePolicy( + "room-1", + "jwt-room", + UpdatePolicyRequest(guestControlPolicy = "guest_play_pause"), + scope, + ) assertEquals(HttpMethod.Patch, captured.method) assertEquals("/api/v1/watch-together/rooms/room-1/policy", captured.path) assertEquals("jwt-room", captured.query["room_token"]) @@ -121,7 +133,7 @@ class WatchTogetherApiTest { @Test fun `closeRoom deletes and maps 204 to Unit with room_token query`() = runTest { val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") - val r = api.closeRoom("room-1", "jwt-room") + val r = api.closeRoom("room-1", "jwt-room", scope) assertEquals(HttpMethod.Delete, captured.method) assertEquals("/api/v1/watch-together/rooms/room-1", captured.path) assertEquals("jwt-room", captured.query["room_token"]) @@ -131,7 +143,7 @@ class WatchTogetherApiTest { @Test fun `listSuggestions gets suggestions with room_token query`() = runTest { val (api, captured) = api(responseBody = """{"suggestions":[]}""") - api.listSuggestions("room-1", "jwt-room") + api.listSuggestions("room-1", "jwt-room", scope) assertEquals(HttpMethod.Get, captured.method) assertEquals("/api/v1/watch-together/rooms/room-1/suggestions", captured.path) assertEquals("jwt-room", captured.query["room_token"]) @@ -146,6 +158,7 @@ class WatchTogetherApiTest { api.addSuggestion( "room-1", "jwt-room", AddSuggestionRequest(contentId = "c", contentType = "movie", title = "T"), + scope, ) assertEquals(HttpMethod.Post, captured.method) assertEquals("/api/v1/watch-together/rooms/room-1/suggestions", captured.path) @@ -155,16 +168,29 @@ class WatchTogetherApiTest { @Test fun `deleteSuggestion deletes suggestion path with room_token query`() = runTest { val (api, captured) = api(responseBody = """{"suggestions":[]}""") - api.deleteSuggestion("room-1", "jwt-room", "sug-5") + api.deleteSuggestion("room-1", "jwt-room", "sug-5", scope) assertEquals(HttpMethod.Delete, captured.method) assertEquals("/api/v1/watch-together/rooms/room-1/suggestions/sug-5", captured.path) assertEquals("jwt-room", captured.query["room_token"]) } + @Test + fun `room and suggestion identifiers are encoded as path segments`() = runTest { + val (api, captured) = api(responseBody = """{"suggestions":[]}""") + + api.deleteSuggestion("room/with ?#", "jwt-room", "suggestion/with ?#", scope) + + assertEquals( + "/api/v1/watch-together/rooms/room%2Fwith%20%3F%23/suggestions/" + + "suggestion%2Fwith%20%3F%23", + captured.path, + ) + } + @Test fun `vote posts vote path with room_token query`() = runTest { val (api, captured) = api(responseBody = """{"suggestions":[]}""") - api.vote("room-1", "jwt-room", "sug-5") + api.vote("room-1", "jwt-room", "sug-5", scope) assertEquals(HttpMethod.Post, captured.method) assertEquals("/api/v1/watch-together/rooms/room-1/suggestions/sug-5/vote", captured.path) assertEquals("jwt-room", captured.query["room_token"]) @@ -173,7 +199,7 @@ class WatchTogetherApiTest { @Test fun `unvote deletes vote path with room_token query`() = runTest { val (api, captured) = api(responseBody = """{"suggestions":[]}""") - api.unvote("room-1", "jwt-room", "sug-5") + api.unvote("room-1", "jwt-room", "sug-5", scope) assertEquals(HttpMethod.Delete, captured.method) assertEquals("/api/v1/watch-together/rooms/room-1/suggestions/sug-5/vote", captured.path) assertEquals("jwt-room", captured.query["room_token"]) @@ -182,7 +208,12 @@ class WatchTogetherApiTest { @Test fun `promote posts suggestion_id with room_token query and decodes room`() = runTest { val (api, captured) = api(responseBody = """{"room":$roomJson}""") - api.promoteSuggestion("room-1", "jwt-room", PromoteSuggestionRequest(suggestionId = "sug-5")) + api.promoteSuggestion( + "room-1", + "jwt-room", + PromoteSuggestionRequest(suggestionId = "sug-5"), + scope, + ) assertEquals(HttpMethod.Post, captured.method) assertEquals("/api/v1/watch-together/rooms/room-1/suggestions/promote", captured.path) assertEquals("jwt-room", captured.query["room_token"]) @@ -196,7 +227,7 @@ class WatchTogetherApiTest { status = HttpStatusCode.Conflict, responseBody = """{"error":"conflict","message":"Already voted"}""", ) - val r = api.vote("room-1", "jwt-room", "sug-5") + val r = api.vote("room-1", "jwt-room", "sug-5", scope) assertIs(r) assertEquals(409, r.code) assertEquals("Already voted", r.message) @@ -208,7 +239,7 @@ class WatchTogetherApiTest { status = HttpStatusCode.Gone, responseBody = """{"error":"gone","message":"Room is no longer active"}""", ) - val r = api.joinRoom(JoinRoomRequest(code = "DEAD0000")) + val r = api.joinRoom(JoinRoomRequest(code = "DEAD0000"), scope) assertIs(r) assertEquals(410, r.code) assertEquals("Room is no longer active", r.message) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/SubtitleCodecAndLanguageTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/SubtitleCodecAndLanguageTest.kt new file mode 100644 index 000000000..d913f7995 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/SubtitleCodecAndLanguageTest.kt @@ -0,0 +1,71 @@ +package org.prairieserver.prairie.playback + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SubtitleCodecAndLanguageTest { + @Test + fun canonicalSubtitleCodecFamilyNormalizesAliases() { + assertNull(canonicalSubtitleCodecFamily(null)) + assertNull(canonicalSubtitleCodecFamily(" ")) + assertNull(canonicalSubtitleCodecFamily("!!!")) + + assertEquals("pgs", canonicalSubtitleCodecFamily(" application/pgs ")) + assertEquals("vobsub", canonicalSubtitleCodecFamily("dvd_subtitle")) + assertEquals("vobsub", canonicalSubtitleCodecFamily("VobSub")) + assertEquals("dvbsub", canonicalSubtitleCodecFamily("dvb_sub")) + assertEquals("subrip", canonicalSubtitleCodecFamily("text/x-subrip")) + assertEquals("subrip", canonicalSubtitleCodecFamily("english.srt")) + assertEquals("webvtt", canonicalSubtitleCodecFamily("text/vtt")) + assertEquals("webvtt", canonicalSubtitleCodecFamily("captions.vtt")) + assertEquals("webvtt", canonicalSubtitleCodecFamily("textvtt")) + assertEquals("tx3g", canonicalSubtitleCodecFamily("mov_text")) + assertEquals("ssa", canonicalSubtitleCodecFamily("ass")) + assertEquals("ssa", canonicalSubtitleCodecFamily("text/x-ssa")) + assertEquals("ttml", canonicalSubtitleCodecFamily("application/ttml+xml")) + assertEquals("cea608", canonicalSubtitleCodecFamily("eia_608")) + assertEquals("cea708", canonicalSubtitleCodecFamily("cea_708")) + assertEquals("customcodec", canonicalSubtitleCodecFamily("custom_codec")) + } + + @Test + fun textAndBitmapMountabilityFollowCanonicalFamilies() { + assertTrue(isTextSubtitleCodecFamily("subrip")) + assertTrue(isTextSubtitleCodecFamily("webvtt")) + assertTrue(isTextSubtitleCodecFamily("ssa")) + assertTrue(isTextSubtitleCodecFamily("ttml")) + assertTrue(isTextSubtitleCodecFamily("tx3g")) + assertFalse(isTextSubtitleCodecFamily("pgs")) + assertFalse(isTextSubtitleCodecFamily(null)) + + assertTrue(isClientMountableBitmapCodecFamily("application/pgs")) + assertFalse(isClientMountableBitmapCodecFamily("vobsub")) + assertFalse(isClientMountableBitmapCodecFamily("dvbsub")) + assertFalse(isClientMountableBitmapCodecFamily("subrip")) + } + + @Test + fun canonicalSubtitleLanguageMapsIsoAliasesAndDropsUndetermined() { + assertNull(canonicalSubtitleLanguage(null)) + assertNull(canonicalSubtitleLanguage(" ")) + assertNull(canonicalSubtitleLanguage("und")) + assertNull(canonicalSubtitleLanguage("UND")) + + assertEquals("en", canonicalSubtitleLanguage("eng")) + assertEquals("en", canonicalSubtitleLanguage("EN-US")) + assertEquals("es", canonicalSubtitleLanguage("spa")) + assertEquals("fr", canonicalSubtitleLanguage("fre")) + assertEquals("fr", canonicalSubtitleLanguage("fra")) + assertEquals("de", canonicalSubtitleLanguage("ger")) + assertEquals("de", canonicalSubtitleLanguage("deu")) + assertEquals("nl", canonicalSubtitleLanguage("dut")) + assertEquals("nl", canonicalSubtitleLanguage("nld")) + assertEquals("ja", canonicalSubtitleLanguage("jpn")) + assertEquals("da", canonicalSubtitleLanguage("dan")) + assertEquals("pt", canonicalSubtitleLanguage("pt_BR")) + assertEquals("ko", canonicalSubtitleLanguage("ko")) + } +} 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 6e9b936c7..f5c8d517f 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprintTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprintTest.kt @@ -3,12 +3,56 @@ package org.prairieserver.prairie.playback 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 kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNull class TrackSelectionFingerprintTest { + @Test + fun downloadedSubtitleIdentityRoundTripsThroughVersionedPreference() { + val identity = SubtitleIdentity.Downloaded( + downloadId = 312, + media = SubtitleMediaIdentity( + trackId = "prairie-downloaded-subtitle:312", + label = "English | SDH", + language = "eng", + codecFamily = "webvtt", + forced = false, + hearingImpaired = true, + ), + ) + + val encoded = encodeSubtitleIdentityPreference(identity) + + assertEquals(identity, decodeSubtitleIdentityPreference(encoded)) + } + + @Test + fun localMedia3SubtitleIdentityRoundTripsExactTrackMetadata() { + val identity = SubtitleIdentity.LocalMedia3( + SubtitleMediaIdentity( + trackId = "decoder:text:9", + label = "Commentary", + language = "fr-CA", + codecFamily = "vtt", + forced = null, + hearingImpaired = false, + ), + ) + + val encoded = encodeSubtitleIdentityPreference(identity) + + assertEquals(identity, decodeSubtitleIdentityPreference(encoded)) + assertNull(decodeSubtitleIdentityPreference(subtitleTrackFingerprint( + PlayerSubtitleInfo(index = 1, label = "Legacy", url = "/1.vtt"), + ))) + assertNull(decodeSubtitleIdentityPreference("prairie-subtitle-v2:{broken")) + } + @Test fun resolvesAudioFingerprintBackToTrackOrdinal() { val tracks = listOf( @@ -31,6 +75,346 @@ class TrackSelectionFingerprintTest { assertEquals(1, resolveSubtitleTrackOrdinal(tracks, subtitleTrackFingerprint(tracks[1]))) } + @Test + fun playerTypedOffAndServerPreferencesResolveOnDetailScreen() { + val tracks = catalogTracksInMixedIndexSpaces() + + assertEquals( + -1, + resolveCatalogSubtitlePreferenceOrdinal( + tracks, + encodeSubtitleIdentityPreference(SubtitleIdentity.Off), + ), + ) + assertEquals( + 3, + resolveCatalogSubtitlePreferenceOrdinal( + tracks, + encodeSubtitleIdentityPreference(SubtitleIdentity.ServerSidecar(serverIndex = 1)), + ), + ) + } + + @Test + fun detailTypedPreferenceDecodesForPlayerWithCombinedServerIndex() { + val tracks = catalogTracksInMixedIndexSpaces() + + val encoded = encodeCatalogSubtitlePreference( + tracks = tracks, + selectedOrdinal = 3, + ) + + val identity = assertIs( + decodeSubtitleIdentityPreference(encoded), + ) + assertEquals(1, identity.serverIndex) + assertEquals("External English", identity.media?.label) + assertEquals("en", identity.media?.language) + } + + @Test + fun detailEmbeddedPreferenceUsesCombinedIndexInsteadOfDemuxIndex() { + val tracks = catalogTracksInMixedIndexSpaces() + + val encoded = encodeCatalogSubtitlePreference( + tracks = tracks, + selectedOrdinal = 0, + ) + val identity = decodeSubtitleIdentityPreference(encoded) + + assertEquals(2, (identity as SubtitleIdentity.Embedded).serverIndex) + assertEquals("Embedded English", identity.media.label) + } + + @Test + fun embeddedPgsPreferencePersistsAsClientMounted() { + val tracks = listOf( + SubtitleTrack(index = 2, codec = "hdmv_pgs_subtitle", language = "eng", title = "English PGS"), + ) + + val encoded = encodeCatalogSubtitlePreference(tracks, selectedOrdinal = 0) + + assertIs(decodeSubtitleIdentityPreference(encoded!!)) + assertEquals(0, resolveCatalogSubtitlePreferenceOrdinal(tracks, encoded)) + } + + @Test + fun embeddedVobsubPreferencePersistsAsBurnIn() { + val tracks = listOf( + SubtitleTrack(index = 3, codec = "dvd_subtitle", language = "eng", title = "English VobSub"), + ) + + val encoded = encodeCatalogSubtitlePreference(tracks, selectedOrdinal = 0) + + assertIs(decodeSubtitleIdentityPreference(encoded!!)) + assertEquals(0, resolveCatalogSubtitlePreferenceOrdinal(tracks, encoded)) + } + + @Test + fun detailPreferenceResolverFallsBackToLegacyFingerprint() { + val tracks = catalogTracksInMixedIndexSpaces() + + assertEquals( + 2, + resolveCatalogSubtitlePreferenceOrdinal( + tracks, + subtitleTrackFingerprint(tracks[2]), + ), + ) + } + + @Test + fun externalTypedPreferenceFollowsStableMetadataAcrossCombinedIndexReorder() { + val original = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "en", title = "English", external = true), + SubtitleTrack(index = 0, codec = "srt", language = "fr", title = "French", external = true), + ) + val reordered = listOf(original[1], original[0]) + val saved = encodeCatalogSubtitlePreference(original, selectedOrdinal = 0) + + assertEquals( + 1, + resolveCatalogSubtitlePreferenceOrdinal(reordered, saved), + ) + } + + @Test + fun embeddedTypedPreferenceFollowsStableMetadataAcrossCombinedIndexReorder() { + val external = SubtitleTrack( + index = 0, + codec = "srt", + language = "fr", + title = "French", + external = true, + ) + val english = SubtitleTrack( + index = 17, + codec = "ass", + language = "en", + title = "English embedded", + external = false, + ) + val dutch = SubtitleTrack( + index = 23, + codec = "ass", + language = "nl", + title = "Dutch embedded", + external = false, + ) + val original = listOf(external, english, dutch) + val reordered = listOf(external, dutch, english) + val saved = encodeCatalogSubtitlePreference(original, selectedOrdinal = 1) + + assertEquals( + 2, + resolveCatalogSubtitlePreferenceOrdinal(reordered, saved), + ) + } + + @Test + fun typedServerPreferenceSafelyMissesWhenStableMetadataNoLongerMatches() { + val original = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "en", title = "English", external = true), + ) + val replacement = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "es", title = "Spanish", external = true), + ) + val saved = encodeCatalogSubtitlePreference(original, selectedOrdinal = 0) + + assertNull(resolveCatalogSubtitlePreferenceOrdinal(replacement, saved)) + } + + @Test + fun typedServerPreferenceWithOnlyForcedFalseDoesNotRemapArbitrarySoleTrack() { + val weak = encodeSubtitleIdentityPreference( + SubtitleIdentity.ServerSidecar( + serverIndex = 0, + media = SubtitleMediaIdentity(forced = false), + ), + ) + val replacement = listOf( + SubtitleTrack( + index = 9, + codec = "srt", + language = "es", + title = "Spanish", + external = true, + ), + ) + + assertNull(resolveCatalogSubtitlePreferenceOrdinal(replacement, weak)) + } + + @Test + fun typedServerPreferenceRequiresStoredTrackIdToMatch() { + val withTrackId = encodeSubtitleIdentityPreference( + SubtitleIdentity.ServerSidecar( + serverIndex = 0, + media = SubtitleMediaIdentity( + trackId = "stable-track", + label = "English", + language = "en", + codecFamily = "srt", + forced = false, + ), + ), + ) + val rowWithoutTrackId = listOf( + SubtitleTrack( + index = 9, + codec = "srt", + language = "en", + title = "English", + external = true, + ), + ) + + assertNull(resolveCatalogSubtitlePreferenceOrdinal(rowWithoutTrackId, withTrackId)) + } + + @Test + fun legacyTypedServerPreferenceWithoutMediaKeepsCombinedIndexCompatibility() { + val legacy = encodeSubtitleIdentityPreference( + SubtitleIdentity.ServerSidecar(serverIndex = 1), + ) + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "en", title = "English", external = true), + SubtitleTrack(index = 0, codec = "srt", language = "fr", title = "French", external = true), + ) + + assertEquals(1, resolveCatalogSubtitlePreferenceOrdinal(tracks, legacy)) + } + + @Test + fun playerCanonicalSubripPreferenceResolvesCatalogSrt() { + val preference = encodedPlayerServerPreference("English", "en", "subrip") + val catalog = listOf( + SubtitleTrack( + index = 7, + codec = "srt", + language = "en", + title = "English", + external = true, + ), + ) + + assertEquals(0, resolveCatalogSubtitlePreferenceOrdinal(catalog, preference)) + } + + @Test + fun playerCanonicalSsaPreferenceResolvesCatalogAss() { + val preference = encodedPlayerServerPreference("Styled English", "en", "ssa") + val catalog = listOf( + SubtitleTrack( + index = 7, + codec = "ass", + language = "en", + title = "Styled English", + external = true, + ), + ) + + assertEquals(0, resolveCatalogSubtitlePreferenceOrdinal(catalog, preference)) + } + + @Test + fun playerCanonicalLanguagePreferenceResolvesCatalogIso639Aliases() { + val englishPreference = encodedPlayerServerPreference("English", "en", "subrip") + val frenchPreference = encodedPlayerServerPreference("French", "fr", "subrip") + val catalog = listOf( + SubtitleTrack( + index = 7, + codec = "srt", + language = "eng", + title = "English", + external = true, + ), + SubtitleTrack( + index = 9, + codec = "srt", + language = "fra", + title = "French", + external = true, + ), + ) + + assertEquals(0, resolveCatalogSubtitlePreferenceOrdinal(catalog, englishPreference)) + assertEquals(1, resolveCatalogSubtitlePreferenceOrdinal(catalog, frenchPreference)) + } + + @Test + fun catalogPreferenceSerializesCanonicalSubtitleLanguage() { + val catalog = listOf( + SubtitleTrack( + index = 7, + codec = "srt", + language = "fre", + title = "French", + external = true, + ), + ) + + val identity = assertIs( + decodeSubtitleIdentityPreference(encodeCatalogSubtitlePreference(catalog, 0)), + ) + + assertEquals("fr", identity.media?.language) + } + + @Test + fun playerCanonicalLanguagePreferenceSafelyMissesDifferentCatalogLanguage() { + val preference = encodedPlayerServerPreference("Dialogue", "en", "subrip") + val catalog = listOf( + SubtitleTrack( + index = 7, + codec = "srt", + language = "fra", + title = "Dialogue", + external = true, + ), + ) + + assertNull(resolveCatalogSubtitlePreferenceOrdinal(catalog, preference)) + } + + @Test + fun playerCanonicalLanguagePreferenceSafelyMissesAmbiguousAliasRows() { + val preference = encodedPlayerServerPreference("English", "en", "subrip") + val catalog = listOf( + SubtitleTrack(index = 7, codec = "srt", language = "en", title = "English", external = true), + SubtitleTrack(index = 9, codec = "srt", language = "eng", title = "English", external = true), + ) + + assertNull(resolveCatalogSubtitlePreferenceOrdinal(catalog, preference)) + } + + @Test + fun playerCanonicalCodecPreferenceSafelyMissesDifferentCatalogCodec() { + val preference = encodedPlayerServerPreference("English", "en", "subrip") + val catalog = listOf( + SubtitleTrack( + index = 7, + codec = "ass", + language = "en", + title = "English", + external = true, + ), + ) + + assertNull(resolveCatalogSubtitlePreferenceOrdinal(catalog, preference)) + } + + @Test + fun playerCanonicalCodecPreferenceSafelyMissesAmbiguousCatalogRows() { + val preference = encodedPlayerServerPreference("English", "en", "subrip") + val catalog = listOf( + SubtitleTrack(index = 7, codec = "srt", language = "en", title = "English", external = true), + SubtitleTrack(index = 9, codec = "subrip", language = "en", title = "English", external = true), + ) + + assertNull(resolveCatalogSubtitlePreferenceOrdinal(catalog, preference)) + } + @Test fun playerSubtitleInfoUsesSameSubtitleFingerprintShape() { val catalog = SubtitleTrack(index = 2, codec = "srt", language = "eng", title = "English CC", forced = true) @@ -69,4 +453,51 @@ class TrackSelectionFingerprintTest { assertNull(resolveAudioTrackOrdinal(tracks, "")) assertNull(resolveAudioTrackOrdinal(tracks, "missing")) } + + private fun encodedPlayerServerPreference( + label: String, + language: String, + codecFamily: String, + ): String = encodeSubtitleIdentityPreference( + SubtitleIdentity.ServerSidecar( + serverIndex = 7, + media = SubtitleMediaIdentity( + label = label, + language = language, + codecFamily = codecFamily, + forced = false, + ), + ), + ) + + private fun catalogTracksInMixedIndexSpaces(): List = listOf( + SubtitleTrack( + index = 17, + codec = "ass", + language = "en", + title = "Embedded English", + external = false, + ), + SubtitleTrack( + index = 0, + codec = "srt", + language = "fr", + title = "External French", + external = true, + ), + SubtitleTrack( + index = 23, + codec = "ass", + language = "nl", + title = "Embedded Dutch", + external = false, + ), + SubtitleTrack( + index = 0, + codec = "srt", + language = "en", + title = "External English", + external = true, + ), + ) } 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 b2af150e0..610c343ca 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt @@ -11,18 +11,30 @@ import org.prairieserver.prairie.model.watchtogether.Suggestion 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.IdentityTransitionKind import org.prairieserver.prairie.network.RoomRealtimeEvent import org.prairieserver.prairie.network.WatchTogetherRealtimeClient import org.prairieserver.prairie.network.api.WatchTogetherApi +import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.RoomTransportIntent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -45,34 +57,88 @@ class WatchTogetherRepositoryTest { var createResponse: ApiResult = ApiResult.Success( RoomResponse(RoomSnapshot(roomId = "room-1", code = "ABCD1234"), "jwt-room"), ), - var suggestionsResponse: ApiResult = ApiResult.Success(SuggestionsResponse()), ) : WatchTogetherApi { var lastRoomToken: String? = null var lastRoomId: String? = null var lastSelection: SetSelectionRequest? = null - override suspend fun createRoom(request: CreateRoomRequest) = createResponse - override suspend fun joinRoom(request: JoinRoomRequest) = createResponse - override suspend fun getRoom(roomId: String, roomToken: String) = createResponse.also { lastRoomToken = roomToken } - override suspend fun setSelection(roomId: String, roomToken: String, request: SetSelectionRequest): ApiResult { - lastRoomId = roomId; lastRoomToken = roomToken; lastSelection = request; return createResponse + var lastAuthScope: AuthScopeSnapshot? = null + var createResult: CompletableDeferred>? = null + var selectionResult: CompletableDeferred>? = null + var listSuggestionsResponse: ApiResult = + ApiResult.Success(SuggestionsResponse()) + var listSuggestionsResult: CompletableDeferred>? = null + var listSuggestionsCalls = 0 + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult { + lastAuthScope = scope + return createResult?.await() ?: createResponse } - override suspend fun updatePolicy(roomId: String, roomToken: String, request: UpdatePolicyRequest) = - createResponse.also { lastRoomId = roomId; lastRoomToken = roomToken } - override suspend fun closeRoom(roomId: String, roomToken: String): ApiResult { - lastRoomId = roomId; lastRoomToken = roomToken; return ApiResult.Success(Unit) + override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot) = + createResponse.also { lastAuthScope = scope } + override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = + createResponse.also { lastRoomToken = roomToken; lastAuthScope = scope } + override suspend fun setSelection( + roomId: String, + roomToken: String, + request: SetSelectionRequest, + scope: AuthScopeSnapshot, + ): ApiResult { + lastRoomId = roomId + lastRoomToken = roomToken + lastSelection = request + lastAuthScope = scope + return selectionResult?.await() ?: createResponse } - override suspend fun listSuggestions(roomId: String, roomToken: String) = - suggestionsResponse.also { lastRoomId = roomId; lastRoomToken = roomToken } - override suspend fun addSuggestion(roomId: String, roomToken: String, request: AddSuggestionRequest) = - suggestionsResponse.also { lastRoomId = roomId; lastRoomToken = roomToken } - override suspend fun deleteSuggestion(roomId: String, roomToken: String, suggestionId: String) = - suggestionsResponse.also { lastRoomId = roomId; lastRoomToken = roomToken } - override suspend fun vote(roomId: String, roomToken: String, suggestionId: String) = - suggestionsResponse.also { lastRoomId = roomId; lastRoomToken = roomToken } - override suspend fun unvote(roomId: String, roomToken: String, suggestionId: String) = - suggestionsResponse.also { lastRoomId = roomId; lastRoomToken = roomToken } - override suspend fun promoteSuggestion(roomId: String, roomToken: String, request: PromoteSuggestionRequest) = - createResponse.also { lastRoomId = roomId; lastRoomToken = roomToken } + override suspend fun updatePolicy( + roomId: String, + roomToken: String, + request: UpdatePolicyRequest, + scope: AuthScopeSnapshot, + ) = createResponse.also { lastRoomToken = roomToken; lastAuthScope = scope } + override suspend fun closeRoom( + roomId: String, + roomToken: String, + scope: AuthScopeSnapshot, + ): ApiResult { + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(Unit) + } + override suspend fun listSuggestions(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = + (listSuggestionsResult?.await() ?: listSuggestionsResponse).also { + listSuggestionsCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + } + override suspend fun addSuggestion( + roomId: String, + roomToken: String, + request: AddSuggestionRequest, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } + override suspend fun deleteSuggestion( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } + override suspend fun vote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } + override suspend fun unvote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } + override suspend fun promoteSuggestion( + roomId: String, + roomToken: String, + request: PromoteSuggestionRequest, + scope: AuthScopeSnapshot, + ) = createResponse.also { lastRoomToken = roomToken; lastAuthScope = scope } } private class FakeRealtime( @@ -81,6 +147,13 @@ class WatchTogetherRepositoryTest { var connectCount = 0 /** When true, the returned flow throws immediately on collection. */ var failConnect = false + var terminateImmediately = false + var attachCount = 0 + val transportActions = mutableListOf() + val readySessions = mutableListOf() + val bufferingSessions = mutableListOf() + var transportResultGate: CompletableDeferred? = null + var connectBehavior: ((Int) -> Flow)? = null /** * When non-null, each connect attempt emits this one event and then throws. * Models a flapping server: healthy traffic is observed but the connection @@ -88,10 +161,21 @@ class WatchTogetherRepositoryTest { * failures were reset per-event rather than per-clean-completion. */ var flappingEvent: RoomRealtimeEvent? = null - val calls = mutableListOf() - override fun connect(roomId: String, roomToken: String): Flow { + override fun connect( + roomId: String, + roomToken: String, + authScope: AuthScopeSnapshot?, + ): Flow { connectCount++ + connectedRoomIds += roomId + connectedRoomTokens += roomToken + connectedAuthScopes += authScope + connectBehavior?.let { return it(connectCount) } if (failConnect) return flow { throw IllegalStateException("boom") } + if (terminateImmediately) return flow { + emit(RoomRealtimeEvent.Opened) + emit(RoomRealtimeEvent.TransportTerminated()) + } val flapEvent = flappingEvent if (flapEvent != null) return flow { emit(flapEvent) @@ -99,26 +183,57 @@ class WatchTogetherRepositoryTest { } return events.asSharedFlow() } - override suspend fun attachSession(sessionId: String) { calls.add("attach:$sessionId") } - override suspend fun transportRequest(action: String, positionSeconds: Double?, isPaused: Boolean) { - calls.add("transport:$action:$positionSeconds:$isPaused") + val connectedRoomIds = mutableListOf() + val connectedRoomTokens = mutableListOf() + val connectedAuthScopes = mutableListOf() + override suspend fun attachSession(sessionId: String): Boolean { + attachCount++ + return true } - override suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean) { - calls.add("state:$sessionId:$positionSeconds:$isPaused") + override suspend fun transportRequest(action: String, positionSeconds: Double?, isPaused: Boolean): Boolean { + transportActions += action + return true } - override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean) { - calls.add("ready:$sessionId:$positionSeconds:$isPaused") + override suspend fun currentConnectionId(): Long? = connectCount.toLong().takeIf { it > 0L } + override suspend fun transportRequestOnConnection( + connectionId: Long, + action: String, + positionSeconds: Double?, + isPaused: Boolean, + ): Boolean { + transportResultGate?.let { return it.await() } + transportActions += action + return true } - override suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean) { - calls.add("buffering:$sessionId:$positionSeconds:$isPaused") + override suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean) = true + override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean { + readySessions += sessionId + return true } - override suspend fun ping(clientSentAt: String) { calls.add("ping:$clientSentAt") } + override suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean { + bufferingSessions += sessionId + return true + } + override suspend fun ping(clientSentAt: String) = true } + private val scopeA = AuthScopeSnapshot( + serverId = "server-a", + profileId = "profile-a", + serverUrl = "https://a.example", + profileToken = "profile-token-a", + identityGeneration = 1L, + ) + private fun repo( api: FakeApi = FakeApi(), realtime: FakeRealtime = FakeRealtime(), - ) = WatchTogetherRepository(api = api, realtimeFactory = { realtime }) + authScopeProvider: suspend () -> AuthScopeSnapshot? = { scopeA }, + ) = WatchTogetherRepository( + api = api, + realtimeFactory = { realtime }, + authScopeProvider = authScopeProvider, + ) // ---- create stores room token ------------------------------------------- @@ -133,47 +248,498 @@ class WatchTogetherRepositoryTest { } @Test - fun `room scoped REST calls use stored token and update local state`() = runTest { - val api = FakeApi( - suggestionsResponse = ApiResult.Success( - SuggestionsResponse(suggestions = listOf(suggestion("s1", votedByMe = true))), + fun `join hydrates suggestions that predate the websocket connection`() = runTest { + val api = FakeApi().apply { + listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse(suggestions = listOf(suggestion("existing"))), + ) + } + val repository = repo(api = api) + + val result = repository.joinRoom(JoinRoomRequest(code = "ABCD1234")) + + assertIs>(result) + assertEquals(1, api.listSuggestionsCalls) + assertEquals(listOf("existing"), repository.suggestions.value.map { it.id }) + } + + @Test + fun `join returns obsolete when its hydration lease is replaced by another room`() = runTest { + val hydration = CompletableDeferred>() + val api = FakeApi().apply { listSuggestionsResult = hydration } + val repository = repo(api = api) + val joiningA = async { repository.joinRoom(JoinRoomRequest(code = "ABCD1234")) } + runCurrent() + + api.createResponse = ApiResult.Success( + RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), + ) + assertIs>(repository.createRoom(CreateRoomRequest())) + hydration.complete(ApiResult.Success(SuggestionsResponse(suggestions = listOf(suggestion("stale"))))) + + val staleResult = assertIs(joiningA.await()) + assertEquals("obsolete_room_request", staleResult.error) + assertEquals("room-2", repository.roomSnapshot.value?.roomId) + assertTrue(repository.suggestions.value.isEmpty()) + } + + @Test + fun `join returns obsolete when a newer room request starts during hydration`() = runTest { + val hydration = CompletableDeferred>() + val newerCreate = CompletableDeferred>() + val api = FakeApi().apply { + listSuggestionsResult = hydration + createResult = newerCreate + } + val repository = repo(api = api) + val joiningA = async { repository.joinRoom(JoinRoomRequest(code = "ABCD1234")) } + runCurrent() + val creatingB = async { repository.createRoom(CreateRoomRequest()) } + runCurrent() + + hydration.complete(ApiResult.Success(SuggestionsResponse(suggestions = listOf(suggestion("stale"))))) + + val staleResult = assertIs(joiningA.await()) + assertEquals("obsolete_room_request", staleResult.error) + assertTrue(repository.suggestions.value.isEmpty()) + newerCreate.complete( + ApiResult.Success( + RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), + ), + ) + assertIs>(creatingB.await()) + } + + @Test + fun `transport lease cannot send into a replacement room`() = runTest { + val api = FakeApi() + 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() + val authorizationA = requireNotNull(repository.currentTransportAuthorization()) + + api.createResponse = ApiResult.Success( + RoomResponse( + RoomSnapshot( + roomId = "room-2", + code = "EFGH5678", + phase = org.prairieserver.prairie.model.watchtogether.RoomPhase.Playing, + selfCanControlTransport = true, + ), + "jwt-room-2", + ), + ) + repository.createRoom(CreateRoomRequest()) + + assertFalse( + repository.transportRequestForAuthorization( + authorization = authorizationA, + intent = RoomTransportIntent.PlayPause, + action = "pause", + positionSeconds = 42.0, + isPaused = true, + ), + ) + assertTrue(realtime.transportActions.isEmpty()) + connection.cancel() + } + + @Test + fun `transport lease revalidates authority after policy changes in the same room`() = runTest { + val api = FakeApi().apply { + createResponse = ApiResult.Success( + RoomResponse( + RoomSnapshot( + roomId = "room-1", + code = "ABCD1234", + phase = org.prairieserver.prairie.model.watchtogether.RoomPhase.Playing, + selfCanControlTransport = true, + ), + "jwt-room", + ), + ) + } + 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() + val authorizationBeforePolicyChange = requireNotNull(repository.currentTransportAuthorization()) + realtime.events.emit( + RoomRealtimeEvent.SnapshotEvent( + api.createResponse.let { (it as ApiResult.Success).data.room.copy(selfCanControlTransport = false) }, ), ) + runCurrent() + + assertFalse( + repository.transportRequestForAuthorization( + authorization = authorizationBeforePolicyChange, + intent = RoomTransportIntent.PlayPause, + action = "pause", + positionSeconds = 42.0, + isPaused = true, + ), + ) + assertTrue(realtime.transportActions.isEmpty()) + connection.cancel() + } + + @Test + fun `blocked physical send does not hold room replacement or reset mutex`() = runTest { + val api = FakeApi().apply { + createResponse = ApiResult.Success( + RoomResponse( + RoomSnapshot( + roomId = "room-1", + code = "ABCD1234", + phase = org.prairieserver.prairie.model.watchtogether.RoomPhase.Playing, + selfCanControlTransport = true, + ), + "jwt-room", + ), + ) + } + val realtime = FakeRealtime().apply { + transportResultGate = CompletableDeferred() + } + val repository = repo(api = api, realtime = realtime) + repository.createRoom(CreateRoomRequest()) + val connection = launch { repository.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + val authorization = requireNotNull(repository.currentTransportAuthorization()) + + val blockedSend = async { + repository.transportRequestForAuthorization( + authorization = authorization, + intent = RoomTransportIntent.PlayPause, + action = "pause", + positionSeconds = 42.0, + isPaused = true, + ) + } + runCurrent() + val reset = async { repository.reset() } + runCurrent() + + assertTrue(reset.isCompleted) + assertFalse(blockedSend.isCompleted) + realtime.transportResultGate?.complete(false) + assertFalse(blockedSend.await()) + connection.cancel() + } + + @Test + fun `room binding retains the auth scope captured when it was created`() = runTest { + val api = FakeApi() + var currentScope = scopeA + val r = repo(api = api, authScopeProvider = { currentScope }) + r.createRoom(CreateRoomRequest()) + currentScope = scopeA.copy( + serverId = "server-b", + serverUrl = "https://b.example", + identityGeneration = 2L, + ) + + r.setSelection(SetSelectionRequest(contentId = "tt-9")) + + assertEquals(scopeA, api.lastAuthScope) + } + + @Test + fun `realtime reconnect uses the auth scope captured by the room binding`() = runTest { + val realtime = FakeRealtime() + var currentScope = scopeA + val r = repo(realtime = realtime, authScopeProvider = { currentScope }) + r.createRoom(CreateRoomRequest()) + currentScope = scopeA.copy( + serverId = "server-b", + serverUrl = "https://b.example", + identityGeneration = 2L, + ) + + val connection = launch { r.connect("room-1") } + runCurrent() + + assertEquals(1, realtime.connectedAuthScopes.size) + assertEquals(scopeA, realtime.connectedAuthScopes.single()) + connection.cancel() + } + + @Test + fun `every reconnect attempt retains the room auth scope`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) flow { throw IllegalStateException("drop") } else events.asSharedFlow() + } + } + val r = repo(realtime = realtime) + r.createRoom(CreateRoomRequest()) + val connection = launch { r.connect("room-1") } + runCurrent() + advanceTimeBy(WatchTogetherRepository.BACKOFF_MS.first()) + runCurrent() + + assertEquals(2, realtime.connectedAuthScopes.size) + assertTrue(realtime.connectedAuthScopes.all { it == scopeA }) + connection.cancel() + } + + @Test + fun `create response cannot mint a room after the auth identity changes`() = runTest { + val api = FakeApi() + val pending = CompletableDeferred>() + api.createResult = pending + var currentScope = scopeA + val r = repo(api = api, authScopeProvider = { currentScope }) + val create = launch { r.createRoom(CreateRoomRequest()) } + runCurrent() + currentScope = scopeA.copy(identityGeneration = 2L) + + pending.complete(api.createResponse) + create.join() + + assertNull(r.roomSnapshot.value) + } + + @Test + fun `identity barrier invalidates a pending create before identity mutation`() = runTest { + val api = FakeApi() + val pending = CompletableDeferred>() + api.createResult = pending + val repository = repo(api = api) + val barrier = DefaultIdentityTransitionBarrier() + RoomSession(repository, backgroundScope, barrier) + var createResult: ApiResult? = null + val create = launch { + createResult = repository.createRoom(CreateRoomRequest()) + } + runCurrent() + + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { + assertNull(repository.roomSnapshot.value) + } + pending.complete(api.createResponse) + create.join() + + assertIs(createResult) + assertNull(repository.roomSnapshot.value) + } + + @Test + fun `blank room token fails closed without replacing the active room`() = runTest { + val api = FakeApi() val r = repo(api = api) + r.createRoom(CreateRoomRequest()) + api.createResponse = ApiResult.Success( + RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), ""), + ) + + val result = r.createRoom(CreateRoomRequest()) - r.joinRoom(JoinRoomRequest(code = "ABCD1234")) - r.updatePolicy(UpdatePolicyRequest(guestControlPolicy = "host")) + assertIs(result) + assertEquals("room-1", r.roomSnapshot.value?.roomId) + r.setSelection(SetSelectionRequest(contentId = "still-a")) assertEquals("room-1", api.lastRoomId) assertEquals("jwt-room", api.lastRoomToken) + } - r.refreshSuggestions() - assertTrue(r.suggestions.value.single().votedByMe) - r.addSuggestion(AddSuggestionRequest(contentId = "movie-1", contentType = "movie", title = "Movie")) - r.deleteSuggestion("s1") - r.vote("s1") - r.unvote("s1") + @Test + fun `mismatched room response fails closed without replacing the active room`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(CreateRoomRequest()) + api.createResponse = ApiResult.Success( + RoomResponse(RoomSnapshot(roomId = "", code = "EFGH5678"), "jwt-room-2"), + ) - r.promoteSuggestion(PromoteSuggestionRequest(suggestionId = "s1")) + val result = r.createRoom(CreateRoomRequest()) + + assertIs(result) assertEquals("room-1", r.roomSnapshot.value?.roomId) + } - r.closeRoom() - assertEquals("room-1", api.lastRoomId) - assertEquals("jwt-room", api.lastRoomToken) + @Test + fun `room scoped response for another room is rejected`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(CreateRoomRequest()) + api.createResponse = ApiResult.Success( + RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), + ) + + val result = r.setSelection(SetSelectionRequest(contentId = "wrong-room")) + + assertIs(result) + assertEquals("room-1", r.roomSnapshot.value?.roomId) + } + + @Test + fun `create fails closed when there is no authenticated scope`() = runTest { + val r = repo(authScopeProvider = { null }) + + val result = r.createRoom(CreateRoomRequest()) + + assertIs(result) + assertNull(r.roomSnapshot.value) + } + + @Test + fun `connect rejects a room id that does not match the active binding`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(CreateRoomRequest()) + + val job = launch { r.connect("room-2") } + runCurrent() + + assertEquals(0, realtime.connectCount) + job.cancel() + } + + @Test + fun `local delivery failure is surfaced as a non terminal room error`() = runTest { + val repository = repo() + val error = async { repository.errors.first() } + runCurrent() + + repository.reportDeliveryFailure("room_transport_unavailable") + + assertEquals("room_transport_unavailable", error.await()) + assertNull(repository.roomClosedReason.value) } @Test - fun `room scoped REST failures do not overwrite current snapshot or suggestions`() = runTest { - val api = FakeApi( - createResponse = ApiResult.Error(code = 500, error = "server_error", message = "nope"), - suggestionsResponse = ApiResult.Error(code = 500, error = "server_error", message = "nope"), + fun `controller recreation shares successful delivery state for the same epoch`() = runTest { + val repository = repo() + val key = repository.roomDeliveryLatch.keyOrNull( + WatchTogetherConnectionState(generation = 7, epoch = 2, writable = true), + "playback-1", + )!! + + repository.roomDeliveryLatch.recordAttach(key, delivered = true) + + assertTrue(repository.roomDeliveryLatch.isAttached(key)) + assertTrue(!repository.roomDeliveryLatch.needsAttach(key)) + } + + @Test + fun `two participants independently receive room state commands and terminal close`() = runTest { + val sharedSnapshot = snapshot(memberCount = 2) + val response = ApiResult.Success(RoomResponse(sharedSnapshot, "jwt-room")) + val hostRealtime = FakeRealtime() + val guestRealtime = FakeRealtime() + val host = repo(api = FakeApi(response), realtime = hostRealtime) + val guest = repo(api = FakeApi(response), realtime = guestRealtime) + host.createRoom(CreateRoomRequest()) + guest.createRoom(CreateRoomRequest()) + val hostCommand = async { host.transportCommands.first() } + val guestCommand = async { guest.transportCommands.first() } + val hostConnection = launch { host.connect("room-1") } + val guestConnection = launch { guest.connect("room-1") } + runCurrent() + val command = org.prairieserver.prairie.model.watchtogether.TransportCommand( + commandId = "cmd-1", + sessionId = "playback-1", + selectionRevision = sharedSnapshot.selectionRevision, + action = org.prairieserver.prairie.model.watchtogether.TransportAction.Seek, + positionSeconds = 42.0, + executeAt = "2026-07-27T12:00:00Z", ) + + hostRealtime.events.emit(RoomRealtimeEvent.Opened) + guestRealtime.events.emit(RoomRealtimeEvent.Opened) + hostRealtime.events.emit(RoomRealtimeEvent.SnapshotEvent(sharedSnapshot)) + guestRealtime.events.emit(RoomRealtimeEvent.SnapshotEvent(sharedSnapshot)) + hostRealtime.events.emit(RoomRealtimeEvent.TransportCommandEvent(command)) + guestRealtime.events.emit(RoomRealtimeEvent.TransportCommandEvent(command)) + runCurrent() + + assertEquals(sharedSnapshot, host.roomSnapshot.value) + assertEquals(sharedSnapshot, guest.roomSnapshot.value) + val hostScheduled = hostCommand.await() + val guestScheduled = guestCommand.await() + assertEquals(command, hostScheduled.command) + assertEquals(command, guestScheduled.command) + assertEquals(host.connectionState.value, hostScheduled.connection) + assertEquals(guest.connectionState.value, guestScheduled.connection) + assertTrue(host.attachSession("host-playback")) + assertTrue(guest.attachSession("guest-playback")) + assertTrue(host.transportRequest("pause", 42.0, true)) + assertTrue(guest.transportRequest("play", 42.0, false)) + assertTrue(host.buffering("host-playback", 42.0, true)) + assertTrue(guest.ready("guest-playback", 42.0, false)) + assertEquals(listOf("pause"), hostRealtime.transportActions) + assertEquals(listOf("play"), guestRealtime.transportActions) + + hostRealtime.events.emit(RoomRealtimeEvent.Closed("host_left")) + guestRealtime.events.emit(RoomRealtimeEvent.Closed("host_left")) + hostConnection.join() + guestConnection.join() + + assertEquals("host_left", host.roomClosedReason.value) + assertEquals("host_left", guest.roomClosedReason.value) + assertEquals(1, hostRealtime.connectCount) + assertEquals(1, guestRealtime.connectCount) + } + + @Test + fun `late room A REST completion cannot overwrite replacement room B`() = runTest { + val api = FakeApi() val r = repo(api = api) + r.createRoom(CreateRoomRequest()) + val pending = CompletableDeferred>() + api.selectionResult = pending + var staleResult: ApiResult? = null + val stale = launch { + staleResult = r.setSelection(SetSelectionRequest(contentId = "late-a")) + } + runCurrent() - r.setSelection(SetSelectionRequest(contentId = "missing")) - r.refreshSuggestions() + api.selectionResult = null + api.createResponse = ApiResult.Success( + RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), + ) + r.createRoom(CreateRoomRequest()) + pending.complete( + ApiResult.Success( + RoomResponse(RoomSnapshot(roomId = "room-1", selectionRevision = 99), "jwt-room"), + ), + ) + stale.join() - assertNull(r.roomSnapshot.value) - assertTrue(r.suggestions.value.isEmpty()) + assertEquals("room-2", r.roomSnapshot.value?.roomId) + assertIs(staleResult) + } + + @Test + fun `late room A socket event cannot overwrite replacement room B`() = runTest { + val api = FakeApi() + val realtime = FakeRealtime() + val r = repo(api = api, realtime = realtime) + r.createRoom(CreateRoomRequest()) + val oldConnection = launch { r.connect("room-1") } + runCurrent() + + api.createResponse = ApiResult.Success( + RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), + ) + r.createRoom(CreateRoomRequest()) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "room-1", revision = 99))) + advanceUntilIdle() + + assertEquals("room-2", r.roomSnapshot.value?.roomId) + assertTrue(oldConnection.isCompleted) + assertTrue(!r.attachSession("session-b")) + assertEquals(0, realtime.attachCount) } // ---- snapshot fold -------------------------------------------------------- @@ -193,43 +759,20 @@ class WatchTogetherRepositoryTest { } @Test - fun `realtime send methods are no-ops until connected and then delegate`() = runTest { + fun `snapshot for another room cannot replace the active room`() = runTest { val realtime = FakeRealtime() val r = repo(realtime = realtime) - - r.attachSession("before") - assertTrue(realtime.calls.isEmpty()) - r.createRoom(CreateRoomRequest()) val job = launch { r.connect("room-1") } - advanceUntilIdle() + runCurrent() - r.attachSession("session-1") - r.transportRequest("pause", 12.5, true) - r.stateReport("session-1", 13.0, false) - r.ready("session-1", 14.0, false) - r.buffering("session-1", 15.0, true) - r.ping("2026-06-12T08:00:00Z") + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "room-2", revision = 9))) + runCurrent() - assertEquals( - listOf( - "attach:session-1", - "transport:pause:12.5:true", - "state:session-1:13.0:false", - "ready:session-1:14.0:false", - "buffering:session-1:15.0:true", - "ping:2026-06-12T08:00:00Z", - ), - realtime.calls, - ) + assertEquals("room-1", r.roomSnapshot.value?.roomId) job.cancel() } - @Test - fun `connect returns immediately when no realtime client is available`() = runTest { - WatchTogetherRepository(api = FakeApi(), realtimeFactory = { null }).connect("room-1") - } - // ---- suggestions fold + voted_by_me re-merge ------------------------------ @Test @@ -296,8 +839,9 @@ class WatchTogetherRepositoryTest { assertTrue(r.suggestions.value.isEmpty()) // After reset the room token is gone; a room-scoped call must not reuse it. api.lastRoomToken = null - r.setSelection(SetSelectionRequest(contentId = "x")) - assertEquals("", api.lastRoomToken) // repository passes empty token when none stored + val result = r.setSelection(SetSelectionRequest(contentId = "x")) + assertIs(result) + assertNull(api.lastRoomToken) // fail fast: no request is sent with an empty token job.cancel() } @@ -365,6 +909,11 @@ class WatchTogetherRepositoryTest { advanceUntilIdle() assertEquals(1, realtime.connectCount) assertTrue(job.isCompleted || job.isCancelled) + + // A terminal binding is tombstoned. Reusing the old room id cannot + // create a fresh connection without a new successful create/join. + r.connect("room-1") + assertEquals(1, realtime.connectCount) } @Test @@ -380,9 +929,140 @@ class WatchTogetherRepositoryTest { realtime.events.emit(RoomRealtimeEvent.Closed(null)) advanceUntilIdle() assertEquals(1, realtime.connectCount) + assertEquals("room_closed", r.roomClosedReason.value) assertTrue(job.isCompleted || job.isCancelled) } + @Test + fun `replacement during reconnect backoff cannot open another stale socket`() = runTest { + val api = FakeApi() + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) flow { throw IllegalStateException("drop") } else events.asSharedFlow() + } + } + val r = repo(api = api, realtime = realtime) + r.createRoom(CreateRoomRequest()) + val old = launch { r.connect("room-1") } + runCurrent() // first attempt failed and is waiting in its 500 ms backoff + + api.createResponse = ApiResult.Success( + RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), + ) + r.createRoom(CreateRoomRequest()) + advanceUntilIdle() + + assertEquals(1, realtime.connectCount) + assertTrue(old.isCompleted) + } + + @Test + fun `newest duplicate connect exclusively owns folds and writable state`() = runTest { + val first = FakeRealtime() + val second = FakeRealtime() + var factoryCall = 0 + val r = WatchTogetherRepository( + api = FakeApi(), + realtimeFactory = { if (factoryCall++ == 0) first else second }, + authScopeProvider = { scopeA }, + ) + r.createRoom(CreateRoomRequest()) + val old = launch { r.connect("room-1") } + runCurrent() + first.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + assertTrue(r.connectionState.value.writable) + val newest = launch { r.connect("room-1") } + runCurrent() + assertTrue(!r.attachSession("too-early")) + assertEquals(0, second.attachCount) + + second.events.emit(RoomRealtimeEvent.Opened) + second.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(revision = 2))) + runCurrent() + first.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(revision = 99))) + runCurrent() + + assertEquals(2L, r.roomSnapshot.value?.selectionRevision) + assertTrue(r.connectionState.value.writable) + assertTrue(old.isCompleted) + newest.cancel() + } + + @Test + fun `throw after opened marks the connection unwritable during backoff`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) flow { + emit(RoomRealtimeEvent.Opened) + throw IllegalStateException("drop after open") + } else events.asSharedFlow() + } + } + val r = repo(realtime = realtime) + r.createRoom(CreateRoomRequest()) + val job = launch { r.connect("room-1") } + runCurrent() + + assertTrue(!r.connectionState.value.writable) + assertTrue(!r.attachSession("during-backoff")) + assertEquals(0, realtime.attachCount) + job.cancel() + } + + @Test + fun `queued transport command retains the exact epoch that emitted it across reconnect`() = runTest { + val command = org.prairieserver.prairie.model.watchtogether.TransportCommand( + commandId = "epoch-1-command", + sessionId = "playback-1", + selectionRevision = 1, + executeAt = "2026-07-27T12:00:00Z", + ) + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { + emit(RoomRealtimeEvent.Opened) + emit(RoomRealtimeEvent.TransportCommandEvent(command)) + emit(RoomRealtimeEvent.TransportTerminated()) + } + } else { + events.asSharedFlow() + } + } + } + val r = repo(realtime = realtime) + r.createRoom(CreateRoomRequest()) + val received = async { r.transportCommands.first() } + val job = launch { r.connect("room-1") } + runCurrent() + val scheduled = received.await() + + assertEquals(1L, scheduled.connection.epoch) + assertTrue(!r.connectionState.value.writable) + advanceTimeBy(500) + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + + assertEquals(2L, r.connectionState.value.epoch) + assertTrue(scheduled.connection != r.connectionState.value) + job.cancel() + } + + @Test + fun `send fails before the active connection reports writable`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(CreateRoomRequest()) + val job = launch { r.connect("room-1") } + runCurrent() + + assertTrue(!r.attachSession("session-1")) + assertEquals(0, realtime.attachCount) + job.cancel() + } + // ---- reconnect gives up after max consecutive failures ---------------------- @Test @@ -403,6 +1083,93 @@ class WatchTogetherRepositoryTest { assertNull(r.roomSnapshot.value) } + @Test + fun `physical transport termination reconnects and counts toward the cap`() = runTest { + val realtime = FakeRealtime().apply { terminateImmediately = true } + val r = repo(realtime = realtime) + r.createRoom(CreateRoomRequest()) + + val job = launch { r.connect("room-1") } + advanceUntilIdle() + + assertTrue(job.isCompleted) + assertEquals(WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS, realtime.connectCount) + assertEquals("connection_lost", r.roomClosedReason.value) + assertTrue(!r.connectionState.value.writable) + assertEquals( + WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS.toLong(), + r.connectionState.value.epoch, + ) + } + + @Test + fun `snapshot plus thirty stable seconds resets the reconnect failure window`() = runTest { + var nowMs = 0L + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + when { + attempt <= 5 -> flow { throw IllegalStateException("early failure") } + attempt == 6 -> flow { + emit(RoomRealtimeEvent.Opened) + nowMs = WatchTogetherRepository.STABLE_CONNECTION_MS + emit(RoomRealtimeEvent.SnapshotEvent(snapshot())) + emit(RoomRealtimeEvent.TransportTerminated()) + } + attempt <= 10 -> flow { throw IllegalStateException("later failure") } + else -> events.asSharedFlow() + } + } + } + val r = WatchTogetherRepository( + api = FakeApi(), + realtimeFactory = { realtime }, + monotonicNowMs = { nowMs }, + authScopeProvider = { scopeA }, + ) + r.createRoom(CreateRoomRequest()) + + val job = launch { r.connect("room-1") } + advanceUntilIdle() + + assertEquals(11, realtime.connectCount) + assertTrue(job.isActive) + assertNull(r.roomClosedReason.value) + job.cancel() + } + + @Test + fun `wrong room snapshot cannot reset the reconnect failure window`() = runTest { + var nowMs = 0L + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt < WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS) { + flow { throw IllegalStateException("early failure") } + } else { + flow { + emit(RoomRealtimeEvent.Opened) + nowMs = WatchTogetherRepository.STABLE_CONNECTION_MS + emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "wrong-room"))) + emit(RoomRealtimeEvent.TransportTerminated()) + } + } + } + } + val r = WatchTogetherRepository( + api = FakeApi(), + realtimeFactory = { realtime }, + monotonicNowMs = { nowMs }, + authScopeProvider = { scopeA }, + ) + r.createRoom(CreateRoomRequest()) + + val job = launch { r.connect("room-1") } + advanceUntilIdle() + + assertTrue(job.isCompleted) + assertEquals(WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS, realtime.connectCount) + assertEquals("connection_lost", r.roomClosedReason.value) + } + // ---- flapping server hits the cap (regression for Issue 1) ----------------- @Test diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt new file mode 100644 index 000000000..56d4c57c9 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt @@ -0,0 +1,212 @@ +package org.prairieserver.prairie.watchtogether + +import kotlinx.coroutines.delay +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.TransportCommand +import org.prairieserver.prairie.repository.WatchTogetherConnectionState +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RoomDeliveryLatchTest { + private val open1 = WatchTogetherConnectionState(generation = 7, epoch = 1, writable = true) + private val open2 = WatchTogetherConnectionState(generation = 7, epoch = 2, writable = true) + + @Test + fun `attach is delivered once per connection epoch and playback session`() { + val latch = RoomDeliveryLatch() + val first = assertNotNull(latch.keyOrNull(open1, "session-a")) + + assertTrue(latch.needsAttach(first)) + latch.recordAttach(first, delivered = true) + assertFalse(latch.needsAttach(first)) + assertTrue(latch.needsAttach(assertNotNull(latch.keyOrNull(open2, "session-a")))) + assertTrue(latch.needsAttach(assertNotNull(latch.keyOrNull(open2, "session-b")))) + } + + @Test + fun `failed attach and readiness remain retryable`() { + val latch = RoomDeliveryLatch() + val key = assertNotNull(latch.keyOrNull(open1, "session-a")) + + latch.recordAttach(key, delivered = false) + assertTrue(latch.needsAttach(key)) + latch.recordReadiness(key, buffering = true, delivered = false) + assertTrue(latch.needsReadiness(key, buffering = true)) + } + + @Test + fun `readiness is keyed by epoch session and buffering value`() { + val latch = RoomDeliveryLatch() + val first = assertNotNull(latch.keyOrNull(open1, "session-a")) + latch.recordReadiness(first, buffering = false, delivered = true) + + assertFalse(latch.needsReadiness(first, buffering = false)) + assertTrue(latch.needsReadiness(first, buffering = true)) + assertTrue( + latch.needsReadiness( + assertNotNull(latch.keyOrNull(open2, "session-a")), + buffering = false, + ), + ) + } + + @Test + fun `non writable connection cannot mint a delivery key`() { + assertNull( + RoomDeliveryLatch().keyOrNull( + WatchTogetherConnectionState(generation = 7, epoch = 1, writable = false), + "session-a", + ), + ) + } + + @Test + fun `ping cannot start until a playback session has attached`() { + val latch = RoomDeliveryLatch() + val key = latch.keyOrNull(open1, null) + + assertFalse(latch.isAttached(key)) + } + + @Test + fun `delayed command for session A cannot mutate replacement session B`() { + val command = TransportCommand( + commandId = "cmd-1", + sessionId = "session-a", + selectionRevision = 3, + executeAt = "2026-07-27T12:00:00Z", + ) + val room = RoomSnapshot(roomId = "room-1", selectionRevision = 3) + + assertTrue( + scheduledCommandStillCurrent( + command = command, + acceptedPlaybackSessionId = "session-a", + acceptedRoomId = "room-1", + acceptedConnection = open1, + currentPlaybackSessionId = "session-a", + currentRoom = room, + currentConnection = open1, + ), + ) + assertFalse( + scheduledCommandStillCurrent( + command = command, + acceptedPlaybackSessionId = "session-a", + acceptedRoomId = "room-1", + acceptedConnection = open1, + currentPlaybackSessionId = "session-b", + currentRoom = room, + currentConnection = open1, + ), + ) + assertFalse( + scheduledCommandStillCurrent( + command = command, + acceptedPlaybackSessionId = "session-a", + acceptedRoomId = "room-1", + acceptedConnection = open1, + currentPlaybackSessionId = "session-a", + currentRoom = room.copy(selectionRevision = 4), + currentConnection = open1, + ), + ) + } + + @Test + fun `delayed command for room A cannot mutate room B reusing session and revision`() { + val command = TransportCommand( + commandId = "cmd-1", + sessionId = "session-a", + selectionRevision = 3, + executeAt = "2026-07-27T12:00:00Z", + ) + val replacementRoom = RoomSnapshot(roomId = "room-b", selectionRevision = 3) + + assertFalse( + scheduledCommandStillCurrent( + command = command, + acceptedPlaybackSessionId = "session-a", + acceptedRoomId = "room-a", + acceptedConnection = open1, + currentPlaybackSessionId = "session-a", + currentRoom = replacementRoom, + currentConnection = open1.copy(generation = 8), + ), + ) + assertFalse( + scheduledCommandStillCurrent( + command = command, + acceptedPlaybackSessionId = "session-a", + acceptedRoomId = "room-a", + acceptedConnection = open1, + currentPlaybackSessionId = "session-a", + currentRoom = replacementRoom.copy(roomId = "room-a"), + currentConnection = open2, + ), + ) + } + + @Test + fun `delayed command does not block a newer immediate command`() = runTest { + val dispatcher = RoomCommandDispatcher(backgroundScope) + val applied = mutableListOf() + + dispatcher.dispatch { + delay(1_000) + applied += "older-delayed" + } + dispatcher.dispatch { + applied += "newer-immediate" + } + runCurrent() + + assertTrue(applied == listOf("newer-immediate")) + advanceTimeBy(1_000) + runCurrent() + assertTrue(applied == listOf("newer-immediate", "older-delayed")) + } + + @Test + fun `finishing one repeated command preserves the other dispatch token`() { + val pending = RoomPendingExecutions() + val firstC1 = pending.record(executeAtMs = 1_000) + val c2 = pending.record(executeAtMs = 10) + val secondC1 = pending.record(executeAtMs = 1_000) + + pending.finish(firstC1) + pending.finish(c2) + + assertEquals(1_000, pending.nearestTo(nowMs = 0)) + pending.finish(secondC1) + assertNull(pending.nearestTo(nowMs = 0)) + } + + @Test + fun `disposing controller lifetime cancels only its child collectors`() = runTest { + val lifetime = RoomControllerLifetime(backgroundScope) + var childFinished = false + lifetime.scope.launch { + try { + awaitCancellation() + } finally { + childFinished = true + } + } + runCurrent() + + lifetime.dispose() + runCurrent() + + assertTrue(childFinished) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomEntryUiPolicyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomEntryUiPolicyTest.kt new file mode 100644 index 000000000..6a2292fb9 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomEntryUiPolicyTest.kt @@ -0,0 +1,13 @@ +package org.prairieserver.prairie.watchtogether + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RoomEntryUiPolicyTest { + @Test + fun `entry can dismiss only while no room mutation is running`() { + assertTrue(canDismissRoomEntry(isBusy = false)) + assertFalse(canDismissRoomEntry(isBusy = true)) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt new file mode 100644 index 000000000..2a878d236 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt @@ -0,0 +1,212 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RoomSessionTest { + @Test + fun `local next up cannot replace an active Watch Together room`() { + assertTrue(!shouldNavigateToLocalNext(inWatchTogetherRoom = true)) + assertTrue(shouldNavigateToLocalNext(inWatchTogetherRoom = false)) + } + + @Test + fun `lobby and player adoption of the same room reuses one socket`() = runTest { + val repository = FakeRoomSessionRepository() + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + + session.adopt("room-a").join() + session.adopt("room-a").join() + runCurrent() + + assertEquals(listOf("room-a"), repository.started) + session.leave() + } + + @Test + fun `replacement cancels and joins room A before room B starts`() = runTest { + val repository = FakeRoomSessionRepository() + val finalizerGate = CompletableDeferred() + repository.finalizerGates["room-a"] = finalizerGate + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + session.enter("room-a") + runCurrent() + + val replacement = launch { session.enter("room-b") } + runCurrent() + assertEquals(listOf("room-a"), repository.started) + assertEquals(listOf("room-a"), repository.canceling) + + finalizerGate.complete(Unit) + replacement.join() + runCurrent() + + assertEquals(listOf("room-a", "room-b"), repository.started) + session.leave() + } + + @Test + fun `concurrent enters serialize and newest room owns the connection`() = runTest { + val repository = FakeRoomSessionRepository() + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + + val first = launch { session.enter("room-a") } + val second = launch { session.enter("room-b") } + first.join() + second.join() + runCurrent() + + assertEquals(listOf("room-a", "room-b"), repository.started) + assertEquals(listOf("room-a"), repository.canceling) + assertTrue(session.isActive()) + session.leave() + } + + @Test + fun `leave joins the connection before resetting repository state`() = runTest { + val repository = FakeRoomSessionRepository() + val finalizerGate = CompletableDeferred() + repository.finalizerGates["room-a"] = finalizerGate + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + session.enter("room-a") + runCurrent() + + val leaving = launch { session.leave() } + runCurrent() + assertEquals(0, repository.resetCount) + + finalizerGate.complete(Unit) + leaving.join() + + assertEquals(1, repository.resetCount) + assertTrue(!session.isActive()) + } + + @Test + fun `durable departure survives cancellation of the initiating screen`() = runTest { + val repository = FakeRoomSessionRepository() + val finalizerGate = CompletableDeferred() + repository.finalizerGates["room-a"] = finalizerGate + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + session.adopt("room-a").join() + + val screen = launch { + session.depart() + } + screen.join() + screen.cancel() + runCurrent() + assertEquals(0, repository.resetCount) + + finalizerGate.complete(Unit) + runCurrent() + + assertEquals(1, repository.resetCount) + assertTrue(!session.isActive()) + } + + @Test + fun `identity WILL_CHANGE cancels joins and resets before mutation`() = runTest { + val repository = FakeRoomSessionRepository() + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + session.enter("room-a") + runCurrent() + var mutationObservedSafeState = false + + barrier.changing(IdentityTransitionKind.SERVER_SWITCH) { + mutationObservedSafeState = + repository.canceling == listOf("room-a") && repository.resetCount == 1 + } + + assertTrue(mutationObservedSafeState) + assertTrue(!session.isActive()) + } + + @Test + fun `identity mutation waits for socket finalizer and reset`() = runTest { + val repository = FakeRoomSessionRepository() + val finalizerGate = CompletableDeferred() + repository.finalizerGates["room-a"] = finalizerGate + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + session.enter("room-a") + var mutationStarted = false + + val transition = launch { + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { + mutationStarted = true + } + } + runCurrent() + + assertTrue(!mutationStarted) + assertEquals(0, repository.resetCount) + finalizerGate.complete(Unit) + transition.join() + + assertTrue(mutationStarted) + assertEquals(1, repository.resetCount) + } + + @Test + fun `every identity transition kind resets the room before mutation`() = runTest { + IdentityTransitionKind.entries.forEach { kind -> + val repository = FakeRoomSessionRepository() + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + session.enter("room-${kind.name}") + var mutationObservedSafeState = false + + barrier.changing(kind) { + mutationObservedSafeState = + repository.canceling == listOf("room-${kind.name}") && + repository.resetCount == 1 && + !session.isActive() + } + + assertTrue(mutationObservedSafeState, "room remained visible during $kind") + } + } + + private class FakeRoomSessionRepository : RoomSessionRepository { + override val roomSnapshot = MutableStateFlow(null) + override val roomClosedReason = MutableStateFlow(null) + val started = mutableListOf() + val canceling = mutableListOf() + val finalizerGates = mutableMapOf>() + var resetCount = 0 + + override suspend fun connect(roomId: String) { + started += roomId + try { + awaitCancellation() + } finally { + canceling += roomId + withContext(NonCancellable) { + finalizerGates[roomId]?.await() + } + } + } + + override suspend fun reset() { + resetCount++ + roomSnapshot.value = null + } + + override suspend fun closeRoom(): ApiResult = ApiResult.Success(Unit) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomTransportAuthorityTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomTransportAuthorityTest.kt index 8c459ac53..a37411e72 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomTransportAuthorityTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomTransportAuthorityTest.kt @@ -5,7 +5,9 @@ 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.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -63,4 +65,62 @@ class RoomTransportAuthorityTest { assertFalse(roomTransportAuthorized(null, RoomTransportIntent.PlayPause)) assertFalse(roomTransportAuthorized(null, RoomTransportIntent.Seek)) } + + @Test + fun guestHostOnlyExternalPauseIsRestoredWithoutRequest() { + val snapshot = fixture( + GuestControlPolicy.HostOnly, + MemberRole.Guest, + canControl = false, + ).copy(isPaused = false) + + assertEquals( + ExternalPlayPauseDecision( + restorePlayWhenReady = true, + requestIsPaused = null, + ), + externalPlayPauseDecision(snapshot, localPlayWhenReady = false), + ) + } + + @Test + fun authorizedExternalPauseIsRestoredAndRequestedThroughRoom() { + val snapshot = fixture( + GuestControlPolicy.GuestPlayPause, + MemberRole.Guest, + canControl = true, + ).copy(isPaused = false) + + assertEquals( + ExternalPlayPauseDecision( + restorePlayWhenReady = true, + requestIsPaused = true, + ), + externalPlayPauseDecision(snapshot, localPlayWhenReady = false), + ) + } + + @Test + fun roomAppliedStateDoesNotLoopBackIntoTransportRequest() { + val snapshot = fixture( + GuestControlPolicy.GuestPlayPause, + MemberRole.Guest, + canControl = true, + ).copy(isPaused = true) + + assertNull(externalPlayPauseDecision(snapshot, localPlayWhenReady = false)) + } + + @Test + fun externalStateOutsidePlayingIsNotReconciled() { + val snapshot = fixture( + GuestControlPolicy.HostOnly, + MemberRole.Guest, + canControl = false, + phase = RoomPhase.Lobby, + ) + + assertNull(externalPlayPauseDecision(snapshot, localPlayWhenReady = false)) + assertNull(externalPlayPauseDecision(null, localPlayWhenReady = false)) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomVoteTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomVoteTest.kt new file mode 100644 index 000000000..bd70a6115 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomVoteTest.kt @@ -0,0 +1,43 @@ +package org.prairieserver.prairie.watchtogether + +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.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion + +class RoomVoteTest { + @Test + fun `winner is the first server-ordered suggestion with votes`() { + val winner = suggestion("winner", votes = 3) + val runnerUp = suggestion("runner-up", votes = 2) + + assertEquals(winner, roomVoteWinner(listOf(winner, runnerUp))) + } + + @Test + fun `room with no votes has no winner`() { + assertNull(roomVoteWinner(listOf(suggestion("oldest", votes = 0)))) + assertNull(roomVoteWinner(emptyList())) + } + + @Test + fun `vote-room predicate follows the snapshot selection mode`() { + assertTrue(RoomSnapshot(roomId = "room", selectionMode = RoomSelectionMode.Vote).isVoteRoom()) + assertFalse(RoomSnapshot(roomId = "room", selectionMode = RoomSelectionMode.HostPick).isVoteRoom()) + assertFalse(null.isVoteRoom()) + } + + private fun suggestion(id: String, votes: Int) = Suggestion( + id = id, + roomId = "room", + contentId = "content-$id", + contentType = "movie", + title = id, + voteCount = votes, + createdAt = "2026-07-27T00:00:00Z", + ) +}