diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c9e02e92..e4dcac75 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -298,23 +298,37 @@ dependencies {
// Testing
testImplementation(libs.junit5)
+ testImplementation(libs.junit)
testRuntimeOnly(libs.junit5.launcher)
+ testRuntimeOnly(libs.junit5.vintage)
testImplementation(libs.coroutines.test)
testImplementation(libs.mockk)
+ testImplementation(libs.robolectric)
testImplementation(libs.ktor.client.mock)
testImplementation(libs.mockwebserver3)
+ testImplementation(project(":player-test-fixtures"))
detektPlugins(libs.detekt.compose.rules)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.mockwebserver3)
+ androidTestImplementation(libs.kaspresso)
+ androidTestImplementation(libs.kaspresso.compose)
+ androidTestImplementation(libs.kakao.compose)
+ add("instrumentationImplementation", project(":player-test-fixtures"))
+ add("instrumentationImplementation", libs.mockwebserver3)
+ add("instrumentationImplementation", libs.androidx.drawerlayout)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.profileinstaller)
}
+android.testOptions {
+ unitTests.isIncludeAndroidResources = true
+}
+
baselineProfile {
saveInSrc = true
automaticGenerationDuringBuild = false
diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml
new file mode 100644
index 00000000..72559ce3
--- /dev/null
+++ b/app/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/PlayerInstrumentationTestCase.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/PlayerInstrumentationTestCase.kt
new file mode 100644
index 00000000..31f2f2b5
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/PlayerInstrumentationTestCase.kt
@@ -0,0 +1,26 @@
+package com.kino.puber.ui.feature.player
+
+import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
+import com.kaspersky.components.composesupport.config.withComposeSupport
+import com.kaspersky.kaspresso.kaspresso.Kaspresso
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kino.puber.ui.feature.player.component.PlayerComposeScreen
+import io.github.kakaocup.compose.node.element.ComposeScreen
+
+internal abstract class PlayerInstrumentationTestCase : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple(),
+)
+
+internal abstract class PlayerComposeInstrumentationTestCase : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.withComposeSupport(),
+) {
+ protected fun onPlayerScreen(
+ semanticsProvider: SemanticsNodeInteractionsProvider,
+ actions: PlayerComposeScreen.() -> Unit,
+ ) {
+ ComposeScreen.onComposeScreen(
+ semanticsProvider = semanticsProvider,
+ function = actions,
+ )
+ }
+}
diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerComposeScreen.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerComposeScreen.kt
new file mode 100644
index 00000000..d36963b9
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerComposeScreen.kt
@@ -0,0 +1,88 @@
+package com.kino.puber.ui.feature.player.component
+
+import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
+import androidx.compose.ui.test.hasAnyDescendant
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.isFocused
+import io.github.kakaocup.compose.node.element.ComposeScreen
+import io.github.kakaocup.compose.node.element.KNode
+
+internal class PlayerComposeScreen(
+ semanticsProvider: SemanticsNodeInteractionsProvider,
+) : ComposeScreen(
+ semanticsProvider = semanticsProvider,
+ viewBuilderAction = {
+ useUnmergedTree = true
+ hasTestTag(PlayerScreenTestTags.Root)
+ },
+) {
+
+ val playerSurface: KNode = child {
+ useUnmergedTree = true
+ hasTestTag(PlayerScreenTestTags.Surface)
+ }
+
+ val playPauseButton: KNode = child {
+ useUnmergedTree = true
+ hasTestTag(PlayerScreenTestTags.PlayPause)
+ }
+
+ val seekBar: KNode = child {
+ useUnmergedTree = true
+ hasTestTag(PlayerScreenTestTags.SeekBar)
+ }
+
+ val markWatchedButton: KNode = tagged(PlayerScreenTestTags.MarkWatched)
+ val episodesButton: KNode = tagged(PlayerScreenTestTags.Episodes)
+ val audioSubtitlesButton: KNode = tagged(PlayerScreenTestTags.AudioSubtitles)
+ val videoSettingsButton: KNode = tagged(PlayerScreenTestTags.VideoSettings)
+ val resumeButton: KNode = tagged(PlayerScreenTestTags.ResumeContinue)
+ val retryButton: KNode = tagged(PlayerScreenTestTags.Retry)
+ val nextEpisodeButton: KNode = tagged(PlayerScreenTestTags.NextEpisode)
+
+ val focusedMarkWatchedButton: KNode = focusedTag(PlayerScreenTestTags.MarkWatched)
+ val focusedEpisodesButton: KNode = focusedTag(PlayerScreenTestTags.Episodes)
+ val focusedAudioSubtitlesButton: KNode = focusedTag(PlayerScreenTestTags.AudioSubtitles)
+ val focusedVideoSettingsButton: KNode = focusedTag(PlayerScreenTestTags.VideoSettings)
+ val focusedResumeButton: KNode = focusedTag(PlayerScreenTestTags.ResumeContinue)
+ val focusedRetryButton: KNode = focusedTag(PlayerScreenTestTags.Retry)
+ val focusedNextEpisodeButton: KNode = focusedTag(PlayerScreenTestTags.NextEpisode)
+
+ fun panelItem(group: String, index: Int): KNode =
+ tagged(PlayerScreenTestTags.panelItem(group, index))
+
+ fun focusedPanelItem(group: String, index: Int): KNode =
+ focusedTag(PlayerScreenTestTags.panelItem(group, index))
+
+ fun tagged(tag: String): KNode = child {
+ useUnmergedTree = true
+ hasTestTag(tag)
+ }
+
+ fun focusedTag(tag: String): KNode {
+ val tagMatcher = hasTestTag(tag)
+ val matcher = isFocused() and (
+ tagMatcher or
+ hasAnyDescendant(tagMatcher)
+ )
+ return child {
+ useUnmergedTree = true
+ addSemanticsMatcher(matcher)
+ }
+ }
+
+ fun text(text: String, substring: Boolean = false): KNode = child {
+ useUnmergedTree = true
+ hasText(text, substring = substring)
+ }
+
+ fun focusedText(text: String, substring: Boolean = false): KNode {
+ val textMatcher = hasText(text, substring = substring)
+ val matcher = isFocused() and (textMatcher or hasAnyDescendant(textMatcher))
+ return child {
+ useUnmergedTree = true
+ addSemanticsMatcher(matcher)
+ }
+ }
+}
diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt
index f4a24829..30135997 100644
--- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt
+++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt
@@ -1,26 +1,15 @@
package com.kino.puber.ui.feature.player.component
-import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.ui.semantics.SemanticsProperties
-import androidx.compose.ui.semantics.getOrNull
-import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsNotFocused
-import androidx.compose.ui.test.hasText
-import androidx.compose.ui.test.isFocused
-import androidx.compose.ui.test.isFocusable
+import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.onParent
-import androidx.compose.ui.test.onRoot
-import androidx.compose.ui.test.performKeyInput
import androidx.lifecycle.Lifecycle
-import androidx.test.platform.app.InstrumentationRegistry
import com.kino.puber.core.ui.uikit.component.moviesList.VideoGridItemUIState
import com.kino.puber.core.ui.uikit.component.moviesList.VideoGridUIState
import com.kino.puber.core.ui.uikit.component.moviesList.VideoItemUIState
@@ -40,6 +29,7 @@ import com.kino.puber.ui.feature.player.model.ResumeDialogState
import com.kino.puber.ui.feature.player.model.SoundModeUIState
import com.kino.puber.ui.feature.player.model.SpeedUIState
import com.kino.puber.ui.feature.player.model.SubtitleTrackUIState
+import com.kino.puber.ui.feature.player.PlayerComposeInstrumentationTestCase
import com.kino.puber.ui.feature.player.vm.ControlsStateMachine
import com.kino.puber.ui.feature.player.vm.PlaybackIntent
import org.junit.Assert.assertEquals
@@ -48,361 +38,444 @@ import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
-private const val AUDIO_BUTTON = "Аудио и субтитры"
-private const val VIDEO_BUTTON = "Видео"
-private const val EPISODES_BUTTON = "Серии"
-private const val SOUND_ITEM = "Стерео 2.0"
-private const val QUALITY_ITEM = "1080p"
-private const val EPISODE_ITEM = "1. Первое включение"
-private const val MARK_WATCHED_BUTTON = "Просмотрено"
-
-internal class PlayerScreenContentFocusTest {
+internal class PlayerScreenContentFocusTest : PlayerComposeInstrumentationTestCase() {
@get:Rule
val composeRule = createAndroidComposeRule()
+ private val robot by lazy { PlayerTvRobot(composeRule) }
+
@Test
- fun initialControlsBackMovesFocusToPlayer_andDpadDownReopensFirstButton() {
+ fun initialControlsBackMovesFocusToPlayer_andDpadDownReopensFirstButton() = run {
val harness = render()
- assertExactlyOneFocusedNode()
- assertFocusedControl()
- assertPlayerAnchorFocusable(expected = false)
- assertFocusSurvivesRecomposition(harness, ::assertFocusedControl)
-
- sendKey(KeyEvent.KEYCODE_BACK)
- waitForControlsToBeDisposed()
-
- assertEquals(0, harness.exitRequestCount)
- assertExactlyOneFocusedNode()
- assertFocusedPlayerSurface()
- assertPlayerAnchorFocusable(expected = true)
- assertHiddenControlsAreNotFocused()
+ step("Initial controls own the single focus target across recomposition") {
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ robot.focus.assertPlayerAnchorFocusable(expected = false)
+ harness.bumpPosition()
+ composeRule.waitForIdle()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ }
- sendKey(KeyEvent.KEYCODE_DPAD_DOWN)
+ step("Back hides controls and transfers focus to the player surface") {
+ robot.pressBack()
+ robot.waitForControlsToBeDisposed()
+ assertEquals(0, harness.exitRequestCount)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedPlayerSurface()
+ robot.focus.assertPlayerAnchorFocusable(expected = true)
+ robot.focus.assertHiddenControlsAreNotFocused()
+ }
- assertExactlyOneFocusedNode()
- assertFocusedControl()
- assertControlsVisible(harness)
+ step("D-pad Down reopens controls on the first button") {
+ robot.press(PlayerRemoteKey.Down)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ robot.assertControlsVisible(harness.content.controlsVisible)
+ }
}
@Test
- fun playerToControlsHandoff_keepsPlayerFocused_untilDelayedControlFocusSucceeds() {
+ fun playerToControlsHandoff_keepsPlayerFocused_untilDelayedControlFocusSucceeds() = run {
val harness = render()
hideControls(harness)
composeRule.mainClock.autoAdvance = false
try {
- sendKeyWithoutSettling(KeyEvent.KEYCODE_DPAD_DOWN)
- composeRule.mainClock.advanceTimeByFrame()
- composeRule.waitForIdle()
-
- assertTrue(harness.content.controlsVisible)
- assertExactlyOneFocusedNode()
- assertFocusedPlayerSurface()
- assertPlayerAnchorFocusable(expected = true)
-
- composeRule.mainClock.advanceTimeByFrame()
- composeRule.waitForIdle()
-
- assertExactlyOneFocusedNode()
- assertFocusedControl()
- assertHiddenPlayerIsNotFocused()
+ step("D-pad Down reveals controls while the player retains focus") {
+ robot.pressWithoutSettling(PlayerRemoteKey.Down)
+ robot.advanceOneFrame()
+ assertTrue(harness.content.controlsVisible)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedPlayerSurface()
+ robot.focus.assertPlayerAnchorFocusable(expected = true)
+ }
- composeRule.mainClock.advanceTimeByFrame()
- composeRule.waitForIdle()
+ step("The delayed handoff focuses the first control") {
+ robot.advanceOneFrame()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ robot.focus.assertHiddenPlayerIsNotFocused()
+ }
- assertExactlyOneFocusedNode()
- assertFocusedControl()
- assertPlayerAnchorFocusable(expected = false)
+ step("The player anchor becomes non-focusable after handoff") {
+ robot.advanceOneFrame()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ robot.focus.assertPlayerAnchorFocusable(expected = false)
+ }
} finally {
composeRule.mainClock.autoAdvance = true
}
}
@Test
- fun resumeDialogOwnsFocus_thenSelectionTransfersToPlayerAnchor() {
+ fun resumeDialogOwnsFocus_thenSelectionTransfersToPlayerAnchor() = run {
val harness = render(content(resumeDialog = resumeDialog()))
- composeRule.onNodeWithText("Продолжить").assertIsFocused()
- assertExactlyOneFocusedNode()
- assertPlayerAnchorFocusable(expected = false)
- assertFocusSurvivesRecomposition(harness) {
- composeRule.onNodeWithText("Продолжить").assertIsFocused()
+ step("The resume dialog owns focus across recomposition") {
+ robot.screen {
+ focusedResumeButton.assertIsFocused()
+ }
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertPlayerAnchorFocusable(expected = false)
+ harness.bumpPosition()
+ composeRule.waitForIdle()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.screen {
+ focusedResumeButton.assertIsFocused()
+ }
}
- composeRule.onNodeWithText("Продолжить").performKeyInput {
- keyDown(androidx.compose.ui.input.key.Key.Enter)
- keyUp(androidx.compose.ui.input.key.Key.Enter)
+ step("Selecting Continue transfers focus to the player anchor") {
+ robot.press(PlayerRemoteKey.Select)
+ assertTrue(harness.content.resumeDialog == null)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedPlayerSurface()
+ robot.focus.assertPlayerAnchorFocusable(expected = true)
}
- composeRule.waitForIdle()
-
- assertTrue(harness.content.resumeDialog == null)
- assertExactlyOneFocusedNode()
- assertFocusedPlayerSurface()
- assertPlayerAnchorFocusable(expected = true)
}
@Test
- fun hiddenPlayer_dpadUpReopensSeekBar_andDpadDownReopensButtons() {
+ fun hiddenPlayer_dpadUpReopensSeekBar_andDpadDownReopensButtons() = run {
val harness = render()
hideControls(harness)
- sendKey(KeyEvent.KEYCODE_DPAD_UP)
- assertExactlyOneFocusedNode()
- assertFocusedSeekBar()
- assertEquals(listOf(FocusTarget.SeekBar), harness.revealedTargets)
+ step("D-pad Up reopens controls on the seek bar") {
+ robot.press(PlayerRemoteKey.Up)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedSeekBar()
+ assertEquals(listOf(FocusTarget.SeekBar), harness.revealedTargets)
+ }
- hideControls(harness)
- sendKey(KeyEvent.KEYCODE_DPAD_DOWN)
- assertExactlyOneFocusedNode()
- assertFocusedControl()
- assertEquals(
- listOf(FocusTarget.SeekBar, FocusTarget.Buttons),
- harness.revealedTargets,
- )
+ step("D-pad Down reopens controls on the first button") {
+ hideControls(harness)
+ robot.press(PlayerRemoteKey.Down)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ assertEquals(
+ listOf(FocusTarget.SeekBar, FocusTarget.Buttons),
+ harness.revealedTargets,
+ )
+ }
}
@Test
- fun hiddenPlayer_directionalSeekPerformsOneAction_andFocusesSeekBar() {
+ fun hiddenPlayer_directionalSeekPerformsOneAction_andFocusesSeekBar() = run {
val harness = render()
hideControls(harness)
- sendKey(KeyEvent.KEYCODE_DPAD_LEFT)
- assertExactlyOneFocusedNode()
- assertFocusedSeekBar()
- assertEquals(1, harness.seekBackwardCount)
- assertEquals(0, harness.seekForwardCount)
- assertEquals(listOf(FocusTarget.SeekBar), harness.revealedTargets)
+ step("D-pad Left performs one backward seek and focuses the seek bar") {
+ robot.press(PlayerRemoteKey.Left)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedSeekBar()
+ assertEquals(1, harness.seekBackwardCount)
+ assertEquals(0, harness.seekForwardCount)
+ assertEquals(listOf(FocusTarget.SeekBar), harness.revealedTargets)
+ }
- hideControls(harness)
- sendKey(KeyEvent.KEYCODE_DPAD_RIGHT)
- assertExactlyOneFocusedNode()
- assertFocusedSeekBar()
- assertEquals(1, harness.seekBackwardCount)
- assertEquals(1, harness.seekForwardCount)
- assertEquals(
- listOf(FocusTarget.SeekBar, FocusTarget.SeekBar),
- harness.revealedTargets,
- )
+ step("D-pad Right performs one forward seek and preserves the seek target") {
+ hideControls(harness)
+ robot.press(PlayerRemoteKey.Right)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedSeekBar()
+ assertEquals(1, harness.seekBackwardCount)
+ assertEquals(1, harness.seekForwardCount)
+ assertEquals(
+ listOf(FocusTarget.SeekBar, FocusTarget.SeekBar),
+ harness.revealedTargets,
+ )
+ }
}
@Test
- fun hiddenPlayer_dpadCenterPerformsOnePlaybackAction_andFocusesFirstButton() {
+ fun hiddenPlayer_dpadCenterPerformsOnePlaybackAction_andFocusesFirstButton() = run {
val harness = render()
hideControls(harness)
- sendKey(KeyEvent.KEYCODE_DPAD_CENTER)
-
- assertExactlyOneFocusedNode()
- assertFocusedControl()
- assertEquals(1, harness.togglePlayPauseCount)
- assertEquals(listOf(FocusTarget.Buttons), harness.revealedTargets)
+ step("D-pad Center toggles playback once and focuses the first button") {
+ robot.press(PlayerRemoteKey.Select)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ assertEquals(1, harness.togglePlayPauseCount)
+ assertEquals(listOf(FocusTarget.Buttons), harness.revealedTargets)
+ }
}
@Test
- fun hiddenPlayer_enterPerformsOnePlaybackAction_andFocusesFirstButton() {
+ fun hiddenPlayer_enterPerformsOnePlaybackAction_andFocusesFirstButton() = run {
val harness = render()
hideControls(harness)
- sendKey(KeyEvent.KEYCODE_ENTER)
-
- assertExactlyOneFocusedNode()
- assertFocusedControl()
- assertEquals(1, harness.togglePlayPauseCount)
- assertEquals(listOf(FocusTarget.Buttons), harness.revealedTargets)
+ step("Enter toggles playback once and focuses the first button") {
+ robot.press(PlayerRemoteKey.Enter)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ assertEquals(1, harness.togglePlayPauseCount)
+ assertEquals(listOf(FocusTarget.Buttons), harness.revealedTargets)
+ }
}
@Test
- fun hiddenPlayer_mediaSeekAndPlaybackKeysPerformOneAction_andKeepTargetContract() {
+ fun hiddenPlayer_mediaSeekAndPlaybackKeysPerformOneAction_andKeepTargetContract() = run {
val harness = render()
- hideControls(harness)
- sendKey(KeyEvent.KEYCODE_MEDIA_REWIND)
- assertFocusedSeekBar()
- assertEquals(1, harness.seekBackwardCount)
- assertEquals(1, harness.revealedTargets.size)
- assertEquals(FocusTarget.SeekBar, harness.revealedTargets.single())
-
- hideControls(harness)
- sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)
- assertFocusedSeekBar()
- assertEquals(1, harness.seekForwardCount)
-
- hideControls(harness)
- sendKey(KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD)
- assertFocusedSeekBar()
- assertEquals(2, harness.seekBackwardCount)
-
- hideControls(harness)
- sendKey(KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD)
- assertFocusedSeekBar()
- assertEquals(2, harness.seekForwardCount)
+ step("Media rewind and fast-forward keys seek once and focus the seek bar") {
+ hideControls(harness)
+ robot.press(PlayerRemoteKey.Rewind)
+ robot.focus.assertFocusedSeekBar()
+ assertEquals(1, harness.seekBackwardCount)
+ assertEquals(1, harness.revealedTargets.size)
+ assertEquals(FocusTarget.SeekBar, harness.revealedTargets.single())
+
+ hideControls(harness)
+ robot.press(PlayerRemoteKey.FastForward)
+ robot.focus.assertFocusedSeekBar()
+ assertEquals(1, harness.seekForwardCount)
+ }
- hideControls(harness)
- sendKey(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
- assertFocusedControl()
- assertEquals(1, harness.togglePlayPauseCount)
- assertEquals(FocusTarget.Buttons, harness.revealedTargets.last())
+ step("Media skip keys preserve one seek action per key") {
+ hideControls(harness)
+ robot.press(PlayerRemoteKey.SkipBackward)
+ robot.focus.assertFocusedSeekBar()
+ assertEquals(2, harness.seekBackwardCount)
- hideControls(harness)
- sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
- assertFocusedControl()
- assertEquals(2, harness.togglePlayPauseCount)
+ hideControls(harness)
+ robot.press(PlayerRemoteKey.SkipForward)
+ robot.focus.assertFocusedSeekBar()
+ assertEquals(2, harness.seekForwardCount)
+ }
- hideControls(harness)
- sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
- assertFocusedControl()
- assertEquals(3, harness.togglePlayPauseCount)
+ step("Media playback keys toggle once and focus the first button") {
+ hideControls(harness)
+ robot.press(PlayerRemoteKey.PlayPause)
+ robot.focus.assertFocusedControl()
+ assertEquals(1, harness.togglePlayPauseCount)
+ assertEquals(FocusTarget.Buttons, harness.revealedTargets.last())
+
+ hideControls(harness)
+ robot.press(PlayerRemoteKey.Play)
+ robot.focus.assertFocusedControl()
+ assertEquals(2, harness.togglePlayPauseCount)
+
+ hideControls(harness)
+ robot.press(PlayerRemoteKey.Pause)
+ robot.focus.assertFocusedControl()
+ assertEquals(3, harness.togglePlayPauseCount)
+ }
}
@Test
- fun controlsTraversal_opensAudioPanel_andBackRestoresAudioButton() {
+ fun controlsTraversal_opensAudioPanel_andBackRestoresAudioButton() = run {
val harness = render()
- focusButton(AUDIO_BUTTON, steps = 1)
- sendKey(KeyEvent.KEYCODE_ENTER)
-
- composeRule.onNodeWithText(SOUND_ITEM).assertIsFocused()
- assertExactlyOneFocusedNode()
- assertPlayerAnchorFocusable(expected = false)
- composeRule.onNodeWithText(AUDIO_BUTTON).assertDoesNotExist()
- assertFocusSurvivesRecomposition(harness) {
- composeRule.onNodeWithText(SOUND_ITEM).assertIsFocused()
+ step("D-pad traversal focuses and opens Audio and subtitles") {
+ robot.screen {
+ robot.focusButton(focusedAudioSubtitlesButton, dpadRightSteps = 1)
+ }
+ robot.press(PlayerRemoteKey.Enter)
}
- sendKey(KeyEvent.KEYCODE_BACK)
+ step("The sound item owns focus across recomposition") {
+ robot.screen {
+ focusedPanelItem("sound", 0).assertIsFocused()
+ audioSubtitlesButton.assertDoesNotExist()
+ }
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertPlayerAnchorFocusable(expected = false)
+ harness.bumpPosition()
+ composeRule.waitForIdle()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.screen {
+ focusedPanelItem("sound", 0).assertIsFocused()
+ }
+ }
- assertExactlyOneFocusedNode()
- waitUntilFocusedText(AUDIO_BUTTON)
- assertEquals(ActivePanel.None, harness.content.activePanel)
+ step("Back closes the panel and restores Audio and subtitles focus") {
+ robot.pressBack()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.screen {
+ robot.waitUntilFocused(focusedAudioSubtitlesButton)
+ }
+ assertEquals(ActivePanel.None, harness.content.activePanel)
+ }
}
@Test
- fun controlsTraversal_opensVideoPanel_andBackRestoresVideoButton() {
+ fun controlsTraversal_opensVideoPanel_andBackRestoresVideoButton() = run {
val harness = render()
- focusButton(VIDEO_BUTTON, steps = 2)
- sendKey(KeyEvent.KEYCODE_ENTER)
-
- composeRule.onNodeWithText(QUALITY_ITEM).assertIsFocused()
- assertExactlyOneFocusedNode()
- assertPlayerAnchorFocusable(expected = false)
+ step("D-pad traversal focuses and opens Video settings") {
+ robot.screen {
+ robot.focusButton(focusedVideoSettingsButton, dpadRightSteps = 2)
+ }
+ robot.press(PlayerRemoteKey.Enter)
+ }
- sendKey(KeyEvent.KEYCODE_BACK)
+ step("The selected quality owns the single panel focus target") {
+ robot.screen {
+ focusedPanelItem("quality", 0).assertIsFocused()
+ }
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertPlayerAnchorFocusable(expected = false)
+ }
- assertExactlyOneFocusedNode()
- waitUntilFocusedText(VIDEO_BUTTON)
- assertEquals(ActivePanel.None, harness.content.activePanel)
+ step("Back closes the panel and restores Video settings focus") {
+ robot.pressBack()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.screen {
+ robot.waitUntilFocused(focusedVideoSettingsButton)
+ }
+ assertEquals(ActivePanel.None, harness.content.activePanel)
+ }
}
@Test
- fun seriesEpisodesPanel_focusesCurrentEpisode_andBackRestoresEpisodesButton() {
+ fun seriesEpisodesPanel_focusesCurrentEpisode_andBackRestoresEpisodesButton() = run {
val harness = render(content(isMovie = false))
- focusButton(EPISODES_BUTTON, steps = 1)
- sendKey(KeyEvent.KEYCODE_ENTER)
-
- composeRule.onNodeWithText(EPISODE_ITEM).assertIsFocused()
- assertExactlyOneFocusedNode()
- assertPlayerAnchorFocusable(expected = false)
+ step("D-pad traversal focuses and opens Episodes") {
+ robot.screen {
+ robot.focusButton(focusedEpisodesButton, dpadRightSteps = 1)
+ }
+ robot.press(PlayerRemoteKey.Enter)
+ }
- sendKey(KeyEvent.KEYCODE_BACK)
+ step("The current episode model owns the single panel focus target") {
+ robot.screen {
+ focusedText(currentEpisodeTitle(harness.content)).assertIsFocused()
+ }
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertPlayerAnchorFocusable(expected = false)
+ }
- assertExactlyOneFocusedNode()
- waitUntilFocusedText(EPISODES_BUTTON)
- assertEquals(ActivePanel.None, harness.content.activePanel)
+ step("Back closes the panel and restores Episodes focus") {
+ robot.pressBack()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.screen {
+ robot.waitUntilFocused(focusedEpisodesButton)
+ }
+ assertEquals(ActivePanel.None, harness.content.activePanel)
+ }
}
@Test
- fun controlsTraversal_movesFromSeekBarToButtonRow_withOneFocusedNode() {
+ fun controlsTraversal_movesFromSeekBarToButtonRow_withOneFocusedNode() = run {
val harness = render()
hideControls(harness)
- sendKey(KeyEvent.KEYCODE_DPAD_UP)
- assertFocusedSeekBar()
+ step("D-pad Up reopens controls with seek-bar focus") {
+ robot.press(PlayerRemoteKey.Up)
+ robot.focus.assertFocusedSeekBar()
+ }
- sendKey(KeyEvent.KEYCODE_DPAD_DOWN)
- assertExactlyOneFocusedNode()
- assertFocusedControl()
- assertTrue(harness.content.controlsVisible)
+ step("D-pad Down moves from the seek bar to the first button") {
+ robot.press(PlayerRemoteKey.Down)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ assertTrue(harness.content.controlsVisible)
+ }
}
@Test
- fun playbackEndedReveal_focusesFirstButton_andPlayerAnchorLosesFocus() {
+ fun playbackEndedReveal_focusesFirstButton_andPlayerAnchorLosesFocus() = run {
val harness = render(content(isMovie = false))
hideControls(harness)
- harness.revealControls(FocusTarget.Buttons)
-
- assertExactlyOneFocusedNode()
- assertFocusedControl()
- assertHiddenPlayerIsNotFocused()
- assertEquals(FocusTarget.Buttons, harness.content.controlsFocusTarget)
+ step("Playback-ended reveal focuses the first button") {
+ harness.revealControls(FocusTarget.Buttons)
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ robot.focus.assertHiddenPlayerIsNotFocused()
+ assertEquals(FocusTarget.Buttons, harness.content.controlsFocusTarget)
+ }
}
@Test
- fun hiddenDisabledExitingAndDetachedControls_neverOwnFocus() {
+ fun hiddenDisabledExitingAndDetachedControls_neverOwnFocus() = run {
val harness = render(
content(
canMarkCurrentWatched = true,
isMarkCurrentWatchedInFlight = true,
),
)
- val disabledControl = composeRule
- .onNodeWithText(MARK_WATCHED_BUTTON, useUnmergedTree = true)
- .onParent()
- .onParent()
-
- disabledControl.assertIsNotEnabled()
- disabledControl.assertIsNotFocused()
- assertExactlyOneFocusedNode()
- assertFocusedControl()
+ val disabledControl = composeRule.onNode(
+ hasTestTag(PlayerScreenTestTags.MarkWatched),
+ useUnmergedTree = true,
+ )
composeRule.mainClock.autoAdvance = false
- composeRule.runOnIdle {
- harness.onAction(PlayerAction.HideControls)
- }
- composeRule.mainClock.advanceTimeBy(FOCUS_TRANSFER_SETTLE_MS)
- composeRule.waitForIdle()
-
- assertFalse(harness.content.controlsVisible)
- composeRule.onNodeWithText(AUDIO_BUTTON).assertExists().assertIsNotFocused()
- disabledControl.assertExists().assertIsNotEnabled().assertIsNotFocused()
- assertExactlyOneFocusedNode()
- assertFocusedPlayerSurface()
+ try {
+ step("A disabled control never owns initial focus") {
+ disabledControl.assertIsNotEnabled()
+ disabledControl.assertIsNotFocused()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedControl()
+ }
- composeRule.mainClock.advanceTimeBy(CONTROLS_EXIT_SETTLE_MS)
- composeRule.waitForIdle()
+ step("Exiting controls transfer focus to the player before disposal") {
+ composeRule.runOnIdle {
+ harness.onAction(PlayerAction.HideControls)
+ }
+ robot.advanceClockBy(FOCUS_TRANSFER_SETTLE_MS)
+ assertFalse(harness.content.controlsVisible)
+ robot.screen {
+ audioSubtitlesButton.assertExists()
+ focusedAudioSubtitlesButton.assertDoesNotExist()
+ }
+ disabledControl.assertExists().assertIsNotEnabled().assertIsNotFocused()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedPlayerSurface()
+ }
- composeRule.onNodeWithText(AUDIO_BUTTON).assertDoesNotExist()
- composeRule.onNodeWithText(MARK_WATCHED_BUTTON).assertDoesNotExist()
- assertExactlyOneFocusedNode()
- assertFocusedPlayerSurface()
- composeRule.mainClock.autoAdvance = true
+ step("Detached controls disappear without stealing player focus") {
+ robot.advanceClockBy(CONTROLS_EXIT_SETTLE_MS)
+ robot.screen {
+ audioSubtitlesButton.assertDoesNotExist()
+ markWatchedButton.assertDoesNotExist()
+ }
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedPlayerSurface()
+ }
+ } finally {
+ composeRule.mainClock.autoAdvance = true
+ }
}
@Test
- fun hiddenSecondBack_exitsOnce_andRecompositionKeepsPlayerFocus() {
+ fun hiddenSecondBack_exitsOnce_andRecompositionKeepsPlayerFocus() = run {
val harness = render()
hideControls(harness)
- assertFocusedPlayerSurface()
- assertFocusSurvivesRecomposition(harness, ::assertFocusedPlayerSurface)
+ step("The hidden player keeps focus across recomposition") {
+ robot.focus.assertFocusedPlayerSurface()
+ harness.bumpPosition()
+ composeRule.waitForIdle()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedPlayerSurface()
+ }
- sendKey(KeyEvent.KEYCODE_BACK)
- assertEquals(1, harness.exitRequestCount)
- assertExactlyOneFocusedNode()
+ step("The second Back exits exactly once") {
+ robot.pressBack()
+ assertEquals(1, harness.exitRequestCount)
+ robot.focus.assertExactlyOneFocusedNode()
+ }
- composeRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
- composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
- composeRule.waitForIdle()
- assertExactlyOneFocusedNode()
- assertFocusedPlayerSurface()
+ step("Lifecycle resume restores the single player focus owner") {
+ composeRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
+ composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
+ composeRule.waitForIdle()
+ robot.focus.assertExactlyOneFocusedNode()
+ robot.focus.assertFocusedPlayerSurface()
+ }
- sendKey(KeyEvent.KEYCODE_DPAD_DOWN)
- assertFocusedControl()
- assertEquals(FocusTarget.Buttons, harness.revealedTargets.last())
+ step("D-pad Down after resume reopens the first button") {
+ robot.press(PlayerRemoteKey.Down)
+ robot.focus.assertFocusedControl()
+ assertEquals(FocusTarget.Buttons, harness.revealedTargets.last())
+ }
}
private fun render(
@@ -426,156 +499,7 @@ internal class PlayerScreenContentFocusTest {
private fun hideControls(harness: PlayerHarness) {
harness.onAction(PlayerAction.HideControls)
composeRule.waitForIdle()
- assertFocusedPlayerSurface()
- }
-
- private fun focusButton(text: String, steps: Int) {
- repeat(steps) {
- sendKey(KeyEvent.KEYCODE_DPAD_RIGHT)
- }
- composeRule.onNodeWithText(text).assertIsFocused()
- }
-
- private fun assertFocusSurvivesRecomposition(
- harness: PlayerHarness,
- assertFocusedOwner: () -> Unit,
- ) {
- harness.bumpPosition()
- composeRule.waitForIdle()
- assertExactlyOneFocusedNode()
- assertFocusedOwner()
- }
-
- private fun assertExactlyOneFocusedNode() {
- assertEquals(
- "PlayerScreen must have exactly one focused semantics node",
- 1,
- composeRule.onAllNodes(isFocused(), useUnmergedTree = true)
- .fetchSemanticsNodes()
- .size,
- )
- }
-
- private fun assertFocusedPlayerSurface() {
- val focusedNode = focusedNode()
- val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot
- assertTrue(
- "Focused player anchor must cover the root width",
- focusedNode.boundsInRoot.width >= rootBounds.width * 0.9f,
- )
- assertTrue(
- "Focused player anchor must cover the root height",
- focusedNode.boundsInRoot.height >= rootBounds.height * 0.9f,
- )
- }
-
- private fun assertFocusedSeekBar() {
- val focusedNode = focusedNode()
- val rootWidth = composeRule.onRoot().fetchSemanticsNode().boundsInRoot.width
- assertTrue(
- "Seek bar must be the focused wide control: node=${focusedNode.boundsInRoot}, root=$rootWidth",
- focusedNode.boundsInRoot.width >= rootWidth * 0.5f,
- )
- assertTrue(
- "Seek bar must not be the full-screen player anchor: node=${focusedNode.boundsInRoot}, root=$rootWidth",
- focusedNode.boundsInRoot.width < rootWidth * 0.9f,
- )
- }
-
- private fun assertFocusedControl() {
- waitUntilFocusedTarget { node ->
- val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot
- node.boundsInRoot.width < rootBounds.width * 0.5f &&
- node.boundsInRoot.height < rootBounds.height * 0.9f
- }
- val focusedNode = focusedNode()
- val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot
- assertTrue(
- "Control focus must not remain on the full-screen player anchor",
- focusedNode.boundsInRoot.width < rootBounds.width * 0.5f &&
- focusedNode.boundsInRoot.height < rootBounds.height * 0.9f,
- )
- }
-
- private fun assertHiddenControlsAreNotFocused() {
- composeRule.onNodeWithText(AUDIO_BUTTON).assertDoesNotExist()
- composeRule.onNodeWithText(VIDEO_BUTTON).assertDoesNotExist()
- }
-
- private fun assertHiddenPlayerIsNotFocused() {
- val focusedNode = focusedNode()
- val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot
- assertFalse(
- "Player anchor must lose focus after controls reopen",
- focusedNode.boundsInRoot.width >= rootBounds.width * 0.9f,
- )
- }
-
- private fun assertPlayerAnchorFocusable(expected: Boolean) {
- val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot
- val fullScreenFocusableCount = composeRule
- .onAllNodes(isFocusable(), useUnmergedTree = true)
- .fetchSemanticsNodes()
- .count { node ->
- node.boundsInRoot.width >= rootBounds.width * 0.9f &&
- node.boundsInRoot.height >= rootBounds.height * 0.9f
- }
- assertEquals(
- "Only the typed Player owner may expose the full-screen focus anchor",
- if (expected) 1 else 0,
- fullScreenFocusableCount,
- )
- }
-
- private fun focusedNode() = composeRule
- .onAllNodes(isFocused(), useUnmergedTree = true)
- .fetchSemanticsNodes()
- .single()
-
- private fun waitUntilFocusedTarget(predicate: (androidx.compose.ui.semantics.SemanticsNode) -> Boolean) {
- composeRule.waitUntil(timeoutMillis = 5_000) {
- composeRule.onAllNodes(isFocused(), useUnmergedTree = true)
- .fetchSemanticsNodes()
- .singleOrNull()
- ?.let(predicate) == true
- }
- }
-
- private fun waitUntilFocusedText(text: String) {
- composeRule.waitUntil(timeoutMillis = 5_000) {
- runCatching {
- composeRule.onNodeWithText(text)
- .fetchSemanticsNode()
- .config
- .getOrNull(SemanticsProperties.Focused) == true
- }.getOrDefault(false)
- }
- composeRule.onNodeWithText(text).assertIsFocused()
- }
-
- private fun assertControlsVisible(harness: PlayerHarness) {
- assertTrue(harness.content.controlsVisible)
- composeRule.onNodeWithText(AUDIO_BUTTON).assertIsDisplayed()
- }
-
- private fun waitForControlsToBeDisposed() {
- composeRule.waitUntil(timeoutMillis = 5_000) {
- composeRule.onAllNodes(
- hasText(AUDIO_BUTTON),
- useUnmergedTree = true,
- ).fetchSemanticsNodes().isEmpty()
- }
- }
-
- private fun sendKey(keyCode: Int) {
- sendKeyWithoutSettling(keyCode)
- composeRule.waitForIdle()
- }
-
- private fun sendKeyWithoutSettling(keyCode: Int) {
- val instrumentation = InstrumentationRegistry.getInstrumentation()
- instrumentation.sendKeySync(KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
- instrumentation.sendKeySync(KeyEvent(KeyEvent.ACTION_UP, keyCode))
+ robot.focus.assertFocusedPlayerSurface()
}
private fun content(
@@ -603,10 +527,12 @@ internal class PlayerScreenContentFocusTest {
selectedAudioTrackIndex = 0,
subtitleTracks = listOf(SubtitleTrackUIState(0, "Выкл.", "", "")),
selectedSubtitleIndex = 0,
- soundModes = listOf(SoundModeUIState(0, SOUND_ITEM)),
+ soundModes = listOf(SoundModeUIState(0, FIXTURE_SOUND_MODE)),
selectedSoundModeIndex = 0,
subtitleSize = SubtitleSize.MEDIUM,
- qualities = listOf(QualityUIState(0, QUALITY_ITEM, 1080, 1920, 1080)),
+ qualities = listOf(
+ QualityUIState(0, FIXTURE_QUALITY, 1080, 1920, 1080),
+ ),
selectedQualityIndex = 0,
speeds = listOf(SpeedUIState(0, "Норм.", 1f)),
selectedSpeedIndex = 0,
@@ -633,7 +559,7 @@ internal class PlayerScreenContentFocusTest {
items = listOf(
VideoItemUIState(
id = CURRENT_EPISODE_ID,
- title = EPISODE_ITEM,
+ title = FIXTURE_EPISODE_TITLE,
imageUrl = "",
bigImageUrl = "",
showTitle = true,
@@ -652,6 +578,15 @@ internal class PlayerScreenContentFocusTest {
episodeInfo = null,
)
+ private fun currentEpisodeTitle(content: PlayerContentState): String =
+ content.episodes
+ ?.list
+ ?.filterIsInstance()
+ ?.flatMap(VideoGridItemUIState.Items::items)
+ ?.single { it.id == content.currentEpisodeId }
+ ?.title
+ ?: error("Missing current episode model ${content.currentEpisodeId}")
+
private class PlayerHarness(
initialContent: PlayerContentState,
) {
@@ -753,6 +688,9 @@ internal class PlayerScreenContentFocusTest {
private companion object {
const val CURRENT_EPISODE_ID = 101
+ const val FIXTURE_SOUND_MODE = "Fixture sound mode"
+ const val FIXTURE_QUALITY = "Fixture quality"
+ const val FIXTURE_EPISODE_TITLE = "Fixture episode"
const val FOCUS_TRANSFER_SETTLE_MS = 64L
const val CONTROLS_EXIT_SETTLE_MS = 1_000L
}
diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenE2ETest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenE2ETest.kt
new file mode 100644
index 00000000..a86355ed
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenE2ETest.kt
@@ -0,0 +1,1307 @@
+package com.kino.puber.ui.feature.player.component
+
+import android.app.Activity
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.getOrNull
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.hasAnyDescendant
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.isFocused
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onRoot
+import androidx.lifecycle.Lifecycle
+import androidx.media3.common.C
+import androidx.media3.common.Player
+import androidx.media3.ui.PlayerView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.tv.material3.Text
+import com.kino.puber.R
+import com.kino.puber.core.ui.navigation.AppLauncher
+import com.kino.puber.core.ui.navigation.AppRouter
+import com.kino.puber.core.ui.navigation.PuberScreen
+import com.kino.puber.core.ui.navigation.Screens
+import com.kino.puber.core.ui.navigation.component.FlowComponent
+import com.kino.puber.core.ui.uikit.theme.PuberTheme
+import com.kino.puber.data.api.models.ItemType
+import com.kino.puber.data.repository.PlayerPreferencesRepository
+import com.kino.puber.playertestfixtures.FixtureId
+import com.kino.puber.playertestfixtures.PlayerTestFixtures
+import com.kino.puber.playertestfixtures.network.LoopbackNetworkJournal
+import com.kino.puber.playertestfixtures.server.HermeticRoute
+import com.kino.puber.playertestfixtures.server.HermeticTestServer
+import com.kino.puber.playertestfixtures.server.QueryMatchMode
+import com.kino.puber.playertestfixtures.server.ResponsePlan
+import com.kino.puber.profile.PlayerTestControl
+import com.kino.puber.ui.ScreensImpl
+import com.kino.puber.ui.feature.player.PlayerComposeInstrumentationTestCase
+import com.kino.puber.ui.feature.player.model.PlayerScreenParams
+import com.kino.puber.ui.feature.player.model.PlayerStartMode
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+import kotlinx.parcelize.Parcelize
+import mockwebserver3.RecordedRequest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import io.github.kakaocup.compose.node.element.ComposeScreen
+import org.koin.core.context.GlobalContext
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+/**
+ * Production-screen coverage. Every case launches the real [PlayerScreen]
+ * through the same FlowComponent and screen Koin scope used by the app.
+ */
+@RunWith(AndroidJUnit4::class)
+internal class PlayerScreenE2ETest : PlayerComposeInstrumentationTestCase() {
+
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private lateinit var fixture: PlayerE2EFixture
+
+ @Before
+ fun setUp() {
+ fixture = PlayerE2EFixture(composeRule)
+ fixture.start()
+ }
+
+ @After
+ fun tearDown() {
+ fixture.close()
+ }
+
+ @Test
+ fun movieScreen_realRemoteControlsPanelsAndRecreation_restoreFocusTracksAndMediaState() = run {
+ with(fixture) {
+ val itemId = 7901
+ val media = MovieMediaRoots(itemId)
+ step("Launch the production movie screen on typed hermetic HLS routes") {
+ server.reset(movieRoutes(itemId, watchingTime = 0, media = media))
+ launchPlayer(
+ itemId = itemId,
+ startMode = PlayerStartMode.StartFromBeginning,
+ )
+ assertFocusedPlayPause()
+ awaitPlayerReady()
+ awaitPlayerCondition("initial movie position advances") {
+ playerPosition() >= POSITION_FENCE_MS
+ }
+ }
+
+ step("The domain TV remote toggles playback through the player focus anchor") {
+ robot.pressBack()
+ assertFocusedPlayerSurface()
+ robot.press(PlayerRemoteKey.PlayPause)
+ awaitPlayerCondition("media key pauses playback") { !playerIsPlaying() }
+ assertFocusedPlayPause()
+ robot.pressBack()
+ assertFocusedPlayerSurface()
+ robot.press(PlayerRemoteKey.PlayPause)
+ awaitPlayerCondition("media key resumes playback") { playerIsPlaying() }
+ assertFocusedPlayPause()
+ robot.pressBack()
+ assertFocusedPlayerSurface()
+ robot.press(PlayerRemoteKey.PlayPause)
+ awaitPlayerCondition("second media key pauses playback") { !playerIsPlaying() }
+ assertFocusedPlayPause()
+ }
+
+ step("Seek-bar and button traversal retains one concrete focused node") {
+ robot.press(PlayerRemoteKey.Up)
+ assertFocusedSeekBar()
+ robot.press(PlayerRemoteKey.Down)
+ assertFocusedPlayPause()
+ robot.press(PlayerRemoteKey.Right)
+ assertFocusedTag(PlayerScreenTestTags.MarkWatched, "mark-watched button")
+ robot.press(PlayerRemoteKey.Right)
+ assertFocusedTag(PlayerScreenTestTags.AudioSubtitles, "audio/subtitle button")
+ }
+
+ step("Select Spanish audio and external subtitles through typed panel items") {
+ robot.press(PlayerRemoteKey.Select)
+ assertFocusedPanelItem("sound", 0)
+ robot.press(PlayerRemoteKey.Right)
+ assertFocusedPanelItem("audio", 0)
+ robot.press(PlayerRemoteKey.Down)
+ assertFocusedPanelItem("audio", 1)
+ robot.press(PlayerRemoteKey.Select)
+ awaitPlayerCondition("Spanish audio is selected in UI state and Media3") {
+ selectedAudioLanguage() == SPANISH_LANGUAGE &&
+ preferredAudioLanguage() == SPANISH_LANGUAGE
+ }
+
+ robot.press(PlayerRemoteKey.Right)
+ assertFocusedPanelItem("subtitle", 1)
+ robot.press(PlayerRemoteKey.Select)
+ awaitPlayerCondition("external subtitle is persisted and selected in Media3") {
+ preferredSubtitleLanguage() == SUBTITLE_LANGUAGE &&
+ selectedTextTrack() &&
+ preferredTextLanguages().contains(SUBTITLE_LANGUAGE)
+ }
+ robot.pressBack()
+ assertFocusedTag(PlayerScreenTestTags.AudioSubtitles, "audio/subtitle button")
+ }
+
+ step("Playback requests the selected subtitle before pausing again") {
+ robot.press(PlayerRemoteKey.Left)
+ assertFocusedTag(PlayerScreenTestTags.MarkWatched, "mark-watched button")
+ robot.press(PlayerRemoteKey.Left)
+ assertFocusedPlayPause()
+ robot.press(PlayerRemoteKey.Select)
+ awaitPlayerCondition("selected external subtitle loads during playback") {
+ playerIsPlaying()
+ }
+ awaitJournalPath("${media.high}/subtitle.vtt")
+ robot.press(PlayerRemoteKey.Select)
+ awaitPlayerCondition("playback pauses after external subtitle request") {
+ !playerIsPlaying()
+ }
+ }
+
+ step("Switch to the lower quality and prove the production Media3 request") {
+ robot.press(PlayerRemoteKey.Right)
+ assertFocusedTag(PlayerScreenTestTags.MarkWatched, "mark-watched button")
+ robot.press(PlayerRemoteKey.Right)
+ assertFocusedTag(PlayerScreenTestTags.AudioSubtitles, "audio/subtitle button")
+ robot.press(PlayerRemoteKey.Right)
+ assertFocusedTag(PlayerScreenTestTags.VideoSettings, "video-settings button")
+ robot.press(PlayerRemoteKey.Select)
+ assertFocusedPanelItem("quality", 0)
+ robot.press(PlayerRemoteKey.Down)
+ assertFocusedPanelItem("quality", 1)
+ robot.press(PlayerRemoteKey.Down)
+ assertFocusedPanelItem("quality", 2)
+ robot.press(PlayerRemoteKey.Select)
+ awaitPlayerCondition("manual quality changes the production Media3 item") {
+ currentMediaPath() == "${media.low}/master.m3u8"
+ }
+ awaitJournalPath("${media.low}/master.m3u8")
+ robot.pressBack()
+ assertFocusedTag(PlayerScreenTestTags.VideoSettings, "video-settings button")
+ robot.pressBack()
+ assertFocusedPlayerSurface()
+ robot.press(PlayerRemoteKey.Down)
+ assertFocusedPlayPause()
+ }
+
+ step("Activity recreation restores production playback and selected tracks") {
+ val previousActivity = AtomicReference()
+ composeRule.activityRule.scenario.onActivity(previousActivity::set)
+ composeRule.activityRule.scenario.recreate()
+ setPlayerContent()
+ composeRule.activityRule.scenario.onActivity { recreatedActivity ->
+ assertTrue(
+ "ActivityScenario.recreate must replace the host activity",
+ recreatedActivity !== previousActivity.get(),
+ )
+ }
+ assertFocusedPlayPause()
+ awaitPlayerReady()
+ awaitPlayerCondition("recreated UI state restores audio and subtitle choices") {
+ preferredAudioLanguage() == SPANISH_LANGUAGE &&
+ preferredSubtitleLanguage() == SUBTITLE_LANGUAGE
+ }
+ awaitPlayerCondition("recreated Media3 restores audio and subtitle choices") {
+ selectedAudioLanguage() == SPANISH_LANGUAGE &&
+ selectedTextTrack() &&
+ preferredTextLanguages().contains(SUBTITLE_LANGUAGE)
+ }
+ }
+
+ step("Recreated video and audio panels close on the current router and restore focus") {
+ robot.press(PlayerRemoteKey.Right)
+ assertFocusedTag(PlayerScreenTestTags.MarkWatched, "mark-watched button")
+ robot.press(PlayerRemoteKey.Right)
+ assertFocusedTag(PlayerScreenTestTags.AudioSubtitles, "audio/subtitle button")
+ robot.press(PlayerRemoteKey.Right)
+ assertFocusedTag(PlayerScreenTestTags.VideoSettings, "video-settings button")
+ robot.press(PlayerRemoteKey.Select)
+ assertFocusedPanelItem("quality", 0)
+ robot.pressBack()
+ assertFalse(
+ "Video-settings panel Back must not pop PlayerScreen after recreation",
+ isTextDisplayed(HOST_TEXT),
+ )
+ assertFocusedTag(PlayerScreenTestTags.VideoSettings, "video-settings button")
+
+ robot.press(PlayerRemoteKey.Left)
+ assertFocusedTag(PlayerScreenTestTags.AudioSubtitles, "audio/subtitle button")
+ robot.press(PlayerRemoteKey.Select)
+ assertFocusedPanelItem("sound", 0)
+ assertEquals(SPANISH_LANGUAGE, preferredAudioLanguage())
+ assertEquals(SUBTITLE_LANGUAGE, preferredSubtitleLanguage())
+ robot.pressBack()
+ assertFalse(
+ "Audio/subtitle panel Back must not pop PlayerScreen after recreation",
+ isTextDisplayed(HOST_TEXT),
+ )
+ assertFocusedTag(PlayerScreenTestTags.AudioSubtitles, "audio/subtitle button")
+ }
+
+ step("Recreated directional seeks preserve surface focus and fail-closed requests") {
+ robot.pressBack()
+ assertFocusedPlayerSurface()
+ robot.press(PlayerRemoteKey.PlayPause)
+ awaitPlayerCondition("recreated playback starts or replays before seek checks") {
+ playerIsPlaying() && playerPosition() >= POSITION_FENCE_MS
+ }
+ assertFocusedPlayPause()
+ robot.pressBack()
+ assertFocusedPlayerSurface()
+ robot.press(PlayerRemoteKey.PlayPause)
+ awaitPlayerCondition("recreated playback pauses from the player surface") {
+ !playerIsPlaying() && playerPosition() >= POSITION_FENCE_MS
+ }
+ assertFocusedPlayPause()
+ robot.pressBack()
+ assertFocusedPlayerSurface()
+ val beforeBackwardSeek = playerPosition()
+ robot.press(PlayerRemoteKey.Left)
+ awaitPlayerCondition("D-pad left seeks backward") {
+ playerPosition() < beforeBackwardSeek
+ }
+ awaitText(
+ context.getString(
+ R.string.player_seek_backward,
+ SEEK_STEP_SECONDS,
+ ),
+ )
+ assertFocusedPlayerSurface()
+ val beforeForwardSeek = playerPosition()
+ robot.press(PlayerRemoteKey.Right)
+ awaitPlayerCondition("D-pad right seeks forward") {
+ playerPosition() > beforeForwardSeek
+ }
+ awaitText(
+ context.getString(
+ R.string.player_seek_forward,
+ SEEK_STEP_SECONDS,
+ ),
+ )
+ assertFocusedPlayerSurface()
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ }
+
+ step("The unchanged second Back exits the recreated PlayerScreen") {
+ robot.pressBack()
+ awaitPlayerCondition("recreated PlayerScreen exits on the unchanged second Back") {
+ isTextDisplayed(HOST_TEXT)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun movieScreen_continueCrossesSavedFence_andSeparatesPauseBackgroundAndExitSaves() = run {
+ with(fixture) {
+ val itemId = 7902
+ val media = MovieMediaRoots(itemId)
+ step("Launch the saved movie and continue past its persisted position") {
+ server.reset(
+ movieRoutes(
+ itemId,
+ watchingTime = SAVED_POSITION_SECONDS,
+ media = media,
+ ),
+ )
+ launchPlayer(itemId)
+ assertFocusedTag(PlayerScreenTestTags.ResumeContinue, "Continue button")
+ assertTrue(playerPosition() < SAVED_POSITION_FENCE_MS)
+ robot.press(PlayerRemoteKey.Select)
+ awaitPlayerCondition("Continue crosses the saved-position fence") {
+ playerPosition() >= SAVED_POSITION_FENCE_MS
+ }
+ assertFocusedPlayerSurface()
+ }
+
+ lateinit var pauseSave: RecordedRequest
+ step("Pause creates a typed progress request after observable player state") {
+ robot.press(PlayerRemoteKey.PlayPause)
+ awaitPlayerCondition("pause is observable before its save") {
+ !playerIsPlaying()
+ }
+ assertFocusedPlayPause()
+ pauseSave = awaitProgressRequest(
+ "pause progress save",
+ itemId,
+ video = 1,
+ season = null,
+ )
+ assertProgressTimeAtLeast(pauseSave, MIN_SAVED_TIME_SECONDS)
+ }
+
+ lateinit var backgroundSave: RecordedRequest
+ step("Backgrounding creates a distinct progress request and keeps playback paused") {
+ robot.press(PlayerRemoteKey.Select)
+ awaitPlayerCondition("play resumes before background") { playerIsPlaying() }
+ composeRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
+ backgroundSave = awaitProgressRequest(
+ "background progress save",
+ itemId,
+ video = 1,
+ season = null,
+ )
+ assertProgressTimeAtLeast(backgroundSave, MIN_SAVED_TIME_SECONDS)
+ composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
+ setPlayerContentIfMissing()
+ awaitPlayerCondition("background keeps the player paused") {
+ !playerIsPlaying()
+ }
+ assertFocusedPlayerSurface()
+ }
+
+ step("Exit drains its own progress request after the canonical second Back") {
+ robot.press(PlayerRemoteKey.PlayPause)
+ awaitPlayerCondition("play resumes before exit") { playerIsPlaying() }
+ assertFocusedPlayPause()
+ robot.pressBack()
+ assertFocusedPlayerSurface()
+ robot.pressBack()
+ val exitSave = awaitProgressRequest(
+ "exit progress save",
+ itemId,
+ video = 1,
+ season = null,
+ )
+ assertProgressTimeAtLeast(exitSave, MIN_SAVED_TIME_SECONDS)
+ awaitPlayerCondition("Back exits only after the final progress write drains") {
+ isTextDisplayed(HOST_TEXT)
+ }
+ assertTrue(pauseSave !== backgroundSave)
+ assertTrue(backgroundSave !== exitSave)
+ }
+ }
+ }
+
+ @Test
+ fun movieScreen_failedMedia_focusesRetry_recoversAndBackDrainsProgressBeforeExit() = run {
+ with(fixture) {
+ val itemId = 7903
+ val media = MovieMediaRoots(itemId)
+ step("Launch the typed failing HLS route and focus the stable Retry control") {
+ server.reset(retryMovieRoutes(itemId, media))
+ launchPlayer(
+ itemId = itemId,
+ startMode = PlayerStartMode.StartFromBeginning,
+ )
+ assertFocusedPlayPause()
+ assertFocusedTagEventually(PlayerScreenTestTags.Retry, "Retry button")
+ assertEquals(
+ RETRY_FAILURE_REQUESTS,
+ routeRequestCount(media.high, "master"),
+ )
+ }
+
+ step("Retry reaches real playback after the programmed response sequence") {
+ robot.press(PlayerRemoteKey.Select)
+ awaitPlayerReady()
+ awaitPlayerCondition("Retry reaches real playback") {
+ playerIsPlaying() &&
+ currentMediaPath() == "${media.high}/master.m3u8"
+ }
+ assertTrue(
+ routeRequestCount(media.high, "master") > RETRY_FAILURE_REQUESTS,
+ )
+ assertFocusedPlayPause()
+ }
+
+ step("Recovered playback drains progress before the canonical Back exit") {
+ robot.pressBack()
+ assertFocusedPlayerSurface()
+ robot.pressBack()
+ awaitProgressRequest(
+ "Retry journey exit save",
+ itemId,
+ video = 1,
+ season = null,
+ )
+ awaitPlayerCondition("Back exits recovered playback") {
+ isTextDisplayed(HOST_TEXT)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun seriesScreen_nextEpisodeDpadJourney_usesDistinctRoutesAndEpisodeProgressIdentity() = run {
+ with(fixture) {
+ val itemId = 7904
+ val media = SeriesMediaRoots(itemId)
+ step("Launch episode one and focus the stable next-episode action") {
+ server.reset(seriesRoutes(itemId, media))
+ launchPlayer(
+ itemId = itemId,
+ seasonNumber = 1,
+ episodeNumber = 1,
+ startMode = PlayerStartMode.StartFromBeginning,
+ )
+ assertFocusedPlayPause()
+ awaitPlayerReady()
+ awaitJournalPath("${media.episodeOne}/master.m3u8")
+ assertFocusedTagEventually(
+ PlayerScreenTestTags.NextEpisode,
+ "next-episode button",
+ )
+ }
+
+ lateinit var firstEpisodeSave: RecordedRequest
+ step("Selecting next episode saves episode one under its typed video identity") {
+ robot.press(PlayerRemoteKey.Select)
+ firstEpisodeSave = awaitProgressRequest(
+ "first episode transition save",
+ itemId,
+ video = 1,
+ season = 1,
+ )
+ assertNull(firstEpisodeSave.url.queryParameter("episode"))
+ }
+
+ step("Episode two owns the production player and a distinct media route") {
+ awaitJournalPath("${media.episodeTwo}/master.m3u8")
+ awaitPlayerCondition("second episode owns the active production player") {
+ currentMediaPath() == "${media.episodeTwo}/master.m3u8"
+ }
+ awaitText(
+ context.getString(
+ R.string.player_season_episode_title,
+ 1,
+ 2,
+ "Second episode",
+ ),
+ )
+ awaitPlayerReady()
+ }
+
+ step("Pausing episode two saves progress under video two and fails closed") {
+ if (isTagDisplayed(PlayerScreenTestTags.AudioSubtitles)) {
+ robot.pressBack()
+ }
+ assertFocusedPlayerSurface()
+ robot.press(PlayerRemoteKey.PlayPause)
+ awaitPlayerCondition("second episode pause is observable") {
+ !playerIsPlaying()
+ }
+ val secondEpisodeSave = awaitProgressRequest(
+ "second episode pause save",
+ itemId,
+ video = 2,
+ season = 1,
+ )
+ assertNull(secondEpisodeSave.url.queryParameter("episode"))
+ assertTrue(
+ "Expected distinct episode media routes: " +
+ server.requestJournal.entries,
+ server.requestJournal.entries.any {
+ it.path == "${media.episodeOne}/master.m3u8"
+ } && server.requestJournal.entries.any {
+ it.path == "${media.episodeTwo}/master.m3u8"
+ },
+ )
+ assertEquals("1", firstEpisodeSave.url.queryParameter("video"))
+ assertEquals("2", secondEpisodeSave.url.queryParameter("video"))
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ }
+ }
+ }
+}
+
+private class PlayerE2EFixture(
+ val composeRule: androidx.compose.ui.test.junit4.AndroidComposeTestRule<
+ androidx.test.ext.junit.rules.ActivityScenarioRule,
+ ComponentActivity,
+ >,
+) {
+
+ private val instrumentation
+ get() = InstrumentationRegistry.getInstrumentation()
+
+ val context
+ get() = instrumentation.targetContext
+
+ lateinit var server: PlayerTestControl
+ private set
+ private lateinit var scenarioToken: String
+ private lateinit var flowScopeName: String
+ private lateinit var currentScreen: PlayerScreen
+ private var currentItemId: Int = 0
+ private val ownedItemIds = mutableSetOf()
+
+ val robot = PlayerTvRobot(composeRule) {
+ composeRule.activityRule.scenario.onActivity { activity ->
+ activity.onBackPressedDispatcher.onBackPressed()
+ }
+ }
+
+ fun start() {
+ LoopbackNetworkJournal(context).clear()
+ RecordingAppLauncher.reset()
+ scenarioToken = UUID.randomUUID().toString()
+ server = PlayerTestControl()
+ server.start()
+ }
+
+ fun close() {
+ var teardownFailure: Throwable? = null
+ runCatching {
+ if (composeRule.activityRule.scenario.state != Lifecycle.State.DESTROYED) {
+ composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
+ composeRule.activityRule.scenario.onActivity { activity ->
+ activity.setContent {}
+ }
+ composeRule.waitForIdle()
+ }
+ }.onFailure { teardownFailure = it }
+
+ ownedItemIds.forEach { itemId ->
+ PlayerPreferencesRepository(context).saveTrackPreferences(
+ itemId = itemId,
+ audioLang = null,
+ audioLabel = null,
+ subtitleLang = null,
+ subtitleUrl = null,
+ )
+ }
+
+ if (::server.isInitialized) {
+ try {
+ server.awaitQuiescence(timeout = 10, unit = TimeUnit.SECONDS)
+ server.assertNoUnknownRequests()
+ } catch (error: Throwable) {
+ teardownFailure = teardownFailure ?: error
+ } finally {
+ server.close()
+ }
+ }
+
+ val egress = LoopbackNetworkJournal(context).snapshot()
+ if (egress.isNotEmpty()) {
+ teardownFailure = teardownFailure ?: AssertionError("Unexpected network egress: $egress")
+ }
+ RecordingAppLauncher.reset()
+ teardownFailure?.let { throw it }
+ }
+
+ fun launchPlayer(
+ itemId: Int,
+ seasonNumber: Int? = null,
+ episodeNumber: Int? = null,
+ startMode: PlayerStartMode = PlayerStartMode.ResumeIfAvailable,
+ ) {
+ ownedItemIds += itemId
+ currentItemId = itemId
+ flowScopeName = "PlayerScreenE2ETest:$itemId:$scenarioToken"
+ currentScreen = PlayerScreen(
+ PlayerScreenParams(
+ itemId = itemId,
+ seasonNumber = seasonNumber,
+ episodeNumber = episodeNumber,
+ startMode = startMode,
+ ),
+ )
+ setPlayerContent()
+ composeRule.waitUntil(PLAYER_TIMEOUT_MS) {
+ server.requestJournal.entries.any { it.path == "/v1/items/$itemId" }
+ }
+ awaitPlayerCondition("production UI player for item $itemId") {
+ uiPlayerRead { playbackState } != null
+ }
+ }
+
+ fun setPlayerContent() {
+ composeRule.activityRule.scenario.onActivity { activity ->
+ activity.setContent {
+ PuberTheme {
+ FlowComponent(
+ scopeName = flowScopeName,
+ screen = PlayerE2EHostScreen,
+ moduleFactory = { scopeId, _ ->
+ module {
+ scope(named(scopeId)) {
+ scoped { RecordingAppLauncher }
+ scoped { ScreensImpl }
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+ composeRule.waitForIdle()
+ composeRule.waitUntil(FOCUS_TIMEOUT_MS) {
+ GlobalContext.get().getScopeOrNull(flowScopeName) != null
+ }
+ if (GlobalContext.get().getScopeOrNull(playerScopeId()) == null) {
+ onMain { flowRouter().navigateTo(currentScreen) }
+ composeRule.waitForIdle()
+ }
+ }
+
+ fun setPlayerContentIfMissing() {
+ if (runCatching { uiPlayerRead { playbackState } }.getOrNull() == null) {
+ setPlayerContent()
+ }
+ }
+
+ private fun flowRouter(): AppRouter {
+ val scope = GlobalContext.get().getScopeOrNull(flowScopeName)
+ ?: error("Missing flow scope $flowScopeName")
+ return scope.get()
+ }
+
+ private fun playerScopeId(): String {
+ val screenKey = currentScreen.key
+ return "currentScreen$flowScopeName$screenKey:$screenKey"
+ }
+
+ fun preferredAudioLanguage(): String? =
+ PlayerPreferencesRepository(context).getPreferredAudioLang(currentItemId)
+
+ fun preferredSubtitleLanguage(): String? =
+ PlayerPreferencesRepository(context).getPreferredSubtitleLang(currentItemId)
+
+ fun playerPosition(): Long = uiPlayerRead { currentPosition } ?: 0L
+
+ fun playerIsPlaying(): Boolean = uiPlayerRead { isPlaying } == true
+
+ fun currentMediaPath(): String? =
+ uiPlayerRead { currentMediaItem?.localConfiguration?.uri?.encodedPath }
+
+ fun selectedAudioLanguage(): String? = uiPlayerRead {
+ currentTracks
+ .groups
+ .filter { it.type == C.TRACK_TYPE_AUDIO }
+ .firstOrNull { it.isSelected }
+ ?.getTrackFormat(0)
+ ?.language
+ }
+
+ fun selectedTextTrack(): Boolean = uiPlayerRead {
+ currentTracks
+ .groups
+ .filter { it.type == C.TRACK_TYPE_TEXT }
+ .any { it.isSelected }
+ } == true
+
+ fun preferredTextLanguages(): List =
+ uiPlayerRead { trackSelectionParameters.preferredTextLanguages.toList() }.orEmpty()
+
+ fun awaitPlayerReady() {
+ awaitPlayerCondition("Media3 READY without PlayerScreen error") {
+ uiPlayerRead { playbackState } == Player.STATE_READY
+ }
+ }
+
+ fun awaitPlayerCondition(
+ label: String,
+ condition: () -> Boolean,
+ ) {
+ try {
+ composeRule.waitUntil(PLAYER_TIMEOUT_MS) {
+ runCatching(condition).getOrDefault(false)
+ }
+ } catch (error: Throwable) {
+ throw AssertionError("$label was not reached; diagnostics=${diagnostics()}", error)
+ }
+ }
+
+ private fun diagnostics(): String {
+ val player = runCatching {
+ uiPlayerRead {
+ "state=$playbackState,playing=$isPlaying," +
+ "position=$currentPosition,duration=$duration," +
+ "media=${currentMediaItem?.localConfiguration?.uri?.encodedPath}," +
+ "textSelected=${currentTracks.groups.any { group ->
+ group.type == C.TRACK_TYPE_TEXT && group.isSelected
+ }},preferredText=${trackSelectionParameters.preferredTextLanguages}," +
+ "savedSubtitle=${preferredSubtitleLanguage()}"
+ }
+ }.getOrNull()
+ return "player=$player,requests=${server.requestJournal.entries.takeLast(16)}"
+ }
+
+ private fun uiPlayerRead(read: Player.() -> T): T? {
+ val result = AtomicReference?>(null)
+ composeRule.activityRule.scenario.onActivity { activity ->
+ result.set(
+ runCatching {
+ activity.window.decorView.findPlayerView()?.player?.read()
+ },
+ )
+ }
+ return requireNotNull(result.get()).getOrThrow()
+ }
+
+ fun assertFocusedPlayPause() {
+ ComposeScreen.onComposeScreen(composeRule) {
+ playPauseButton.assertIsDisplayed()
+ playPauseButton.assertIsFocused()
+ }
+ assertSingleFocusedNode(
+ isFocused() and hasTestTag(PlayerScreenTestTags.PlayPause),
+ "play/pause button",
+ )
+ }
+
+ fun assertFocusedSeekBar() {
+ ComposeScreen.onComposeScreen(composeRule) {
+ seekBar.assertIsDisplayed()
+ seekBar.assertIsFocused()
+ }
+ val interaction = assertSingleFocusedNode(
+ isFocused() and hasTestTag(PlayerScreenTestTags.SeekBar),
+ "seek bar",
+ )
+ val rootBounds = composeRule.onRoot().getUnclippedBoundsInRoot()
+ val focusedBounds = interaction.getUnclippedBoundsInRoot()
+ val focusedHeight = (focusedBounds.bottom - focusedBounds.top).value
+ val focusedWidth = (focusedBounds.right - focusedBounds.left).value
+ val rootHeight = (rootBounds.bottom - rootBounds.top).value
+ val rootWidth = (rootBounds.right - rootBounds.left).value
+ assertTrue(
+ "Expected bounded seek bar focus, root=$rootBounds focused=$focusedBounds",
+ focusedHeight < rootHeight / 4f &&
+ focusedWidth > rootWidth / 2f,
+ )
+ }
+
+ fun assertFocusedPlayerSurface() {
+ awaitAbsentTag(PlayerScreenTestTags.AudioSubtitles)
+ ComposeScreen.onComposeScreen(composeRule) {
+ playerSurface.assertIsDisplayed()
+ playerSurface.assertIsFocused()
+ }
+ val rootBounds = composeRule.onRoot().fetchSemanticsNode().boundsInRoot
+ try {
+ composeRule.waitUntil(FOCUS_TIMEOUT_MS) {
+ val focused = focusedNodes()
+ focused.isNotEmpty() && focused.all { node ->
+ node.boundsInRoot.width >= rootBounds.width * FULLSCREEN_FOCUS_FRACTION &&
+ node.boundsInRoot.height >= rootBounds.height * FULLSCREEN_FOCUS_FRACTION
+ }
+ }
+ } catch (error: Throwable) {
+ throw AssertionError(
+ "Player surface focus did not settle; root=$rootBounds," +
+ " focused=${focusedSummary(focusedNodes())}",
+ error,
+ )
+ }
+ val focused = focusedNodes()
+ focused.forEach { node ->
+ assertFalse(
+ "Focused player branch contains a disabled node: ${focusedSummary(focused)}",
+ node.config.contains(SemanticsProperties.Disabled),
+ )
+ composeRule.onNode(
+ SemanticsMatcher("focused player node ${node.id}") { it.id == node.id },
+ useUnmergedTree = true,
+ ).assertIsDisplayed().assertIsFocused()
+ }
+ }
+
+ fun assertFocusedTag(tag: String, description: String) {
+ assertSingleFocusedNode(focusedTagMatcher(tag), description)
+ ComposeScreen.onComposeScreen(composeRule) {
+ focusedTag(tag).assertIsDisplayed()
+ focusedTag(tag).assertIsFocused()
+ }
+ }
+
+ fun assertFocusedPanelItem(group: String, index: Int) {
+ val tag = PlayerScreenTestTags.panelItem(group, index)
+ assertFocusedTag(tag, "$group panel item $index")
+ }
+
+ fun assertFocusedTagEventually(tag: String, description: String) {
+ val matcher = focusedTagMatcher(tag)
+ composeRule.waitUntil(PLAYER_TIMEOUT_MS) {
+ composeRule.onAllNodes(matcher, useUnmergedTree = true)
+ .fetchSemanticsNodes()
+ .size == 1
+ }
+ assertSingleFocusedNode(matcher, description)
+ }
+
+ private fun assertSingleFocusedNode(
+ matcher: SemanticsMatcher,
+ description: String,
+ ) = composeRule.onNode(matcher, useUnmergedTree = true).also { interaction ->
+ try {
+ composeRule.waitUntil(FOCUS_TIMEOUT_MS) {
+ composeRule.onAllNodes(matcher, useUnmergedTree = true)
+ .fetchSemanticsNodes()
+ .size == 1
+ }
+ } catch (error: Throwable) {
+ throw AssertionError(
+ "$description did not acquire focus; focused=${focusedSummary(focusedNodes())}",
+ error,
+ )
+ }
+ val allFocused = composeRule.onAllNodes(isFocused(), useUnmergedTree = true)
+ .fetchSemanticsNodes()
+ val expected = composeRule.onAllNodes(matcher, useUnmergedTree = true)
+ .fetchSemanticsNodes()
+ .single()
+ assertEquals(
+ "Exactly one concrete node must own focus for $description; focused=${focusedSummary(allFocused)}",
+ 1,
+ allFocused.size,
+ )
+ assertEquals(
+ "Focus escaped from $description; focused=${focusedSummary(allFocused)}",
+ expected.id,
+ allFocused.single().id,
+ )
+ assertFalse(
+ "$description is disabled",
+ expected.config.contains(SemanticsProperties.Disabled),
+ )
+ interaction.assertIsDisplayed().assertIsFocused()
+ }
+
+ private fun focusedTagMatcher(tag: String): SemanticsMatcher {
+ val tagMatcher = hasTestTag(tag)
+ return isFocused() and (
+ tagMatcher or
+ hasAnyDescendant(tagMatcher)
+ )
+ }
+
+ private fun focusedSummary(nodes: List): String =
+ nodes.joinToString { node ->
+ "id=${node.id},text=${node.config.getOrNull(SemanticsProperties.Text)}," +
+ "bounds=${node.boundsInRoot}"
+ }
+
+ private fun focusedNodes(): List =
+ composeRule.onAllNodes(isFocused(), useUnmergedTree = true)
+ .fetchSemanticsNodes()
+
+ fun awaitText(text: String) {
+ composeRule.waitUntil(PLAYER_TIMEOUT_MS) {
+ composeRule.onAllNodesWithText(text, substring = true)
+ .fetchSemanticsNodes()
+ .isNotEmpty()
+ }
+ composeRule.onNodeWithText(text, substring = true).assertIsDisplayed()
+ }
+
+ private fun awaitAbsentTag(tag: String) {
+ composeRule.waitUntil(FOCUS_TIMEOUT_MS) {
+ composeRule.onAllNodes(hasTestTag(tag), useUnmergedTree = true)
+ .fetchSemanticsNodes()
+ .isEmpty()
+ }
+ }
+
+ fun isTagDisplayed(tag: String): Boolean =
+ composeRule.onAllNodes(hasTestTag(tag), useUnmergedTree = true)
+ .fetchSemanticsNodes()
+ .isNotEmpty()
+
+ fun isTextDisplayed(text: String): Boolean =
+ composeRule.onAllNodesWithText(text, useUnmergedTree = true)
+ .fetchSemanticsNodes()
+ .isNotEmpty()
+
+ fun awaitJournalPath(path: String) {
+ try {
+ composeRule.waitUntil(PLAYER_TIMEOUT_MS) {
+ server.requestJournal.entries.any { it.path == path }
+ }
+ } catch (error: Throwable) {
+ throw AssertionError(
+ "Request path $path was not observed; diagnostics=${diagnostics()}",
+ error,
+ )
+ }
+ }
+
+ fun awaitProgressRequest(
+ description: String,
+ itemId: Int,
+ video: Int,
+ season: Int?,
+ ): RecordedRequest = awaitRequest(description) { request ->
+ request.url.encodedPath == "/v1/watching/marktime" &&
+ request.url.queryParameter("id") == itemId.toString() &&
+ request.url.queryParameter("video") == video.toString() &&
+ request.url.queryParameter("season") == season?.toString() &&
+ request.url.queryParameter("time")?.toIntOrNull() != null
+ }
+
+ fun assertProgressTimeAtLeast(request: RecordedRequest, expectedSeconds: Int) {
+ val savedTime = request.url.queryParameter("time")?.toIntOrNull()
+ assertNotNull("Missing typed progress time in ${request.url}", savedTime)
+ assertTrue(
+ "Expected progress >= $expectedSeconds seconds, request=${request.url}",
+ requireNotNull(savedTime) >= expectedSeconds,
+ )
+ }
+
+ private fun awaitRequest(
+ description: String,
+ predicate: (RecordedRequest) -> Boolean,
+ ): RecordedRequest {
+ val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(PLAYER_TIMEOUT_MS)
+ while (System.nanoTime() < deadline) {
+ val request = server.takeRequest(timeout = 100, unit = TimeUnit.MILLISECONDS)
+ if (request != null && predicate(request)) return request
+ }
+ fail("Missing $description; journal=${server.requestJournal.entries.takeLast(16)}")
+ error("unreachable")
+ }
+
+ fun routeRequestCount(root: String, leaf: String): Int =
+ server.requestJournal.matchedRoutes[mediaRouteId(root, leaf)] ?: 0
+
+ fun movieRoutes(
+ itemId: Int,
+ watchingTime: Int,
+ media: MovieMediaRoots,
+ ): List =
+ apiRoutes(
+ itemId = itemId,
+ details = movieDetails(itemId, watchingTime, media),
+ ) + mediaRoutes(media.high) + mediaRoutes(media.low)
+
+ fun retryMovieRoutes(
+ itemId: Int,
+ media: MovieMediaRoots,
+ ): List {
+ val failure = HermeticTestServer.text(
+ body = "missing",
+ status = 404,
+ contentType = HLS_CONTENT_TYPE,
+ )
+ val success = HermeticTestServer.text(
+ body = fixtureText(FixtureId.HlsMaster),
+ contentType = HLS_CONTENT_TYPE,
+ )
+ val retrySequence = HermeticTestServer.sequence(
+ *(List(RETRY_FAILURE_REQUESTS) { failure } + success).toTypedArray(),
+ )
+ return apiRoutes(
+ itemId = itemId,
+ details = movieDetails(itemId, watchingTime = 0, media = media),
+ ) + mediaRoutes(media.high, masterResponse = retrySequence) + mediaRoutes(media.low)
+ }
+
+ fun seriesRoutes(
+ itemId: Int,
+ media: SeriesMediaRoots,
+ ): List =
+ apiRoutes(
+ itemId = itemId,
+ details = seriesDetails(itemId, media),
+ ) + mediaRoutes(media.episodeOne) + mediaRoutes(media.episodeTwo)
+
+ private fun apiRoutes(
+ itemId: Int,
+ details: String,
+ ): List = listOf(
+ server.route(
+ id = "details-$itemId",
+ path = "/v1/items/$itemId",
+ response = HermeticTestServer.text(
+ body = details,
+ contentType = "application/json; charset=utf-8",
+ ),
+ required = true,
+ ),
+ server.route(
+ id = "marktime-$itemId",
+ path = "/v1/watching/marktime",
+ query = mapOf("id" to itemId.toString()),
+ queryMode = QueryMatchMode.Contains,
+ response = HermeticTestServer.text(
+ body = """{"id":$itemId,"status":0,"time":1}""",
+ contentType = "application/json; charset=utf-8",
+ ),
+ ),
+ server.route(
+ id = "toggle-$itemId",
+ path = "/v1/watching/toggle",
+ query = mapOf("id" to itemId.toString()),
+ queryMode = QueryMatchMode.Contains,
+ response = HermeticTestServer.text(
+ body = """{"status":1,"watched":1}""",
+ contentType = "application/json; charset=utf-8",
+ ),
+ ),
+ )
+
+ private fun mediaRoutes(
+ root: String,
+ masterResponse: ResponsePlan = HermeticTestServer.text(
+ body = fixtureText(FixtureId.HlsMaster),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ): List {
+ val routes = mutableListOf(
+ server.route(
+ id = mediaRouteId(root, "master"),
+ path = "$root/master.m3u8",
+ queryMode = QueryMatchMode.Contains,
+ response = masterResponse,
+ ),
+ server.route(
+ id = mediaRouteId(root, "video-360"),
+ path = "$root/video_360.m3u8",
+ response = HermeticTestServer.text(
+ body = fixtureText(FixtureId.HlsVideo360Playlist),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = mediaRouteId(root, "video-720"),
+ path = "$root/video_720.m3u8",
+ response = HermeticTestServer.text(
+ body = fixtureText(FixtureId.HlsVideo720Playlist),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = mediaRouteId(root, "audio-en"),
+ path = "$root/audio_english.m3u8",
+ response = HermeticTestServer.text(
+ body = fixtureText(FixtureId.HlsAudioEnglishPlaylist),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = mediaRouteId(root, "audio-es"),
+ path = "$root/audio_spanish.m3u8",
+ response = HermeticTestServer.text(
+ body = fixtureText(FixtureId.HlsAudioSpanishPlaylist),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = mediaRouteId(root, "subtitle"),
+ path = "$root/subtitle.vtt",
+ queryMode = QueryMatchMode.Contains,
+ response = HermeticTestServer.text(
+ body = fixtureText(FixtureId.SubtitleWebVtt),
+ contentType = "text/vtt",
+ ),
+ ),
+ )
+ listOf(
+ "video_360_000.ts",
+ "video_360_001.ts",
+ "video_720_000.ts",
+ "video_720_001.ts",
+ "audio_english_000.ts",
+ "audio_english_001.ts",
+ "audio_english_002.ts",
+ "audio_spanish_000.ts",
+ "audio_spanish_001.ts",
+ "audio_spanish_002.ts",
+ ).forEach { filename ->
+ routes += server.route(
+ id = mediaRouteId(root, filename),
+ path = "$root/$filename",
+ response = HermeticTestServer.bytes(
+ body = PlayerTestFixtures.openPath("hls/$filename", context).use { it.readBytes() },
+ contentType = "video/mp2t",
+ ),
+ )
+ }
+ return routes
+ }
+
+ private fun movieDetails(
+ itemId: Int,
+ watchingTime: Int,
+ media: MovieMediaRoots,
+ ): String =
+ """
+ {
+ "item": {
+ "id": $itemId,
+ "title": "Synthetic movie",
+ "type": "${ItemType.MOVIE.value}",
+ "videos": [{
+ "id": ${itemId + 1},
+ "number": 1,
+ "duration": 4,
+ "files": [
+ {
+ "url": {"hls": "${mediaUrl("${media.high}/master.m3u8?scenario=$scenarioToken")}"},
+ "quality": "$HIGH_QUALITY_LABEL",
+ "quality_id": 2,
+ "w": 1280,
+ "h": 720
+ },
+ {
+ "url": {"hls": "${mediaUrl("${media.low}/master.m3u8?scenario=$scenarioToken")}"},
+ "quality": "$LOW_QUALITY_LABEL",
+ "quality_id": 1,
+ "w": 640,
+ "h": 360
+ }
+ ],
+ "subtitles": [{
+ "lang": "$SUBTITLE_LANGUAGE",
+ "url": "${mediaUrl(
+ "${media.high}/subtitle.vtt?signature=screen-test&scenario=$scenarioToken",
+ )}"
+ }],
+ "watching": {"time": $watchingTime, "duration": 4, "status": 0}
+ }]
+ }
+ }
+ """.trimIndent()
+
+ private fun seriesDetails(
+ itemId: Int,
+ media: SeriesMediaRoots,
+ ): String =
+ """
+ {
+ "item": {
+ "id": $itemId,
+ "title": "Synthetic series",
+ "type": "${ItemType.SERIAL.value}",
+ "seasons": [{
+ "id": ${itemId + 10},
+ "number": 1,
+ "episodes": [
+ {
+ "id": ${itemId + EPISODE_ONE_ID_OFFSET},
+ "number": 1,
+ "title": "First episode",
+ "duration": 4,
+ "files": [{
+ "url": {"hls": "${mediaUrl("${media.episodeOne}/master.m3u8?scenario=$scenarioToken")}"},
+ "quality": "$HIGH_QUALITY_LABEL",
+ "quality_id": 2
+ }],
+ "watching": {"time": 0, "duration": 4, "status": 0}
+ },
+ {
+ "id": ${itemId + EPISODE_TWO_ID_OFFSET},
+ "number": 2,
+ "title": "Second episode",
+ "duration": 4,
+ "files": [{
+ "url": {"hls": "${mediaUrl("${media.episodeTwo}/master.m3u8?scenario=$scenarioToken")}"},
+ "quality": "$HIGH_QUALITY_LABEL",
+ "quality_id": 2
+ }],
+ "watching": {"time": 0, "duration": 4, "status": 0}
+ }
+ ]
+ }]
+ }
+ }
+ """.trimIndent()
+
+ private fun fixtureText(fixture: FixtureId): String =
+ PlayerTestFixtures.readBytes(fixture, context).toString(Charsets.UTF_8)
+
+ private fun mediaUrl(path: String): String =
+ server.url(path).replace("http://localhost:", "http://127.0.0.1:")
+
+ private fun mediaRouteId(root: String, leaf: String): String =
+ "${root.trim('/').replace('/', '-')}:$leaf"
+
+ private fun onMain(block: () -> T): T {
+ val result = AtomicReference?>(null)
+ instrumentation.runOnMainSync {
+ result.set(runCatching(block))
+ }
+ return requireNotNull(result.get()).getOrThrow()
+ }
+
+}
+
+private data class MovieMediaRoots(val itemId: Int) {
+ val high = "/media/movie-$itemId/high"
+ val low = "/media/movie-$itemId/low"
+}
+
+private data class SeriesMediaRoots(val itemId: Int) {
+ val episodeOne = "/media/series-$itemId/episode-1"
+ val episodeTwo = "/media/series-$itemId/episode-2"
+}
+
+private const val HLS_CONTENT_TYPE = "application/vnd.apple.mpegurl"
+private const val PLAYER_TIMEOUT_MS = 30_000L
+private const val FOCUS_TIMEOUT_MS = 5_000L
+private const val POSITION_FENCE_MS = 500L
+private const val SAVED_POSITION_SECONDS = 2
+private const val SAVED_POSITION_FENCE_MS = 1_750L
+private const val MIN_SAVED_TIME_SECONDS = 1
+private const val SEEK_STEP_SECONDS = 10
+private const val RETRY_FAILURE_REQUESTS = 6
+private const val FULLSCREEN_FOCUS_FRACTION = 0.8f
+private const val ENGLISH_AUDIO_LABEL = "English"
+private const val SPANISH_AUDIO_LABEL = "Español"
+private const val SPANISH_LANGUAGE = "es"
+private const val SUBTITLE_LANGUAGE = "en"
+private const val HIGH_QUALITY_LABEL = "720p"
+private const val LOW_QUALITY_LABEL = "360p"
+private const val EPISODE_ONE_ID_OFFSET = 11
+private const val EPISODE_TWO_ID_OFFSET = 12
+private const val HOST_TEXT = "Player E2E host"
+
+@Parcelize
+private data object PlayerE2EHostScreen : PuberScreen {
+ @Composable
+ override fun Content() {
+ Text(HOST_TEXT)
+ }
+}
+
+private fun View.findPlayerView(): PlayerView? {
+ if (this is PlayerView) return this
+ if (this !is ViewGroup) return null
+ repeat(childCount) { index ->
+ getChildAt(index).findPlayerView()?.let { return it }
+ }
+ return null
+}
+
+private object RecordingAppLauncher : AppLauncher {
+ val finishCount = AtomicInteger()
+
+ fun reset() {
+ finishCount.set(0)
+ }
+
+ override fun restart() = Unit
+
+ override fun finish() {
+ finishCount.incrementAndGet()
+ }
+
+ override fun bind(activity: Activity) = Unit
+
+ override fun unbind() = Unit
+}
diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerTvRobot.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerTvRobot.kt
new file mode 100644
index 00000000..02012af1
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerTvRobot.kt
@@ -0,0 +1,223 @@
+package com.kino.puber.ui.feature.player.component
+
+import android.view.KeyEvent
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.isFocusable
+import androidx.compose.ui.test.isFocused
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.onRoot
+import androidx.test.platform.app.InstrumentationRegistry
+import io.github.kakaocup.compose.node.element.KNode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+
+internal class PlayerTvRobot(
+ private val rule: ComposeContentTestRule,
+ private val canonicalBack: (() -> Unit)? = null,
+) {
+ private val screen = PlayerComposeScreen(rule)
+
+ val focus = PlayerFocusContract(rule, screen)
+
+ fun press(key: PlayerRemoteKey) {
+ pressWithoutSettling(key)
+ rule.waitForIdle()
+ }
+
+ fun pressBack() {
+ canonicalBack?.invoke() ?: press(PlayerRemoteKey.Back)
+ rule.waitForIdle()
+ }
+
+ fun pressWithoutSettling(key: PlayerRemoteKey) {
+ val instrumentation = InstrumentationRegistry.getInstrumentation()
+ instrumentation.sendKeySync(KeyEvent(KeyEvent.ACTION_DOWN, key.keyCode))
+ instrumentation.sendKeySync(KeyEvent(KeyEvent.ACTION_UP, key.keyCode))
+ }
+
+ fun advanceOneFrame() {
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle()
+ }
+
+ fun advanceClockBy(milliseconds: Long) {
+ rule.mainClock.advanceTimeBy(milliseconds)
+ rule.waitForIdle()
+ }
+
+ fun focusButton(button: KNode, dpadRightSteps: Int) {
+ repeat(dpadRightSteps) {
+ press(PlayerRemoteKey.Right)
+ }
+ button.assertIsFocused()
+ }
+
+ fun waitForControlsToBeDisposed() {
+ rule.waitUntil(timeoutMillis = FOCUS_TIMEOUT_MS) {
+ rule.onAllNodes(
+ hasTestTag(PlayerScreenTestTags.AudioSubtitles),
+ useUnmergedTree = true,
+ ).fetchSemanticsNodes().isEmpty()
+ }
+ }
+
+ fun waitUntilFocused(button: KNode) {
+ rule.waitUntil(timeoutMillis = FOCUS_TIMEOUT_MS) {
+ runCatching {
+ button.assertIsFocused()
+ true
+ }.getOrDefault(false)
+ }
+ button.assertIsFocused()
+ }
+
+ fun assertControlsVisible(controlsVisible: Boolean) {
+ assertTrue(controlsVisible)
+ screen.audioSubtitlesButton.assertIsDisplayed()
+ }
+
+ fun screen(actions: PlayerComposeScreen.() -> Unit) {
+ screen.actions()
+ }
+
+ private companion object {
+ const val FOCUS_TIMEOUT_MS = 5_000L
+ }
+}
+
+internal enum class PlayerRemoteKey(internal val keyCode: Int) {
+ Up(KeyEvent.KEYCODE_DPAD_UP),
+ Down(KeyEvent.KEYCODE_DPAD_DOWN),
+ Left(KeyEvent.KEYCODE_DPAD_LEFT),
+ Right(KeyEvent.KEYCODE_DPAD_RIGHT),
+ Select(KeyEvent.KEYCODE_DPAD_CENTER),
+ Enter(KeyEvent.KEYCODE_ENTER),
+ Back(KeyEvent.KEYCODE_BACK),
+ PlayPause(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE),
+ Play(KeyEvent.KEYCODE_MEDIA_PLAY),
+ Pause(KeyEvent.KEYCODE_MEDIA_PAUSE),
+ Rewind(KeyEvent.KEYCODE_MEDIA_REWIND),
+ FastForward(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD),
+ SkipBackward(KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD),
+ SkipForward(KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD),
+}
+
+internal class PlayerFocusContract(
+ private val rule: ComposeContentTestRule,
+ private val screen: PlayerComposeScreen,
+) {
+ fun assertExactlyOneFocusedNode() {
+ val focusedNodes = focusedNodes()
+ assertEquals(
+ "PlayerScreen must have exactly one focused semantics node; " +
+ "focusedBounds=${focusedNodes.map(SemanticsNode::boundsInRoot)}",
+ 1,
+ focusedNodes.size,
+ )
+ }
+
+ fun assertFocusedPlayerSurface() {
+ screen.playerSurface.assertIsDisplayed()
+ screen.playerSurface.assertIsFocused()
+ val focusedNode = focusedNode()
+ val rootBounds = rootBounds()
+ assertTrue(
+ "Focused player anchor must cover the root width: " +
+ "focused=${focusedNode.boundsInRoot}, root=$rootBounds",
+ focusedNode.boundsInRoot.width >= rootBounds.width * 0.9f,
+ )
+ assertTrue(
+ "Focused player anchor must cover the root height: " +
+ "focused=${focusedNode.boundsInRoot}, root=$rootBounds",
+ focusedNode.boundsInRoot.height >= rootBounds.height * 0.9f,
+ )
+ }
+
+ fun assertFocusedSeekBar() {
+ screen.seekBar.assertIsDisplayed()
+ screen.seekBar.assertIsFocused()
+ val focusedNode = focusedNode()
+ val rootWidth = rootBounds().width
+ assertTrue(
+ "Seek bar must be the focused wide control: " +
+ "focused=${focusedNode.boundsInRoot}, rootWidth=$rootWidth",
+ focusedNode.boundsInRoot.width >= rootWidth * 0.5f,
+ )
+ assertTrue(
+ "Seek bar must not be the full-screen player anchor: " +
+ "focused=${focusedNode.boundsInRoot}, rootWidth=$rootWidth",
+ focusedNode.boundsInRoot.width < rootWidth * 0.9f,
+ )
+ }
+
+ fun assertFocusedControl() {
+ screen.playPauseButton.assertIsDisplayed()
+ screen.playPauseButton.assertIsFocused()
+ waitUntilFocusedTarget { node ->
+ val rootBounds = rootBounds()
+ node.boundsInRoot.width < rootBounds.width * 0.5f &&
+ node.boundsInRoot.height < rootBounds.height * 0.9f
+ }
+ val focusedNode = focusedNode()
+ val rootBounds = rootBounds()
+ assertTrue(
+ "Control focus must not remain on the full-screen player anchor: " +
+ "focused=${focusedNode.boundsInRoot}, root=$rootBounds",
+ focusedNode.boundsInRoot.width < rootBounds.width * 0.5f &&
+ focusedNode.boundsInRoot.height < rootBounds.height * 0.9f,
+ )
+ }
+
+ fun assertPlayerAnchorFocusable(expected: Boolean) {
+ val rootBounds = rootBounds()
+ val fullScreenFocusableCount = rule
+ .onAllNodes(isFocusable(), useUnmergedTree = true)
+ .fetchSemanticsNodes()
+ .count { node ->
+ node.boundsInRoot.width >= rootBounds.width * 0.9f &&
+ node.boundsInRoot.height >= rootBounds.height * 0.9f
+ }
+ assertEquals(
+ "Only the typed Player owner may expose the full-screen focus anchor; root=$rootBounds",
+ if (expected) 1 else 0,
+ fullScreenFocusableCount,
+ )
+ }
+
+ fun assertHiddenControlsAreNotFocused() {
+ screen.audioSubtitlesButton.assertDoesNotExist()
+ screen.videoSettingsButton.assertDoesNotExist()
+ }
+
+ fun assertHiddenPlayerIsNotFocused() {
+ val focusedNode = focusedNode()
+ val rootBounds = rootBounds()
+ assertFalse(
+ "Player anchor must lose focus after controls reopen: " +
+ "focused=${focusedNode.boundsInRoot}, root=$rootBounds",
+ focusedNode.boundsInRoot.width >= rootBounds.width * 0.9f,
+ )
+ }
+
+ private fun focusedNode(): SemanticsNode = focusedNodes().single()
+
+ private fun focusedNodes(): List = rule
+ .onAllNodes(isFocused(), useUnmergedTree = true)
+ .fetchSemanticsNodes()
+
+ private fun rootBounds() = rule.onRoot().fetchSemanticsNode().boundsInRoot
+
+ private fun waitUntilFocusedTarget(predicate: (SemanticsNode) -> Boolean) {
+ rule.waitUntil(timeoutMillis = FOCUS_TIMEOUT_MS) {
+ focusedNodes().singleOrNull()?.let(predicate) == true
+ }
+ }
+
+ private companion object {
+ const val FOCUS_TIMEOUT_MS = 5_000L
+ }
+}
diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerVideoSurfaceTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerVideoSurfaceTest.kt
index 8ba52001..c573aaec 100644
--- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerVideoSurfaceTest.kt
+++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerVideoSurfaceTest.kt
@@ -9,6 +9,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.tv.material3.MaterialTheme
import com.kino.puber.domain.model.SubtitleSize
+import com.kino.puber.ui.feature.player.PlayerInstrumentationTestCase
import com.kino.puber.ui.feature.player.model.ActivePanel
import com.kino.puber.ui.feature.player.model.PlayerContentState
import com.kino.puber.ui.feature.player.vm.PlaybackIntent
@@ -22,77 +23,89 @@ import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
-internal class PlayerVideoSurfaceTest {
+internal class PlayerVideoSurfaceTest : PlayerInstrumentationTestCase() {
@get:Rule
val composeRule = createAndroidComposeRule()
@Test
- fun playbackSnapshots_reachAttachedWindow_andClearWithPlayerOnRelease() {
+ fun playbackSnapshots_reachAttachedWindow_andClearWithPlayerOnRelease() = run {
val contentState = mutableStateOf(content())
val surfaceVisible = mutableStateOf(true)
val playerViewReference = AtomicReference()
var player: ExoPlayer? = null
- composeRule.setContent {
- if (surfaceVisible.value) {
- MaterialTheme {
- PlayerVideoSurface(
- content = contentState.value,
- exoPlayer = { player },
- playerViewFactory = { context ->
- player = player ?: ExoPlayer.Builder(context).build()
- PlayerView(context).also(playerViewReference::set)
- },
- )
+ step("Attach the production video surface to an idle player") {
+ composeRule.setContent {
+ if (surfaceVisible.value) {
+ MaterialTheme {
+ PlayerVideoSurface(
+ content = contentState.value,
+ exoPlayer = { player },
+ playerViewFactory = { context ->
+ player = player ?: ExoPlayer.Builder(context).build()
+ PlayerView(context).also(playerViewReference::set)
+ },
+ )
+ }
}
}
- }
- composeRule.waitUntil { playerViewReference.get()?.isAttachedToWindow == true }
- composeRule.runOnIdle {
- val playerView = playerViewReference.get()
- assertFalse(playerView.keepScreenOn)
- assertSame(player, playerView.player)
- assertWindowKeepScreenOn(expected = false)
+ composeRule.waitUntil { playerViewReference.get()?.isAttachedToWindow == true }
+ composeRule.runOnIdle {
+ val playerView = playerViewReference.get()
+ assertFalse(playerView.keepScreenOn)
+ assertSame(player, playerView.player)
+ assertWindowKeepScreenOn(expected = false)
+ }
}
- publish(contentState, playbackState = Player.STATE_READY, playWhenReady = true)
- assertAttachedKeepScreenOn(playerViewReference, expected = true)
-
- publish(contentState, playbackState = Player.STATE_BUFFERING, playWhenReady = true)
- assertAttachedKeepScreenOn(playerViewReference, expected = true)
-
- publish(contentState, playbackState = Player.STATE_READY, playWhenReady = false)
- assertAttachedKeepScreenOn(playerViewReference, expected = false)
+ step("Acquire keep-screen-on ownership while READY and playing") {
+ publish(contentState, playbackState = Player.STATE_READY, playWhenReady = true)
+ assertAttachedKeepScreenOn(playerViewReference, expected = true)
+ }
- publish(
- contentState = contentState,
- playbackState = Player.STATE_READY,
- playWhenReady = true,
- suppressionReason = Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS,
- )
- assertAttachedKeepScreenOn(playerViewReference, expected = false)
+ step("Retain keep-screen-on ownership while actively buffering") {
+ publish(contentState, playbackState = Player.STATE_BUFFERING, playWhenReady = true)
+ assertAttachedKeepScreenOn(playerViewReference, expected = true)
+ }
- composeRule.runOnIdle {
- surfaceVisible.value = false
+ step("Release keep-screen-on ownership when playback pauses") {
+ publish(contentState, playbackState = Player.STATE_READY, playWhenReady = false)
+ assertAttachedKeepScreenOn(playerViewReference, expected = false)
}
- composeRule.waitUntil {
- playerViewReference.get().player == null &&
- !playerViewReference.get().keepScreenOn
+
+ step("Keep ownership released while playback is suppressed") {
+ publish(
+ contentState = contentState,
+ playbackState = Player.STATE_READY,
+ playWhenReady = true,
+ suppressionReason = Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS,
+ )
+ assertAttachedKeepScreenOn(playerViewReference, expected = false)
}
- composeRule.runOnIdle {
- val playerView = playerViewReference.get()
- assertFalse(playerView.keepScreenOn)
- assertNull(playerView.player)
- assertWindowKeepScreenOn(expected = false)
- player?.release()
- player = null
+
+ step("Detach the released surface and clear player and window ownership") {
+ composeRule.runOnIdle {
+ surfaceVisible.value = false
+ }
+ composeRule.waitUntil {
+ playerViewReference.get().player == null &&
+ !playerViewReference.get().keepScreenOn
+ }
+ composeRule.runOnIdle {
+ val playerView = playerViewReference.get()
+ assertFalse(playerView.keepScreenOn)
+ assertNull(playerView.player)
+ assertWindowKeepScreenOn(expected = false)
+ player?.release()
+ player = null
+ }
}
}
@Test
- fun activeBuffering_reacquiresWindowOwnershipAtPlayerViewAttachBoundary() {
+ fun activeBuffering_reacquiresWindowOwnershipAtPlayerViewAttachBoundary() = run {
val contentState = mutableStateOf(
content().withPlaybackSnapshot(
PlaybackStatePolicy.derive(
@@ -106,102 +119,120 @@ internal class PlayerVideoSurfaceTest {
val playerViewReference = AtomicReference()
var player: ExoPlayer? = null
- composeRule.setContent {
- MaterialTheme {
- PlayerVideoSurface(
- content = contentState.value,
- exoPlayer = { player },
- playerViewFactory = { context ->
- player = ExoPlayer.Builder(context).build()
- object : PlayerView(context) {
- override fun onAttachedToWindow() {
- super.onAttachedToWindow()
- composeRule.activity.window.clearFlags(
- WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
- )
- }
- }.also(playerViewReference::set)
- },
- )
+ step("Attach an actively buffering surface after the view clears the window flag") {
+ composeRule.setContent {
+ MaterialTheme {
+ PlayerVideoSurface(
+ content = contentState.value,
+ exoPlayer = { player },
+ playerViewFactory = { context ->
+ player = ExoPlayer.Builder(context).build()
+ object : PlayerView(context) {
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ composeRule.activity.window.clearFlags(
+ WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
+ )
+ }
+ }.also(playerViewReference::set)
+ },
+ )
+ }
}
}
- composeRule.waitUntil { playerViewReference.get()?.isAttachedToWindow == true }
- composeRule.waitUntil {
- composeRule.activity.window.attributes.flags and
- WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON != 0
+ step("Reacquire view and window ownership at the attach boundary") {
+ composeRule.waitUntil { playerViewReference.get()?.isAttachedToWindow == true }
+ composeRule.waitUntil {
+ composeRule.activity.window.attributes.flags and
+ WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON != 0
+ }
+ composeRule.runOnIdle {
+ assertTrue(playerViewReference.get().keepScreenOn)
+ assertWindowKeepScreenOn(expected = true)
+ }
}
- composeRule.runOnIdle {
- assertTrue(playerViewReference.get().keepScreenOn)
- assertWindowKeepScreenOn(expected = true)
- playerViewReference.get().player = null
- player?.release()
- player = null
+
+ step("Detach the player before releasing the real ExoPlayer") {
+ composeRule.runOnIdle {
+ playerViewReference.get().player = null
+ player?.release()
+ player = null
+ }
}
}
@Test
- fun preExistingWindowKeepScreenOn_survivesPlaybackTransitionsAndRelease() {
+ fun preExistingWindowKeepScreenOn_survivesPlaybackTransitionsAndRelease() = run {
val contentState = mutableStateOf(content())
val surfaceVisible = mutableStateOf(true)
val playerViewReference = AtomicReference()
var player: ExoPlayer? = null
- composeRule.runOnIdle {
- composeRule.activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- assertWindowKeepScreenOn(expected = true)
+ step("Establish pre-existing window keep-screen-on ownership") {
+ composeRule.runOnIdle {
+ composeRule.activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ assertWindowKeepScreenOn(expected = true)
+ }
}
try {
- composeRule.setContent {
- if (surfaceVisible.value) {
- MaterialTheme {
- PlayerVideoSurface(
- content = contentState.value,
- exoPlayer = { player },
- playerViewFactory = { context ->
- player = player ?: ExoPlayer.Builder(context).build()
- PlayerView(context).also(playerViewReference::set)
- },
- )
+ step("Attach the surface without claiming the pre-existing window flag") {
+ composeRule.setContent {
+ if (surfaceVisible.value) {
+ MaterialTheme {
+ PlayerVideoSurface(
+ content = contentState.value,
+ exoPlayer = { player },
+ playerViewFactory = { context ->
+ player = player ?: ExoPlayer.Builder(context).build()
+ PlayerView(context).also(playerViewReference::set)
+ },
+ )
+ }
}
}
+ composeRule.waitUntil { playerViewReference.get()?.isAttachedToWindow == true }
+ assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = false)
}
- composeRule.waitUntil { playerViewReference.get()?.isAttachedToWindow == true }
- assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = false)
+ step("Toggle view ownership through READY and BUFFERING") {
+ publish(contentState, playbackState = Player.STATE_READY, playWhenReady = true)
+ assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = true)
- publish(contentState, playbackState = Player.STATE_READY, playWhenReady = true)
- assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = true)
-
- publish(contentState, playbackState = Player.STATE_BUFFERING, playWhenReady = true)
- assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = true)
+ publish(contentState, playbackState = Player.STATE_BUFFERING, playWhenReady = true)
+ assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = true)
+ }
- publish(contentState, playbackState = Player.STATE_READY, playWhenReady = false)
- assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = false)
+ step("Release only view ownership for pause, suppression, and ENDED") {
+ publish(contentState, playbackState = Player.STATE_READY, playWhenReady = false)
+ assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = false)
- publish(
- contentState = contentState,
- playbackState = Player.STATE_READY,
- playWhenReady = true,
- suppressionReason = Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS,
- )
- assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = false)
-
- publish(contentState, playbackState = Player.STATE_ENDED, playWhenReady = true)
- assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = false)
+ publish(
+ contentState = contentState,
+ playbackState = Player.STATE_READY,
+ playWhenReady = true,
+ suppressionReason = Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS,
+ )
+ assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = false)
- composeRule.runOnIdle {
- surfaceVisible.value = false
- }
- composeRule.waitUntil {
- playerViewReference.get().player == null &&
- !playerViewReference.get().keepScreenOn
+ publish(contentState, playbackState = Player.STATE_ENDED, playWhenReady = true)
+ assertPreExistingWindowFlagPreserved(playerViewReference, viewExpected = false)
}
- composeRule.runOnIdle {
- assertFalse(playerViewReference.get().keepScreenOn)
- assertNull(playerViewReference.get().player)
- assertWindowKeepScreenOn(expected = true)
+
+ step("Detach the surface while preserving external window ownership") {
+ composeRule.runOnIdle {
+ surfaceVisible.value = false
+ }
+ composeRule.waitUntil {
+ playerViewReference.get().player == null &&
+ !playerViewReference.get().keepScreenOn
+ }
+ composeRule.runOnIdle {
+ assertFalse(playerViewReference.get().keepScreenOn)
+ assertNull(playerViewReference.get().player)
+ assertWindowKeepScreenOn(expected = true)
+ }
}
} finally {
try {
diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerDeviceTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerDeviceTest.kt
new file mode 100644
index 00000000..82165c94
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerDeviceTest.kt
@@ -0,0 +1,496 @@
+package com.kino.puber.ui.feature.player.vm
+
+import android.os.Handler
+import android.os.Looper
+import android.view.ViewGroup
+import androidx.activity.ComponentActivity
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
+import androidx.media3.datasource.cache.SimpleCache
+import androidx.media3.database.StandaloneDatabaseProvider
+import androidx.media3.ui.PlayerView
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.kino.puber.data.repository.PlayerPreferencesRepository
+import com.kino.puber.playertestfixtures.FixtureId
+import com.kino.puber.playertestfixtures.PlayerTestFixtures
+import com.kino.puber.playertestfixtures.network.LoopbackNetworkJournal
+import com.kino.puber.playertestfixtures.server.HermeticRoute
+import com.kino.puber.playertestfixtures.server.HermeticTestServer
+import com.kino.puber.playertestfixtures.server.ResponseOutcome
+import com.kino.puber.playertestfixtures.server.ResponsePlan
+import com.kino.puber.profile.PlayerTestControl
+import com.kino.puber.ui.feature.player.PlayerInstrumentationTestCase
+import com.kino.puber.ui.feature.player.model.BufferPreset
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(UnstableApi::class)
+@RunWith(AndroidJUnit4::class)
+internal class PlaybackControllerDeviceTest : PlayerInstrumentationTestCase() {
+
+ private val context
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
+
+ private lateinit var server: PlayerTestControl
+ private lateinit var cacheDirectory: File
+ private lateinit var cache: SimpleCache
+ private lateinit var controller: PlaybackController
+ private lateinit var scenario: ActivityScenario
+ private lateinit var playerView: PlayerView
+
+ @Before
+ fun setUp() {
+ LoopbackNetworkJournal(context).clear()
+ server = PlayerTestControl()
+ server.start(mediaRoutes())
+
+ cacheDirectory = context.cacheDir.resolve("player-device-${UUID.randomUUID()}")
+ cache = SimpleCache(
+ cacheDirectory,
+ LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTES),
+ StandaloneDatabaseProvider(context),
+ )
+ controller = PlaybackController(
+ context = context,
+ okHttpClient = okhttp3.OkHttpClient(),
+ mediaCache = cache,
+ playerPreferencesRepository = PlayerPreferencesRepository(context),
+ )
+ scenario = ActivityScenario.launch(ComponentActivity::class.java)
+ scenario.onActivity { activity ->
+ playerView = PlayerView(activity).apply {
+ useController = false
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ }
+ activity.setContentView(playerView)
+ }
+ }
+
+ @After
+ fun tearDown() {
+ var cleanupFailure: Throwable? = null
+ if (::scenario.isInitialized) {
+ scenario.onActivity {
+ playerView.player = null
+ controller.release()
+ }
+ scenario.close()
+ }
+ if (::cache.isInitialized) {
+ cache.release()
+ }
+ if (::server.isInitialized) {
+ try {
+ server.awaitQuiescence(timeout = 10, unit = TimeUnit.SECONDS)
+ try {
+ server.assertRequiredRoutesRequested()
+ } catch (error: IllegalStateException) {
+ cleanupFailure = error
+ }
+ try {
+ server.assertNoUnknownRequests(MAX_DIAGNOSTIC_REQUESTS)
+ } catch (error: AssertionError) {
+ cleanupFailure?.addSuppressed(error) ?: run {
+ cleanupFailure = error
+ }
+ }
+ } catch (error: Throwable) {
+ cleanupFailure = error
+ } finally {
+ server.close()
+ }
+ }
+ val networkJournal = LoopbackNetworkJournal(context)
+ check(networkJournal.snapshot().isEmpty()) {
+ "Unexpected network egress: ${networkJournal.snapshot()}"
+ }
+ if (::cacheDirectory.isInitialized) {
+ check(!cacheDirectory.exists() || cacheDirectory.deleteRecursively()) {
+ "Failed to clean player cache: $cacheDirectory"
+ }
+ }
+ cleanupFailure?.let { throw it }
+ }
+
+ @Test
+ fun progressiveMp4_reachesReadyRendersFrameAdvancesAndEnds() = run {
+ lateinit var probe: PlayerProbe
+
+ step("Prepare the progressive fixture with the production player") {
+ probe = prepare(FixtureId.ProgressiveMp4, startPositionMs = 0L)
+ }
+
+ step("Reach READY and render the first progressive frame") {
+ await(probe.ready, "progressive READY")
+ await(probe.firstFrame, "progressive first rendered frame")
+ }
+
+ step("Advance progressive playback and reach ENDED") {
+ awaitCondition("progressive position advance") {
+ controller.currentPosition > 0L
+ }
+ await(probe.ended, "progressive ENDED")
+ }
+
+ step("Verify progressive playback and its hermetic request journal") {
+ assertNoPlaybackError(probe)
+ assertTrue(
+ "Expected a progressive MP4 request; journal=${server.requestJournal.entries}",
+ server.requestJournal.entries.any {
+ it.path == "/media/progressive.mp4" &&
+ it.outcome is ResponseOutcome.Completed
+ },
+ )
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ }
+ }
+
+ @Test
+ fun hlsVod_reachesReadyRendersFrameAdvancesAndEnds() = run {
+ lateinit var probe: PlayerProbe
+
+ step("Prepare the HLS fixture with the production player") {
+ probe = prepare(FixtureId.HlsMaster, startPositionMs = 0L)
+ }
+
+ step("Reach READY and render the first HLS frame") {
+ await(probe.ready, "HLS READY")
+ await(probe.firstFrame, "HLS first rendered frame")
+ }
+
+ step("Advance HLS playback and reach ENDED") {
+ awaitCondition("HLS position advance") {
+ controller.currentPosition > 0L
+ }
+ await(probe.ended, "HLS ENDED")
+ }
+
+ step("Verify HLS playback and its manifest and segment requests") {
+ assertNoPlaybackError(probe)
+ val requests = server.requestJournal.entries
+ assertTrue(
+ "Expected HLS master and media playlist requests, journal=$requests",
+ requests.any { it.path == "/media/hls/master.m3u8" } &&
+ requests.any { it.path == "/media/hls/video_360.m3u8" },
+ )
+ assertTrue(
+ "Expected HLS segment request, journal=$requests",
+ requests.any { it.path.startsWith("/media/hls/video_") && it.path.endsWith(".ts") },
+ )
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ }
+ }
+
+ @Test
+ fun progressivePlayback_controlsAndStartPositionUseRealPlayerEvents() = run {
+ lateinit var probe: PlayerProbe
+
+ step("Start progressive playback from the requested position") {
+ probe = prepare(FixtureId.ProgressiveMp4, startPositionMs = 1_000L)
+ await(probe.ready, "progressive READY")
+ await(probe.firstFrame, "progressive first rendered frame")
+ awaitCondition("non-zero start position") {
+ controller.currentPosition >= 900L
+ }
+ }
+
+ step("Pause playback through the production controller") {
+ runOnPlayer { controller.pause() }
+ awaitCondition("pause") { !controller.isPlaying }
+ }
+
+ step("Change speed and seek forward") {
+ runOnPlayer {
+ controller.setSpeed(2f)
+ controller.seekTo(2_000L)
+ }
+ awaitCondition("forward seek") {
+ controller.currentPosition in 1_800L..2_400L
+ }
+ }
+
+ step("Resume playback after the forward seek") {
+ runOnPlayer { controller.play() }
+ awaitCondition("play after seek") { controller.isPlaying }
+ }
+
+ step("Seek backward while playback remains active") {
+ runOnPlayer { controller.seekTo(250L) }
+ awaitCondition("backward seek") {
+ controller.currentPosition in 0L..700L
+ }
+ }
+
+ step("Seek near the end and reach ENDED without playback errors") {
+ runOnPlayer { controller.seekTo(3_700L) }
+ await(probe.ended, "near-end seek ENDED")
+ assertNoPlaybackError(probe)
+ }
+
+ step("Replay from ENDED and preserve the selected speed") {
+ runOnPlayer { controller.play() }
+ awaitCondition("replay after ended") {
+ controller.currentPosition < 1_000L && controller.isPlaying
+ }
+ assertEquals(2f, playerRead { checkNotNull(player).getPlaybackParameters().speed }, 0f)
+ }
+ }
+
+ @Test
+ fun awaitCondition_failsClosedWhenConditionRemainsFalse() = run {
+ lateinit var error: AssertionError
+
+ step("Wait on a condition that deliberately remains false") {
+ error = assertThrows(AssertionError::class.java) {
+ awaitCondition(
+ label = "deliberately false condition",
+ timeoutMs = FAIL_CLOSED_TIMEOUT_MS,
+ ) {
+ false
+ }
+ }
+ }
+
+ step("Report the failed condition with its diagnostic label") {
+ assertTrue(error.message.orEmpty().contains("deliberately false condition"))
+ }
+ }
+
+ @Test
+ fun unexpectedLoopbackRequest_failsClosedWithBoundedDiagnostics() = run {
+ lateinit var error: AssertionError
+
+ try {
+ step("Send a loopback request that has no admitted route") {
+ server.reset(emptyList())
+ okhttp3.OkHttpClient().newCall(
+ okhttp3.Request.Builder()
+ .url(loopbackUrl("/unexpected-player-request"))
+ .build(),
+ ).execute().close()
+ server.awaitQuiescence(timeout = 10, unit = TimeUnit.SECONDS)
+ }
+
+ step("Reject the unknown request with bounded diagnostics") {
+ error = assertThrows(AssertionError::class.java) {
+ server.assertNoUnknownRequests(MAX_DIAGNOSTIC_REQUESTS)
+ }
+ assertTrue(error.message.orEmpty().contains("/unexpected-player-request"))
+ }
+ } finally {
+ server.reset(emptyList())
+ }
+ }
+
+ private fun prepare(
+ fixture: FixtureId,
+ startPositionMs: Long? = null,
+ ): PlayerProbe {
+ val probe = PlayerProbe()
+ val streamUrl = when (fixture) {
+ FixtureId.ProgressiveMp4 -> loopbackUrl("/media/progressive.mp4")
+ FixtureId.HlsMaster -> loopbackUrl("/media/hls/master.m3u8")
+ else -> error("Unsupported device fixture: $fixture")
+ }
+ scenario.onActivity {
+ controller.prepare(
+ streamUrl = streamUrl,
+ subtitles = null,
+ startPosition = startPositionMs,
+ bufferPreset = BufferPreset.SMALL,
+ fastDns = false,
+ )
+ val player = checkNotNull(controller.player)
+ player.addListener(probe)
+ playerView.player = player
+ }
+ return probe
+ }
+
+ private fun loopbackUrl(path: String): String =
+ server.url(path).replace("http://localhost:", "http://127.0.0.1:")
+
+ private fun mediaRoutes(): List {
+ val routes = mutableListOf(
+ server.route(
+ id = "progressive",
+ path = "/media/progressive.mp4",
+ response = ResponsePlan.Range(
+ body = PlayerTestFixtures.readBytes(FixtureId.ProgressiveMp4, context),
+ contentType = "video/mp4",
+ ),
+ ),
+ server.route(
+ id = "master",
+ path = "/media/hls/master.m3u8",
+ response = HermeticTestServer.text(
+ body = PlayerTestFixtures.readBytes(FixtureId.HlsMaster, context)
+ .toString(Charsets.UTF_8),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ )
+ FixtureId.entries
+ .filter { it.path.startsWith("hls/") && it != FixtureId.HlsMaster }
+ .forEach { fixture ->
+ routes += server.route(
+ id = fixture.path,
+ path = "/media/${fixture.path}",
+ response = HermeticTestServer.text(
+ body = PlayerTestFixtures.readBytes(fixture, context)
+ .toString(Charsets.UTF_8),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ )
+ }
+ listOf(
+ "video_360_000.ts",
+ "video_360_001.ts",
+ "video_720_000.ts",
+ "video_720_001.ts",
+ "audio_english_000.ts",
+ "audio_english_001.ts",
+ "audio_english_002.ts",
+ "audio_spanish_000.ts",
+ "audio_spanish_001.ts",
+ "audio_spanish_002.ts",
+ ).forEach { filename ->
+ routes += server.route(
+ id = filename,
+ path = "/media/hls/$filename",
+ response = HermeticTestServer.bytes(
+ body = PlayerTestFixtures.openPath("hls/$filename", context)
+ .use { it.readBytes() },
+ contentType = "video/mp2t",
+ ),
+ )
+ }
+ return routes
+ }
+
+ private fun runOnPlayer(block: () -> Unit) {
+ scenario.onActivity { block() }
+ }
+
+ private fun await(latch: CountDownLatch, label: String) {
+ assertTrue(
+ "$label timed out; diagnostics=${diagnostics()}",
+ latch.await(PLAYER_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ )
+ }
+
+ private fun awaitCondition(
+ label: String,
+ timeoutMs: Long = TimeUnit.SECONDS.toMillis(PLAYER_TIMEOUT_SECONDS),
+ condition: () -> Boolean,
+ ) {
+ val reached = CountDownLatch(1)
+ val conditionSatisfied = AtomicBoolean(false)
+ val handler = Handler(Looper.getMainLooper())
+ val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
+ lateinit var check: Runnable
+ check = Runnable {
+ if (condition()) {
+ conditionSatisfied.set(true)
+ reached.countDown()
+ } else if (System.nanoTime() < deadline) {
+ handler.postDelayed(check, CONDITION_POLL_MS)
+ } else {
+ reached.countDown()
+ }
+ }
+ handler.post(check)
+ val pollingCompleted = reached.await(
+ timeoutMs + CONDITION_TIMEOUT_GRACE_MS,
+ TimeUnit.MILLISECONDS,
+ )
+ handler.removeCallbacks(check)
+ assertTrue(
+ "$label polling timed out; diagnostics=${diagnostics()}",
+ pollingCompleted,
+ )
+ assertTrue(
+ "$label condition remained false; diagnostics=${diagnostics()}",
+ conditionSatisfied.get(),
+ )
+ }
+
+ private fun assertNoPlaybackError(probe: PlayerProbe) {
+ assertTrue(
+ "Playback error=${probe.error.get()?.errorCodeName}; diagnostics=${diagnostics()}",
+ probe.error.get() == null,
+ )
+ }
+
+ private fun diagnostics(): String {
+ val playerState = playerRead {
+ val player = controller.player
+ val debug = controller.getDebugInfo()
+ "state=${player?.playbackState},isPlaying=${player?.isPlaying}," +
+ "position=${player?.currentPosition},duration=${player?.duration}," +
+ "formats=${debug?.videoResolution}/${debug?.videoCodec}/" +
+ "${debug?.audioCodec}"
+ }
+ return "$playerState,requests=${server.requestJournal.entries.takeLast(12)}"
+ }
+
+ private fun playerRead(read: PlaybackController.() -> T): T {
+ val value = AtomicReference()
+ scenario.onActivity { value.set(read(controller)) }
+ return checkNotNull(value.get())
+ }
+
+ private class PlayerProbe : Player.Listener {
+ val ready = CountDownLatch(1)
+ val firstFrame = CountDownLatch(1)
+ val ended = CountDownLatch(1)
+ val error = AtomicReference()
+ val stateEvents = CopyOnWriteArrayList()
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ stateEvents += playbackState
+ when (playbackState) {
+ Player.STATE_READY -> ready.countDown()
+ Player.STATE_ENDED -> ended.countDown()
+ }
+ }
+
+ override fun onRenderedFirstFrame() {
+ firstFrame.countDown()
+ }
+
+ override fun onPlayerError(error: PlaybackException) {
+ this.error.set(error)
+ ended.countDown()
+ }
+ }
+
+ private companion object {
+ const val HLS_CONTENT_TYPE = "application/vnd.apple.mpegurl"
+ const val MAX_CACHE_BYTES = 32L * 1024L * 1024L
+ const val PLAYER_TIMEOUT_SECONDS = 20L
+ const val CONDITION_POLL_MS = 25L
+ const val CONDITION_TIMEOUT_GRACE_MS = 1_000L
+ const val FAIL_CLOSED_TIMEOUT_MS = 100L
+ const val MAX_DIAGNOSTIC_REQUESTS = 8
+ }
+}
diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerNetworkTracksTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerNetworkTracksTest.kt
new file mode 100644
index 00000000..92c8271c
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerNetworkTracksTest.kt
@@ -0,0 +1,950 @@
+package com.kino.puber.ui.feature.player.vm
+
+import android.os.Handler
+import android.os.Looper
+import android.view.ViewGroup
+import androidx.activity.ComponentActivity
+import androidx.media3.common.C
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.Tracks
+import androidx.media3.common.text.Cue
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.common.text.CueGroup
+import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
+import androidx.media3.datasource.cache.SimpleCache
+import androidx.media3.database.StandaloneDatabaseProvider
+import androidx.media3.ui.PlayerView
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.kino.puber.data.api.models.SubtitleLink
+import com.kino.puber.data.repository.PlayerPreferencesRepository
+import com.kino.puber.playertestfixtures.FixtureId
+import com.kino.puber.playertestfixtures.PlayerTestFixtures
+import com.kino.puber.playertestfixtures.network.LoopbackNetworkJournal
+import com.kino.puber.playertestfixtures.server.HermeticRoute
+import com.kino.puber.playertestfixtures.server.HermeticTestServer
+import com.kino.puber.playertestfixtures.server.QueryMatchMode
+import com.kino.puber.playertestfixtures.server.ResponseOutcome
+import com.kino.puber.playertestfixtures.server.ResponsePlan
+import com.kino.puber.profile.PlayerTestControl
+import com.kino.puber.ui.feature.player.PlayerInstrumentationTestCase
+import com.kino.puber.ui.feature.player.model.AudioTrackUIState
+import com.kino.puber.ui.feature.player.model.BufferPreset
+import com.kino.puber.ui.feature.player.model.SubtitleTrackUIState
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(UnstableApi::class)
+@RunWith(AndroidJUnit4::class)
+internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCase() {
+
+ private val context
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
+
+ private lateinit var server: PlayerTestControl
+ private lateinit var cacheDirectory: File
+ private lateinit var cache: SimpleCache
+ private lateinit var controller: PlaybackController
+ private lateinit var scenario: ActivityScenario
+ private lateinit var playerView: PlayerView
+
+ @Before
+ fun setUp() {
+ LoopbackNetworkJournal(context).clear()
+ server = PlayerTestControl()
+ server.start()
+
+ cacheDirectory = context.cacheDir.resolve("player-network-${UUID.randomUUID()}")
+ cache = SimpleCache(
+ cacheDirectory,
+ LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTES),
+ StandaloneDatabaseProvider(context),
+ )
+ controller = PlaybackController(
+ context = context,
+ okHttpClient = okhttp3.OkHttpClient(),
+ mediaCache = cache,
+ playerPreferencesRepository = PlayerPreferencesRepository(context),
+ )
+ scenario = ActivityScenario.launch(ComponentActivity::class.java)
+ scenario.onActivity { activity ->
+ playerView = PlayerView(activity).apply {
+ useController = false
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ }
+ activity.setContentView(playerView)
+ }
+ }
+
+ @After
+ fun tearDown() {
+ if (::scenario.isInitialized) {
+ scenario.onActivity {
+ playerView.player = null
+ controller.release()
+ }
+ scenario.close()
+ }
+ if (::cache.isInitialized) cache.release()
+ if (::server.isInitialized) {
+ try {
+ server.awaitQuiescence(timeout = 10, unit = TimeUnit.SECONDS)
+ server.assertNoUnknownRequests(MAX_DIAGNOSTIC_REQUESTS)
+ } finally {
+ server.close()
+ }
+ }
+ check(LoopbackNetworkJournal(context).snapshot().isEmpty()) {
+ "Unexpected network egress: ${LoopbackNetworkJournal(context).snapshot()}"
+ }
+ if (::cacheDirectory.isInitialized) {
+ check(!cacheDirectory.exists() || cacheDirectory.deleteRecursively()) {
+ "Failed to clean player cache: $cacheDirectory"
+ }
+ }
+ }
+
+ @Test
+ fun hlsTracks_selectsAudioAndSubtitle_observesCueAndDisablesText() = run {
+ lateinit var probe: PlayerProbe
+ lateinit var subtitleUrl: String
+
+ step("Prepare HLS with two AAC renditions and side-loaded WebVTT") {
+ server.reset(commonHlsRoutes())
+ subtitleUrl = loopbackUrl("/media/subtitle.vtt?signature=test-signature")
+ probe = prepare(
+ path = "/media/hls/master.m3u8",
+ subtitles = listOf(
+ SubtitleLink(
+ lang = "en",
+ url = subtitleUrl,
+ ),
+ ),
+ )
+ }
+
+ step("Reach READY and expose both AAC renditions") {
+ awaitReady(probe)
+ runOnPlayer { controller.pause() }
+ awaitCondition("both AAC renditions are exposed") {
+ audioTracks().map(AudioTrackUIState::language).toSet() == setOf("en", "es")
+ }
+ assertEquals(setOf("en", "es"), audioTracks().map(AudioTrackUIState::language).toSet())
+ }
+
+ step("Select the Spanish audio rendition") {
+ runOnPlayer { controller.selectAudioTrack(1) }
+ awaitCondition("Spanish AAC rendition selected") {
+ selectedAudioLanguage() == "es"
+ }
+ }
+
+ step("Select the external WebVTT track without losing audio selection") {
+ val subtitle = SubtitleTrackUIState(
+ index = 1,
+ label = "English",
+ language = "en",
+ url = subtitleUrl,
+ )
+ runOnPlayer { controller.selectSubtitle(subtitle) }
+ awaitCondition("side-loaded WebVTT track enabled on HLS") {
+ !textTracksDisabled() && selectedTextTrack()
+ }
+ assertEquals("es", selectedAudioLanguage())
+ }
+
+ step("Render the known fixture cue and journal its subtitle request") {
+ runOnPlayer {
+ controller.seekTo(900L)
+ controller.play()
+ }
+ awaitCondition("fixture cue is rendered") {
+ probe.cueTexts.any { it.contains("Synthetic player fixture cue") } ||
+ currentCueTexts().any { it.contains("Synthetic player fixture cue") }
+ }
+ assertTrue(server.requestJournal.entries.any { it.path == "/media/subtitle.vtt" })
+ }
+
+ step("Disable text while preserving audio and hermetic routing") {
+ runOnPlayer { controller.selectSubtitle(null) }
+ awaitCondition("WebVTT track disabled") { textTracksDisabled() }
+ assertEquals("es", selectedAudioLanguage())
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ }
+ }
+
+ @Test
+ fun hlsPreparation_exposesCodecTracksBeforeTheFirstMediaChunkCompletes() = run {
+ val gate = CountDownLatch(1)
+ lateinit var probe: PlayerProbe
+
+ step("Block the first HLS video and audio chunks") {
+ server.reset(
+ commonHlsRoutes(
+ extraRoutes = listOf(
+ server.route(
+ id = "chunkless-video-gate",
+ path = "/media/hls/video_720_000.ts",
+ response = HermeticTestServer.delayed(
+ gate = gate,
+ body = fixtureBytes("hls/video_720_000.ts"),
+ contentType = "video/mp2t",
+ ),
+ ),
+ server.route(
+ id = "chunkless-audio-gate",
+ path = "/media/hls/audio_english_000.ts",
+ response = HermeticTestServer.delayed(
+ gate = gate,
+ body = fixtureBytes("hls/audio_english_000.ts"),
+ contentType = "video/mp2t",
+ ),
+ ),
+ ),
+ ),
+ )
+ probe = prepare("/media/hls/master.m3u8")
+ }
+
+ try {
+ step("Expose CODECS-derived audio and video tracks before chunks complete") {
+ awaitCondition("first HLS media chunks are blocked") {
+ server.activeRequestCount > 0
+ }
+ awaitCondition("chunkless HLS preparation exposes CODECS tracks") {
+ server.activeRequestCount > 0 && playerRead {
+ val trackTypes = player?.currentTracks?.groups
+ ?.map { it.type }
+ .orEmpty()
+ C.TRACK_TYPE_VIDEO in trackTypes && C.TRACK_TYPE_AUDIO in trackTypes
+ }
+ }
+ }
+ } finally {
+ gate.countDown()
+ }
+
+ step("Release the chunks and complete preparation without errors") {
+ awaitReady(probe)
+ assertTrue(probe.error.get() == null)
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ }
+ }
+
+ @Test
+ fun hlsSwitch_preservesPositionIntentTracks_andLastStreamWins() = run {
+ lateinit var lowUrl: String
+ lateinit var highUrl: String
+ lateinit var subtitle: SubtitleLink
+ lateinit var subtitleTrack: SubtitleTrackUIState
+ lateinit var probe: PlayerProbe
+ var pausedPosition = 0L
+
+ step("Prepare the low-quality HLS source with external WebVTT") {
+ server.reset(commonHlsRoutes())
+ lowUrl = loopbackUrl("/media/hls/quality-low.m3u8")
+ highUrl = loopbackUrl("/media/hls/quality-high.m3u8")
+ val subtitleUrl = loopbackUrl("/media/subtitle.vtt?scenario=quality-switch")
+ subtitle = SubtitleLink(lang = "en", url = subtitleUrl)
+ subtitleTrack = SubtitleTrackUIState(1, "English", "en", subtitleUrl)
+ probe = prepare("/media/hls/quality-low.m3u8", listOf(subtitle))
+ }
+
+ step("Select Spanish audio and WebVTT before switching quality") {
+ awaitReady(probe)
+ awaitCondition("audio tracks before switch") { audioTracks().size == 2 }
+ runOnPlayer {
+ controller.selectAudioTrack(1)
+ controller.selectSubtitle(subtitleTrack)
+ controller.seekTo(1_500L)
+ }
+ awaitCondition("position before switch") {
+ currentPosition() in 1_000L..2_100L
+ }
+ awaitCondition("Spanish selection before switch") { selectedAudioLanguage() == "es" }
+ awaitCondition("WebVTT selection before switch") {
+ !textTracksDisabled() && selectedTextTrack()
+ }
+ awaitCondition("WebVTT request before switch") {
+ server.requestJournal.entries.any { it.path == "/media/subtitle.vtt" }
+ }
+ }
+
+ step("Pause and switch to high quality with position, intent, and tracks preserved") {
+ runOnPlayer { controller.pause() }
+ awaitCondition("paused switch source") { !isPlaying() }
+ pausedPosition = currentPosition()
+ runOnPlayer { controller.switchStream(highUrl, listOf(subtitle)) }
+ awaitCondition("high quality source prepared") {
+ currentMediaPath() == "/media/hls/quality-high.m3u8" &&
+ playbackState() == Player.STATE_READY
+ }
+ assertFalse(textTracksDisabled())
+ assertTrue(selectedTextTrack())
+ assertFalse(isPlaying())
+ assertEquals(PlaybackIntent.Paused, playbackIntent())
+ assertTrue(currentPosition() in (pausedPosition - 800L)..(pausedPosition + 800L))
+ awaitCondition("audio selection restored after switch") {
+ selectedAudioLanguage() == "es"
+ }
+ }
+
+ step("Resume playback and let the last rapid quality switch win") {
+ runOnPlayer { controller.play() }
+ awaitCondition("play intent restored") {
+ isPlaying() && playbackIntent() == PlaybackIntent.PlayRequested
+ }
+ runOnPlayer {
+ controller.switchStream(lowUrl, listOf(subtitle))
+ controller.switchStream(highUrl, listOf(subtitle))
+ controller.switchStream(lowUrl, listOf(subtitle))
+ }
+ awaitCondition("last rapid switch wins") {
+ currentMediaPath() == "/media/hls/quality-low.m3u8" &&
+ playbackIntent() == PlaybackIntent.PlayRequested
+ }
+ }
+
+ step("Verify both quality requests and the final error-free state") {
+ assertTrue(currentPosition() >= pausedPosition - 1_000L)
+ val masterPaths = server.requestJournal.entries
+ .filter { it.path.startsWith("/media/hls/quality-") }
+ .map { it.path }
+ assertTrue(masterPaths.contains("/media/hls/quality-low.m3u8"))
+ assertTrue(masterPaths.contains("/media/hls/quality-high.m3u8"))
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ assertTrue(probe.error.get() == null)
+ }
+ }
+
+ @Test
+ fun progressiveSwitch_restoresSelectedSubtitleAndPausedIntent() = run {
+ lateinit var subtitleUrl: String
+ lateinit var subtitle: SubtitleTrackUIState
+ lateinit var first: PlayerProbe
+ var positionBeforeSwitch = 0L
+
+ step("Prepare the first progressive source with external WebVTT") {
+ server.reset(commonHlsRoutes())
+ subtitleUrl = loopbackUrl("/media/subtitle.vtt")
+ subtitle = SubtitleTrackUIState(1, "English", "en", subtitleUrl)
+ first = prepare(
+ path = "/media/progressive-a.mp4",
+ subtitles = listOf(SubtitleLink(lang = "en", url = subtitleUrl)),
+ )
+ }
+
+ step("Pause and select WebVTT before the progressive switch") {
+ awaitReady(first)
+ runOnPlayer { controller.pause() }
+ runOnPlayer { controller.selectSubtitle(subtitle) }
+ awaitCondition("subtitle selected before progressive switch") {
+ !textTracksDisabled() && selectedTextTrack()
+ }
+ positionBeforeSwitch = currentPosition()
+ }
+
+ step("Switch sources while preserving paused intent and position") {
+ val secondUrl = loopbackUrl("/media/progressive-b.mp4")
+ runOnPlayer {
+ controller.switchStream(
+ streamUrl = secondUrl,
+ subtitles = listOf(SubtitleLink(lang = "en", url = subtitleUrl)),
+ )
+ }
+ awaitCondition("progressive switch completes") {
+ currentMediaPath() == "/media/progressive-b.mp4" &&
+ playbackState() == Player.STATE_READY
+ }
+ assertEquals(PlaybackIntent.Paused, playbackIntent())
+ assertFalse(isPlaying())
+ assertTrue(
+ currentPosition() in
+ (positionBeforeSwitch - 800L)..(positionBeforeSwitch + 800L),
+ )
+ }
+
+ step("Restore then disable the selected subtitle on the new source") {
+ awaitCondition("subtitle selection restored after progressive switch") {
+ !textTracksDisabled() && selectedTextTrack()
+ }
+ runOnPlayer { controller.selectSubtitle(null) }
+ awaitCondition("subtitle disabled after progressive switch") { textTracksDisabled() }
+ }
+ }
+
+ @Test
+ fun hls404AndMalformedPlaylist_failWithoutUnboundedRetry() = run {
+ step("Fail a missing HLS playlist with a bounded request count") {
+ runPlaylistFailure(
+ path = "/media/fault-404.m3u8",
+ response = HermeticTestServer.text(
+ body = "not found",
+ status = 404,
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ )
+ }
+ step("Fail a malformed HLS playlist with a bounded request count") {
+ runPlaylistFailure(
+ path = "/media/fault-malformed.m3u8",
+ response = HermeticTestServer.text(
+ body = "this is not an HLS playlist",
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun hlsVariant400_fallsBackToTheHealthyVariant() = run {
+ step("Fall back from one HTTP 400 variant failure to the healthy variant") {
+ assertVariantHttpFailureFallsBack(status = 400)
+ }
+ }
+
+ @Test
+ fun hlsVariant502_fallsBackToTheHealthyVariant() = run {
+ step("Fall back from one HTTP 502 variant failure to the healthy variant") {
+ assertVariantHttpFailureFallsBack(status = 502)
+ }
+ }
+
+ private fun assertVariantHttpFailureFallsBack(status: Int) {
+ val validLowPlaylist = playlist(FixtureId.HlsVideo360Playlist)
+ server.reset(
+ commonHlsRoutes(
+ lowPlaylist = HermeticTestServer.sequence(
+ HermeticTestServer.text(
+ body = "variant unavailable",
+ status = status,
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ HermeticTestServer.text(
+ body = validLowPlaylist,
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ ),
+ )
+ val probe = prepare("/media/hls/master.m3u8")
+
+ awaitReady(probe)
+ awaitCondition("healthy HLS variant after $status") {
+ playbackState() == Player.STATE_READY &&
+ server.requestJournal.entries.any { it.path == "/media/hls/video_720.m3u8" }
+ }
+ val failedVariant = server.requestJournal.entries.filter {
+ it.path == "/media/hls/video_360.m3u8"
+ }
+ assertEquals(1, failedVariant.size)
+ assertEquals(
+ status,
+ (failedVariant.single().outcome as ResponseOutcome.Completed).status,
+ )
+ assertTrue(server.requestJournal.entries.any { it.path == "/media/hls/video_720.m3u8" })
+ assertTrue(probe.error.get() == null)
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ }
+
+ @Test
+ fun hlsDelayedAndInterruptedSegment_enterBufferingThenRecover() = run {
+ val gate = CountDownLatch(1)
+ lateinit var delayedProbe: PlayerProbe
+ lateinit var recoveryProbe: PlayerProbe
+
+ step("Delay the first HLS segment and observe BUFFERING") {
+ server.reset(
+ commonHlsRoutes(
+ qualityLowMasterBody = singleVariantMaster("video_360.m3u8"),
+ video360Playlist = HermeticTestServer.text(
+ body = playlist(FixtureId.HlsVideo360Playlist),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ extraRoutes = listOf(
+ server.route(
+ id = "delayed-segment",
+ path = "/media/hls/video_360_000.ts",
+ response = HermeticTestServer.delayed(
+ gate = gate,
+ body = fixtureBytes("hls/video_360_000.ts"),
+ contentType = "video/mp2t",
+ ),
+ ),
+ ),
+ ),
+ )
+ delayedProbe = prepare("/media/hls/quality-low.m3u8")
+ awaitCondition("delayed segment enters BUFFERING") {
+ delayedProbe.stateEvents.contains(Player.STATE_BUFFERING) ||
+ playbackState() == Player.STATE_BUFFERING
+ }
+ }
+
+ step("Release the delayed segment and recover to READY") {
+ gate.countDown()
+ awaitReady(delayedProbe)
+ assertTrue(delayedProbe.error.get() == null)
+ }
+
+ step("Replace the source with a truncated-then-ranged recovery route") {
+ releasePlayerAndReset()
+ val segment = fixtureBytes("hls/video_360_000.ts")
+ server.reset(
+ commonHlsRoutes(
+ qualityLowMasterBody = singleVariantMaster("video_360_recovery.m3u8"),
+ extraRoutes = listOf(
+ server.route(
+ id = "recovery-master",
+ path = "/media/hls/recovery.m3u8",
+ response = HermeticTestServer.text(
+ body = singleVariantMaster("video_360_recovery.m3u8"),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = "recovering-playlist",
+ path = "/media/hls/video_360_recovery.m3u8",
+ response = HermeticTestServer.text(
+ body = playlist(FixtureId.HlsVideo360Playlist)
+ .replace("video_360_", "video_360_recovery_"),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = "recovering-segment",
+ path = "/media/hls/video_360_recovery_000.ts",
+ response = HermeticTestServer.sequence(
+ HermeticTestServer.truncated(
+ body = segment,
+ bytesToWrite = segment.size / 4,
+ contentType = "video/mp2t",
+ ),
+ HermeticTestServer.bytes(segment, contentType = "video/mp2t"),
+ ),
+ ),
+ server.route(
+ id = "recovering-segment-1",
+ path = "/media/hls/video_360_recovery_001.ts",
+ response = HermeticTestServer.bytes(
+ fixtureBytes("hls/video_360_001.ts"),
+ contentType = "video/mp2t",
+ ),
+ ),
+ ),
+ ),
+ )
+ recoveryProbe = prepare("/media/hls/recovery.m3u8")
+ }
+
+ step("Recover the interrupted segment with a ranged second request") {
+ awaitReady(recoveryProbe)
+ val recoveryEntries = server.requestJournal.entries.filter {
+ it.routeId == "recovering-segment"
+ }
+ assertTrue(
+ "Expected interrupted segment recovery; entries=$recoveryEntries",
+ recoveryEntries.size >= 2,
+ )
+ assertEquals(200, (recoveryEntries[0].outcome as ResponseOutcome.Completed).status)
+ assertEquals(200, (recoveryEntries[1].outcome as ResponseOutcome.Completed).status)
+ assertTrue(
+ "Expected a ranged recovery request; entries=$recoveryEntries",
+ recoveryEntries[1].range != null,
+ )
+ assertTrue(recoveryProbe.error.get() == null)
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ }
+ }
+
+ @Test
+ fun playerMediaNetworkIsolation_rejectsExternalRedirectAndSanitizesViolation() = run {
+ lateinit var probe: PlayerProbe
+
+ step("Prepare an HLS redirect to a blocked external origin") {
+ server.reset(
+ listOf(
+ server.route(
+ id = "external-redirect",
+ path = "/media/redirect.m3u8",
+ response = HermeticTestServer.redirect(
+ "https://redirect-user:redirect-password@example.com/blocked" +
+ "?token=must-not-be-recorded",
+ ),
+ ),
+ ),
+ )
+ probe = prepare("/media/redirect.m3u8")
+ }
+
+ step("Fail the external redirect locally and journal one violation") {
+ awaitCondition("external redirect fails locally") { probe.error.get() != null }
+ awaitCondition("one external violation is journaled") {
+ LoopbackNetworkJournal(context).snapshot().size == 1
+ }
+ }
+
+ try {
+ step("Sanitize credentials, query data, and user info from the violation") {
+ val violations = LoopbackNetworkJournal(context).snapshot()
+ assertEquals(1, violations.size)
+ assertTrue(violations.single().startsWith("https://example.com:443"))
+ assertFalse(violations.single().contains("redirect-user"))
+ assertFalse(violations.single().contains("redirect-password"))
+ assertFalse(violations.single().contains("token"))
+ assertFalse(violations.single().contains("?"))
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ }
+ } finally {
+ LoopbackNetworkJournal(context).clear()
+ }
+ }
+
+ private fun runPlaylistFailure(path: String, response: ResponsePlan) {
+ releasePlayerAndReset()
+ server.reset(
+ listOf(
+ server.route(id = "fault", path = path, response = response),
+ ),
+ )
+ val probe = prepare(path)
+ awaitCondition("playlist failure $path") { probe.error.get() != null }
+ val requests = server.requestJournal.entries.filter { it.path == path }
+ assertTrue(
+ "Expected bounded retry count for $path, got ${requests.size}",
+ requests.size in 1..6,
+ )
+ assertTrue(probe.error.get() is PlaybackException)
+ assertEquals(0, server.requestJournal.unknownRequests.size)
+ }
+
+ private fun prepare(
+ path: String,
+ subtitles: List? = null,
+ ): PlayerProbe {
+ val probe = PlayerProbe()
+ val streamUrl = loopbackUrl(path)
+ scenario.onActivity {
+ controller.prepare(
+ streamUrl = streamUrl,
+ subtitles = subtitles,
+ startPosition = 0L,
+ bufferPreset = BufferPreset.SMALL,
+ fastDns = false,
+ )
+ val player = checkNotNull(controller.player)
+ player.addListener(probe)
+ playerView.player = player
+ }
+ return probe
+ }
+
+ private fun releasePlayerAndReset() {
+ scenario.onActivity {
+ playerView.player = null
+ controller.release()
+ }
+ server.awaitQuiescence(timeout = 10, unit = TimeUnit.SECONDS)
+ }
+
+ private fun awaitReady(probe: PlayerProbe) {
+ awaitCondition("READY") {
+ playbackState() == Player.STATE_READY
+ }
+ assertTrue(probe.error.get() == null)
+ }
+
+ private fun currentPosition(): Long = playerRead { currentPosition }
+
+ private fun isPlaying(): Boolean = playerRead { isPlaying }
+
+ private fun playbackIntent(): PlaybackIntent = playerRead { playbackIntent }
+
+ private fun playbackState(): Int = playerRead { player?.playbackState ?: Player.STATE_IDLE }
+
+ private fun audioTracks(): List {
+ val value = AtomicReference>()
+ scenario.onActivity {
+ value.set(
+ controller.player?.currentTracks?.groups
+ ?.filter { it.type == C.TRACK_TYPE_AUDIO }
+ ?.mapIndexed { index, group ->
+ val format = group.getTrackFormat(0)
+ AudioTrackUIState(
+ index = index,
+ label = format.label.orEmpty(),
+ language = format.language.orEmpty(),
+ )
+ }
+ .orEmpty(),
+ )
+ }
+ return value.get().orEmpty()
+ }
+
+ private fun selectedAudioLanguage(): String? = playerRead {
+ player?.currentTracks?.groups
+ ?.filter { it.type == C.TRACK_TYPE_AUDIO }
+ ?.firstOrNull { it.isSelected }
+ ?.getTrackFormat(0)
+ ?.language
+ }
+
+ private fun selectedTextTrack(): Boolean = playerRead {
+ player?.currentTracks?.groups
+ ?.filter { it.type == C.TRACK_TYPE_TEXT }
+ ?.any { it.isSelected } == true
+ }
+
+ private fun textTracksDisabled(): Boolean = playerRead {
+ player?.trackSelectionParameters?.disabledTrackTypes?.contains(C.TRACK_TYPE_TEXT) == true
+ }
+
+ private fun currentMediaPath(): String? = playerRead {
+ player?.currentMediaItem?.localConfiguration?.uri?.encodedPath
+ }
+
+ private fun currentCueTexts(): List = playerRead {
+ player?.currentCues?.cues?.mapNotNull { it.text?.toString() }.orEmpty()
+ }
+
+ private fun playerRead(read: PlaybackController.() -> T): T {
+ val value = AtomicReference()
+ scenario.onActivity { value.set(read(controller)) }
+ return value.get()
+ }
+
+ private fun runOnPlayer(block: () -> Unit) {
+ scenario.onActivity { block() }
+ }
+
+ private fun awaitCondition(label: String, condition: () -> Boolean) {
+ val reached = CountDownLatch(1)
+ val result = AtomicBoolean(false)
+ val handler = Handler(Looper.getMainLooper())
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(PLAYER_TIMEOUT_SECONDS)
+ lateinit var check: Runnable
+ check = Runnable {
+ if (condition()) {
+ result.set(true)
+ reached.countDown()
+ } else if (System.nanoTime() < deadline) {
+ handler.postDelayed(check, CONDITION_POLL_MS)
+ } else {
+ reached.countDown()
+ }
+ }
+ handler.post(check)
+ assertTrue("$label timed out; diagnostics=${diagnostics()}", reached.await(
+ PLAYER_TIMEOUT_SECONDS + 1,
+ TimeUnit.SECONDS,
+ ))
+ handler.removeCallbacks(check)
+ assertTrue("$label was not reached; diagnostics=${diagnostics()}", result.get())
+ }
+
+ private fun diagnostics(): String {
+ val playerState = playerRead {
+ val player = controller.player
+ val debug = controller.getDebugInfo()
+ "state=${player?.playbackState},isPlaying=${player?.isPlaying}," +
+ "position=${player?.currentPosition},duration=${player?.duration}," +
+ "formats=${debug?.videoResolution}/${debug?.videoCodec}/${debug?.audioCodec}"
+ }
+ return "$playerState,requests=${server.requestJournal.entries.takeLast(12)}"
+ }
+
+ private fun loopbackUrl(path: String): String =
+ server.url(path).replace("http://localhost:", "http://127.0.0.1:")
+
+ private fun commonHlsRoutes(
+ masterBody: String = fixtureText(FixtureId.HlsMaster),
+ qualityLowMasterBody: String = singleVariantMaster("video_360.m3u8"),
+ lowPlaylist: ResponsePlan = HermeticTestServer.text(
+ body = playlist(FixtureId.HlsVideo360Playlist),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ highPlaylist: ResponsePlan = HermeticTestServer.text(
+ body = playlist(FixtureId.HlsVideo720Playlist),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ video360Playlist: ResponsePlan? = null,
+ extraRoutes: List = emptyList(),
+ ): List {
+ val routes = mutableListOf(
+ server.route(
+ id = "progressive",
+ path = "/media/progressive.mp4",
+ response = ResponsePlan.Range(
+ body = PlayerTestFixtures.readBytes(FixtureId.ProgressiveMp4, context),
+ contentType = "video/mp4",
+ ),
+ ),
+ server.route(
+ id = "progressive-a",
+ path = "/media/progressive-a.mp4",
+ response = ResponsePlan.Range(
+ body = PlayerTestFixtures.readBytes(FixtureId.ProgressiveMp4, context),
+ contentType = "video/mp4",
+ ),
+ ),
+ server.route(
+ id = "progressive-b",
+ path = "/media/progressive-b.mp4",
+ response = ResponsePlan.Range(
+ body = PlayerTestFixtures.readBytes(FixtureId.ProgressiveMp4, context),
+ contentType = "video/mp4",
+ ),
+ ),
+ server.route(
+ id = "master",
+ path = "/media/hls/master.m3u8",
+ response = HermeticTestServer.text(
+ body = masterBody,
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = "quality-low",
+ path = "/media/hls/quality-low.m3u8",
+ response = HermeticTestServer.text(
+ body = qualityLowMasterBody,
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = "quality-high",
+ path = "/media/hls/quality-high.m3u8",
+ response = HermeticTestServer.text(
+ body = singleVariantMaster("video_720.m3u8"),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = "video-360-playlist",
+ path = "/media/hls/video_360.m3u8",
+ response = video360Playlist ?: lowPlaylist,
+ ),
+ server.route(
+ id = "video-720-playlist",
+ path = "/media/hls/video_720.m3u8",
+ response = highPlaylist,
+ ),
+ server.route(
+ id = "audio-english-playlist",
+ path = "/media/hls/audio_english.m3u8",
+ response = HermeticTestServer.text(
+ body = playlist(FixtureId.HlsAudioEnglishPlaylist),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = "audio-spanish-playlist",
+ path = "/media/hls/audio_spanish.m3u8",
+ response = HermeticTestServer.text(
+ body = playlist(FixtureId.HlsAudioSpanishPlaylist),
+ contentType = HLS_CONTENT_TYPE,
+ ),
+ ),
+ server.route(
+ id = "subtitle",
+ path = "/media/subtitle.vtt",
+ queryMode = QueryMatchMode.Contains,
+ response = HermeticTestServer.text(
+ body = fixtureText(FixtureId.SubtitleWebVtt),
+ contentType = "text/vtt",
+ ),
+ ),
+ )
+ listOf(
+ "video_360_000.ts",
+ "video_360_001.ts",
+ "video_720_000.ts",
+ "video_720_001.ts",
+ "audio_english_000.ts",
+ "audio_english_001.ts",
+ "audio_english_002.ts",
+ "audio_spanish_000.ts",
+ "audio_spanish_001.ts",
+ "audio_spanish_002.ts",
+ ).forEach { filename ->
+ routes += server.route(
+ id = filename,
+ path = "/media/hls/$filename",
+ response = HermeticTestServer.bytes(
+ body = PlayerTestFixtures.openPath("hls/$filename", context).use { it.readBytes() },
+ contentType = "video/mp2t",
+ ),
+ )
+ }
+ return extraRoutes + routes
+ }
+
+ private fun fixtureText(fixture: FixtureId): String =
+ PlayerTestFixtures.readBytes(fixture, context).toString(Charsets.UTF_8)
+
+ private fun playlist(fixture: FixtureId): String = fixtureText(fixture)
+
+ private fun fixtureBytes(path: String): ByteArray =
+ PlayerTestFixtures.openPath(path, context).use { it.readBytes() }
+
+ private fun singleVariantMaster(videoPlaylist: String): String =
+ """
+ #EXTM3U
+ #EXT-X-VERSION:3
+ #EXT-X-INDEPENDENT-SEGMENTS
+ #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="audio_english.m3u8"
+ #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Español",LANGUAGE="es",DEFAULT=NO,AUTOSELECT=YES,URI="audio_spanish.m3u8"
+ #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"
+ $videoPlaylist
+ """.trimIndent()
+
+ private class PlayerProbe : Player.Listener {
+ val stateEvents = CopyOnWriteArrayList()
+ val cueTexts = CopyOnWriteArrayList()
+ val error = AtomicReference()
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ stateEvents += playbackState
+ }
+
+ override fun onTracksChanged(tracks: Tracks) = Unit
+
+ override fun onCues(cueGroup: CueGroup) {
+ cueTexts += cueGroup.cues.mapNotNull { it.text?.toString() }
+ }
+
+ override fun onCues(cues: List) {
+ cueTexts += cues.mapNotNull { it.text?.toString() }
+ }
+
+ override fun onPlayerError(error: PlaybackException) {
+ this.error.set(error)
+ }
+ }
+
+ private companion object {
+ const val HLS_CONTENT_TYPE = "application/vnd.apple.mpegurl"
+ const val MAX_CACHE_BYTES = 32L * 1024L * 1024L
+ const val PLAYER_TIMEOUT_SECONDS = 20L
+ const val CONDITION_POLL_MS = 25L
+ const val MAX_DIAGNOSTIC_REQUESTS = 8
+ }
+}
diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerResourcesTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerResourcesTest.kt
new file mode 100644
index 00000000..6f07f47c
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerResourcesTest.kt
@@ -0,0 +1,826 @@
+package com.kino.puber.ui.feature.player.vm
+
+import android.media.MediaCodecList
+import android.view.ViewGroup
+import androidx.activity.ComponentActivity
+import androidx.lifecycle.Lifecycle
+import androidx.media3.common.C
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.analytics.AnalyticsListener
+import androidx.media3.exoplayer.source.LoadEventInfo
+import androidx.media3.exoplayer.source.MediaLoadData
+import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
+import androidx.media3.datasource.cache.SimpleCache
+import androidx.media3.database.StandaloneDatabaseProvider
+import androidx.media3.ui.PlayerView
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.kino.puber.data.repository.PlayerPreferencesRepository
+import com.kino.puber.playertestfixtures.FixtureId
+import com.kino.puber.playertestfixtures.PlayerTestFixtures
+import com.kino.puber.playertestfixtures.network.LoopbackNetworkJournal
+import com.kino.puber.playertestfixtures.server.HermeticRoute
+import com.kino.puber.playertestfixtures.server.HermeticTestServer
+import com.kino.puber.playertestfixtures.server.ResponsePlan
+import com.kino.puber.profile.PlayerTestControl
+import com.kino.puber.ui.feature.player.PlayerInstrumentationTestCase
+import com.kino.puber.ui.feature.player.model.AudioTrackUIState
+import com.kino.puber.ui.feature.player.model.BufferPreset
+import java.io.File
+import java.io.IOException
+import java.util.UUID
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(UnstableApi::class)
+@RunWith(AndroidJUnit4::class)
+internal class PlaybackControllerResourcesTest : PlayerInstrumentationTestCase() {
+
+ private val context
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
+
+ private lateinit var server: PlayerTestControl
+ private lateinit var cacheDirectory: File
+ private lateinit var cache: SimpleCache
+ private lateinit var controller: PlaybackController
+ private lateinit var scenario: ActivityScenario
+ private lateinit var playerView: PlayerView
+ private var responseGate: CountDownLatch? = null
+
+ @Before
+ fun setUp() {
+ LoopbackNetworkJournal(context).clear()
+ server = PlayerTestControl()
+ server.start()
+
+ cacheDirectory = context.cacheDir.resolve("player-resources-${UUID.randomUUID()}")
+ cache = SimpleCache(
+ cacheDirectory,
+ LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTES),
+ StandaloneDatabaseProvider(context),
+ )
+ controller = PlaybackController(
+ context = context,
+ okHttpClient = okhttp3.OkHttpClient(),
+ mediaCache = cache,
+ playerPreferencesRepository = PlayerPreferencesRepository(context),
+ )
+ scenario = ActivityScenario.launch(ComponentActivity::class.java)
+ attachPlayerView()
+ }
+
+ @After
+ fun tearDown() {
+ responseGate?.countDown()
+ if (::scenario.isInitialized) {
+ scenario.onActivity {
+ playerView.player = null
+ controller.release()
+ }
+ scenario.close()
+ }
+ if (::cache.isInitialized) {
+ cache.release()
+ }
+ if (::server.isInitialized) {
+ try {
+ server.awaitQuiescence(timeout = 10, unit = TimeUnit.SECONDS)
+ server.assertNoUnknownRequests(MAX_DIAGNOSTIC_REQUESTS)
+ } finally {
+ server.close()
+ }
+ }
+ assertTrue(
+ "Owned cache directory was not cleaned: $cacheDirectory",
+ !cacheDirectory.exists() || cacheDirectory.deleteRecursively(),
+ )
+ assertTrue(
+ "Unexpected network egress: ${LoopbackNetworkJournal(context).snapshot()}",
+ LoopbackNetworkJournal(context).snapshot().isEmpty(),
+ )
+ }
+
+ @Test
+ fun progressiveReplay_readsCommittedCacheAfterUpstreamRouteIsRemoved() = run {
+ lateinit var url: String
+ lateinit var first: PlayerProbe
+ lateinit var replay: PlayerProbe
+
+ step("Play the progressive fixture through to committed cache") {
+ server.reset(progressiveRoutes())
+ url = loopbackUrl("/media/progressive.mp4")
+ first = prepare(url)
+ awaitReady(first)
+ await(first.ended, "initial playback ended", first)
+ val cachedBytes = cache.getCachedBytes(url, 0L, C.LENGTH_UNSET.toLong())
+ assertTrue("Expected committed media bytes, got $cachedBytes", cachedBytes > 0L)
+ releaseController(first)
+ }
+
+ step("Replay from cache after removing the upstream route") {
+ server.reset(emptyList())
+ replay = prepare(url)
+ awaitReady(replay)
+ runOnActivity {
+ controller.pause()
+ controller.seekTo(CACHED_REPLAY_SEEK_POSITION_MS)
+ }
+ awaitCondition("cached replay seek crosses the position fence", replay) {
+ playbackState() == Player.STATE_READY &&
+ !isPlaying() &&
+ currentPosition() in CACHED_REPLAY_SEEK_POSITION_MS..
+ CACHED_REPLAY_SEEK_FENCE_END_MS
+ }
+ }
+
+ step("Verify the cached seek is error-free and makes no upstream request") {
+ assertTrue(
+ "Cached replay seek should not request a removed upstream route: " +
+ server.requestJournal.entries,
+ server.requestJournal.entries.isEmpty(),
+ )
+ assertNull("Cached replay seek reported a playback error", replay.error.get())
+ releaseController(replay)
+ assertResourcesQuiescent("cached replay seek", replay)
+ }
+ }
+
+ @Test
+ fun emptyCache_loadsNormally_andCorruptOwnedEntryRecoversWithoutPoisoningNextScenario() = run {
+ val url = loopbackUrl("/media/progressive.mp4")
+ lateinit var corrupt: PlayerProbe
+ lateinit var clean: PlayerProbe
+
+ step("Load an empty cache from the loopback upstream") {
+ server.reset(progressiveRoutes())
+ val miss = prepare(url)
+ awaitReady(miss)
+ assertTrue(
+ "Empty cache should use loopback upstream",
+ server.requestJournal.entries.any { it.path == "/media/progressive.mp4" },
+ )
+ releaseController(miss)
+ }
+
+ step("Handle a corrupt owned cache entry with a controlled outcome") {
+ cache.removeResource(url)
+ writeCorruptCacheEntry(url)
+ server.reset(progressiveRoutes())
+ corrupt = prepare(url)
+ awaitReadyOrError(corrupt)
+ assertTrue(
+ "Corrupt cache must produce a controlled recovery or error: ${diagnostics(corrupt)}",
+ corrupt.ready.count == 0L || corrupt.error.get() != null,
+ )
+ releaseController(corrupt)
+ }
+
+ step("Clean the corrupt entry and prove the next playback is not poisoned") {
+ cache.removeResource(url)
+ assertTrue(cache.getCachedSpans(url).isEmpty())
+ server.reset(progressiveRoutes())
+ clean = prepare(url)
+ awaitReady(clean)
+ assertNull(clean.error.get())
+ assertTrue(
+ "Clean scenario should load after corrupt entry cleanup",
+ server.requestJournal.entries.any { it.path == "/media/progressive.mp4" },
+ )
+ releaseController(clean)
+ assertResourcesQuiescent("clean post-corruption scenario", clean)
+ }
+ }
+
+ @Test
+ fun lifecycle_detachReattach_backgroundForegroundAndRecreation_keepPlayerUsable() = run {
+ lateinit var probe: PlayerProbe
+
+ step("Prepare progressive playback for lifecycle transitions") {
+ server.reset(progressiveRoutes())
+ probe = prepare(loopbackUrl("/media/progressive.mp4"))
+ awaitReady(probe)
+ }
+
+ step("Detach and reattach the PlayerView without losing READY") {
+ runOnActivity { playerView.player = null }
+ assertNull(playerRead { playerView.player })
+ runOnActivity { playerView.player = controller.player }
+ assertNotNull(playerRead { playerView.player })
+ awaitCondition("reattached player remains ready", probe) {
+ playbackState() == Player.STATE_READY
+ }
+ }
+
+ step("Background and foreground the host while retaining the player") {
+ scenario.moveToState(Lifecycle.State.CREATED)
+ scenario.moveToState(Lifecycle.State.RESUMED)
+ attachPlayerView()
+ assertNotNull(playerRead { playerView.player })
+ awaitCondition("foreground player remains ready", probe) {
+ playbackState() == Player.STATE_READY
+ }
+ }
+
+ step("Recreate the activity and restore the PlayerView attachment") {
+ scenario.recreate()
+ attachPlayerView()
+ assertNotNull(playerRead { playerView.player })
+ awaitCondition("recreated activity receives the player", probe) {
+ playbackState() == Player.STATE_READY
+ }
+ assertTrue("Player diagnostics were unexpectedly empty", diagnostics(probe).isNotBlank())
+ }
+
+ step("Release recreated playback with no retained resources") {
+ releaseController(probe)
+ assertResourcesQuiescent("activity recreation", probe)
+ }
+ }
+
+ @Test
+ fun progressiveDecode_reportsMandatoryCapabilitiesDecodersCountersAndAnalytics() = run {
+ lateinit var probe: PlayerProbe
+
+ step("Require H.264 and AAC decoder capabilities on the emulator") {
+ val capabilities = CodecCapabilityReport.capture()
+ assertTrue(
+ "H.264 decoder is mandatory; ${capabilities.boundedSummary()}",
+ capabilities.h264Decoders.isNotEmpty(),
+ )
+ assertTrue(
+ "AAC decoder is mandatory; ${capabilities.boundedSummary()}",
+ capabilities.aacDecoders.isNotEmpty(),
+ )
+ }
+
+ step("Decode the progressive fixture and collect real decoder activity") {
+ server.reset(progressiveRoutes())
+ probe = prepare(loopbackUrl("/media/progressive.mp4"))
+ awaitReady(probe)
+ await(probe.firstFrame, "first frame", probe)
+ awaitCondition("H.264/AAC decoder diagnostics", probe) {
+ val snapshot = decoderSnapshot()
+ snapshot.videoMimeType == MimeTypes.VIDEO_H264 &&
+ snapshot.audioMimeType == MimeTypes.AUDIO_AAC &&
+ snapshot.videoDecoderInitCount > 0 &&
+ snapshot.audioDecoderInitCount > 0 &&
+ snapshot.videoQueuedInputBuffers > 0 &&
+ snapshot.audioQueuedInputBuffers > 0 &&
+ snapshot.renderedVideoBuffers > 0 &&
+ probe.analytics.videoDecoderName.get() != null &&
+ probe.analytics.audioDecoderName.get() != null
+ }
+ }
+
+ step("Verify formats, decoder counters, and bounded analytics") {
+ val snapshot = decoderSnapshot()
+ assertEquals(MimeTypes.VIDEO_H264, snapshot.videoMimeType)
+ assertEquals(MimeTypes.AUDIO_AAC, snapshot.audioMimeType)
+ assertTrue(
+ "Video decoder counters were empty: $snapshot",
+ snapshot.videoQueuedInputBuffers > 0,
+ )
+ assertTrue(
+ "Audio decoder counters were empty: $snapshot",
+ snapshot.audioQueuedInputBuffers > 0,
+ )
+ assertTrue("No video frame was rendered: $snapshot", snapshot.renderedVideoBuffers > 0)
+ assertTrue(
+ "Unexpected analytics load errors: ${probe.analytics.boundedEvents()}",
+ probe.analytics.loadErrors.isEmpty(),
+ )
+ assertTrue(
+ "Unexpected analytics player errors: ${probe.analytics.boundedEvents()}",
+ probe.analytics.playerErrors.isEmpty(),
+ )
+ }
+
+ step("Release decoded playback with no resource ownership retained") {
+ releaseController(probe)
+ assertResourcesQuiescent("mandatory codec diagnostics", probe)
+ }
+ }
+
+ @Test
+ fun releaseDuringBlockedLoad_closesRequestsAndDetachesReleasedView() = run {
+ val gate = CountDownLatch(1)
+ responseGate = gate
+ val url = loopbackUrl("/media/blocked.mp4")
+ lateinit var probe: PlayerProbe
+
+ step("Start a progressive request blocked by the fixture gate") {
+ server.reset(
+ listOf(
+ server.route(
+ id = "blocked",
+ path = "/media/blocked.mp4",
+ response = HermeticTestServer.delayed(
+ gate = gate,
+ body = PlayerTestFixtures.readBytes(FixtureId.ProgressiveMp4, context),
+ contentType = "video/mp4",
+ ),
+ ),
+ ),
+ )
+ probe = prepare(url)
+ awaitCondition("blocked request starts", probe) {
+ server.activeRequestCount > 0
+ }
+ }
+
+ step("Release during the blocked load and detach the PlayerView") {
+ releaseController(probe, awaitRequests = false)
+ gate.countDown()
+ }
+
+ step("Drain the request without stale callbacks or retained resources") {
+ assertResourcesQuiescent("release during blocked load", probe)
+ assertTrue(
+ "Stale callback reported an error: ${probe.error.get()}",
+ probe.error.get() == null,
+ )
+ }
+ }
+
+ @Test
+ fun boundedPreparePlaySeekReleaseLoop_leavesNoActiveRequestsOrOpenSpans() = run {
+ val url = loopbackUrl("/media/progressive.mp4")
+ repeat(STRESS_ITERATIONS) { iteration ->
+ lateinit var probe: PlayerProbe
+
+ step("Prepare stress iteration ${iteration + 1}") {
+ server.reset(progressiveRoutes())
+ probe = prepare(url)
+ awaitReady(probe)
+ }
+
+ step("Play and seek stress iteration ${iteration + 1}") {
+ runOnActivity {
+ controller.pause()
+ controller.seekTo(350L)
+ controller.play()
+ }
+ awaitCondition("stress iteration $iteration advances", probe) {
+ currentPosition() >= 300L
+ }
+ assertNull(probe.error.get())
+ }
+
+ step("Release stress iteration ${iteration + 1} without leaked resources") {
+ releaseController(probe)
+ assertResourcesQuiescent("stress iteration $iteration", probe)
+ }
+ }
+ }
+
+ private fun attachPlayerView() {
+ scenario.onActivity { activity ->
+ playerView = PlayerView(activity).apply {
+ useController = false
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ player = controller.player
+ }
+ activity.setContentView(playerView)
+ }
+ }
+
+ private fun prepare(url: String): PlayerProbe {
+ val probe = PlayerProbe()
+ scenario.onActivity {
+ controller.prepare(
+ streamUrl = url,
+ subtitles = null,
+ startPosition = 0L,
+ bufferPreset = BufferPreset.SMALL,
+ fastDns = false,
+ )
+ val player = checkNotNull(controller.player)
+ player.addListener(probe)
+ player.addAnalyticsListener(probe.analytics)
+ controller.setCallback(probe.callback)
+ playerView.player = player
+ }
+ return probe
+ }
+
+ private fun releaseController(
+ probe: PlayerProbe,
+ awaitRequests: Boolean = true,
+ ) {
+ runOnActivity {
+ playerView.player = null
+ controller.release()
+ probe.callback.markReleased()
+ }
+ assertNull(playerRead { controller.player })
+ assertNull(playerRead { playerView.player })
+ assertFalse(playerRead { playerView.keepScreenOn })
+ if (awaitRequests) {
+ server.awaitQuiescence(timeout = 10, unit = TimeUnit.SECONDS)
+ drainPlayerThread()
+ probe.callback.assertNoCallbacksAfterRelease()
+ }
+ }
+
+ private fun writeCorruptCacheEntry(url: String) {
+ val corruptBytes = "not-a-media-container".toByteArray()
+ val hole = cache.startReadWrite(url, 0L, corruptBytes.size.toLong())
+ try {
+ val file = cache.startFile(url, 0L, corruptBytes.size.toLong())
+ file.outputStream().use { it.write(corruptBytes) }
+ cache.commitFile(file, corruptBytes.size.toLong())
+ } finally {
+ cache.releaseHoleSpan(hole)
+ }
+ assertTrue(cache.getCachedBytes(url, 0L, corruptBytes.size.toLong()) > 0L)
+ }
+
+ private fun progressiveRoutes(): List =
+ listOf(
+ server.route(
+ id = "progressive",
+ path = "/media/progressive.mp4",
+ response = ResponsePlan.Range(
+ body = PlayerTestFixtures.readBytes(FixtureId.ProgressiveMp4, context),
+ contentType = "video/mp4",
+ ),
+ ),
+ )
+
+ private fun awaitReady(probe: PlayerProbe) {
+ await(probe.ready, "READY", probe)
+ assertNull("Unexpected playback error: ${diagnostics(probe)}", probe.error.get())
+ }
+
+ private fun awaitReadyOrError(probe: PlayerProbe) {
+ await(probe.outcome, "corrupt-cache outcome", probe)
+ }
+
+ private fun await(
+ latch: CountDownLatch,
+ label: String,
+ probe: PlayerProbe,
+ ) {
+ assertTrue(
+ "$label timed out; ${diagnostics(probe)}",
+ latch.await(PLAYER_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ )
+ }
+
+ private fun awaitCondition(
+ label: String,
+ probe: PlayerProbe,
+ condition: () -> Boolean,
+ ) {
+ val reached = CountDownLatch(1)
+ val handler = android.os.Handler(android.os.Looper.getMainLooper())
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(PLAYER_TIMEOUT_SECONDS)
+ lateinit var check: Runnable
+ check = Runnable {
+ if (condition()) {
+ reached.countDown()
+ } else if (System.nanoTime() < deadline) {
+ handler.postDelayed(check, CONDITION_POLL_MS)
+ } else {
+ reached.countDown()
+ }
+ }
+ handler.post(check)
+ assertTrue(
+ "$label timed out; ${diagnostics(probe)}",
+ reached.await(PLAYER_TIMEOUT_SECONDS + 1L, TimeUnit.SECONDS),
+ )
+ handler.removeCallbacks(check)
+ assertTrue("$label was not reached; ${diagnostics(probe)}", condition())
+ }
+
+ private fun assertResourcesQuiescent(
+ label: String,
+ probe: PlayerProbe,
+ ) {
+ server.awaitQuiescence(timeout = 10, unit = TimeUnit.SECONDS)
+ drainPlayerThread()
+ probe.callback.assertNoCallbacksAfterRelease()
+ assertEquals("$label left active requests", 0, server.activeRequestCount)
+ assertCacheOwnershipReleased(label)
+ drainPlayerThread()
+ probe.callback.assertNoCallbacksAfterRelease()
+ assertNull("$label retained a controller player", playerRead { controller.player })
+ assertNull("$label retained a PlayerView player", playerRead { playerView.player })
+ assertFalse("$label retained keep-screen-on ownership", playerRead { playerView.keepScreenOn })
+ assertEquals(
+ "$label recorded unexpected egress",
+ emptyList(),
+ LoopbackNetworkJournal(context).snapshot(),
+ )
+ }
+
+ private fun assertCacheOwnershipReleased(label: String) {
+ cache.getKeys().toList().forEach { key ->
+ val lockProbe = awaitCacheWriteLockRelease(key)
+ assertNotNull("$label retained a cache write lock for $key", lockProbe)
+ checkNotNull(lockProbe).let { span ->
+ assertTrue("$label returned a non-hole lock probe for $key: $span", span.isHoleSpan)
+ cache.releaseHoleSpan(span)
+ }
+ cache.removeResource(key)
+ assertTrue("$label recreated cache spans for $key", cache.getCachedSpans(key).isEmpty())
+ }
+ }
+
+ private fun awaitCacheWriteLockRelease(key: String): androidx.media3.datasource.cache.CacheSpan? {
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(CACHE_CLEANUP_TIMEOUT_SECONDS)
+ do {
+ cache.removeResource(key)
+ if (cache.getCachedSpans(key).isEmpty()) {
+ cache.startReadWriteNonBlocking(key, 0L, C.LENGTH_UNSET.toLong())?.let { return it }
+ }
+ } while (
+ System.nanoTime() < deadline &&
+ CountDownLatch(1).await(CACHE_CLEANUP_POLL_MS, TimeUnit.MILLISECONDS).not()
+ )
+ return null
+ }
+
+ private fun drainPlayerThread() {
+ runOnActivity { /* ActivityScenario is a synchronous main-thread fence. */ }
+ }
+
+ private fun diagnostics(probe: PlayerProbe): String {
+ val value = AtomicReference()
+ scenario.onActivity {
+ val player = controller.player
+ val debug = controller.getDebugInfo()
+ val requestTail = server.requestJournal.entries.takeLast(MAX_DIAGNOSTIC_REQUESTS)
+ .map { "${it.method} ${it.path} ${it.range.orEmpty()} ${it.outcome}" }
+ val decoder = decoderSnapshot(player)
+ value.set(
+ "state=${player?.playbackState},isPlaying=${player?.isPlaying}," +
+ "position=${player?.currentPosition},formats=" +
+ "${debug?.videoResolution}/${debug?.videoCodec}/${debug?.audioCodec}," +
+ "decoders=${probe.analytics.decoderSummary()}," +
+ "decoderCounters=$decoder," +
+ "buffered=${debug?.bufferedDuration},error=${probe.error.get()}," +
+ "analytics=${probe.analytics.boundedEvents()}," +
+ "capabilities=${CodecCapabilityReport.capture().boundedSummary()}," +
+ "requests=$requestTail",
+ )
+ }
+ return checkNotNull(value.get())
+ }
+
+ private fun decoderSnapshot(): DecoderSnapshot = playerRead {
+ decoderSnapshot(controller.player)
+ }
+
+ private fun decoderSnapshot(player: androidx.media3.exoplayer.ExoPlayer?): DecoderSnapshot {
+ val videoCounters = player?.videoDecoderCounters?.apply { ensureUpdated() }
+ val audioCounters = player?.audioDecoderCounters?.apply { ensureUpdated() }
+ return DecoderSnapshot(
+ videoMimeType = player?.videoFormat?.sampleMimeType,
+ audioMimeType = player?.audioFormat?.sampleMimeType,
+ videoDecoderInitCount = videoCounters?.decoderInitCount ?: 0,
+ audioDecoderInitCount = audioCounters?.decoderInitCount ?: 0,
+ videoQueuedInputBuffers = videoCounters?.queuedInputBufferCount ?: 0,
+ audioQueuedInputBuffers = audioCounters?.queuedInputBufferCount ?: 0,
+ renderedVideoBuffers = videoCounters?.renderedOutputBufferCount ?: 0,
+ droppedVideoBuffers = videoCounters?.droppedBufferCount ?: 0,
+ )
+ }
+
+ private fun loopbackUrl(path: String): String =
+ server.url(path).replace("http://localhost:", "http://127.0.0.1:")
+
+ private fun runOnActivity(block: () -> Unit) {
+ scenario.onActivity { block() }
+ }
+
+ private fun playerRead(read: () -> T): T {
+ val value = AtomicReference()
+ scenario.onActivity { value.set(read()) }
+ @Suppress("UNCHECKED_CAST")
+ return value.get() as T
+ }
+
+ private fun currentPosition(): Long = playerRead { controller.currentPosition }
+
+ private fun isPlaying(): Boolean = playerRead { controller.player?.isPlaying == true }
+
+ private fun playbackState(): Int =
+ playerRead { controller.player?.playbackState ?: Player.STATE_IDLE }
+
+ private class PlayerProbe : Player.Listener {
+ val ready = CountDownLatch(1)
+ val firstFrame = CountDownLatch(1)
+ val ended = CountDownLatch(1)
+ val outcome = CountDownLatch(1)
+ val error = AtomicReference()
+ val stateEvents = CopyOnWriteArrayList()
+ val callback = CallbackProbe()
+ val analytics = AnalyticsProbe()
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ stateEvents += playbackState
+ when (playbackState) {
+ Player.STATE_READY -> {
+ ready.countDown()
+ outcome.countDown()
+ }
+ Player.STATE_ENDED -> ended.countDown()
+ }
+ }
+
+ override fun onRenderedFirstFrame() {
+ firstFrame.countDown()
+ }
+
+ override fun onPlayerError(error: PlaybackException) {
+ this.error.set(error)
+ outcome.countDown()
+ }
+ }
+
+ private class CallbackProbe : PlaybackControl.Callback {
+ private val released = AtomicBoolean(false)
+ private val eventCount = AtomicInteger()
+ private val countAtRelease = AtomicInteger(-1)
+ private val staleEvents = CopyOnWriteArrayList()
+
+ override fun onPlaybackStateChanged(snapshot: PlaybackSnapshot) {
+ record("state:${snapshot.intent}:${snapshot.isBuffering}:${snapshot.isPlaying}")
+ }
+
+ override fun onPlaybackEnded() {
+ record("ended")
+ }
+
+ override fun onTracksUpdated(audioTracks: List, selectedIndex: Int) {
+ record("tracks:${audioTracks.size}:$selectedIndex")
+ }
+
+ override fun onError(message: String) {
+ record("error")
+ }
+
+ fun markReleased() {
+ countAtRelease.set(eventCount.get())
+ released.set(true)
+ }
+
+ fun assertNoCallbacksAfterRelease() {
+ assertEquals("Callbacks advanced after release: $staleEvents", countAtRelease.get(), eventCount.get())
+ assertTrue("Stale callbacks after release: $staleEvents", staleEvents.isEmpty())
+ }
+
+ private fun record(event: String) {
+ eventCount.incrementAndGet()
+ if (released.get()) {
+ staleEvents += event
+ }
+ }
+ }
+
+ private class AnalyticsProbe : AnalyticsListener {
+ val videoDecoderName = AtomicReference()
+ val audioDecoderName = AtomicReference()
+ val loadErrors = CopyOnWriteArrayList()
+ val playerErrors = CopyOnWriteArrayList()
+ private val events = CopyOnWriteArrayList()
+
+ override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
+ addEvent("state:$state")
+ }
+
+ override fun onVideoDecoderInitialized(
+ eventTime: AnalyticsListener.EventTime,
+ decoderName: String,
+ initializedTimestampMs: Long,
+ initializationDurationMs: Long,
+ ) {
+ videoDecoderName.set(decoderName)
+ addEvent("video-decoder:${decoderName.take(MAX_DECODER_NAME_LENGTH)}")
+ }
+
+ override fun onAudioDecoderInitialized(
+ eventTime: AnalyticsListener.EventTime,
+ decoderName: String,
+ initializedTimestampMs: Long,
+ initializationDurationMs: Long,
+ ) {
+ audioDecoderName.set(decoderName)
+ addEvent("audio-decoder:${decoderName.take(MAX_DECODER_NAME_LENGTH)}")
+ }
+
+ override fun onDroppedVideoFrames(
+ eventTime: AnalyticsListener.EventTime,
+ droppedFrames: Int,
+ elapsedMs: Long,
+ ) {
+ addEvent("dropped:$droppedFrames")
+ }
+
+ override fun onLoadError(
+ eventTime: AnalyticsListener.EventTime,
+ loadEventInfo: LoadEventInfo,
+ mediaLoadData: MediaLoadData,
+ error: IOException,
+ wasCanceled: Boolean,
+ ) {
+ val value = "${error.javaClass.simpleName}:canceled=$wasCanceled"
+ loadErrors += value
+ addEvent("load-error:$value")
+ }
+
+ override fun onPlayerError(
+ eventTime: AnalyticsListener.EventTime,
+ error: PlaybackException,
+ ) {
+ playerErrors += error.errorCodeName
+ addEvent("player-error:${error.errorCodeName}")
+ }
+
+ fun decoderSummary(): String =
+ "video=${videoDecoderName.get()?.take(MAX_DECODER_NAME_LENGTH)}," +
+ "audio=${audioDecoderName.get()?.take(MAX_DECODER_NAME_LENGTH)}"
+
+ fun boundedEvents(): List = events.takeLast(MAX_ANALYTICS_EVENTS)
+
+ private fun addEvent(event: String) {
+ events += event
+ while (events.size > MAX_ANALYTICS_EVENTS) {
+ events.removeAt(0)
+ }
+ }
+ }
+
+ private data class DecoderSnapshot(
+ val videoMimeType: String?,
+ val audioMimeType: String?,
+ val videoDecoderInitCount: Int,
+ val audioDecoderInitCount: Int,
+ val videoQueuedInputBuffers: Int,
+ val audioQueuedInputBuffers: Int,
+ val renderedVideoBuffers: Int,
+ val droppedVideoBuffers: Int,
+ )
+
+ private data class CodecCapabilityReport(
+ val h264Decoders: List,
+ val aacDecoders: List,
+ ) {
+ fun boundedSummary(): String =
+ "H264=${h264Decoders.take(MAX_CAPABILITY_DECODERS)}," +
+ "AAC=${aacDecoders.take(MAX_CAPABILITY_DECODERS)}"
+
+ companion object {
+ fun capture(): CodecCapabilityReport {
+ val decoders = MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos
+ .filterNot { it.isEncoder }
+ return CodecCapabilityReport(
+ h264Decoders = decoders.supporting(MimeTypes.VIDEO_H264),
+ aacDecoders = decoders.supporting(MimeTypes.AUDIO_AAC),
+ )
+ }
+
+ private fun List.supporting(mimeType: String): List =
+ filter { codec ->
+ codec.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }
+ }.map { it.name }.sorted()
+ }
+ }
+
+ private companion object {
+ const val MAX_CACHE_BYTES = 32L * 1024L * 1024L
+ const val PLAYER_TIMEOUT_SECONDS = 20L
+ const val CONDITION_POLL_MS = 25L
+ const val CACHE_CLEANUP_TIMEOUT_SECONDS = 10L
+ const val CACHE_CLEANUP_POLL_MS = 25L
+ const val MAX_DIAGNOSTIC_REQUESTS = 8
+ const val MAX_ANALYTICS_EVENTS = 12
+ const val MAX_CAPABILITY_DECODERS = 4
+ const val MAX_DECODER_NAME_LENGTH = 80
+ const val STRESS_ITERATIONS = 3
+ const val CACHED_REPLAY_SEEK_POSITION_MS = 2_000L
+ const val CACHED_REPLAY_SEEK_FENCE_END_MS = 2_500L
+ }
+}
diff --git a/app/src/instrumentation/java/com/kino/puber/profile/BaselineNetworkBlocker.kt b/app/src/instrumentation/java/com/kino/puber/profile/BaselineNetworkBlocker.kt
index 754804b4..00418667 100644
--- a/app/src/instrumentation/java/com/kino/puber/profile/BaselineNetworkBlocker.kt
+++ b/app/src/instrumentation/java/com/kino/puber/profile/BaselineNetworkBlocker.kt
@@ -1,151 +1,15 @@
package com.kino.puber.profile
import android.content.Context
-import java.io.File
-import java.io.RandomAccessFile
-import java.net.InetSocketAddress
-import java.net.Proxy
-import java.net.ProxySelector
-import java.net.SocketAddress
-import java.net.URI
-import java.nio.charset.StandardCharsets
+import com.kino.puber.playertestfixtures.network.LoopbackNetworkBlocker
+import com.kino.puber.playertestfixtures.network.LoopbackNetworkJournal
internal object BaselineNetworkBlocker {
- @Synchronized
fun install(context: Context, allowedOrigin: String) {
- val normalizedOrigin = allowedOrigin.removeSuffix("/").lowercase()
- val current = installedSelector
- if (current?.allowedOrigin == normalizedOrigin) return
-
- val journal = BaselineNetworkJournal(context.applicationContext)
- val selector = BlockingProxySelector(normalizedOrigin, journal)
- ProxySelector.setDefault(selector)
- installedSelector = selector
- }
-
- private var installedSelector: BlockingProxySelector? = null
-
- private class BlockingProxySelector(
- val allowedOrigin: String,
- private val journal: BaselineNetworkJournal,
- ) : ProxySelector() {
-
- override fun select(uri: URI): List {
- requireNotNull(uri) { "URI must not be null" }
- return if (origin(uri) == allowedOrigin) {
- listOf(Proxy.NO_PROXY)
- } else {
- journal.record(uri)
- listOf(DENY_PROXY)
- }
- }
-
- override fun connectFailed(uri: URI, sa: SocketAddress?, ioe: java.io.IOException) {
- if (origin(uri) != allowedOrigin) {
- journal.record(uri)
- }
- }
-
- private fun origin(uri: URI): String {
- val scheme = uri.scheme.orEmpty().lowercase()
- val host = uri.host.orEmpty().lowercase()
- val port = uri.port.takeIf { it >= 0 } ?: defaultPort(scheme)
- return "$scheme://$host:$port"
- }
-
- private fun defaultPort(scheme: String): Int = when (scheme) {
- "http" -> HTTP_PORT
- "https" -> HTTPS_PORT
- else -> -1
- }
+ LoopbackNetworkBlocker.install(context, allowedOrigin)
}
- private const val HTTP_PORT = 80
- private const val HTTPS_PORT = 443
- private const val DENY_PROXY_PORT = 9
- private val DENY_PROXY = Proxy(
- Proxy.Type.HTTP,
- InetSocketAddress.createUnresolved("127.0.0.1", DENY_PROXY_PORT),
- )
-}
-
-internal class BaselineNetworkJournal(context: Context) {
- private val directory = File(context.noBackupFilesDir, DIRECTORY)
- private val journalFile = File(directory, JOURNAL_FILE)
- private val lockFile = File(directory, LOCK_FILE)
-
- init {
- check(directory.isDirectory || directory.mkdirs()) {
- "Failed to create baseline network journal directory"
- }
- }
-
- fun record(uri: URI) = withFileLock {
- val violation = normalizedViolation(uri)
- val current = readEntries()
- if (violation !in current) {
- writeEntries((current + violation).takeLast(MAX_ENTRIES))
- }
- }
-
- fun snapshot(): List = withFileLock(::readEntries)
-
- fun clear() = withFileLock {
- writeEntries(emptyList())
- }
-
- private fun readEntries(): List {
- if (!journalFile.isFile) return emptyList()
- return journalFile.readLines(StandardCharsets.UTF_8)
- .filter(String::isNotBlank)
- .takeLast(MAX_ENTRIES)
- }
-
- private fun writeEntries(entries: List) {
- val payload = entries.joinToString(ENTRY_SEPARATOR)
- .toByteArray(StandardCharsets.UTF_8)
- RandomAccessFile(journalFile, "rw").use { file ->
- file.setLength(0)
- file.write(payload)
- file.fd.sync()
- }
- }
-
- private fun normalizedViolation(uri: URI): String {
- val scheme = uri.scheme.orEmpty().lowercase()
- val host = uri.host.orEmpty().lowercase()
- val port = uri.port.takeIf { it >= 0 } ?: when (scheme) {
- "http" -> HTTP_PORT
- "https" -> HTTPS_PORT
- else -> -1
- }
- val path = uri.rawPath.orEmpty()
- .ifBlank { "/" }
- .replace('\n', '_')
- .replace('\r', '_')
- return "$scheme://$host:$port$path".take(MAX_ENTRY_LENGTH)
- }
-
- private inline fun withFileLock(block: () -> T): T =
- synchronized(PROCESS_LOCK) {
- RandomAccessFile(lockFile, "rw").use { lockAccess ->
- lockAccess.channel.lock().use {
- block()
- }
- }
- }
-
- private companion object {
- const val HTTP_PORT = 80
- const val HTTPS_PORT = 443
- const val DIRECTORY = "baseline_network_journal"
- const val JOURNAL_FILE = "violations"
- const val LOCK_FILE = "journal.lock"
- const val ENTRY_SEPARATOR = "\n"
- const val MAX_ENTRIES = 64
- const val MAX_ENTRY_LENGTH = 512
-
- val PROCESS_LOCK = Any()
- }
+ fun journal(context: Context): LoopbackNetworkJournal =
+ LoopbackNetworkJournal(context.applicationContext)
}
diff --git a/app/src/instrumentation/java/com/kino/puber/profile/BaselineProfileControlReceiver.kt b/app/src/instrumentation/java/com/kino/puber/profile/BaselineProfileControlReceiver.kt
index 763623ee..65acbc63 100644
--- a/app/src/instrumentation/java/com/kino/puber/profile/BaselineProfileControlReceiver.kt
+++ b/app/src/instrumentation/java/com/kino/puber/profile/BaselineProfileControlReceiver.kt
@@ -8,7 +8,7 @@ import android.content.Intent
internal class BaselineProfileControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
- val journal = BaselineNetworkJournal(context)
+ val journal = BaselineNetworkBlocker.journal(context)
when (intent.action) {
ACTION_CLEAR -> {
journal.clear()
diff --git a/app/src/instrumentation/java/com/kino/puber/profile/PlayerTestControl.kt b/app/src/instrumentation/java/com/kino/puber/profile/PlayerTestControl.kt
new file mode 100644
index 00000000..f3e38042
--- /dev/null
+++ b/app/src/instrumentation/java/com/kino/puber/profile/PlayerTestControl.kt
@@ -0,0 +1,100 @@
+package com.kino.puber.profile
+
+import com.kino.puber.BuildConfig
+import com.kino.puber.playertestfixtures.server.HermeticRequestJournal
+import com.kino.puber.playertestfixtures.server.HermeticRoute
+import com.kino.puber.playertestfixtures.server.HermeticTestServer
+import com.kino.puber.playertestfixtures.server.QueryMatchMode
+import com.kino.puber.playertestfixtures.server.ResponsePlan
+import java.io.Closeable
+import java.util.concurrent.TimeUnit
+import mockwebserver3.RecordedRequest
+
+/**
+ * Instrumentation-owned lifecycle boundary for the common hermetic player
+ * server. Android tests may define routes and inspect bounded evidence, but
+ * they do not create or own the underlying server.
+ */
+internal class PlayerTestControl(
+ port: Int = BuildConfig.BASELINE_MOCK_PORT,
+) : Closeable {
+
+ private val server = HermeticTestServer(port = port)
+
+ val requestJournal: HermeticRequestJournal.Snapshot
+ get() = server.requestJournal
+
+ val activeRequestCount: Int
+ get() = server.activeRequestCount
+
+ fun start(initialRoutes: List = emptyList()) {
+ server.start()
+ server.reset(initialRoutes)
+ }
+
+ fun reset(routes: List) {
+ server.reset(routes)
+ }
+
+ fun route(
+ id: String,
+ method: String = "GET",
+ path: String,
+ query: Map = emptyMap(),
+ queryMode: QueryMatchMode = QueryMatchMode.Exact,
+ response: ResponsePlan,
+ required: Boolean = false,
+ minimumRequests: Int = 1,
+ ): HermeticRoute = server.route(
+ id = id,
+ method = method,
+ path = path,
+ query = query,
+ queryMode = queryMode,
+ response = response,
+ required = required,
+ minimumRequests = minimumRequests,
+ )
+
+ fun url(path: String, query: Map = emptyMap()): String =
+ server.url(path, query)
+
+ fun takeRequest(
+ timeout: Long,
+ unit: TimeUnit,
+ ): RecordedRequest? = server.takeRequest(timeout, unit)
+
+ fun awaitQuiescence(
+ timeout: Long = DEFAULT_QUIESCENCE_TIMEOUT_SECONDS,
+ unit: TimeUnit = TimeUnit.SECONDS,
+ ) {
+ server.awaitQuiescence(timeout, unit)
+ }
+
+ fun assertRequiredRoutesRequested() {
+ val missing = server.verifyRequiredRoutes()
+ if (missing.isNotEmpty()) {
+ throw IllegalStateException("Required routes were not requested: $missing")
+ }
+ }
+
+ fun assertNoUnknownRequests(
+ maxDiagnostics: Int = DEFAULT_MAX_DIAGNOSTIC_REQUESTS,
+ ) {
+ val unknownRequests = requestJournal.unknownRequests
+ if (unknownRequests.isNotEmpty()) {
+ throw AssertionError(
+ "Unexpected loopback requests: ${unknownRequests.takeLast(maxDiagnostics)}",
+ )
+ }
+ }
+
+ override fun close() {
+ server.close()
+ }
+
+ private companion object {
+ const val DEFAULT_QUIESCENCE_TIMEOUT_SECONDS = 10L
+ const val DEFAULT_MAX_DIAGNOSTIC_REQUESTS = 12
+ }
+}
diff --git a/app/src/main/java/com/kino/puber/core/ui/PuberVM.kt b/app/src/main/java/com/kino/puber/core/ui/PuberVM.kt
index 973a15c3..4a58c83e 100644
--- a/app/src/main/java/com/kino/puber/core/ui/PuberVM.kt
+++ b/app/src/main/java/com/kino/puber/core/ui/PuberVM.kt
@@ -27,9 +27,11 @@ import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.CoroutineContext
-abstract class PuberVM(protected val router: AppRouter) : ViewModel(),
+abstract class PuberVM(router: AppRouter) : ViewModel(),
BackButtonDispatcher {
+ protected var router: AppRouter = router
+ private set
protected open val errorHandler: ErrorHandler? = null
protected abstract val initialViewState: ViewState
private val mutableViewState: MutableStateFlow by lazy {
@@ -134,22 +136,26 @@ abstract class PuberVM(protected val router: AppRouter) : ViewModel()
}
@Composable
- fun collectViewState(initial: ViewState = stateValue): State {
+ fun collectViewState(
+ initial: ViewState = stateValue,
+ currentRouter: AppRouter = router,
+ ): State {
ensureStarted()
- DisposableEffect(this@PuberVM) {
- registerBackDispatcher()
+ DisposableEffect(this@PuberVM, currentRouter) {
+ rebindRouter(currentRouter)
+ currentRouter.addBackDispatcher(this@PuberVM)
onDispose {
- removeBackDispatcher()
+ currentRouter.removeBackDispatcher(this@PuberVM)
}
}
return viewState.collectAsStateWithLifecycle(initial)
}
- private fun registerBackDispatcher() {
- router.addBackDispatcher(this)
- }
-
- private fun removeBackDispatcher() {
- router.removeBackDispatcher(this)
+ @VisibleForTesting
+ internal fun rebindRouter(currentRouter: AppRouter) {
+ if (router !== currentRouter) {
+ router.removeBackDispatcher(this)
+ router = currentRouter
+ }
}
}
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/AudioSubtitlesPanel.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/AudioSubtitlesPanel.kt
index d4a56fe9..26485d61 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/AudioSubtitlesPanel.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/AudioSubtitlesPanel.kt
@@ -174,6 +174,7 @@ private fun RowScope.SoundModeColumn(
onItemSelected = onSoundModeSelected,
modifier = Modifier.weight(1f),
firstItemFocusRequester = panelFocusRequester,
+ itemTestTag = { index -> PlayerScreenTestTags.panelItem("sound", index) },
)
}
@@ -193,6 +194,7 @@ private fun RowScope.AudioTrackColumn(
onItemSelected = onAudioTrackSelected,
modifier = Modifier.weight(1f),
firstItemFocusRequester = panelFocusRequester,
+ itemTestTag = { index -> PlayerScreenTestTags.panelItem("audio", index) },
)
}
@@ -211,6 +213,7 @@ private fun RowScope.SubtitleColumn(
onItemSelected = onSubtitleSelected,
modifier = Modifier.weight(1f),
firstItemFocusRequester = panelFocusRequester,
+ itemTestTag = { index -> PlayerScreenTestTags.panelItem("subtitle", index) },
)
}
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/NextEpisodeOverlay.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/NextEpisodeOverlay.kt
index 05b43947..33bea9a4 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/NextEpisodeOverlay.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/NextEpisodeOverlay.kt
@@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
@@ -122,7 +123,9 @@ private fun NextEpisodeButton(
Surface(
onClick = onClick,
- modifier = Modifier.focusRequester(focusRequester),
+ modifier = Modifier
+ .testTag(PlayerScreenTestTags.NextEpisode)
+ .focusRequester(focusRequester),
shape = androidx.tv.material3.ClickableSurfaceDefaults.shape(shape = shape),
colors = androidx.tv.material3.ClickableSurfaceDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f),
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerButtonRow.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerButtonRow.kt
index b04b3ca3..6b09d5a5 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerButtonRow.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerButtonRow.kt
@@ -23,6 +23,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
@@ -92,6 +93,7 @@ private fun PrimaryControls(
PhosphorIcons.Duotone.Eye
},
onClick = actions.onMarkCurrentWatchedClick,
+ modifier = Modifier.testTag(PlayerScreenTestTags.MarkWatched),
selected = state.isCurrentMediaWatched,
enabled = !state.isMarkCurrentWatchedInFlight,
stateDescription = stringResource(
@@ -108,20 +110,26 @@ private fun PrimaryControls(
text = stringResource(R.string.player_button_episodes),
icon = Icons.AutoMirrored.Filled.PlaylistPlay,
onClick = actions.onEpisodesClick,
- modifier = Modifier.focusRequester(focusRequesters.episodesButton),
+ modifier = Modifier
+ .testTag(PlayerScreenTestTags.Episodes)
+ .focusRequester(focusRequesters.episodesButton),
)
}
PlayerButton(
text = stringResource(R.string.player_button_audio_subtitles),
icon = Icons.Default.Subtitles,
onClick = actions.onAudioSubtitlesClick,
- modifier = Modifier.focusRequester(focusRequesters.audioSubtitlesButton),
+ modifier = Modifier
+ .testTag(PlayerScreenTestTags.AudioSubtitles)
+ .focusRequester(focusRequesters.audioSubtitlesButton),
)
PlayerButton(
text = stringResource(R.string.player_button_video),
icon = Icons.Default.Videocam,
onClick = actions.onVideoSettingsClick,
- modifier = Modifier.focusRequester(focusRequesters.videoSettingsButton),
+ modifier = Modifier
+ .testTag(PlayerScreenTestTags.VideoSettings)
+ .focusRequester(focusRequesters.videoSettingsButton),
)
}
}
@@ -134,7 +142,9 @@ private fun PlayPauseButton(
) {
Button(
onClick = onClick,
- modifier = Modifier.focusRequester(focusRequester),
+ modifier = Modifier
+ .testTag(PlayerScreenTestTags.PlayPause)
+ .focusRequester(focusRequester),
colors = transparentButtonColors(),
) {
Icon(
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerControlsOverlay.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerControlsOverlay.kt
index a7821756..4468e7b0 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerControlsOverlay.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerControlsOverlay.kt
@@ -168,6 +168,7 @@ private fun ControlsBottomBar(
onSeekBackward = actions.onSeekBackward,
onTogglePlayPause = actions.onTogglePlayPause,
focusRequester = focusRequesters.seekBar,
+ downFocusRequester = focusRequesters.firstButton,
)
PlayerButtonRow(
state = buttonState,
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerProgressBar.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerProgressBar.kt
index 36780ca1..378485e8 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerProgressBar.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerProgressBar.kt
@@ -30,10 +30,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
@@ -52,6 +54,7 @@ internal fun PlayerProgressBar(
onTogglePlayPause: () -> Unit = {},
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
+ downFocusRequester: FocusRequester? = null,
) {
val progress = remember(currentPosition, duration) { progressOf(currentPosition, duration) }
val bufferedProgress = remember(bufferedPosition, duration) { progressOf(bufferedPosition, duration) }
@@ -83,6 +86,7 @@ internal fun PlayerProgressBar(
shimmerProgress = shimmerProgress,
isBuffering = isBuffering,
focusRequester = focusRequester,
+ downFocusRequester = downFocusRequester,
onSeekForward = onSeekForward,
onSeekBackward = onSeekBackward,
onTogglePlayPause = onTogglePlayPause,
@@ -115,6 +119,7 @@ private fun ProgressBarRow(
shimmerProgress: Float,
isBuffering: Boolean,
focusRequester: FocusRequester,
+ downFocusRequester: FocusRequester?,
onSeekForward: () -> Unit,
onSeekBackward: () -> Unit,
onTogglePlayPause: () -> Unit,
@@ -130,6 +135,7 @@ private fun ProgressBarRow(
shimmerProgress = shimmerProgress,
isBuffering = isBuffering,
focusRequester = focusRequester,
+ downFocusRequester = downFocusRequester,
onSeekForward = onSeekForward,
onSeekBackward = onSeekBackward,
onTogglePlayPause = onTogglePlayPause,
@@ -164,6 +170,7 @@ private fun SeekTrack(
shimmerProgress: Float,
isBuffering: Boolean,
focusRequester: FocusRequester,
+ downFocusRequester: FocusRequester?,
onSeekForward: () -> Unit,
onSeekBackward: () -> Unit,
onTogglePlayPause: () -> Unit,
@@ -172,6 +179,12 @@ private fun SeekTrack(
Box(
modifier = modifier
.height(TRACK_TOUCH_HEIGHT)
+ .testTag(PlayerScreenTestTags.SeekBar)
+ .then(
+ downFocusRequester?.let { target ->
+ Modifier.focusProperties { down = target }
+ } ?: Modifier,
+ )
.focusRequester(focusRequester)
.focusable()
.onKeyEvent { keyEvent ->
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreen.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreen.kt
index 5ce42302..392f5a9f 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreen.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreen.kt
@@ -6,6 +6,8 @@ import androidx.compose.runtime.remember
import androidx.lifecycle.Lifecycle
import cafe.adriel.voyager.core.screen.ScreenKey
import com.kino.puber.core.di.DIScope
+import com.kino.puber.core.di.LocalPuberKoinScope
+import com.kino.puber.core.ui.navigation.AppRouter
import com.kino.puber.core.ui.navigation.FullscreenPuberScreen
import com.kino.puber.core.ui.uikit.component.LifecycleAction
import com.kino.puber.core.ui.uikit.component.ScaffoldMessage
@@ -59,7 +61,8 @@ internal data class PlayerScreen(private val params: PlayerScreenParams) : Fulls
@Composable
override fun Content() = DIScope(scopeName = key, moduleFactory = ::buildModule) {
val vm = puberViewModel()
- val state by vm.collectViewState()
+ val currentRouter = LocalPuberKoinScope.current!!.get()
+ val state by vm.collectViewState(currentRouter = currentRouter)
val onAction: (UIAction) -> Unit = remember(vm) { vm::onAction }
LifecycleAction(
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreenContent.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreenContent.kt
index d70449ef..0f4b57bc 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreenContent.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerScreenContent.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.media3.exoplayer.ExoPlayer
@@ -77,6 +78,7 @@ internal fun PlayerScreenContent(
Box(
modifier = modifier
.fillMaxSize()
+ .testTag(PlayerScreenTestTags.Root)
.background(MaterialTheme.colorScheme.scrim),
) {
when (state) {
@@ -214,6 +216,7 @@ private fun PlayerFocusAnchor(
Box(
modifier = Modifier
.fillMaxSize()
+ .testTag(PlayerScreenTestTags.Surface)
.focusRequester(focusRequester)
.focusable(enabled = isFocusOwner)
.then(
@@ -231,6 +234,22 @@ private fun PlayerFocusAnchor(
)
}
+internal object PlayerScreenTestTags {
+ const val Root = "player_screen"
+ const val Surface = "player_surface"
+ const val PlayPause = "player_play_pause"
+ const val SeekBar = "player_seek_bar"
+ const val MarkWatched = "player_mark_watched"
+ const val Episodes = "player_episodes"
+ const val AudioSubtitles = "player_audio_subtitles"
+ const val VideoSettings = "player_video_settings"
+ const val ResumeContinue = "player_resume_continue"
+ const val Retry = "player_retry"
+ const val NextEpisode = "player_next_episode"
+
+ fun panelItem(group: String, index: Int): String = "player_panel_${group}_$index"
+}
+
private fun handlePlayerKeyEvent(
keyEvent: KeyEvent,
onAction: (UIAction) -> Unit,
@@ -305,7 +324,9 @@ private fun ErrorOverlay(
ErrorButton(
text = stringResource(R.string.player_error_retry),
onClick = onRetry,
- modifier = Modifier.focusRequester(retryFocusRequester),
+ modifier = Modifier
+ .testTag(PlayerScreenTestTags.Retry)
+ .focusRequester(retryFocusRequester),
primary = true,
)
ErrorButton(
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/ResumeDialog.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/ResumeDialog.kt
index e2b06e3f..310d73a7 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/ResumeDialog.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/ResumeDialog.kt
@@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
@@ -123,7 +124,9 @@ private fun ResumeActions(
Row(horizontalArrangement = Arrangement.spacedBy(ACTION_SPACING)) {
Button(
onClick = onResume,
- modifier = Modifier.focusRequester(resumeButtonFocusRequester),
+ modifier = Modifier
+ .testTag(PlayerScreenTestTags.ResumeContinue)
+ .focusRequester(resumeButtonFocusRequester),
) {
Text(text = stringResource(R.string.player_resume_continue))
}
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/SettingsPanelColumn.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/SettingsPanelColumn.kt
index 3e93e55e..90b1250d 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/SettingsPanelColumn.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/SettingsPanelColumn.kt
@@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon
@@ -33,6 +34,7 @@ internal fun SettingsPanelColumn(
onItemSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
firstItemFocusRequester: FocusRequester? = null,
+ itemTestTag: ((Int) -> String)? = null,
) {
val primaryColor = MaterialTheme.colorScheme.primary
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
@@ -58,6 +60,7 @@ internal fun SettingsPanelColumn(
text = item,
selected = index == selectedIndex,
focusRequester = firstItemFocusRequester.takeIf { index == 0 },
+ testTag = itemTestTag?.invoke(index),
colors = SettingsPanelItemColors(
primary = primaryColor,
onSurface = onSurfaceColor,
@@ -79,12 +82,15 @@ private fun SettingsPanelItem(
text: String,
selected: Boolean,
focusRequester: FocusRequester?,
+ testTag: String?,
colors: SettingsPanelItemColors,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
- modifier = focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier,
+ modifier = Modifier
+ .then(testTag?.let { Modifier.testTag(it) } ?: Modifier)
+ .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = colors.primary.copy(alpha = 0.2f),
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/VideoSettingsPanel.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/VideoSettingsPanel.kt
index 423f1cda..3fe9c7dc 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/VideoSettingsPanel.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/VideoSettingsPanel.kt
@@ -183,6 +183,7 @@ private fun ColumnScope.VideoSettingsColumns(
firstItemFocusRequester = panelFocusRequester.takeIf {
initialFocusTarget == VideoSettingsFocusTarget.Speed
},
+ itemTestTag = { index -> PlayerScreenTestTags.panelItem("speed", index) },
)
SettingsPanelColumn(
header = stringResource(R.string.player_panel_aspect_ratio),
@@ -193,6 +194,7 @@ private fun ColumnScope.VideoSettingsColumns(
firstItemFocusRequester = panelFocusRequester.takeIf {
initialFocusTarget == VideoSettingsFocusTarget.AspectRatio
},
+ itemTestTag = { index -> PlayerScreenTestTags.panelItem("aspect", index) },
)
SettingsPanelColumn(
header = stringResource(R.string.player_panel_buffer),
@@ -203,6 +205,7 @@ private fun ColumnScope.VideoSettingsColumns(
firstItemFocusRequester = panelFocusRequester.takeIf {
initialFocusTarget == VideoSettingsFocusTarget.Buffer
},
+ itemTestTag = { index -> PlayerScreenTestTags.panelItem("buffer", index) },
)
}
}
@@ -221,6 +224,7 @@ private fun RowScope.QualityColumn(
onItemSelected = onQualitySelected,
modifier = Modifier.weight(1f),
firstItemFocusRequester = panelFocusRequester,
+ itemTestTag = { index -> PlayerScreenTestTags.panelItem("quality", index) },
)
}
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/vm/Ac3FallbackPolicy.kt b/app/src/main/java/com/kino/puber/ui/feature/player/vm/Ac3FallbackPolicy.kt
new file mode 100644
index 00000000..f0e57fc0
--- /dev/null
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/vm/Ac3FallbackPolicy.kt
@@ -0,0 +1,25 @@
+package com.kino.puber.ui.feature.player.vm
+
+internal class Ac3FallbackPolicy {
+
+ private var fallbackApplied = false
+
+ fun reset() {
+ fallbackApplied = false
+ }
+
+ fun onDecoderInitializationFailure(positionMs: Long): Decision {
+ return if (fallbackApplied) {
+ Decision.Terminal
+ } else {
+ fallbackApplied = true
+ Decision.Retry(positionMs)
+ }
+ }
+
+ sealed interface Decision {
+ data class Retry(val positionMs: Long) : Decision
+
+ data object Terminal : Decision
+ }
+}
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/vm/ControlsStateMachine.kt b/app/src/main/java/com/kino/puber/ui/feature/player/vm/ControlsStateMachine.kt
index 2edc69db..e712900e 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/vm/ControlsStateMachine.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/vm/ControlsStateMachine.kt
@@ -2,6 +2,7 @@ package com.kino.puber.ui.feature.player.vm
import com.kino.puber.ui.feature.player.model.ActivePanel
import com.kino.puber.ui.feature.player.model.FocusTarget
+import com.kino.puber.ui.feature.player.model.PlayerContentState
internal class ControlsStateMachine {
@@ -102,3 +103,13 @@ internal class ControlsStateMachine {
}
}
}
+
+internal fun PlayerContentState.withControlsState(
+ state: ControlsStateMachine.State,
+): PlayerContentState {
+ return copy(
+ controlsVisible = state.controlsVisible,
+ controlsFocusTarget = state.focusTarget,
+ activePanel = state.activePanel,
+ )
+}
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlaybackCallbackGate.kt b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlaybackCallbackGate.kt
new file mode 100644
index 00000000..08427037
--- /dev/null
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlaybackCallbackGate.kt
@@ -0,0 +1,38 @@
+package com.kino.puber.ui.feature.player.vm
+
+internal class PlaybackCallbackGate {
+ class Session internal constructor(internal val generation: Long)
+
+ private val lock = Any()
+ private var callback: PlaybackControl.Callback? = null
+ private var generation = 0L
+
+ fun setCallback(callback: PlaybackControl.Callback) {
+ synchronized(lock) {
+ this.callback = callback
+ }
+ }
+
+ fun beginSession(): Session {
+ return synchronized(lock) {
+ Session(++generation)
+ }
+ }
+
+ fun invalidate() {
+ synchronized(lock) {
+ generation += 1
+ }
+ }
+
+ fun dispatch(
+ session: Session,
+ action: (PlaybackControl.Callback?) -> Unit,
+ ) {
+ synchronized(lock) {
+ if (session.generation == generation) {
+ action(callback)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlaybackController.kt b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlaybackController.kt
index dffb80ac..8d0977b8 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlaybackController.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlaybackController.kt
@@ -2,7 +2,6 @@ package com.kino.puber.ui.feature.player.vm
import android.content.Context
import androidx.annotation.OptIn
-import androidx.core.net.toUri
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Format
@@ -23,7 +22,6 @@ import androidx.media3.exoplayer.source.BehindLiveWindowException
import androidx.media3.extractor.DefaultExtractorsFactory
import okhttp3.OkHttpClient
import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter
@@ -35,7 +33,6 @@ import com.kino.puber.data.repository.PlayerPreferencesRepository
import com.kino.puber.ui.feature.player.model.AudioTrackUIState
import com.kino.puber.ui.feature.player.model.BufferPreset
import com.kino.puber.ui.feature.player.model.SubtitleTrackUIState
-import java.util.Locale
internal interface PlaybackControl {
interface Callback : PlaybackEventSink {
@@ -78,10 +75,13 @@ internal class PlaybackController(
private var exoPlayer: ExoPlayer? = null
private var trackSelector: DefaultTrackSelector? = null
- private var callback: PlaybackControl.Callback? = null
- private var ac3FallbackApplied = false
+ private var playerListener: Player.Listener? = null
+ private var callbackSession: PlaybackCallbackGate.Session? = null
private var useFastDns = true
private var pendingSubtitleTrack: SubtitleTrackUIState? = null
+ private val ac3FallbackPolicy = Ac3FallbackPolicy()
+ private val callbackGate = PlaybackCallbackGate()
+ private val mediaItemFactory = PlaybackMediaItemFactory()
@OptIn(UnstableApi::class)
private val bandwidthMeter = DefaultBandwidthMeter.Builder(context).build()
@@ -97,49 +97,65 @@ internal class PlaybackController(
get() = playbackSnapshot().shouldKeepScreenOn
override val bufferedPosition: Long get() = exoPlayer?.bufferedPosition ?: 0L
- private val playerListener = object : Player.Listener {
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- notifyPlaybackState()
- }
-
- override fun onPlaybackStateChanged(playbackState: Int) {
- exoPlayer?.let { player ->
- PlaybackTransitions.dispatchPlaybackState(
- engine = ExoPlayerPlaybackEngine(player),
- playbackState = playbackState,
- sink = callback,
- )
+ private fun createPlayerListener(session: PlaybackCallbackGate.Session) =
+ object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ notifyPlaybackState(session)
}
- when (playbackState) {
- Player.STATE_READY -> notifyTracksUpdated()
- else -> {}
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ callbackGate.dispatch(session) { callback ->
+ exoPlayer?.let { player ->
+ PlaybackTransitions.dispatchPlaybackState(
+ engine = ExoPlayerPlaybackEngine(player),
+ playbackState = playbackState,
+ sink = callback,
+ )
+ }
+ if (playbackState == Player.STATE_READY) {
+ notifyTracksUpdated(callback)
+ }
+ }
}
- }
- override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
- notifyPlaybackState()
- }
+ override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
+ notifyPlaybackState(session)
+ }
- override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) {
- notifyPlaybackState()
- }
+ override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) {
+ notifyPlaybackState(session)
+ }
- override fun onTracksChanged(tracks: Tracks) {
- notifyTracksUpdated()
- applyPendingSubtitleSelection()
- }
+ override fun onTracksChanged(tracks: Tracks) {
+ callbackGate.dispatch(session) { callback ->
+ notifyTracksUpdated(callback)
+ applyPendingSubtitleSelection()
+ }
+ }
- override fun onPlayerError(error: PlaybackException) {
- val cause = error.cause
- when {
- cause is BehindLiveWindowException -> recoverBehindLiveWindow()
- cause.isAc3DecoderInitializationException() -> disableAc3AndRetry()
- else -> callback?.onError(
- error.localizedMessage ?: context.getString(R.string.player_error_playback)
- )
+ override fun onPlayerError(error: PlaybackException) {
+ callbackGate.dispatch(session) { callback ->
+ val cause = error.cause
+ when {
+ cause is BehindLiveWindowException -> recoverBehindLiveWindow()
+ cause.isAc3DecoderInitializationException() -> when (
+ val decision = ac3FallbackPolicy.onDecoderInitializationFailure(
+ exoPlayer?.currentPosition ?: 0L,
+ )
+ ) {
+ is Ac3FallbackPolicy.Decision.Retry ->
+ disableAc3AndRetry(decision.positionMs)
+ Ac3FallbackPolicy.Decision.Terminal -> callback?.onError(
+ context.getString(R.string.player_error_playback)
+ )
+ }
+ else -> callback?.onError(
+ error.localizedMessage ?: context.getString(R.string.player_error_playback)
+ )
+ }
+ }
}
}
- }
private fun recoverBehindLiveWindow() {
exoPlayer?.let { player ->
@@ -153,7 +169,7 @@ internal class PlaybackController(
}
override fun setCallback(callback: PlaybackControl.Callback) {
- this.callback = callback
+ callbackGate.setCallback(callback)
}
@OptIn(UnstableApi::class)
@@ -165,7 +181,7 @@ internal class PlaybackController(
fastDns: Boolean,
) {
release()
- ac3FallbackApplied = false
+ ac3FallbackPolicy.reset()
useFastDns = fastDns
val bufferParams = DeviceBufferConfig.resolve(context, bufferPreset)
@@ -215,8 +231,8 @@ internal class PlaybackController(
.setHandleAudioBecomingNoisy(true)
.setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true)
.build()
- .apply { addListener(playerListener) }
exoPlayer = player
+ bindCallbackSession(player)
val mediaItem = buildMediaItem(streamUrl, subtitles)
setMediaSource(player, mediaItem, streamUrl)
@@ -245,6 +261,7 @@ internal class PlaybackController(
override fun switchStream(streamUrl: String, subtitles: List?) {
val player = exoPlayer ?: return
+ bindCallbackSession(player)
val engine = ExoPlayerPlaybackEngine(player)
PlaybackTransitions.switchStream(
engine = engine,
@@ -254,6 +271,15 @@ internal class PlaybackController(
notifyPlaybackState()
}
+ private fun bindCallbackSession(player: ExoPlayer) {
+ val session = callbackGate.beginSession()
+ val listener = createPlayerListener(session)
+ playerListener?.let(player::removeListener)
+ player.addListener(listener)
+ callbackSession = session
+ playerListener = listener
+ }
+
override fun play() {
exoPlayer?.let { player ->
PlaybackTransitions.play(ExoPlayerPlaybackEngine(player))
@@ -306,7 +332,10 @@ internal class PlaybackController(
}
override fun release() {
- exoPlayer?.removeListener(playerListener)
+ callbackGate.invalidate()
+ callbackSession = null
+ playerListener?.let { exoPlayer?.removeListener(it) }
+ playerListener = null
exoPlayer?.release()
exoPlayer = null
trackSelector = null
@@ -314,16 +343,9 @@ internal class PlaybackController(
}
@OptIn(UnstableApi::class)
- private fun disableAc3AndRetry() {
- if (ac3FallbackApplied) {
- callback?.onError(context.getString(R.string.player_error_playback))
- return
- }
- ac3FallbackApplied = true
-
+ private fun disableAc3AndRetry(positionMs: Long) {
val player = exoPlayer ?: return
val selector = trackSelector ?: return
- val position = player.currentPosition
player.stop()
@@ -332,39 +354,13 @@ internal class PlaybackController(
.setExceedAudioConstraintsIfNecessary(false)
.build()
- player.seekTo(position)
+ player.seekTo(positionMs)
player.prepare()
player.playWhenReady = true
}
private fun buildMediaItem(streamUrl: String, subtitles: List?): MediaItem {
- val builder = MediaItem.Builder().setUri(streamUrl)
- if (!subtitles.isNullOrEmpty()) {
- val subtitleConfigs = subtitles.map { sub ->
- val stableKey = sub.url.stableSubtitleKey()
- MediaItem.SubtitleConfiguration.Builder(sub.url.toUri())
- .setMimeType(subtitleMimeType(sub.url))
- .setLanguage(sub.lang)
- .setLabel(stableKey)
- .setId(stableKey)
- .build()
- }
- builder.setSubtitleConfigurations(subtitleConfigs)
- }
- return builder.build()
- }
-
- private fun subtitleMimeType(url: String): String {
- val normalizedUrl = url
- .substringBefore('?')
- .substringBefore('#')
- .lowercase(Locale.ROOT)
- return when {
- normalizedUrl.endsWith(".vtt") || normalizedUrl.endsWith(".webvtt") -> MimeTypes.TEXT_VTT
- normalizedUrl.endsWith(".ass") || normalizedUrl.endsWith(".ssa") -> MimeTypes.TEXT_SSA
- normalizedUrl.endsWith(".ttml") || normalizedUrl.endsWith(".xml") -> MimeTypes.APPLICATION_TTML
- else -> MimeTypes.APPLICATION_SUBRIP
- }
+ return mediaItemFactory.build(streamUrl, subtitles)
}
@OptIn(UnstableApi::class)
@@ -388,10 +384,12 @@ internal class PlaybackController(
private fun setMediaSource(player: ExoPlayer, mediaItem: MediaItem, streamUrl: String) {
val dsFactory = dataSourceFactory ?: return
if (streamUrl.contains(".m3u8") || streamUrl.contains("hls")) {
- val hlsSource = HlsMediaSource.Factory(dsFactory)
- .setAllowChunklessPreparation(true)
+ val hlsMediaItem = mediaItem.buildUpon()
+ .setMimeType(MimeTypes.APPLICATION_M3U8)
+ .build()
+ val hlsSource = createMediaSourceFactory(dsFactory)
.setLoadErrorHandlingPolicy(HlsErrorPolicy())
- .createMediaSource(mediaItem)
+ .createMediaSource(hlsMediaItem)
player.setMediaSource(hlsSource)
} else {
player.setMediaItem(mediaItem)
@@ -464,11 +462,17 @@ internal class PlaybackController(
}
private fun notifyPlaybackState() {
- val player = exoPlayer ?: return
- PlaybackTransitions.dispatchPlaybackSnapshot(
- engine = ExoPlayerPlaybackEngine(player),
- sink = callback,
- )
+ callbackSession?.let(::notifyPlaybackState)
+ }
+
+ private fun notifyPlaybackState(session: PlaybackCallbackGate.Session) {
+ callbackGate.dispatch(session) { callback ->
+ val player = exoPlayer ?: return@dispatch
+ PlaybackTransitions.dispatchPlaybackSnapshot(
+ engine = ExoPlayerPlaybackEngine(player),
+ sink = callback,
+ )
+ }
}
private inner class ExoPlayerPlaybackEngine(
@@ -530,7 +534,7 @@ internal class PlaybackController(
}
}
- private fun notifyTracksUpdated() {
+ private fun notifyTracksUpdated(callback: PlaybackControl.Callback?) {
val player = exoPlayer ?: return
val audioGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
if (audioGroups.isEmpty()) return
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlaybackMediaItemFactory.kt b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlaybackMediaItemFactory.kt
new file mode 100644
index 00000000..7b0bb6d3
--- /dev/null
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlaybackMediaItemFactory.kt
@@ -0,0 +1,40 @@
+package com.kino.puber.ui.feature.player.vm
+
+import androidx.core.net.toUri
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MimeTypes
+import com.kino.puber.data.api.models.SubtitleLink
+import java.util.Locale
+
+internal class PlaybackMediaItemFactory {
+
+ fun build(streamUrl: String, subtitles: List?): MediaItem {
+ val builder = MediaItem.Builder().setUri(streamUrl)
+ if (!subtitles.isNullOrEmpty()) {
+ val subtitleConfigs = subtitles.map { subtitle ->
+ val stableKey = subtitle.url.stableSubtitleKey()
+ MediaItem.SubtitleConfiguration.Builder(subtitle.url.toUri())
+ .setMimeType(subtitleMimeType(subtitle.url))
+ .setLanguage(subtitle.lang)
+ .setLabel(stableKey)
+ .setId(stableKey)
+ .build()
+ }
+ builder.setSubtitleConfigurations(subtitleConfigs)
+ }
+ return builder.build()
+ }
+
+ fun subtitleMimeType(url: String): String {
+ val normalizedUrl = url
+ .substringBefore('?')
+ .substringBefore('#')
+ .lowercase(Locale.ROOT)
+ return when {
+ normalizedUrl.endsWith(".vtt") || normalizedUrl.endsWith(".webvtt") -> MimeTypes.TEXT_VTT
+ normalizedUrl.endsWith(".ass") || normalizedUrl.endsWith(".ssa") -> MimeTypes.TEXT_SSA
+ normalizedUrl.endsWith(".ttml") || normalizedUrl.endsWith(".xml") -> MimeTypes.APPLICATION_TTML
+ else -> MimeTypes.APPLICATION_SUBRIP
+ }
+ }
+}
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt
index 098b1f22..c90933a9 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt
@@ -289,7 +289,9 @@ internal class PlayerVM(
isMarkCurrentWatchedInFlight = watchedMutationsInFlight[token.key].orZero() > 0,
)
- publishPreparedContent(contentState, resumeDialog)
+ controlsHideJob?.cancel()
+ controlsStateMachine.initialize(resumeDialogVisible = resumeDialog != null)
+ updateViewState(PlayerViewState.Content(contentState.withControlsState(controlsStateMachine.state)))
autoMarkHandledToken = token.takeIf { resolved.isCurrentMediaWatched }
episodeSwitchInProgress = false
initializePlayer(savedPosition = if (resumeDialog != null) null else 0L)
@@ -298,19 +300,6 @@ internal class PlayerVM(
loadSkipSegments(item, resolved.seasonNumber, resolved.episodeNumber, token)
}
- private fun publishPreparedContent(
- contentState: PlayerContentState,
- resumeDialog: ResumeDialogState?,
- ) {
- controlsHideJob?.cancel()
- controlsStateMachine.initialize(resumeDialogVisible = resumeDialog != null)
- updateViewState(
- PlayerViewState.Content(
- contentState.withControlsState(controlsStateMachine.state),
- ),
- )
- }
-
private fun isCurrentPrepare(generation: Long): Boolean {
return !closing && generation == mediaGeneration
}
@@ -494,12 +483,6 @@ internal class PlayerVM(
processEffects(effects)
}
- private fun showControlsPersistently(focusTarget: FocusTarget) {
- val effects = controlsStateMachine.showControlsPersistently(focusTarget)
- applyControlsState()
- processEffects(effects)
- }
-
private fun hideControls() {
val effects = controlsStateMachine.hideControls()
applyControlsState()
@@ -528,10 +511,7 @@ internal class PlayerVM(
}
private fun applyControlsState() {
- val cs = controlsStateMachine.state
- updateContent {
- withControlsState(cs)
- }
+ updateContent { withControlsState(controlsStateMachine.state) }
}
private fun processEffects(effects: List) {
@@ -999,7 +979,11 @@ internal class PlayerVM(
!content.isMovie &&
content.hasNextEpisode &&
content.nextEpisodeCountdown == null -> startNextEpisodeCountdown()
- !content.isMovie -> showControlsPersistently(FocusTarget.Buttons)
+ !content.isMovie -> {
+ val effects = controlsStateMachine.showControlsPersistently(FocusTarget.Buttons)
+ applyControlsState()
+ processEffects(effects)
+ }
}
}
@@ -1048,16 +1032,6 @@ internal class PlayerVM(
scheduleControlsHide()
}
- private fun PlayerContentState.withControlsState(
- state: ControlsStateMachine.State,
- ): PlayerContentState {
- return copy(
- controlsVisible = state.controlsVisible,
- controlsFocusTarget = state.focusTarget,
- activePanel = state.activePanel,
- )
- }
-
private fun pauseForBackground() {
val wasPlayRequested = playbackController.playbackIntent == PlaybackIntent.PlayRequested
if (wasPlayRequested) {
diff --git a/app/src/test/kotlin/com/kino/puber/data/api/MockWebServerTestSupport.kt b/app/src/test/kotlin/com/kino/puber/data/api/MockWebServerTestSupport.kt
index 67505286..7e1c32d9 100644
--- a/app/src/test/kotlin/com/kino/puber/data/api/MockWebServerTestSupport.kt
+++ b/app/src/test/kotlin/com/kino/puber/data/api/MockWebServerTestSupport.kt
@@ -1,41 +1,39 @@
package com.kino.puber.data.api
-import java.net.InetAddress
-import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
-import mockwebserver3.Dispatcher
import mockwebserver3.MockResponse
import mockwebserver3.MockResponseBody
-import mockwebserver3.MockWebServer
import mockwebserver3.RecordedRequest
+import com.kino.puber.playertestfixtures.server.HermeticTestServer
+import com.kino.puber.playertestfixtures.server.QueryMatchMode
+import com.kino.puber.playertestfixtures.server.ResponsePlan
import okio.Buffer
import okio.BufferedSink
internal class MockWebServerTestSupport : AutoCloseable {
- private val routes = ConcurrentHashMap MockResponse>()
- private val server = MockWebServer().apply {
- dispatcher = object : Dispatcher() {
- override fun dispatch(request: RecordedRequest): MockResponse =
- routes[request.url.encodedPath]?.invoke(request)
- ?: response(
- status = 404,
- body = """{"error":"unexpected test route"}""",
- )
- }
- }
+ private val server = HermeticTestServer()
init {
- server.start(InetAddress.getLoopbackAddress(), 0)
+ server.start()
}
val requestCount: Int
- get() = server.requestCount
+ get() = server.requestJournal.entries.size
- fun url(path: String): String = server.url(path).toString()
+ fun url(path: String): String = server.url(path)
fun route(path: String, handler: (RecordedRequest) -> MockResponse) {
- routes[normalizePath(path)] = handler
+ val normalizedPath = normalizePath(path)
+ server.addRoute(
+ server.route(
+ id = normalizedPath,
+ method = "*",
+ path = normalizedPath,
+ queryMode = QueryMatchMode.Contains,
+ response = ResponsePlan.Dynamic(handler),
+ ),
+ )
}
fun response(status: Int, body: String): MockResponse =
diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/Ac3FallbackPolicyTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/Ac3FallbackPolicyTest.kt
new file mode 100644
index 00000000..0606845f
--- /dev/null
+++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/Ac3FallbackPolicyTest.kt
@@ -0,0 +1,34 @@
+package com.kino.puber.ui.feature.player.vm
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+internal class Ac3FallbackPolicyTest {
+
+ @Test
+ fun decoderFailure_retriesOnceAtCurrentPosition_thenBecomesTerminal() {
+ val policy = Ac3FallbackPolicy()
+
+ assertEquals(
+ Ac3FallbackPolicy.Decision.Retry(positionMs = 137_000L),
+ policy.onDecoderInitializationFailure(positionMs = 137_000L),
+ )
+ assertEquals(
+ Ac3FallbackPolicy.Decision.Terminal,
+ policy.onDecoderInitializationFailure(positionMs = 141_000L),
+ )
+ }
+
+ @Test
+ fun reset_startsANewFallbackWindow() {
+ val policy = Ac3FallbackPolicy()
+ policy.onDecoderInitializationFailure(positionMs = 10_000L)
+
+ policy.reset()
+
+ assertEquals(
+ Ac3FallbackPolicy.Decision.Retry(positionMs = 25_000L),
+ policy.onDecoderInitializationFailure(positionMs = 25_000L),
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/AudioTrackPreferenceResolverTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/AudioTrackPreferenceResolverTest.kt
new file mode 100644
index 00000000..bbb47ae2
--- /dev/null
+++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/AudioTrackPreferenceResolverTest.kt
@@ -0,0 +1,64 @@
+package com.kino.puber.ui.feature.player.vm
+
+import com.kino.puber.ui.feature.player.model.AudioTrackUIState
+import com.kino.puber.ui.feature.player.model.SubtitleTrackUIState
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+internal class AudioTrackPreferenceResolverTest {
+
+ private val resolver = AudioTrackPreferenceResolver()
+
+ @Test
+ fun findAudioTrackIndex_prefersExactThenNormalizedLabel() {
+ val tracks = listOf(
+ AudioTrackUIState(0, "01. Original (ENG)", "eng"),
+ AudioTrackUIState(1, "02. Original (ENG)", "eng"),
+ AudioTrackUIState(2, "03. Многоголосый. Red Head Sound (RUS)", "rus"),
+ )
+
+ assertEquals(1, resolver.findAudioTrackIndex(tracks, "02. Original (ENG)", "eng"))
+ assertEquals(0, resolver.findAudioTrackIndex(tracks, "1. Original (ENG)", "eng"))
+ assertEquals(2, resolver.findAudioTrackIndex(tracks, "99. Многоголосый. Other (RUS)", "rus"))
+ }
+
+ @Test
+ fun findAudioTrackIndex_fallsBackToLanguageAndReturnsNoMatch() {
+ val tracks = listOf(
+ AudioTrackUIState(0, "English", "eng"),
+ AudioTrackUIState(1, "Русский", "rus"),
+ )
+
+ assertEquals(1, resolver.findAudioTrackIndex(tracks, null, "rus"))
+ assertEquals(-1, resolver.findAudioTrackIndex(tracks, null, "deu"))
+ }
+
+ @Test
+ fun findSubtitleTrackIndex_matchesSignedUrlByStablePathBeforeLanguage() {
+ val tracks = listOf(
+ SubtitleTrackUIState(0, "Off", "", ""),
+ SubtitleTrackUIState(1, "Russian", "rus", "https://cdn/subtitles/rus.vtt?token=new"),
+ SubtitleTrackUIState(2, "Russian SDH", "rus", "https://cdn/subtitles/rus-sdh.vtt?token=new"),
+ )
+
+ assertEquals(
+ 1,
+ resolver.findSubtitleTrackIndex(
+ tracks = tracks,
+ preferredLang = "rus",
+ preferredUrl = "https://cdn/subtitles/rus.vtt?token=old#signed",
+ ),
+ )
+ }
+
+ @Test
+ fun findSubtitleTrackIndex_rejectsAmbiguousLanguageWithoutUrl() {
+ val tracks = listOf(
+ SubtitleTrackUIState(0, "Off", "", ""),
+ SubtitleTrackUIState(1, "Russian", "rus", "https://cdn/subtitles/rus.vtt"),
+ SubtitleTrackUIState(2, "Russian SDH", "rus", "https://cdn/subtitles/rus-sdh.vtt"),
+ )
+
+ assertEquals(-1, resolver.findSubtitleTrackIndex(tracks, "rus", null))
+ }
+}
diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/HlsErrorPolicyTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/HlsErrorPolicyTest.kt
new file mode 100644
index 00000000..2981c502
--- /dev/null
+++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/HlsErrorPolicyTest.kt
@@ -0,0 +1,105 @@
+package com.kino.puber.ui.feature.player.vm
+
+import androidx.media3.common.C
+import androidx.media3.common.ParserException
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.HttpDataSource
+import androidx.media3.exoplayer.source.LoadEventInfo
+import androidx.media3.exoplayer.source.MediaLoadData
+import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
+import java.io.FileNotFoundException
+import java.io.IOException
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Test
+
+@OptIn(UnstableApi::class)
+internal class HlsErrorPolicyTest {
+
+ private val policy = HlsErrorPolicy()
+
+ @Test
+ fun retryDelay_isLinearAndCapped_forRetryableErrors() {
+ assertEquals(1_000L, policy.getRetryDelayMsFor(errorInfo(errorCount = 1)))
+ assertEquals(2_000L, policy.getRetryDelayMsFor(errorInfo(errorCount = 2)))
+ assertEquals(5_000L, policy.getRetryDelayMsFor(errorInfo(errorCount = 5)))
+ assertEquals(5_000L, policy.getRetryDelayMsFor(errorInfo(errorCount = 9)))
+ assertEquals(1_000L, policy.getRetryDelayMsFor(errorInfo(invalidResponse(404))))
+ }
+
+ @Test
+ fun retryDelay_isUnset_forParserAndFileNotFoundErrors() {
+ assertEquals(
+ C.TIME_UNSET,
+ policy.getRetryDelayMsFor(errorInfo(exception = ParserException.createForMalformedManifest("bad", null))),
+ )
+ assertEquals(
+ C.TIME_UNSET,
+ policy.getRetryDelayMsFor(errorInfo(exception = FileNotFoundException("missing"))),
+ )
+ }
+
+ @Test
+ fun minimumRetryCount_isFive() {
+ assertEquals(5, policy.getMinimumLoadableRetryCount(C.DATA_TYPE_MEDIA))
+ }
+
+ @Test
+ fun fallback_selectsTrackForBadRequestAndBadGateway_whenTrackIsAvailable() {
+ listOf(400, 502).forEach { status ->
+ val fallback = policy.getFallbackSelectionFor(
+ fallbackOptions(numberOfTracks = 2),
+ errorInfo(exception = invalidResponse(status)),
+ )
+
+ assertEquals(LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK, fallback?.type)
+ assertEquals(60_000L, fallback?.exclusionDurationMs)
+ }
+ }
+
+ @Test
+ fun fallback_isAbsent_whenStatusOrTrackAvailabilityDoesNotQualify() {
+ assertNull(
+ policy.getFallbackSelectionFor(
+ fallbackOptions(numberOfTracks = 2),
+ errorInfo(exception = invalidResponse(404)),
+ ),
+ )
+ assertNull(
+ policy.getFallbackSelectionFor(
+ fallbackOptions(numberOfTracks = 1),
+ errorInfo(exception = invalidResponse(400)),
+ ),
+ )
+ }
+
+ private fun errorInfo(
+ exception: IOException = IOException("transient"),
+ errorCount: Int = 1,
+ ): LoadErrorHandlingPolicy.LoadErrorInfo {
+ return LoadErrorHandlingPolicy.LoadErrorInfo(
+ mockk(),
+ mockk(),
+ exception,
+ errorCount,
+ )
+ }
+
+ private fun fallbackOptions(numberOfTracks: Int) = LoadErrorHandlingPolicy.FallbackOptions(
+ 1,
+ 0,
+ numberOfTracks,
+ 0,
+ )
+
+ private fun invalidResponse(status: Int) = HttpDataSource.InvalidResponseCodeException(
+ status,
+ "failure",
+ null,
+ emptyMap(),
+ mockk(),
+ byteArrayOf(),
+ )
+}
diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerCallbackGenerationTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerCallbackGenerationTest.kt
new file mode 100644
index 00000000..61acd1fa
--- /dev/null
+++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerCallbackGenerationTest.kt
@@ -0,0 +1,175 @@
+package com.kino.puber.ui.feature.player.vm
+
+import android.app.Application
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.TrackSelectionParameters
+import androidx.media3.common.Tracks
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.cache.Cache
+import androidx.media3.exoplayer.ExoPlayer
+import com.kino.puber.data.repository.PlayerPreferencesRepository
+import com.kino.puber.ui.feature.player.model.AudioTrackUIState
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import okhttp3.OkHttpClient
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+internal class PlaybackControllerCallbackGenerationTest {
+
+ @Test
+ fun rapidSwitches_rejectSupersededProductionListenersAndAdmitCurrentCallbacks() {
+ val context = RuntimeEnvironment.getApplication()
+ val player = mockk(relaxed = true)
+ val addedListeners = mutableListOf()
+ val removedListeners = mutableListOf()
+ val selectedStreams = mutableListOf()
+ var currentPosition = C_POSITION_MS
+ var duration = DURATION_MS
+ var bufferedPosition = C_BUFFERED_POSITION_MS
+ var isPlaying = true
+ var playWhenReady = true
+ var playbackState = Player.STATE_READY
+ var playbackSuppressionReason = Player.PLAYBACK_SUPPRESSION_REASON_NONE
+ var trackSelectionParameters = mockk(relaxed = true)
+
+ every { player.addListener(capture(addedListeners)) } just runs
+ every { player.removeListener(capture(removedListeners)) } just runs
+ every { player.setMediaItem(capture(selectedStreams)) } just runs
+ every { player.currentPosition } answers { currentPosition }
+ every { player.duration } answers { duration }
+ every { player.bufferedPosition } answers { bufferedPosition }
+ every { player.isPlaying } answers { isPlaying }
+ every { player.playWhenReady } answers { playWhenReady }
+ every { player.playWhenReady = any() } answers { playWhenReady = firstArg() }
+ every { player.playbackState } answers { playbackState }
+ every { player.playbackSuppressionReason } answers { playbackSuppressionReason }
+ every { player.trackSelectionParameters } answers { trackSelectionParameters }
+ every { player.trackSelectionParameters = any() } answers {
+ trackSelectionParameters = firstArg()
+ }
+ every { player.currentTracks } returns Tracks.EMPTY
+
+ val callback = RecordingCallback()
+ val controller = PlaybackController(
+ context = context,
+ okHttpClient = OkHttpClient(),
+ mediaCache = mockk(relaxed = true),
+ playerPreferencesRepository = PlayerPreferencesRepository(context),
+ )
+ controller.setCallback(callback)
+ controller.setPrivateField("exoPlayer", player)
+ controller.setPrivateField("dataSourceFactory", mockk(relaxed = true))
+
+ controller.switchStream(STREAM_A, subtitles = null)
+ val listenerA = addedListeners.single()
+ controller.switchStream(STREAM_B, subtitles = null)
+ val listenerB = addedListeners.last()
+ controller.switchStream(STREAM_C, subtitles = null)
+ val listenerC = addedListeners.last()
+
+ assertEquals(listOf(STREAM_A, STREAM_B, STREAM_C), selectedStreams.map(::streamUri))
+ assertEquals(listOf(listenerA, listenerB), removedListeners)
+ assertEquals(3, addedListeners.distinct().size)
+
+ callback.clear()
+ listenerC.onPlaybackStateChanged(Player.STATE_READY)
+ val admittedCState = callback.snapshots.single()
+ assertEquals(C_POSITION_MS, admittedCState.position)
+ assertEquals(PlaybackIntent.PlayRequested, admittedCState.intent)
+ assertEquals(false, admittedCState.isBuffering)
+
+ repeat(POST_C_STABILITY_DELIVERIES) {
+ listenerA.onPlayerError(playbackError(STALE_A_ERROR))
+ currentPosition = B_POSITION_MS
+ bufferedPosition = B_BUFFERED_POSITION_MS
+ isPlaying = false
+ playWhenReady = false
+ playbackState = Player.STATE_BUFFERING
+ listenerB.onPlaybackStateChanged(Player.STATE_BUFFERING)
+ currentPosition = C_POSITION_MS
+ bufferedPosition = C_BUFFERED_POSITION_MS
+ isPlaying = true
+ playWhenReady = true
+ playbackState = Player.STATE_READY
+ }
+
+ assertEquals(listOf(admittedCState), callback.snapshots)
+ assertEquals(emptyList(), callback.errors)
+ assertEquals(STREAM_C, streamUri(selectedStreams.last()))
+ assertEquals(C_POSITION_MS, controller.currentPosition)
+ assertEquals(PlaybackIntent.PlayRequested, controller.playbackIntent)
+ assertEquals(true, controller.isPlaying)
+ assertSame(listenerC, addedListeners.last())
+
+ listenerC.onPlaybackStateChanged(Player.STATE_READY)
+ listenerC.onPlayerError(playbackError(CURRENT_C_ERROR))
+
+ assertEquals(listOf(admittedCState, admittedCState), callback.snapshots)
+ assertEquals(listOf(CURRENT_C_ERROR), callback.errors)
+ }
+
+ private fun playbackError(message: String) = PlaybackException(
+ message,
+ null,
+ PlaybackException.ERROR_CODE_UNSPECIFIED,
+ )
+
+ private fun streamUri(mediaItem: MediaItem): String {
+ return mediaItem.localConfiguration?.uri.toString()
+ }
+
+ private fun PlaybackController.setPrivateField(name: String, value: Any) {
+ PlaybackController::class.java.getDeclaredField(name).apply {
+ isAccessible = true
+ set(this@setPrivateField, value)
+ }
+ }
+
+ private class RecordingCallback : PlaybackControl.Callback {
+ val snapshots = mutableListOf()
+ val errors = mutableListOf()
+
+ override fun onPlaybackStateChanged(snapshot: PlaybackSnapshot) {
+ snapshots += snapshot
+ }
+
+ override fun onPlaybackEnded() = Unit
+
+ override fun onTracksUpdated(audioTracks: List, selectedIndex: Int) = Unit
+
+ override fun onError(message: String) {
+ errors += message
+ }
+
+ fun clear() {
+ snapshots.clear()
+ errors.clear()
+ }
+ }
+
+ private companion object {
+ const val STREAM_A = "http://127.0.0.1/quality-a.mp4"
+ const val STREAM_B = "http://127.0.0.1/quality-b.mp4"
+ const val STREAM_C = "http://127.0.0.1/quality-c.mp4"
+ const val STALE_A_ERROR = "Stale A error"
+ const val CURRENT_C_ERROR = "Current C error"
+ const val C_POSITION_MS = 30_000L
+ const val C_BUFFERED_POSITION_MS = 90_000L
+ const val B_POSITION_MS = 5_000L
+ const val B_BUFFERED_POSITION_MS = 10_000L
+ const val DURATION_MS = 2_400_000L
+ const val POST_C_STABILITY_DELIVERIES = 3
+ }
+}
diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackMediaItemFactoryTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackMediaItemFactoryTest.kt
new file mode 100644
index 00000000..60b626b5
--- /dev/null
+++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackMediaItemFactoryTest.kt
@@ -0,0 +1,57 @@
+package com.kino.puber.ui.feature.player.vm
+
+import android.app.Application
+import androidx.media3.common.MimeTypes
+import com.kino.puber.data.api.models.SubtitleLink
+import org.junit.Assert.assertEquals
+import org.junit.runner.RunWith
+import org.junit.Test
+import org.robolectric.annotation.Config
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+internal class PlaybackMediaItemFactoryTest {
+
+ private val factory = PlaybackMediaItemFactory()
+
+ @Test
+ fun build_preservesStreamAndBuildsStableSubtitleConfigurations() {
+ val item = factory.build(
+ streamUrl = "http://127.0.0.1:8080/video.m3u8",
+ subtitles = listOf(
+ SubtitleLink(
+ lang = "rus",
+ url = "http://127.0.0.1:8080/subtitles/rus.vtt?token=secret#cue",
+ ),
+ SubtitleLink(
+ lang = "eng",
+ url = "http://127.0.0.1:8080/subtitles/eng.webvtt?expires=123",
+ ),
+ ),
+ )
+
+ assertEquals("http://127.0.0.1:8080/video.m3u8", item.localConfiguration?.uri.toString())
+ val subtitles = item.localConfiguration?.subtitleConfigurations.orEmpty()
+ assertEquals(listOf("rus", "eng"), subtitles.map { it.language })
+ assertEquals(listOf("rus.vtt", "eng.webvtt"), subtitles.map { it.id.orEmpty() })
+ assertEquals(listOf("rus.vtt", "eng.webvtt"), subtitles.map { it.label })
+ assertEquals(listOf(MimeTypes.TEXT_VTT, MimeTypes.TEXT_VTT), subtitles.map { it.mimeType })
+ assertEquals(
+ listOf(
+ "http://127.0.0.1:8080/subtitles/rus.vtt?token=secret#cue",
+ "http://127.0.0.1:8080/subtitles/eng.webvtt?expires=123",
+ ),
+ subtitles.map { it.uri.toString() },
+ )
+ }
+
+ @Test
+ fun subtitleMimeType_handlesQueryFragmentAndFallbackExtensions() {
+ assertEquals(MimeTypes.TEXT_VTT, factory.subtitleMimeType("https://test/subtitles/a.VTT?sig=1#x"))
+ assertEquals(MimeTypes.TEXT_VTT, factory.subtitleMimeType("https://test/subtitles/a.webvtt"))
+ assertEquals(MimeTypes.TEXT_SSA, factory.subtitleMimeType("https://test/subtitles/a.ssa"))
+ assertEquals(MimeTypes.APPLICATION_TTML, factory.subtitleMimeType("https://test/subtitles/a.XML#cue"))
+ assertEquals(MimeTypes.APPLICATION_SUBRIP, factory.subtitleMimeType("https://test/subtitles/a.txt"))
+ }
+}
diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMCallbackGenerationTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMCallbackGenerationTest.kt
new file mode 100644
index 00000000..fcc75888
--- /dev/null
+++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMCallbackGenerationTest.kt
@@ -0,0 +1,183 @@
+package com.kino.puber.ui.feature.player.vm
+
+import com.kino.puber.data.api.models.SubtitleLink
+import com.kino.puber.ui.feature.player.model.BufferPreset
+import com.kino.puber.ui.feature.player.model.PlayerAction
+import com.kino.puber.ui.feature.player.model.PlayerViewState
+import com.kino.puber.ui.feature.player.model.QualityUIState
+import com.kino.puber.ui.feature.player.model.SubtitleTrackUIState
+import com.kino.puber.util.MainDispatcherExtension
+import io.mockk.coEvery
+import io.mockk.every
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+
+internal class PlayerVMCallbackGenerationTest : PlayerVMTestFixture() {
+
+ companion object {
+ @JvmField
+ @RegisterExtension
+ val mainDispatcher = MainDispatcherExtension()
+
+ private const val POST_C_STABILITY_DELIVERIES = 3
+ }
+
+ @Test
+ fun callbackAfterRelease_doesNotMutateReloadedStateOrLeakMappedError() {
+ val controlledPlayback = ControlledPlaybackControl()
+ playbackController = controlledPlayback
+ val vm = startedVM()
+ val releasedSession = controlledPlayback.currentSession
+
+ controlledPlayback.deliverError(releasedSession, "Mapped host error")
+ assertEquals(
+ PlayerViewState.Error("Mapped host error"),
+ vm.testStateValue,
+ )
+
+ vm.onAction(PlayerAction.RetryPlayback)
+ val reloadedState = vm.testStateValue
+ assertTrue(reloadedState is PlayerViewState.Content)
+
+ controlledPlayback.deliverError(releasedSession, "Stale released error")
+
+ assertEquals(reloadedState, vm.testStateValue)
+ assertEquals(
+ listOf("Mapped host error"),
+ controlledPlayback.deliveredErrors,
+ )
+ }
+
+ @Test
+ fun rapidQualitySwitches_ignoreSupersededCallbacksAfterFinalStreamState() {
+ val controlledPlayback = ControlledPlaybackControl()
+ playbackController = controlledPlayback
+ coEvery { contentStateFactory.build(any(), any(), any(), any(), any(), any()) } returns
+ testContentState.copy(
+ qualities = listOf(
+ QualityUIState(0, "A", 1, 640, 360),
+ QualityUIState(1, "B", 2, 1280, 720),
+ QualityUIState(2, "C", 3, 1920, 1080),
+ ),
+ )
+ every { interactor.selectStreamUrl(any(), any()) } answers {
+ "https://test/${secondArg()}.m3u8"
+ }
+ val vm = startedVM()
+ val sessionA = controlledPlayback.currentSession
+
+ vm.onAction(PlayerAction.SelectQuality(1))
+ val sessionB = controlledPlayback.currentSession
+ vm.onAction(PlayerAction.SelectQuality(2))
+ val sessionC = controlledPlayback.currentSession
+ controlledPlayback.deliverPlaybackState(
+ sessionC,
+ PlaybackSnapshot(
+ isPlaying = true,
+ intent = PlaybackIntent.PlayRequested,
+ shouldKeepScreenOn = true,
+ shouldResumeAfterStreamSwitch = true,
+ isBuffering = false,
+ position = 30_000L,
+ duration = 2_400_000L,
+ buffered = 90_000L,
+ ),
+ )
+ val finalStreamState = contentState(vm)
+ assertEquals(2, finalStreamState.selectedQualityIndex)
+ assertEquals(30_000L, finalStreamState.currentPosition)
+
+ repeat(POST_C_STABILITY_DELIVERIES) {
+ controlledPlayback.deliverError(sessionA, "Stale A error")
+ controlledPlayback.deliverPlaybackState(
+ sessionB,
+ PlaybackSnapshot(
+ isPlaying = false,
+ intent = PlaybackIntent.Paused,
+ shouldKeepScreenOn = false,
+ shouldResumeAfterStreamSwitch = false,
+ isBuffering = true,
+ position = 5_000L,
+ duration = 2_400_000L,
+ buffered = 10_000L,
+ ),
+ )
+ }
+
+ assertEquals(finalStreamState, contentState(vm))
+ assertEquals(listOf("state"), controlledPlayback.deliveredEvents)
+ }
+
+ private class ControlledPlaybackControl : PlaybackControl {
+ private val callbackGate = PlaybackCallbackGate()
+ var currentSession = callbackGate.beginSession()
+ private set
+ val deliveredErrors = mutableListOf()
+ val deliveredEvents = mutableListOf()
+
+ override val currentPosition: Long = 0L
+ override val duration: Long = 2_400_000L
+ override val isPlaying: Boolean = true
+ override val playbackIntent: PlaybackIntent = PlaybackIntent.PlayRequested
+ override val shouldKeepScreenOn: Boolean = true
+ override val bufferedPosition: Long = 0L
+
+ override fun setCallback(callback: PlaybackControl.Callback) {
+ callbackGate.setCallback(callback)
+ }
+
+ override fun prepare(
+ streamUrl: String,
+ subtitles: List?,
+ startPosition: Long?,
+ bufferPreset: BufferPreset,
+ fastDns: Boolean,
+ ) {
+ currentSession = callbackGate.beginSession()
+ }
+
+ override fun switchStream(streamUrl: String, subtitles: List?) {
+ currentSession = callbackGate.beginSession()
+ }
+
+ override fun play() = Unit
+
+ override fun pause() = Unit
+
+ override fun seekTo(positionMs: Long) = Unit
+
+ override fun setSpeed(speed: Float) = Unit
+
+ override fun selectAudioTrack(groupIndex: Int) = Unit
+
+ override fun selectSubtitle(track: SubtitleTrackUIState?) = Unit
+
+ override fun release() {
+ callbackGate.invalidate()
+ }
+
+ fun deliverError(session: PlaybackCallbackGate.Session, message: String) {
+ callbackGate.dispatch(session) { callback ->
+ callback?.onError(message)
+ if (callback != null) {
+ deliveredErrors += message
+ deliveredEvents += "error"
+ }
+ }
+ }
+
+ fun deliverPlaybackState(
+ session: PlaybackCallbackGate.Session,
+ snapshot: PlaybackSnapshot,
+ ) {
+ callbackGate.dispatch(session) { callback ->
+ callback?.onPlaybackStateChanged(snapshot)
+ if (callback != null) {
+ deliveredEvents += "state"
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt
index c7f2bdc4..c4ccc14c 100644
--- a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt
+++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt
@@ -4,6 +4,7 @@ import com.kino.puber.R
import com.kino.puber.core.content.ContentChangeSet
import com.kino.puber.core.content.ContentChangeType
import com.kino.puber.core.error.DefaultErrorHandler
+import com.kino.puber.core.ui.navigation.AppRouter
import com.kino.puber.core.ui.navigation.RESULT_CONTENT_CHANGED
import com.kino.puber.data.api.models.Item
import com.kino.puber.data.api.models.ItemType
@@ -23,6 +24,7 @@ import io.ktor.client.plugins.HttpRequestTimeoutException
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
+import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.NonCancellable
@@ -317,6 +319,22 @@ internal class PlayerVMTest : PlayerVMTestFixture() {
assertEquals(ActivePanel.None, contentState(vm).activePanel)
}
+ @Test
+ fun rebindRouter_routesPanelBackThroughRecreatedFlowRouter() {
+ val previousRouter = router
+ val recreatedRouter = mockk(relaxed = true)
+ val vm = startedVM()
+ vm.onAction(PlayerAction.OpenAudioSubtitlesPanel)
+
+ vm.rebindRouter(recreatedRouter)
+ vm.onBackPressed()
+
+ assertEquals(ActivePanel.None, contentState(vm).activePanel)
+ verify { previousRouter.removeBackDispatcher(vm) }
+ verify { recreatedRouter.addBackDispatcher(vm) }
+ verify(exactly = 0) { previousRouter.addBackDispatcher(vm) }
+ }
+
// endregion
// region Error
diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/SubtitleTrackIdentityTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/SubtitleTrackIdentityTest.kt
new file mode 100644
index 00000000..e229621d
--- /dev/null
+++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/SubtitleTrackIdentityTest.kt
@@ -0,0 +1,33 @@
+package com.kino.puber.ui.feature.player.vm
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+internal class SubtitleTrackIdentityTest {
+
+ @Test
+ fun stableSubtitleKey_excludesSignedQueryAndFragment() {
+ assertEquals(
+ "episode-1.vtt",
+ "https://cdn.example/subtitles/episode-1.vtt?token=secret#captions".stableSubtitleKey(),
+ )
+ }
+
+ @Test
+ fun stableSubtitleKey_keepsDistinctSubtitlePaths() {
+ assertEquals(
+ "episode-1.vtt",
+ "https://cdn.example/subtitles/episode-1.vtt?expires=1".stableSubtitleKey(),
+ )
+ assertEquals(
+ "episode-1-forced.vtt",
+ "https://cdn.example/subtitles/episode-1-forced.vtt?expires=1".stableSubtitleKey(),
+ )
+ }
+
+ @Test
+ fun stableSubtitleKey_handlesEmptyAndNonStandardUrls() {
+ assertEquals("", "".stableSubtitleKey())
+ assertEquals("captions.vtt", "captions.vtt?token=1#cue".stableSubtitleKey())
+ }
+}
diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts
index 22186d52..1db0a895 100644
--- a/baselineprofile/build.gradle.kts
+++ b/baselineprofile/build.gradle.kts
@@ -42,4 +42,5 @@ dependencies {
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.test.runner)
implementation(libs.mockwebserver3)
+ implementation(project(":player-test-fixtures"))
}
diff --git a/baselineprofile/src/main/AndroidManifest.xml b/baselineprofile/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..72559ce3
--- /dev/null
+++ b/baselineprofile/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
diff --git a/baselineprofile/src/main/java/com/kino/puber/baselineprofile/BaselineMockBackend.kt b/baselineprofile/src/main/java/com/kino/puber/baselineprofile/BaselineMockBackend.kt
index 7f4f9530..aae38225 100644
--- a/baselineprofile/src/main/java/com/kino/puber/baselineprofile/BaselineMockBackend.kt
+++ b/baselineprofile/src/main/java/com/kino/puber/baselineprofile/BaselineMockBackend.kt
@@ -3,13 +3,14 @@ package com.kino.puber.baselineprofile
import android.content.Context
import java.io.Closeable
import java.util.concurrent.atomic.AtomicLong
-import mockwebserver3.Dispatcher
-import mockwebserver3.MockResponse
-import mockwebserver3.MockWebServer
import mockwebserver3.RecordedRequest
import okhttp3.HttpUrl
import org.json.JSONArray
import org.json.JSONObject
+import com.kino.puber.playertestfixtures.server.HermeticRequestJournal
+import com.kino.puber.playertestfixtures.server.HermeticTestServer
+import com.kino.puber.playertestfixtures.server.QueryMatchMode as HermeticQueryMatchMode
+import com.kino.puber.playertestfixtures.server.ResponsePlan
/**
* A route-based backend for profile and macrobenchmark journeys.
@@ -23,17 +24,15 @@ class BaselineMockBackend(
private val fixtures: BaselineFixtures = BaselineFixtures.synthetic(),
) : Closeable {
- private val server = MockWebServer()
+ private val server = HermeticTestServer(port)
private val generation = AtomicLong(0)
private val lock = Any()
private var activeScenario = BaselineScenario.Startup
private var activeRoutes: List = emptyList()
- private val matchedCounts = linkedMapOf()
- private val unknownRequests = mutableListOf()
private var started = false
val baseUrl: String
- get() = server.url("/").toString()
+ get() = server.baseUrl
val generationId: Long
get() = generation.get()
@@ -45,10 +44,13 @@ class BaselineMockBackend(
val requestJournal: BaselineRequestJournal
get() = synchronized(lock) {
+ val snapshot = server.requestJournal
BaselineRequestJournal(
generationId = generationId,
- matched = matchedCounts.toMap(),
- unknown = unknownRequests.toList(),
+ matched = activeRoutes.associateWith { route ->
+ snapshot.matchedRoutes[route.description] ?: 0
+ },
+ unknown = snapshot.unknownRequests.map(BaselineRequest::from),
)
}
@@ -56,8 +58,7 @@ class BaselineMockBackend(
synchronized(lock) {
check(!started) { "BaselineMockBackend is already started" }
fixtures.validate()
- server.dispatcher = routeDispatcher()
- server.start(port)
+ server.start()
started = true
reset(activeScenario)
}
@@ -82,7 +83,8 @@ class BaselineMockBackend(
while (System.nanoTime() < deadline) {
val observed = synchronized(lock) {
val route = activeRoutes.firstOrNull(::isStartupHomeRoute)
- route != null && (matchedCounts[route] ?: 0) > 0
+ route != null &&
+ (server.requestJournal.matchedRoutes[route.description] ?: 0) > 0
}
if (observed) {
return
@@ -101,27 +103,47 @@ class BaselineMockBackend(
check(started) { "Call start() before reset()" }
activeScenario = scenario
activeRoutes = routesFor(scenario)
- matchedCounts.clear()
- unknownRequests.clear()
+ server.reset(
+ activeRoutes.map { route ->
+ server.route(
+ id = route.description,
+ method = route.method,
+ path = route.path,
+ query = route.query,
+ queryMode = route.queryMode.toHermetic(),
+ response = ResponsePlan.Text(
+ status = 200,
+ body = route.body,
+ contentType = "application/json; charset=utf-8",
+ ),
+ required = route.required,
+ minimumRequests = route.minimumRequests,
+ )
+ },
+ )
generation.incrementAndGet()
}
}
fun verify(): BaselineVerification {
synchronized(lock) {
+ val snapshot = server.requestJournal
+ val matched = activeRoutes.associateWith { route ->
+ snapshot.matchedRoutes[route.description] ?: 0
+ }
val missing = activeRoutes
- .filter { it.required && (matchedCounts[it] ?: 0) < it.minimumRequests }
+ .filter { it.required && (matched[it] ?: 0) < it.minimumRequests }
.map { it.description }
.toMutableList()
if (activeScenario == BaselineScenario.BrowseAndDetails) {
- val detailIds = matchedCounts
+ val detailIds = matched
.filter { (route, count) ->
count > 0 && route.path.startsWith("/v1/items/") &&
route.path.removePrefix("/v1/items/").toIntOrNull() != null
}
.map { it.key.path.removePrefix("/v1/items/").toInt() }
.toSet()
- val similarIds = matchedCounts
+ val similarIds = matched
.filter { (route, count) ->
count > 0 && route.path == "/v1/items/similar"
}
@@ -134,12 +156,12 @@ class BaselineMockBackend(
return BaselineVerification(
generationId = generationId,
scenario = activeScenario,
- matchedRequests = matchedCounts.values.sum(),
- matchedRoutes = matchedCounts
+ matchedRequests = matched.values.sum(),
+ matchedRoutes = matched
.filterValues { it > 0 }
.entries
.associate { (route, count) -> route.description to count },
- unknownRequests = unknownRequests.toList(),
+ unknownRequests = snapshot.unknownRequests.map(BaselineRequest::from),
missingRequiredRoutes = missing,
)
}
@@ -147,9 +169,7 @@ class BaselineMockBackend(
fun url(route: BaselineMockRoute): String {
check(started) { "Call start() before url()" }
- return server.url(route.path).newBuilder().apply {
- route.query.forEach { (name, value) -> addQueryParameter(name, value) }
- }.build().toString()
+ return server.url(route.path, route.query)
}
override fun close() {
@@ -160,23 +180,6 @@ class BaselineMockBackend(
}
}
- private fun routeDispatcher() = object : Dispatcher() {
- override fun dispatch(request: RecordedRequest): MockResponse {
- synchronized(lock) {
- val route = activeRoutes.firstOrNull { it.matches(request) }
- if (route == null) {
- unknownRequests += BaselineRequest.from(request)
- return jsonResponse(
- code = 404,
- body = """{"error":"unknown baseline route"}""",
- )
- }
- matchedCounts[route] = (matchedCounts[route] ?: 0) + 1
- return jsonResponse(body = route.body)
- }
- }
- }
-
private fun routesFor(scenario: BaselineScenario): List {
val routes = mutableListOf()
fun route(
@@ -321,13 +324,6 @@ class BaselineMockBackend(
return routes
}
- private fun jsonResponse(code: Int = 200, body: String): MockResponse =
- MockResponse.Builder()
- .code(code)
- .addHeader("Content-Type", "application/json; charset=utf-8")
- .body(body)
- .build()
-
private companion object {
const val STARTUP_HOME_PATH = "/v1/items/fresh"
const val STARTUP_HOME_SHORTCUT = "fresh"
@@ -393,6 +389,9 @@ data class BaselineRequest(
companion object {
fun from(request: RecordedRequest): BaselineRequest =
BaselineRequest(request.method, request.url.encodedPath)
+
+ fun from(request: HermeticRequestJournal.RequestEntry): BaselineRequest =
+ BaselineRequest(request.method, request.path)
}
}
@@ -649,3 +648,9 @@ private fun JSONObject.optionalString(name: String): String? =
}
private fun List.nullIfEmpty(): List? = takeIf(List::isNotEmpty)
+
+private fun QueryMatchMode.toHermetic(): HermeticQueryMatchMode =
+ when (this) {
+ QueryMatchMode.Exact -> HermeticQueryMatchMode.Exact
+ QueryMatchMode.Contains -> HermeticQueryMatchMode.Contains
+ }
diff --git a/build.gradle.kts b/build.gradle.kts
index ac0d86f1..2af13d26 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,9 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.detekt) apply false
alias(libs.plugins.kotlin.ksp) apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.androidx.baselineprofile) apply false
-}
\ No newline at end of file
+}
diff --git a/docs/testing/player-playback.md b/docs/testing/player-playback.md
new file mode 100644
index 00000000..6c1ab105
--- /dev/null
+++ b/docs/testing/player-playback.md
@@ -0,0 +1,179 @@
+# Player playback test guide
+
+This document describes the hermetic test pyramid for the Puber video player.
+The suite uses synthetic, committed media only and does not require KinoPub
+credentials, authenticated responses, a production backend, or a public
+stream.
+
+## Test layers
+
+| Layer | Entry point | What it proves |
+| --- | --- | --- |
+| Fixture integrity | `:player-test-fixtures:test` and its Android test | The same committed bytes, metadata, playlist references, rendition labels, and WebVTT cue are available to JVM and Android tests. |
+| Host policy/state | `:app:testDevDebugUnitTest` and `:app:testProdDebugUnitTest`, filtered to `com.kino.puber.ui.feature.player.vm.*` | HLS error policy, subtitle MIME and identity, audio/subtitle preference matching, AC3 fallback state, VM transitions, and generation guards. |
+| Technical player instrumentation | `PlaybackControllerDeviceTest`, `PlaybackControllerNetworkTracksTest`, `PlaybackControllerResourcesTest`, and `PlayerVideoSurfaceTest` | The production `PlaybackController`, Media3 player, `PlayerView`, and `PlayerVideoSurface` load loopback MP4/HLS, select tracks/cues, recover from programmed faults, release cleanly, and enforce technical surface/window ownership. |
+| Compose UI instrumentation | `PlayerScreenContentFocusTest` and `PlayerScreenE2ETest` | The production player UI handles concrete TV focus and D-pad/media/back events; the end-to-end class additionally launches the real `PlayerScreen`/Koin scope to prove media, lifecycle progress saves, resume, and series episode identity. |
+
+The instrumentation tests use a per-test `SimpleCache`, unique scenario
+identities, event/latch/player fences instead of `Thread.sleep`, and bounded
+failure diagnostics. Teardown waits for zero active server requests, releases
+the player and surface, removes only test-owned cache state, and fails on
+unknown or external-origin requests.
+
+The four technical classes use `PlayerInstrumentationTestCase` with
+`Kaspresso.Builder.simple()`. `PlayerScreenContentFocusTest` and
+`PlayerScreenE2ETest` use `PlayerComposeInstrumentationTestCase` with
+`Kaspresso.Builder.withComposeSupport()`. `PlayerVideoSurfaceTest` is technical
+`PlayerVideoSurface`/window-ownership coverage, not a production
+`PlayerScreen`/Koin journey.
+
+Every test method directly owns its Kaspresso `run` block and its meaningful
+named `step(...)` narrative. The Compose UI tests use the player screen/robot
+abstractions and minimal stable player-only test tags. Controller, Media3,
+network, cache, lifecycle, codec, request-journal, resource, and surface
+assertions remain direct and event-based inside their owning steps.
+
+## Fixture pack and provenance
+
+The single source of truth is
+`player-test-fixtures/src/main/assets/player-fixtures`. It contains:
+
+- a four-second H.264/AAC progressive MP4;
+- an HLS VOD master with 360p and 720p H.264 variants;
+- English (`en`, `English`) and Spanish (`es`, `Español`) AAC renditions;
+- a WebVTT subtitle with a cue from 1,000 ms through 2,000 ms;
+- the HLS playlists and committed MPEG-TS segments required by those routes.
+
+Expected metadata is in
+`player-test-fixtures/src/main/assets/player-fixtures/fixture-manifest.properties`.
+The complete byte manifest is
+`player-test-fixtures/src/main/assets/player-fixtures/SHA256SUMS`. This
+packaged manifest is also the JVM and Android runtime verification source;
+there is no second generated copy. Generation details and synthetic-input
+provenance are in `player-test-fixtures/fixtures-provenance.md`.
+
+Regeneration is a maintainer-only action. It requires `ffmpeg 9.0.1`, uses
+only `lavfi` test-pattern and sine-wave inputs, and is deliberately not part
+of Gradle or test execution:
+
+```sh
+FFMPEG_BIN=/path/to/ffmpeg tools/generate-player-test-fixtures.sh --force
+(cd player-test-fixtures/src/main/assets/player-fixtures &&
+ shasum -a 256 -c SHA256SUMS)
+```
+
+The generator refuses another ffmpeg version and refuses output outside the
+fixture directory. A normal test run verifies the committed hashes instead of
+transcoding media.
+
+The pack contains no third-party or production media and has no media license
+to reproduce. The only external tool assumption is the locally installed
+`ffmpeg 9.0.1` generator binary; ffmpeg is not packaged in the application or
+test artifacts.
+
+## Hermetic server and network policy
+
+`HermeticTestServer` matches typed method/path/query routes independently of
+request order. It supports static bytes/text, redirects, byte ranges, response
+sequences, delayed responses, truncation, disconnects, and recovery. Its
+bounded journal records only sanitized method/path, selected headers, range,
+route, outcome, and completion state.
+
+The app instrumentation flavor pins API and media routes to the server's
+`127.0.0.1` origin. `LoopbackNetworkBlocker` rejects every other origin
+through the existing PUB-77 fail-closed control and records a normalized
+violation. Unknown routes and unexpected egress fail teardown. Authorization
+values, credentials, redirect query data, authenticated payloads, broad UI
+dumps, and raw logs are not test evidence.
+
+## Verification entry points
+
+The player-owned host entry points are:
+
+- fixture tests: `:player-test-fixtures:test`;
+- APK-pair resolver regression:
+ `tools/test-resolve-android-test-apk-pair`;
+- player VM suites: `:app:testDevDebugUnitTest` and
+ `:app:testProdDebugUnitTest`, filtered to
+ `com.kino.puber.ui.feature.player.vm.*`;
+- production and Android-test compilation:
+ `:app:compileDevDebugKotlin` and
+ `:app:compileInstrumentationDebugAndroidTestKotlin`.
+
+Repository checkout and worktree command selection is owned by `AGENTS.md`;
+this guide intentionally lists Gradle targets rather than duplicating wrapper
+procedures.
+
+## APK pair validation
+
+`tools/resolve-android-test-apk-pair` and its executable regression suite,
+`tools/test-resolve-android-test-apk-pair`, are project-owned. Keep them with
+the variants and manifests whose pairing rules they enforce.
+
+Kent's runtime procedure validates and installs supplied APKs but does not
+choose a target/test pair or prove that both APKs belong to the current build.
+Before the runtime procedure begins, the resolver therefore:
+
+- requires both variant metadata files and both resolved APKs to be at least
+ as new as the caller's build-start timestamp;
+- accepts exactly one regular, non-symlink, unfiltered `SINGLE` APK from each
+ metadata file;
+- reads the APK manifests and requires
+ `androidx.test.runner.AndroidJUnitRunner`;
+- requires the instrumentation `targetPackage` to match the selected pairing
+ contract;
+- emits the validated paths, application IDs, target package, runner, and
+ SHA-256 values as one bounded JSON object.
+
+The self-test must remain beside the resolver because it fail-closes stale,
+ambiguous, split, path-escaping, symlinked, wrong-runner, wrong-package, and
+cross-contract pairs without requiring an emulator.
+
+## Device acceptance contract
+
+The complete package
+`com.kino.puber.ui.feature.player` runs against a fresh
+`app-android-test` resolver-validated target/test APK pair. Final acceptance
+requires two clean passes without flaky retry. Each pass preserves the
+hermetic network, concrete focus, track selection, lifecycle/restoration,
+cache/resource cleanup, codec, request-journal, and production-data-safety
+oracles described above. Resolver output remains the authority for APK paths,
+package IDs, instrumentation target package, runner, and SHA-256 values.
+
+Kent owns emulator leasing, exact-serial targeting, preservation-safe
+installation, runtime cleanup, and evidence capture. Follow
+`.kent/context/smoke.md` and `.kent/commands/smoke-test.md`; those procedures
+are intentionally not copied into this product guide.
+
+## Emulator capabilities and diagnostics
+
+The mandatory device path is H.264/AAC. A device lacking that capability
+produces a concise capability failure and does not silently turn decode
+coverage into a pass. HEVC and real AC3 decode diagnostics are intentionally
+not part of this fixture pack or acceptance suite; AC3 retry/fallback remains
+mandatory in the host state-machine tests.
+
+There are no assumption-based skips for mandatory scenarios. A capability
+report may explain a failed H.264/AAC setup, while the intentionally omitted
+HEVC/AC3 decode diagnostics are out of scope rather than a passing skip.
+
+On failure, diagnostics are bounded to player state, selected formats,
+decoder name/counters, relevant Media3 analytics events, capability
+information, and a sanitized request-journal tail. Startup, dropped-frame,
+and rebuffer measurements are informational and have no absolute emulator
+performance threshold.
+
+## Production artifact exclusion
+
+The fixture module is test-only. `:app` references it through
+`testImplementation` and the `instrumentationImplementation` flavor edge;
+`:baselineprofile` is itself a test module. No production
+`implementation`, `prodImplementation`, or runtime edge may resolve
+`:player-test-fixtures`.
+
+Verification covers `prodReleaseRuntimeClasspath`, the prod debug/release APKs,
+and the prod release AAB. Bounded artifact listings must contain no fixture
+prefix, fixture hashes/provenance, test-control component, server package,
+Kaspresso, or Kakao dependency. If release signing credentials are absent in a
+local worktree, report that packaging limitation; never copy credentials into
+the worktree or weaken the production exclusion check.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e97e5388..6d53b9ee 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -19,7 +19,9 @@ coroutines = "1.10.2"
timber = "5.0.1"
junit = "4.13.2"
junit5 = "5.14.2"
+junitVintage = "5.14.2"
mockk = "1.14.9"
+robolectric = "4.16.1"
koin = "4.2.1"
security = "1.1.0"
voyager = "1.1.0-beta03"
@@ -31,10 +33,14 @@ profileinstaller = "1.4.1"
uiautomator = "2.3.0"
androidxTestExtJunit = "1.3.0"
androidxTestRunner = "1.7.0"
+kaspresso = "1.6.1"
+kakaoCompose = "1.1.0"
+drawerLayout = "1.1.1"
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
@@ -114,8 +120,10 @@ media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasourc
junit = { group = "junit", name = "junit", version.ref = "junit" }
junit5 = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit5" }
junit5-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" }
+junit5-vintage = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junitVintage" }
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
+robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
# Baseline Profile & Benchmark
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
@@ -123,3 +131,7 @@ androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profi
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
+kaspresso = { group = "com.kaspersky.android-components", name = "kaspresso", version.ref = "kaspresso" }
+kaspresso-compose = { group = "com.kaspersky.android-components", name = "kaspresso-compose-support", version.ref = "kaspresso" }
+kakao-compose = { group = "io.github.kakaocup", name = "compose", version.ref = "kakaoCompose" }
+androidx-drawerlayout = { group = "androidx.drawerlayout", name = "drawerlayout", version.ref = "drawerLayout" }
diff --git a/player-test-fixtures/build.gradle.kts b/player-test-fixtures/build.gradle.kts
new file mode 100644
index 00000000..0c295350
--- /dev/null
+++ b/player-test-fixtures/build.gradle.kts
@@ -0,0 +1,36 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "com.kino.puber.playertestfixtures"
+ compileSdk = Versions.CompileSdk
+
+ defaultConfig {
+ minSdk = Versions.MinSdk
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ sourceSets["main"].assets.srcDir("src/main/assets")
+ sourceSets["test"].resources.srcDir("src/main/assets")
+
+ compileOptions {
+ sourceCompatibility = Versions.JavaVersionCompat
+ targetCompatibility = Versions.JavaVersionCompat
+ }
+}
+
+kotlin {
+ jvmToolchain(Versions.JavaVersionCompat.majorVersion.toInt())
+}
+
+dependencies {
+ implementation(libs.mockwebserver3)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.androidx.test.runner)
+}
+
+tasks.withType().configureEach {
+ classpath += files("src/main/assets")
+}
diff --git a/player-test-fixtures/fixtures-provenance.md b/player-test-fixtures/fixtures-provenance.md
new file mode 100644
index 00000000..e1758080
--- /dev/null
+++ b/player-test-fixtures/fixtures-provenance.md
@@ -0,0 +1,8 @@
+# Generated by tools/generate-player-test-fixtures.sh
+
+Tool: ffmpeg 9.0.1
+Inputs: lavfi testsrc2 video pattern and lavfi sine tones at 440 Hz and 660 Hz.
+Container: progressive H.264/AAC MP4; HLS MPEG-TS VOD playlists with two video variants and two AAC renditions.
+Duration: 4 seconds; segment target: 2 seconds.
+The generated bytes are committed. Gradle and tests verify hashes and do not transcode.
+Regeneration is a maintainer action and requires the exact ffmpeg version above.
diff --git a/player-test-fixtures/src/androidTest/kotlin/com/kino/puber/playertestfixtures/PlayerTestFixturesAndroidTest.kt b/player-test-fixtures/src/androidTest/kotlin/com/kino/puber/playertestfixtures/PlayerTestFixturesAndroidTest.kt
new file mode 100644
index 00000000..b1110fdd
--- /dev/null
+++ b/player-test-fixtures/src/androidTest/kotlin/com/kino/puber/playertestfixtures/PlayerTestFixturesAndroidTest.kt
@@ -0,0 +1,25 @@
+package com.kino.puber.playertestfixtures
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PlayerTestFixturesAndroidTest {
+
+ @Test
+ fun packagedAndroidAsset_isTheSameCommittedFixture() {
+ val context = InstrumentationRegistry.getInstrumentation().context
+ val checksums = PlayerTestFixtures.committedChecksums(context)
+
+ assertTrue("fixture checksum manifest is missing", checksums.isNotEmpty())
+ checksums.forEach { (path, expected) ->
+ assertTrue(
+ "$path is missing or changed in packaged Android assets",
+ PlayerTestFixtures.verifySha256(path, expected, context),
+ )
+ }
+ }
+}
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/SHA256SUMS b/player-test-fixtures/src/main/assets/player-fixtures/SHA256SUMS
new file mode 100644
index 00000000..efedaf33
--- /dev/null
+++ b/player-test-fixtures/src/main/assets/player-fixtures/SHA256SUMS
@@ -0,0 +1,20 @@
+# Generated by tools/generate-player-test-fixtures.sh
+# Paths are relative to player-test-fixtures/src/main/assets/player-fixtures
+be066640856b0bafbe89b98ffb9d8242aef9dd80755e81632845acd2c6859592 fixture-manifest.properties
+d0b66ca1cd5cf07f1653e6d186c9aa2bf98c12ae3411684d5a9d4990f0faac24 hls/audio_english_000.ts
+f2835f530d2b5a429beca76edaec079e4ab7678523f8f895d97af287efc77ebb hls/audio_english_001.ts
+0b4a3635ca144ac737dfbabd915a35906a8198c6d986381ed59ff0075223588a hls/audio_english_002.ts
+638546fcf88de892ebf4c35abba3b96a87a1e8288db6e6c8e0213102ba74546b hls/audio_english.m3u8
+f4258109fb94de22cde3e6bcef2d063253d86fd1439c455101257ee2a26295a6 hls/audio_spanish_000.ts
+5d031767863b8894f788ea6c55d9fb368b2197634e23c2e1bb6d788eb5279eb0 hls/audio_spanish_001.ts
+e0c4b54aa29660c8730a21ffd6725d728ac9299cbfd582e483ad08aded6113b6 hls/audio_spanish_002.ts
+57628e73ee5e3813ad203033f8d5d7bc729d32d70bd3a9756c1cfa1adaf7a40d hls/audio_spanish.m3u8
+f49ce7e56a295611d77085ce6dda7e9d4232738d9e69f140f3e72362bc82413a hls/master.m3u8
+2886e10d3a695dbe1fc76c2e5990794602c0c3f75b6f875dd834f0c6fac9e919 hls/video_360_000.ts
+584395ec46703e2c377dacaf7c25f4da282037c1097ab6cf500db76ea950858d hls/video_360_001.ts
+23b1ecbd66d278f3d75082204ab380e234bf7dd876391b5a5bd84c597c81103d hls/video_360.m3u8
+39b126ca12647e3819b9c353c9a7e0ee97a6f41c270ae8bfd2370bcc690e30ea hls/video_720_000.ts
+a625ffd32332483b7b090d7f5c599634c131056fcffbd492a5d2ba7667b24ece hls/video_720_001.ts
+db1893295cd255c902a33580a1149779255965ffef2afd66d224e975a77e9cc8 hls/video_720.m3u8
+66e3482994223b65cc915d37218558051216a6668e8b6429102efc35a27ee796 progressive.mp4
+aa39ae5106857d2e539299d0ce5d552ce8f5f96c1594f954c7c8a0eed69729e6 subtitle.vtt
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/fixture-manifest.properties b/player-test-fixtures/src/main/assets/player-fixtures/fixture-manifest.properties
new file mode 100644
index 00000000..4e223d4b
--- /dev/null
+++ b/player-test-fixtures/src/main/assets/player-fixtures/fixture-manifest.properties
@@ -0,0 +1,19 @@
+# Generated by tools/generate-player-test-fixtures.sh
+fixture.ProgressiveMp4.duration_ms=4000
+fixture.ProgressiveMp4.sha256=66e3482994223b65cc915d37218558051216a6668e8b6429102efc35a27ee796
+fixture.HlsMaster.sha256=f49ce7e56a295611d77085ce6dda7e9d4232738d9e69f140f3e72362bc82413a
+fixture.HlsVideo360Playlist.duration_ms=4000
+fixture.HlsVideo360Playlist.sha256=23b1ecbd66d278f3d75082204ab380e234bf7dd876391b5a5bd84c597c81103d
+fixture.HlsVideo720Playlist.duration_ms=4000
+fixture.HlsVideo720Playlist.sha256=db1893295cd255c902a33580a1149779255965ffef2afd66d224e975a77e9cc8
+fixture.HlsAudioEnglishPlaylist.duration_ms=4000
+fixture.HlsAudioEnglishPlaylist.language=en
+fixture.HlsAudioEnglishPlaylist.label=English
+fixture.HlsAudioEnglishPlaylist.sha256=638546fcf88de892ebf4c35abba3b96a87a1e8288db6e6c8e0213102ba74546b
+fixture.HlsAudioSpanishPlaylist.duration_ms=4000
+fixture.HlsAudioSpanishPlaylist.language=es
+fixture.HlsAudioSpanishPlaylist.label=Español
+fixture.HlsAudioSpanishPlaylist.sha256=57628e73ee5e3813ad203033f8d5d7bc729d32d70bd3a9756c1cfa1adaf7a40d
+fixture.SubtitleWebVtt.cue_start_ms=1000
+fixture.SubtitleWebVtt.cue_end_ms=2000
+fixture.SubtitleWebVtt.sha256=aa39ae5106857d2e539299d0ce5d552ce8f5f96c1594f954c7c8a0eed69729e6
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english.m3u8 b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english.m3u8
new file mode 100644
index 00000000..edb5874e
--- /dev/null
+++ b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english.m3u8
@@ -0,0 +1,12 @@
+#EXTM3U
+#EXT-X-VERSION:6
+#EXT-X-TARGETDURATION:2
+#EXT-X-MEDIA-SEQUENCE:0
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXTINF:2.005333,
+audio_english_000.ts
+#EXTINF:2.005333,
+audio_english_001.ts
+#EXTINF:0.021333,
+audio_english_002.ts
+#EXT-X-ENDLIST
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english_000.ts b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english_000.ts
new file mode 100644
index 00000000..c728e799
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english_000.ts differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english_001.ts b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english_001.ts
new file mode 100644
index 00000000..4e73f6fb
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english_001.ts differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english_002.ts b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english_002.ts
new file mode 100644
index 00000000..43fb7872
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_english_002.ts differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish.m3u8 b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish.m3u8
new file mode 100644
index 00000000..ed3f7e7b
--- /dev/null
+++ b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish.m3u8
@@ -0,0 +1,12 @@
+#EXTM3U
+#EXT-X-VERSION:6
+#EXT-X-TARGETDURATION:2
+#EXT-X-MEDIA-SEQUENCE:0
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXTINF:2.005333,
+audio_spanish_000.ts
+#EXTINF:2.005333,
+audio_spanish_001.ts
+#EXTINF:0.021333,
+audio_spanish_002.ts
+#EXT-X-ENDLIST
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish_000.ts b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish_000.ts
new file mode 100644
index 00000000..78870a6a
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish_000.ts differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish_001.ts b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish_001.ts
new file mode 100644
index 00000000..0fda65dd
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish_001.ts differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish_002.ts b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish_002.ts
new file mode 100644
index 00000000..f429c1c3
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/hls/audio_spanish_002.ts differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/master.m3u8 b/player-test-fixtures/src/main/assets/player-fixtures/hls/master.m3u8
new file mode 100644
index 00000000..17c9e02e
--- /dev/null
+++ b/player-test-fixtures/src/main/assets/player-fixtures/hls/master.m3u8
@@ -0,0 +1,9 @@
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-INDEPENDENT-SEGMENTS
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="audio_english.m3u8"
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Español",LANGUAGE="es",DEFAULT=NO,AUTOSELECT=YES,URI="audio_spanish.m3u8"
+#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"
+video_360.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"
+video_720.m3u8
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/video_360.m3u8 b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_360.m3u8
new file mode 100644
index 00000000..1fb7e8b2
--- /dev/null
+++ b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_360.m3u8
@@ -0,0 +1,11 @@
+#EXTM3U
+#EXT-X-VERSION:6
+#EXT-X-TARGETDURATION:2
+#EXT-X-MEDIA-SEQUENCE:0
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXT-X-INDEPENDENT-SEGMENTS
+#EXTINF:2.000000,
+video_360_000.ts
+#EXTINF:2.000000,
+video_360_001.ts
+#EXT-X-ENDLIST
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/video_360_000.ts b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_360_000.ts
new file mode 100644
index 00000000..ecf62409
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_360_000.ts differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/video_360_001.ts b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_360_001.ts
new file mode 100644
index 00000000..65575b4a
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_360_001.ts differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/video_720.m3u8 b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_720.m3u8
new file mode 100644
index 00000000..e7db0852
--- /dev/null
+++ b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_720.m3u8
@@ -0,0 +1,11 @@
+#EXTM3U
+#EXT-X-VERSION:6
+#EXT-X-TARGETDURATION:2
+#EXT-X-MEDIA-SEQUENCE:0
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXT-X-INDEPENDENT-SEGMENTS
+#EXTINF:2.000000,
+video_720_000.ts
+#EXTINF:2.000000,
+video_720_001.ts
+#EXT-X-ENDLIST
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/video_720_000.ts b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_720_000.ts
new file mode 100644
index 00000000..b3698c6c
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_720_000.ts differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/hls/video_720_001.ts b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_720_001.ts
new file mode 100644
index 00000000..055f0fd1
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/hls/video_720_001.ts differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/progressive.mp4 b/player-test-fixtures/src/main/assets/player-fixtures/progressive.mp4
new file mode 100644
index 00000000..52879e13
Binary files /dev/null and b/player-test-fixtures/src/main/assets/player-fixtures/progressive.mp4 differ
diff --git a/player-test-fixtures/src/main/assets/player-fixtures/subtitle.vtt b/player-test-fixtures/src/main/assets/player-fixtures/subtitle.vtt
new file mode 100644
index 00000000..e34b3090
--- /dev/null
+++ b/player-test-fixtures/src/main/assets/player-fixtures/subtitle.vtt
@@ -0,0 +1,4 @@
+WEBVTT
+
+00:00:01.000 --> 00:00:02.000
+Synthetic player fixture cue
diff --git a/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/PlayerTestFixtures.kt b/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/PlayerTestFixtures.kt
new file mode 100644
index 00000000..5e5d42a3
--- /dev/null
+++ b/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/PlayerTestFixtures.kt
@@ -0,0 +1,145 @@
+package com.kino.puber.playertestfixtures
+
+import android.content.Context
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.security.MessageDigest
+import java.nio.charset.StandardCharsets
+import java.util.Properties
+
+/**
+ * The single source of truth for the deterministic media pack used by player
+ * host and instrumentation tests.
+ *
+ * The files are packaged as Android assets and JVM resources from the same
+ * directory. Production modules do not depend on this test-only library.
+ */
+object PlayerTestFixtures {
+
+ private const val ROOT = "player-fixtures"
+ private const val MANIFEST = "$ROOT/fixture-manifest.properties"
+ private const val SHA256SUMS = "$ROOT/SHA256SUMS"
+ private val checksumLine = Regex("([0-9a-f]{64}) {2}([^\\s]+)")
+
+ val catalog: FixtureCatalog by lazy { FixtureCatalog(loadProperties()) }
+
+ fun catalog(context: Context): FixtureCatalog =
+ FixtureCatalog(loadProperties(context))
+
+ fun open(fixture: FixtureId, context: Context? = null): InputStream {
+ return openPath(fixture.path, context)
+ }
+
+ fun openPath(path: String, context: Context? = null): InputStream {
+ require(path.isRelativePath()) { "Fixture path must be relative: $path" }
+ val resourcePath = "$ROOT/$path"
+ return context?.assets?.open(resourcePath)
+ ?: checkNotNull(javaClass.classLoader?.getResourceAsStream(resourcePath)) {
+ "Fixture resource is not available: $resourcePath"
+ }
+ }
+
+ fun readBytes(fixture: FixtureId, context: Context? = null): ByteArray =
+ open(fixture, context).use(InputStream::readBytes)
+
+ fun verifySha256(fixture: FixtureId, context: Context? = null): Boolean {
+ val expected = (context?.let(::catalog) ?: catalog).metadata(fixture).sha256
+ return verifySha256(fixture.path, expected, context)
+ }
+
+ fun verifySha256(
+ path: String,
+ expected: String,
+ context: Context? = null,
+ ): Boolean {
+ require(expected.matches(Regex("[0-9a-f]{64}"))) {
+ "Expected SHA-256 must be 64 lowercase hexadecimal characters"
+ }
+ val actual = MessageDigest.getInstance("SHA-256")
+ .digest(openPath(path, context).use(InputStream::readBytes))
+ .toHex()
+ return actual == expected
+ }
+
+ fun committedChecksums(context: Context? = null): Map =
+ openPath(SHA256SUMS.removePrefix("$ROOT/"), context).bufferedReader().useLines {
+ parseCommittedChecksums(it)
+ }
+
+ internal fun parseCommittedChecksums(lines: Sequence): Map =
+ buildMap {
+ lines.forEachIndexed { index, line ->
+ if (line.isBlank() || line.startsWith("#")) {
+ return@forEachIndexed
+ }
+ val match = requireNotNull(checksumLine.matchEntire(line)) {
+ "Malformed SHA256SUMS entry at line ${index + 1}"
+ }
+ val (hash, path) = match.destructured
+ require(path.isRelativePath()) {
+ "Unsafe SHA256SUMS path at line ${index + 1}: $path"
+ }
+ require(put(path, hash) == null) {
+ "Duplicate SHA256SUMS path at line ${index + 1}: $path"
+ }
+ }
+ }
+
+ private fun loadProperties(context: Context? = null): Properties =
+ openPath(MANIFEST.removePrefix("$ROOT/"), context).use { stream ->
+ Properties().apply {
+ load(InputStreamReader(stream, StandardCharsets.UTF_8))
+ }
+ }
+
+ private fun ByteArray.toHex(): String =
+ joinToString("") { byte -> "%02x".format(byte) }
+
+ private fun String.isRelativePath(): Boolean =
+ isNotBlank() && !startsWith('/') && !contains('\\') &&
+ !contains('\n') && !contains('\r') &&
+ split('/').none { it == "." || it == ".." || it.isBlank() }
+}
+
+enum class FixtureId(
+ val path: String,
+) {
+ ProgressiveMp4("progressive.mp4"),
+ HlsMaster("hls/master.m3u8"),
+ HlsVideo360Playlist("hls/video_360.m3u8"),
+ HlsVideo720Playlist("hls/video_720.m3u8"),
+ HlsAudioEnglishPlaylist("hls/audio_english.m3u8"),
+ HlsAudioSpanishPlaylist("hls/audio_spanish.m3u8"),
+ SubtitleWebVtt("subtitle.vtt"),
+}
+
+data class FixtureMetadata(
+ val id: FixtureId,
+ val durationMs: Long?,
+ val language: String?,
+ val label: String?,
+ val cueStartMs: Long?,
+ val cueEndMs: Long?,
+ val sha256: String,
+)
+
+class FixtureCatalog internal constructor(
+ private val properties: Properties,
+) {
+ val all: List = FixtureId.entries.map(::metadata)
+
+ fun metadata(id: FixtureId): FixtureMetadata {
+ val prefix = "fixture.${id.name}"
+ return FixtureMetadata(
+ id = id,
+ durationMs = properties.getProperty("$prefix.duration_ms")?.toLongOrNull(),
+ language = properties.getProperty("$prefix.language"),
+ label = properties.getProperty("$prefix.label"),
+ cueStartMs = properties.getProperty("$prefix.cue_start_ms")?.toLongOrNull(),
+ cueEndMs = properties.getProperty("$prefix.cue_end_ms")?.toLongOrNull(),
+ sha256 = checkNotNull(properties.getProperty("$prefix.sha256")) {
+ "Missing SHA-256 for ${id.name}"
+ },
+ )
+ }
+}
diff --git a/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/network/LoopbackNetworkBlocker.kt b/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/network/LoopbackNetworkBlocker.kt
new file mode 100644
index 00000000..4f6c2d43
--- /dev/null
+++ b/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/network/LoopbackNetworkBlocker.kt
@@ -0,0 +1,59 @@
+package com.kino.puber.playertestfixtures.network
+
+import android.content.Context
+import java.net.InetSocketAddress
+import java.net.Proxy
+import java.net.ProxySelector
+import java.net.SocketAddress
+import java.net.URI
+
+object LoopbackNetworkBlocker {
+ private var installedSelector: BlockingProxySelector? = null
+
+ @Synchronized
+ fun install(context: Context, allowedOrigin: String) {
+ val normalizedOrigin = allowedOrigin.removeSuffix("/").lowercase()
+ if (installedSelector?.allowedOrigin == normalizedOrigin) return
+ val selector = BlockingProxySelector(
+ allowedOrigin = normalizedOrigin,
+ journal = LoopbackNetworkJournal(context.applicationContext),
+ )
+ ProxySelector.setDefault(selector)
+ installedSelector = selector
+ }
+
+ private class BlockingProxySelector(
+ val allowedOrigin: String,
+ private val journal: LoopbackNetworkJournal,
+ ) : ProxySelector() {
+ override fun select(uri: URI): List {
+ requireNotNull(uri) { "URI must not be null" }
+ return if (origin(uri) == allowedOrigin) {
+ listOf(Proxy.NO_PROXY)
+ } else {
+ journal.record(uri)
+ listOf(DENY_PROXY)
+ }
+ }
+
+ override fun connectFailed(uri: URI, sa: SocketAddress?, ioe: java.io.IOException) {
+ if (origin(uri) != allowedOrigin) journal.record(uri)
+ }
+
+ private fun origin(uri: URI): String {
+ val scheme = uri.scheme.orEmpty().lowercase()
+ val host = uri.host.orEmpty().lowercase()
+ val port = uri.port.takeIf { it >= 0 } ?: when (scheme) {
+ "http" -> 80
+ "https" -> 443
+ else -> -1
+ }
+ return "$scheme://$host:$port"
+ }
+ }
+
+ private val DENY_PROXY = Proxy(
+ Proxy.Type.HTTP,
+ InetSocketAddress.createUnresolved("127.0.0.1", 9),
+ )
+}
diff --git a/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/network/LoopbackNetworkJournal.kt b/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/network/LoopbackNetworkJournal.kt
new file mode 100644
index 00000000..7d0310ba
--- /dev/null
+++ b/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/network/LoopbackNetworkJournal.kt
@@ -0,0 +1,75 @@
+package com.kino.puber.playertestfixtures.network
+
+import android.content.Context
+import java.io.File
+import java.io.RandomAccessFile
+import java.net.URI
+import java.nio.charset.StandardCharsets
+
+class LoopbackNetworkJournal(context: Context) {
+ private val directory = File(context.noBackupFilesDir, DIRECTORY)
+ private val journalFile = File(directory, JOURNAL_FILE)
+ private val lockFile = File(directory, LOCK_FILE)
+
+ init {
+ check(directory.isDirectory || directory.mkdirs()) {
+ "Failed to create loopback network journal directory"
+ }
+ }
+
+ fun record(uri: URI) = withFileLock {
+ val violation = normalize(uri)
+ val current = readEntries()
+ if (violation !in current) writeEntries((current + violation).takeLast(MAX_ENTRIES))
+ }
+
+ fun snapshot(): List = withFileLock(::readEntries)
+
+ fun clear() = withFileLock { writeEntries(emptyList()) }
+
+ private fun readEntries(): List {
+ if (!journalFile.isFile) return emptyList()
+ return journalFile.readLines(StandardCharsets.UTF_8)
+ .filter(String::isNotBlank)
+ .takeLast(MAX_ENTRIES)
+ }
+
+ private fun writeEntries(entries: List) {
+ val payload = entries.joinToString("\n").toByteArray(StandardCharsets.UTF_8)
+ RandomAccessFile(journalFile, "rw").use { file ->
+ file.setLength(0)
+ file.write(payload)
+ file.fd.sync()
+ }
+ }
+
+ private fun normalize(uri: URI): String {
+ val scheme = uri.scheme.orEmpty().lowercase()
+ val host = uri.host.orEmpty().lowercase()
+ val port = uri.port.takeIf { it >= 0 } ?: when (scheme) {
+ "http" -> 80
+ "https" -> 443
+ else -> -1
+ }
+ val path = uri.rawPath.orEmpty().ifBlank { "/" }
+ .replace('\n', '_')
+ .replace('\r', '_')
+ return "$scheme://$host:$port$path".take(MAX_ENTRY_LENGTH)
+ }
+
+ private inline fun withFileLock(block: () -> T): T =
+ synchronized(PROCESS_LOCK) {
+ RandomAccessFile(lockFile, "rw").use { access ->
+ access.channel.lock().use { block() }
+ }
+ }
+
+ private companion object {
+ const val DIRECTORY = "player_network_journal"
+ const val JOURNAL_FILE = "violations"
+ const val LOCK_FILE = "journal.lock"
+ const val MAX_ENTRIES = 64
+ const val MAX_ENTRY_LENGTH = 512
+ val PROCESS_LOCK = Any()
+ }
+}
diff --git a/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/server/HermeticRequestJournal.kt b/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/server/HermeticRequestJournal.kt
new file mode 100644
index 00000000..f190f3b1
--- /dev/null
+++ b/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/server/HermeticRequestJournal.kt
@@ -0,0 +1,120 @@
+package com.kino.puber.playertestfixtures.server
+
+import java.util.concurrent.atomic.AtomicLong
+import mockwebserver3.RecordedRequest
+
+/**
+ * Bounded request evidence. Only transport metadata needed by tests is kept;
+ * authorization, cookies, and query values are intentionally excluded.
+ */
+class HermeticRequestJournal(
+ private val maxEntries: Int = 256,
+) {
+ private val lock = Any()
+ private val nextId = AtomicLong()
+ private val entries = ArrayDeque()
+ private val matched = linkedMapOf()
+ private var active = 0
+ private var generation = 0L
+
+ val generationId: Long
+ get() = synchronized(lock) { generation }
+
+ val activeRequestCount: Int
+ get() = synchronized(lock) { active }
+
+ fun reset() = synchronized(lock) {
+ check(active == 0) { "Cannot reset with $active active request(s)" }
+ entries.clear()
+ matched.clear()
+ active = 0
+ generation += 1
+ }
+
+ fun begin(request: RecordedRequest, routeId: String?): Long =
+ synchronized(lock) {
+ val id = nextId.incrementAndGet()
+ routeId?.let { matched[it] = (matched[it] ?: 0) + 1 }
+ active += 1
+ add(
+ RequestEntry(
+ id = id,
+ method = request.method,
+ path = request.url.encodedPath.ifBlank { "/" },
+ queryKeys = request.url.queryParameterNames.toList().sorted(),
+ headers = selectedHeaders(request),
+ range = request.headers["Range"],
+ routeId = routeId,
+ outcome = null,
+ ),
+ )
+ id
+ }
+
+ fun complete(requestId: Long, outcome: ResponseOutcome) = synchronized(lock) {
+ val index = entries.indexOfFirst { it.id == requestId }
+ if (index >= 0 && entries[index].outcome == null) {
+ entries[index] = entries[index].copy(outcome = outcome)
+ active = (active - 1).coerceAtLeast(0)
+ }
+ }
+
+ fun matchedCount(routeId: String): Int = synchronized(lock) {
+ matched[routeId] ?: 0
+ }
+
+ fun snapshot(): Snapshot = synchronized(lock) {
+ Snapshot(
+ generationId = generation,
+ entries = entries.toList(),
+ matchedRoutes = matched.toMap(),
+ activeRequestCount = active,
+ )
+ }
+
+ private fun add(entry: RequestEntry) {
+ entries.addLast(entry)
+ while (entries.size > maxEntries) entries.removeFirst()
+ }
+
+ private fun selectedHeaders(request: RecordedRequest): Map =
+ SELECTED_HEADERS.mapNotNull { name ->
+ request.headers[name]?.let { name to it.take(MAX_HEADER_LENGTH) }
+ }.toMap()
+
+ data class Snapshot(
+ val generationId: Long,
+ val entries: List,
+ val matchedRoutes: Map,
+ val activeRequestCount: Int,
+ ) {
+ val unknownRequests: List
+ get() = entries.filter { it.routeId == null }
+ }
+
+ data class RequestEntry(
+ val id: Long,
+ val method: String,
+ val path: String,
+ val queryKeys: List,
+ val headers: Map,
+ val range: String?,
+ val routeId: String?,
+ val outcome: ResponseOutcome?,
+ )
+
+ private companion object {
+ const val MAX_HEADER_LENGTH = 256
+ val SELECTED_HEADERS = listOf(
+ "Accept",
+ "Cache-Control",
+ "Content-Type",
+ "Pragma",
+ )
+ }
+}
+
+sealed interface ResponseOutcome {
+ data class Completed(val status: Int) : ResponseOutcome
+ data object UnknownRoute : ResponseOutcome
+}
diff --git a/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/server/HermeticTestServer.kt b/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/server/HermeticTestServer.kt
new file mode 100644
index 00000000..8b8e5698
--- /dev/null
+++ b/player-test-fixtures/src/main/kotlin/com/kino/puber/playertestfixtures/server/HermeticTestServer.kt
@@ -0,0 +1,522 @@
+package com.kino.puber.playertestfixtures.server
+
+import java.io.Closeable
+import java.net.InetAddress
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import mockwebserver3.Dispatcher
+import mockwebserver3.MockResponse
+import mockwebserver3.MockResponseBody
+import mockwebserver3.MockWebServer
+import mockwebserver3.RecordedRequest
+import mockwebserver3.SocketEffect
+import okhttp3.HttpUrl
+import okio.BufferedSink
+
+/**
+ * A deterministic, loopback-only HTTP server for host and instrumentation
+ * player tests. Routes are matched by request identity rather than queue
+ * order, so concurrent Media3 requests are safe to dispatch.
+ */
+class HermeticTestServer(
+ private val port: Int = 0,
+) : Closeable {
+
+ private val server = MockWebServer()
+ private val lock = Any()
+ private val journal = HermeticRequestJournal()
+ private var routes: List = emptyList()
+ private var started = false
+
+ internal var beforeRequestAdmissionForTest: () -> Unit = {}
+
+ val baseUrl: String
+ get() {
+ check(started) { "Call start() before reading baseUrl" }
+ return server.url("/").toString()
+ }
+
+ val generationId: Long
+ get() = journal.generationId
+
+ val requestJournal: HermeticRequestJournal.Snapshot
+ get() = journal.snapshot()
+
+ val activeRequestCount: Int
+ get() = journal.activeRequestCount
+
+ fun start() {
+ synchronized(lock) {
+ check(!started) { "HermeticTestServer is already started" }
+ server.dispatcher = dispatcher()
+ server.start(InetAddress.getByName("127.0.0.1"), port)
+ started = true
+ }
+ }
+
+ fun reset(newRoutes: List) {
+ synchronized(lock) {
+ check(started) { "Call start() before reset()" }
+ journal.reset()
+ routes = newRoutes.toList()
+ }
+ }
+
+ fun addRoute(route: HermeticRoute) {
+ synchronized(lock) {
+ check(started) { "Call start() before addRoute()" }
+ routes = routes + route
+ }
+ }
+
+ fun takeRequest(
+ timeout: Long,
+ unit: TimeUnit,
+ ): RecordedRequest? = server.takeRequest(timeout, unit)
+
+ fun route(
+ id: String,
+ method: String = "GET",
+ path: String,
+ query: Map = emptyMap(),
+ queryMode: QueryMatchMode = QueryMatchMode.Exact,
+ response: ResponsePlan,
+ required: Boolean = false,
+ minimumRequests: Int = 1,
+ ): HermeticRoute = HermeticRoute(
+ id = id,
+ method = method,
+ path = path,
+ query = query,
+ queryMode = queryMode,
+ response = response,
+ required = required,
+ minimumRequests = minimumRequests,
+ )
+
+ fun url(path: String, query: Map = emptyMap()): String {
+ check(started) { "Call start() before url()" }
+ return server.url(path).newBuilder().apply {
+ query.forEach { (name, value) -> addQueryParameter(name, value) }
+ }.build().toString()
+ }
+
+ fun awaitQuiescence(
+ timeout: Long = 5,
+ unit: TimeUnit = TimeUnit.SECONDS,
+ ) {
+ val deadline = System.nanoTime() + unit.toNanos(timeout)
+ while (System.nanoTime() < deadline) {
+ if (activeRequestCount == 0) return
+ Thread.yield()
+ }
+ error("Hermetic server still has $activeRequestCount active request(s)")
+ }
+
+ fun verifyRequiredRoutes(): List =
+ synchronized(lock) {
+ routes
+ .filter { it.required && journal.matchedCount(it.id) < it.minimumRequests }
+ .map { it.description }
+ }
+
+ override fun close() {
+ synchronized(lock) {
+ if (!started) return
+ started = false
+ server.close()
+ }
+ }
+
+ private fun dispatcher() = object : Dispatcher() {
+ override fun dispatch(request: RecordedRequest): MockResponse {
+ val (route, requestId) = synchronized(lock) {
+ val selectedRoute = routes.firstOrNull { it.matches(request) }
+ beforeRequestAdmissionForTest()
+ selectedRoute to journal.begin(request, selectedRoute?.id)
+ }
+ if (route == null) {
+ return ResponsePlan.Text(
+ status = 404,
+ body = """{"error":"unknown hermetic route"}""",
+ contentType = "application/json; charset=utf-8",
+ ).toMockResponse(request) {
+ journal.complete(requestId, ResponseOutcome.UnknownRoute)
+ }
+ }
+
+ val occurrence = journal.matchedCount(route.id)
+ return route.response.responseFor(request, occurrence).toMockResponse(request) {
+ journal.complete(requestId, ResponseOutcome.Completed(it))
+ }
+ }
+ }
+
+ companion object {
+ fun bytes(
+ body: ByteArray,
+ status: Int = 200,
+ contentType: String = "application/octet-stream",
+ ): ResponsePlan = ResponsePlan.Bytes(status, body, contentType)
+
+ fun text(
+ body: String,
+ status: Int = 200,
+ contentType: String = "text/plain; charset=utf-8",
+ ): ResponsePlan = ResponsePlan.Text(status, body, contentType)
+
+ fun redirect(location: String, status: Int = 302): ResponsePlan =
+ ResponsePlan.Redirect(status, location)
+
+ fun sequence(vararg responses: ResponsePlan): ResponsePlan =
+ ResponsePlan.Sequence(responses.toList())
+
+ fun delayed(
+ gate: CountDownLatch,
+ body: ByteArray,
+ status: Int = 200,
+ contentType: String = "application/octet-stream",
+ ): ResponsePlan = ResponsePlan.Delayed(status, body, contentType, gate)
+
+ fun truncated(
+ body: ByteArray,
+ bytesToWrite: Int,
+ status: Int = 200,
+ contentType: String = "application/octet-stream",
+ ): ResponsePlan = ResponsePlan.Truncated(status, body, contentType, bytesToWrite)
+
+ fun disconnected(
+ body: ByteArray = ByteArray(0),
+ status: Int = 200,
+ contentType: String = "application/octet-stream",
+ ): ResponsePlan = ResponsePlan.Disconnected(status, body, contentType)
+ }
+}
+
+data class HermeticRoute(
+ val id: String,
+ val method: String,
+ val path: String,
+ val query: Map,
+ val queryMode: QueryMatchMode,
+ val response: ResponsePlan,
+ val required: Boolean,
+ val minimumRequests: Int,
+) {
+ val description: String
+ get() = buildString {
+ append(method)
+ append(' ')
+ append(path)
+ if (query.isNotEmpty()) {
+ append('?')
+ append(query.entries.joinToString("&") { "${it.key}=${it.value}" })
+ }
+ }
+
+ fun matches(request: RecordedRequest): Boolean =
+ (method == "*" || method.equals(request.method, ignoreCase = true)) &&
+ matches(request.url)
+
+ fun matches(url: HttpUrl): Boolean {
+ val expectedPath = path.trimEnd('/').ifEmpty { "/" }
+ val actualPath = url.encodedPath.trimEnd('/').ifEmpty { "/" }
+ if (actualPath != expectedPath) return false
+ val actualQuery = url.queryParameterNames.associateWith { name ->
+ url.queryParameter(name).orEmpty()
+ }
+ return when (queryMode) {
+ QueryMatchMode.Exact -> actualQuery == query
+ QueryMatchMode.Contains -> query.all { actualQuery[it.key] == it.value }
+ }
+ }
+}
+
+enum class QueryMatchMode {
+ Exact,
+ Contains,
+}
+
+sealed interface ResponsePlan {
+ fun responseFor(request: RecordedRequest, occurrence: Int): ResponsePlan =
+ this
+
+ fun toMockResponse(
+ request: RecordedRequest,
+ onComplete: (status: Int) -> Unit,
+ ): MockResponse
+
+ data class Bytes(
+ val status: Int,
+ val body: ByteArray,
+ val contentType: String,
+ val headers: Map = emptyMap(),
+ ) : ResponsePlan {
+ override fun toMockResponse(
+ request: RecordedRequest,
+ onComplete: (status: Int) -> Unit,
+ ): MockResponse =
+ MockResponse.Builder()
+ .code(status)
+ .addHeader("Content-Type", contentType)
+ .addHeader("Connection", "close")
+ .apply {
+ this@Bytes.headers.forEach { (name, value) ->
+ addHeader(name, value)
+ }
+ }
+ .body(
+ completedBody(
+ body = body,
+ status = status,
+ onComplete = onComplete,
+ declaredLength = body.size.toLong(),
+ ),
+ )
+ .build()
+ }
+
+ data class Text(
+ val status: Int,
+ val body: String,
+ val contentType: String,
+ val headers: Map = emptyMap(),
+ ) : ResponsePlan {
+ override fun toMockResponse(
+ request: RecordedRequest,
+ onComplete: (status: Int) -> Unit,
+ ): MockResponse =
+ Bytes(status, body.toByteArray(), contentType, headers)
+ .toMockResponse(request, onComplete)
+ }
+
+ data class Redirect(
+ val status: Int,
+ val location: String,
+ ) : ResponsePlan {
+ override fun toMockResponse(
+ request: RecordedRequest,
+ onComplete: (status: Int) -> Unit,
+ ): MockResponse =
+ MockResponse.Builder()
+ .code(status)
+ .addHeader("Location", location)
+ .addHeader("Connection", "close")
+ .body(
+ completedBody(
+ body = ByteArray(0),
+ status = status,
+ onComplete = onComplete,
+ declaredLength = 0,
+ ),
+ )
+ .build()
+ }
+
+ data class Sequence(
+ val responses: List,
+ ) : ResponsePlan {
+ init {
+ require(responses.isNotEmpty()) { "A response sequence cannot be empty" }
+ }
+
+ override fun responseFor(request: RecordedRequest, occurrence: Int): ResponsePlan =
+ responses[(occurrence - 1).coerceAtMost(responses.lastIndex)]
+
+ override fun toMockResponse(
+ request: RecordedRequest,
+ onComplete: (status: Int) -> Unit,
+ ): MockResponse =
+ responses.last().toMockResponse(request, onComplete)
+ }
+
+ data class Delayed(
+ val status: Int,
+ val body: ByteArray,
+ val contentType: String,
+ val gate: CountDownLatch,
+ ) : ResponsePlan {
+ override fun toMockResponse(
+ request: RecordedRequest,
+ onComplete: (status: Int) -> Unit,
+ ): MockResponse =
+ MockResponse.Builder()
+ .code(status)
+ .addHeader("Content-Type", contentType)
+ .addHeader("Connection", "close")
+ .body(
+ completedBody(
+ body = body,
+ status = status,
+ onComplete = onComplete,
+ gate = gate,
+ declaredLength = body.size.toLong(),
+ ),
+ )
+ .build()
+ }
+
+ data class Truncated(
+ val status: Int,
+ val body: ByteArray,
+ val contentType: String,
+ val bytesToWrite: Int,
+ ) : ResponsePlan {
+ init {
+ require(bytesToWrite in 0..body.size) {
+ "bytesToWrite must be between zero and body size"
+ }
+ }
+
+ override fun toMockResponse(
+ request: RecordedRequest,
+ onComplete: (status: Int) -> Unit,
+ ): MockResponse {
+ val partial = body.copyOf(bytesToWrite)
+ return MockResponse.Builder()
+ .code(status)
+ .addHeader("Content-Type", contentType)
+ .addHeader("Connection", "close")
+ .body(
+ completedBody(
+ body = partial,
+ status = status,
+ onComplete = onComplete,
+ declaredLength = body.size.toLong(),
+ ),
+ )
+ .setHeader("Content-Length", body.size)
+ .onResponseEnd(SocketEffect.CloseSocket())
+ .build()
+ }
+ }
+
+ data class Disconnected(
+ val status: Int,
+ val body: ByteArray,
+ val contentType: String,
+ ) : ResponsePlan {
+ override fun toMockResponse(
+ request: RecordedRequest,
+ onComplete: (status: Int) -> Unit,
+ ): MockResponse =
+ MockResponse.Builder()
+ .code(status)
+ .addHeader("Content-Type", contentType)
+ .addHeader("Connection", "close")
+ .body(
+ completedBody(
+ body = body,
+ status = status,
+ onComplete = onComplete,
+ declaredLength = body.size.toLong(),
+ ),
+ )
+ .onResponseBody(SocketEffect.CloseSocket())
+ .build()
+ }
+
+ data class Range(
+ val body: ByteArray,
+ val contentType: String = "application/octet-stream",
+ val status: Int = 200,
+ ) : ResponsePlan {
+ override fun responseFor(request: RecordedRequest, occurrence: Int): ResponsePlan {
+ val range = parseRange(request.headers["Range"], body.size)
+ ?: return Bytes(status, body, contentType)
+ return Bytes(
+ status = 206,
+ body = body.copyOfRange(range.first, range.last + 1),
+ contentType = contentType,
+ headers = mapOf(
+ "Accept-Ranges" to "bytes",
+ "Content-Range" to "bytes ${range.first}-${range.last}/${body.size}",
+ ),
+ )
+ }
+
+ override fun toMockResponse(
+ request: RecordedRequest,
+ onComplete: (status: Int) -> Unit,
+ ): MockResponse =
+ Bytes(status, body, contentType, mapOf("Accept-Ranges" to "bytes"))
+ .toMockResponse(request, onComplete)
+ }
+
+ class Dynamic(
+ private val handler: (RecordedRequest) -> MockResponse,
+ ) : ResponsePlan {
+ override fun toMockResponse(
+ request: RecordedRequest,
+ onComplete: (status: Int) -> Unit,
+ ): MockResponse {
+ val response = handler(request)
+ val responseBody = response.body
+ val body = responseBody
+ ?: completedBody(ByteArray(0), response.code, onComplete, declaredLength = 0)
+ return response.newBuilder()
+ .body(
+ if (responseBody == null) body
+ else completedBody(responseBody, response.code, onComplete),
+ )
+ .build()
+ }
+ }
+}
+
+private fun completedBody(
+ body: ByteArray,
+ status: Int,
+ onComplete: (status: Int) -> Unit,
+ gate: CountDownLatch? = null,
+ declaredLength: Long,
+): MockResponseBody =
+ object : MockResponseBody {
+ override val contentLength: Long = declaredLength
+
+ override fun writeTo(sink: BufferedSink) {
+ try {
+ if (gate != null) {
+ check(gate.await(10, TimeUnit.SECONDS)) {
+ "Response gate was not released"
+ }
+ }
+ sink.write(body)
+ sink.flush()
+ onComplete(status)
+ } catch (error: Throwable) {
+ onComplete(599)
+ throw error
+ }
+ }
+ }
+
+private fun completedBody(
+ body: MockResponseBody,
+ status: Int,
+ onComplete: (status: Int) -> Unit,
+): MockResponseBody =
+ object : MockResponseBody {
+ override val contentLength: Long = body.contentLength
+
+ override fun writeTo(sink: BufferedSink) {
+ try {
+ body.writeTo(sink)
+ sink.flush()
+ onComplete(status)
+ } catch (error: Throwable) {
+ onComplete(599)
+ throw error
+ }
+ }
+ }
+
+private fun parseRange(header: String?, size: Int): IntRange? {
+ if (header == null || !header.startsWith("bytes=")) return null
+ val values = header.removePrefix("bytes=").substringBefore(',').split('-', limit = 2)
+ if (values.size != 2) return null
+ val start = values[0].toIntOrNull() ?: return null
+ val end = values[1].toIntOrNull() ?: size - 1
+ if (start < 0 || start >= size || end < start) return null
+ return start..end.coerceAtMost(size - 1)
+}
diff --git a/player-test-fixtures/src/test/kotlin/com/kino/puber/playertestfixtures/PlayerTestFixturesTest.kt b/player-test-fixtures/src/test/kotlin/com/kino/puber/playertestfixtures/PlayerTestFixturesTest.kt
new file mode 100644
index 00000000..5f1245f2
--- /dev/null
+++ b/player-test-fixtures/src/test/kotlin/com/kino/puber/playertestfixtures/PlayerTestFixturesTest.kt
@@ -0,0 +1,140 @@
+package com.kino.puber.playertestfixtures
+
+import java.security.MessageDigest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.fail
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PlayerTestFixturesTest {
+
+ @Test
+ fun everyCatalogEntry_isPresentAndMatchesCommittedSha256() {
+ val catalog = PlayerTestFixtures.catalog
+
+ assertEquals(FixtureId.entries.toSet(), catalog.all.map(FixtureMetadata::id).toSet())
+ FixtureId.entries.forEach { fixture ->
+ assertTrue("$fixture is missing or changed", PlayerTestFixtures.verifySha256(fixture))
+ }
+ }
+
+ @Test
+ fun everyCommittedPackFile_matchesItsSha256AndExpectedFileSet() {
+ val checksums = PlayerTestFixtures.committedChecksums()
+
+ assertEquals(
+ setOf(
+ "fixture-manifest.properties",
+ "hls/audio_english_000.ts",
+ "hls/audio_english_001.ts",
+ "hls/audio_english_002.ts",
+ "hls/audio_english.m3u8",
+ "hls/audio_spanish_000.ts",
+ "hls/audio_spanish_001.ts",
+ "hls/audio_spanish_002.ts",
+ "hls/audio_spanish.m3u8",
+ "hls/master.m3u8",
+ "hls/video_360_000.ts",
+ "hls/video_360_001.ts",
+ "hls/video_360.m3u8",
+ "hls/video_720_000.ts",
+ "hls/video_720_001.ts",
+ "hls/video_720.m3u8",
+ "progressive.mp4",
+ "subtitle.vtt",
+ ),
+ checksums.keys,
+ )
+ checksums.forEach { (path, expected) ->
+ val actual = MessageDigest.getInstance("SHA-256")
+ .digest(PlayerTestFixtures.openPath(path).use { it.readBytes() })
+ .joinToString("") { "%02x".format(it) }
+ assertEquals(path, expected, actual)
+ }
+ }
+
+ @Test
+ fun checksumManifest_rejectsMalformedDuplicateAndUnsafeEntries() {
+ val hash = "0".repeat(64)
+
+ assertChecksumManifestRejected("not-a-checksum progressive.mp4")
+ assertChecksumManifestRejected("$hash progressive.mp4")
+ assertChecksumManifestRejected("$hash ../progressive.mp4")
+ assertChecksumManifestRejected("$hash hls\\master.m3u8")
+ assertChecksumManifestRejected(
+ "$hash progressive.mp4",
+ "$hash progressive.mp4",
+ )
+ }
+
+ @Test
+ fun catalog_containsPlaybackTracksAndKnownSubtitleCue() {
+ val catalog = PlayerTestFixtures.catalog
+
+ assertEquals(4_000L, catalog.metadata(FixtureId.ProgressiveMp4).durationMs)
+ assertEquals("en", catalog.metadata(FixtureId.HlsAudioEnglishPlaylist).language)
+ assertEquals("Español", catalog.metadata(FixtureId.HlsAudioSpanishPlaylist).label)
+ assertEquals(1_000L, catalog.metadata(FixtureId.SubtitleWebVtt).cueStartMs)
+ assertEquals(2_000L, catalog.metadata(FixtureId.SubtitleWebVtt).cueEndMs)
+ }
+
+ @Test
+ fun hlsMaster_referencesBothVideoVariantsAndAudioRenditions() {
+ val master = PlayerTestFixtures.readBytes(FixtureId.HlsMaster).decodeToString()
+
+ assertTrue(master.contains("video_360.m3u8"))
+ assertTrue(master.contains("video_720.m3u8"))
+ assertTrue(master.contains("RESOLUTION=640x360"))
+ assertTrue(master.contains("RESOLUTION=1280x720"))
+ assertTrue(master.contains("audio_english.m3u8"))
+ assertTrue(master.contains("audio_spanish.m3u8"))
+ }
+
+ @Test
+ fun everyHlsPlaylist_referencesCommittedSegments() {
+ listOf(
+ FixtureId.HlsVideo360Playlist,
+ FixtureId.HlsVideo720Playlist,
+ FixtureId.HlsAudioEnglishPlaylist,
+ FixtureId.HlsAudioSpanishPlaylist,
+ ).forEach { playlist ->
+ val segments = PlayerTestFixtures.readBytes(playlist)
+ .decodeToString()
+ .lineSequence()
+ .filter { it.endsWith(".ts") }
+ .toList()
+
+ assertTrue("$playlist must contain at least two segments", segments.size >= 2)
+ segments.forEach { segment ->
+ val path = "${playlist.path.substringBeforeLast('/')}/$segment"
+ assertTrue("$path is not in the committed checksum manifest", path in PlayerTestFixtures.committedChecksums())
+ PlayerTestFixtures.openPath(path).use { input ->
+ assertTrue("$path is empty", input.readBytes().isNotEmpty())
+ }
+ }
+ }
+ }
+
+ @Test
+ fun fixtureHashes_useSha256LowercaseHex() {
+ PlayerTestFixtures.catalog.all.forEach { metadata ->
+ assertEquals(64, metadata.sha256.length)
+ assertEquals(metadata.sha256.lowercase(), metadata.sha256)
+ assertEquals(
+ metadata.sha256,
+ MessageDigest.getInstance("SHA-256")
+ .digest(PlayerTestFixtures.readBytes(metadata.id))
+ .joinToString("") { "%02x".format(it) },
+ )
+ }
+ }
+
+ private fun assertChecksumManifestRejected(vararg lines: String) {
+ try {
+ PlayerTestFixtures.parseCommittedChecksums(lines.asSequence())
+ fail("Expected checksum manifest to be rejected: ${lines.joinToString()}")
+ } catch (_: IllegalArgumentException) {
+ // Expected: checksum manifests are parsed fail-closed.
+ }
+ }
+}
diff --git a/player-test-fixtures/src/test/kotlin/com/kino/puber/playertestfixtures/server/HermeticTestServerTest.kt b/player-test-fixtures/src/test/kotlin/com/kino/puber/playertestfixtures/server/HermeticTestServerTest.kt
new file mode 100644
index 00000000..33a0d486
--- /dev/null
+++ b/player-test-fixtures/src/test/kotlin/com/kino/puber/playertestfixtures/server/HermeticTestServerTest.kt
@@ -0,0 +1,311 @@
+package com.kino.puber.playertestfixtures.server
+
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HermeticTestServerTest {
+
+ @Test
+ fun routesMatchMethodPathAndQueryWithoutQueueOrderAndRedactValues() {
+ HermeticTestServer().use { server ->
+ server.start()
+ server.reset(
+ listOf(
+ server.route(
+ id = "serial",
+ path = "/v1/items",
+ query = mapOf("type" to "serial"),
+ response = HermeticTestServer.text("serial"),
+ ),
+ server.route(
+ id = "movie",
+ path = "/v1/items",
+ query = mapOf("type" to "movie"),
+ response = HermeticTestServer.text("movie"),
+ ),
+ ),
+ )
+
+ assertEquals("serial", request(server.url("/v1/items", mapOf("type" to "serial"))).text)
+ assertEquals("movie", request(server.url("/v1/items", mapOf("type" to "movie"))).text)
+
+ val snapshot = server.requestJournal
+ assertTrue(snapshot.entries.all { "type" in it.queryKeys })
+ assertTrue(snapshot.entries.none { it.path.contains("serial") || it.path.contains("movie") })
+ assertEquals(0, snapshot.activeRequestCount)
+ }
+ }
+
+ @Test
+ fun sequenceRedirectAndRangeResponsesAreDeterministic() {
+ HermeticTestServer().use { server ->
+ server.start()
+ server.reset(
+ listOf(
+ server.route(
+ id = "sequence",
+ path = "/sequence",
+ response = HermeticTestServer.sequence(
+ HermeticTestServer.text("first", status = 503),
+ HermeticTestServer.text("second"),
+ ),
+ ),
+ server.route(
+ id = "redirect",
+ path = "/redirect",
+ response = HermeticTestServer.redirect("/sequence"),
+ ),
+ server.route(
+ id = "range",
+ path = "/range",
+ response = ResponsePlan.Range("abcdef".toByteArray()),
+ ),
+ ),
+ )
+
+ assertEquals(503, request(server.url("/sequence")).status)
+ assertEquals(200, request(server.url("/sequence")).status)
+ val redirect = request(server.url("/redirect"), followRedirects = false)
+ assertEquals(302, redirect.status)
+ assertEquals("/sequence", redirect.location)
+ val range = request(server.url("/range"), range = "bytes=1-3")
+ assertEquals(206, range.status)
+ assertArrayEquals("bcd".toByteArray(), range.bytes)
+ assertEquals("bytes 1-3/6", range.contentRange ?: range.headers.toString())
+ }
+ }
+
+ @Test
+ fun unknownRoutesAreRecordedAndDelayedResponsesTrackActiveRequests() {
+ HermeticTestServer().use { server ->
+ server.start()
+ val gate = CountDownLatch(1)
+ server.reset(
+ listOf(
+ server.route(
+ id = "delayed",
+ path = "/delayed",
+ response = HermeticTestServer.delayed(gate, "payload".toByteArray()),
+ ),
+ ),
+ )
+
+ val unknown = request(server.url("/unexpected?token=secret"), followRedirects = false)
+ assertEquals(404, unknown.status)
+ val requestThread = Thread {
+ request(server.url("/delayed"))
+ }
+ requestThread.start()
+ eventually { server.activeRequestCount == 1 }
+ assertTrue(server.requestJournal.entries.none { it.path.contains("secret") })
+ gate.countDown()
+ requestThread.join(5_000)
+ assertFalse(requestThread.isAlive)
+ server.awaitQuiescence()
+ assertEquals(1, server.requestJournal.unknownRequests.size)
+ }
+ }
+
+ @Test
+ fun resetStartsNewGenerationOnlyAfterRequestsQuiesce() {
+ HermeticTestServer().use { server ->
+ server.start()
+ server.reset(
+ listOf(
+ server.route(
+ id = "first",
+ path = "/first",
+ response = HermeticTestServer.text("first"),
+ ),
+ ),
+ )
+ val firstGeneration = server.generationId
+ request(server.url("/first"))
+ server.reset(
+ listOf(
+ server.route(
+ id = "second",
+ path = "/second",
+ response = HermeticTestServer.text("second"),
+ ),
+ ),
+ )
+ assertTrue(server.generationId > firstGeneration)
+ assertEquals(0, server.requestJournal.entries.size)
+ assertEquals(404, request(server.url("/first")).status)
+ assertEquals(200, request(server.url("/second")).status)
+ }
+ }
+
+ @Test
+ fun concurrentResetCannotReplaceRoutesBeforeRequestAdmission() {
+ HermeticTestServer().use { server ->
+ server.start()
+ val admissionSelected = CountDownLatch(1)
+ val allowAdmission = CountDownLatch(1)
+ val allowResponse = CountDownLatch(1)
+ server.beforeRequestAdmissionForTest = {
+ admissionSelected.countDown()
+ check(allowAdmission.await(5, TimeUnit.SECONDS)) {
+ "Timed out waiting to admit selected route"
+ }
+ }
+ server.reset(
+ listOf(
+ server.route(
+ id = "delayed-old",
+ path = "/delayed-old",
+ response = HermeticTestServer.delayed(
+ gate = allowResponse,
+ body = "old".toByteArray(),
+ ),
+ ),
+ server.route(
+ id = "old-only",
+ path = "/old-only",
+ response = HermeticTestServer.text("old-only"),
+ ),
+ ),
+ )
+ val firstGeneration = server.generationId
+ val requestResult = AtomicReference()
+ val requestFailure = AtomicReference()
+ val requestThread = Thread {
+ runCatching { request(server.url("/delayed-old")) }
+ .onSuccess(requestResult::set)
+ .onFailure(requestFailure::set)
+ }
+ requestThread.start()
+ assertTrue(admissionSelected.await(5, TimeUnit.SECONDS))
+
+ val resetFailure = AtomicReference()
+ val resetThread = Thread {
+ runCatching {
+ server.reset(
+ listOf(
+ server.route(
+ id = "new-only",
+ path = "/new-only",
+ response = HermeticTestServer.text("new-only"),
+ ),
+ ),
+ )
+ }.onFailure(resetFailure::set)
+ }
+ resetThread.start()
+ allowAdmission.countDown()
+ resetThread.join(5_000)
+
+ assertFalse(resetThread.isAlive)
+ assertTrue(resetFailure.get() is IllegalStateException)
+ assertEquals(firstGeneration, server.generationId)
+ allowResponse.countDown()
+ requestThread.join(5_000)
+ assertFalse(requestThread.isAlive)
+ assertNull(requestFailure.get())
+ assertEquals("old", requestResult.get().text)
+ assertEquals("old-only", request(server.url("/old-only")).text)
+ assertEquals(404, request(server.url("/new-only")).status)
+
+ server.awaitQuiescence()
+ server.beforeRequestAdmissionForTest = {}
+ server.reset(
+ listOf(
+ server.route(
+ id = "new-only",
+ path = "/new-only",
+ response = HermeticTestServer.text("new-only"),
+ ),
+ ),
+ )
+
+ assertTrue(server.generationId > firstGeneration)
+ assertEquals(404, request(server.url("/old-only")).status)
+ assertEquals("new-only", request(server.url("/new-only")).text)
+ assertEquals(1, server.requestJournal.unknownRequests.size)
+ }
+ }
+
+ @Test
+ fun truncationAndDisconnectAreObservableWithoutLeakingActiveRequests() {
+ HermeticTestServer().use { server ->
+ server.start()
+ server.reset(
+ listOf(
+ server.route(
+ id = "truncated",
+ path = "/truncated",
+ response = HermeticTestServer.truncated(
+ body = "complete".toByteArray(),
+ bytesToWrite = 2,
+ ),
+ ),
+ server.route(
+ id = "disconnected",
+ path = "/disconnected",
+ response = HermeticTestServer.disconnected(
+ body = "interrupted".toByteArray(),
+ ),
+ ),
+ ),
+ )
+
+ val truncated = request(server.url("/truncated"))
+ assertArrayEquals("co".toByteArray(), truncated.bytes)
+ val disconnected = request(server.url("/disconnected"))
+ assertTrue(disconnected.bytes.size < "interrupted".length)
+ server.awaitQuiescence()
+ assertEquals(0, server.requestJournal.activeRequestCount)
+ assertEquals(2, server.requestJournal.entries.size)
+ }
+ }
+
+ private fun eventually(predicate: () -> Boolean) {
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2)
+ while (System.nanoTime() < deadline && !predicate()) Thread.yield()
+ assertTrue(predicate())
+ }
+
+ private fun request(
+ url: String,
+ followRedirects: Boolean = true,
+ range: String? = null,
+ ): Response {
+ val connection = URL(url).openConnection() as HttpURLConnection
+ connection.instanceFollowRedirects = followRedirects
+ range?.let { connection.setRequestProperty("Range", it) }
+ return try {
+ val status = connection.responseCode
+ val stream = if (status >= 400) connection.errorStream else connection.inputStream
+ val bytes = stream?.use { it.readBytes() } ?: ByteArray(0)
+ Response(
+ status = status,
+ bytes = bytes,
+ text = String(bytes),
+ location = connection.getHeaderField("Location"),
+ contentRange = connection.getHeaderField("Content-Range"),
+ headers = connection.headerFields,
+ )
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ private data class Response(
+ val status: Int,
+ val bytes: ByteArray,
+ val text: String,
+ val location: String?,
+ val contentRange: String?,
+ val headers: Map>,
+ )
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 34e823b2..1ff48e4c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,4 +21,5 @@ dependencyResolutionManagement {
rootProject.name = "Puber"
include(":app")
-include(":baselineprofile")
\ No newline at end of file
+include(":baselineprofile")
+include(":player-test-fixtures")
diff --git a/tools/generate-player-test-fixtures.sh b/tools/generate-player-test-fixtures.sh
new file mode 100755
index 00000000..c4eca9d1
--- /dev/null
+++ b/tools/generate-player-test-fixtures.sh
@@ -0,0 +1,199 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+readonly REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
+readonly MODULE_ROOT="$REPO_ROOT/player-test-fixtures"
+readonly DEFAULT_OUTPUT="$MODULE_ROOT/src/main/assets/player-fixtures"
+readonly FFMPEG_VERSION_REQUIRED="9.0.1"
+
+output="$DEFAULT_OUTPUT"
+force=0
+
+usage() {
+ echo "Usage: $0 [--output DIR] [--force]" >&2
+}
+
+while (($# > 0)); do
+ case "$1" in
+ --output)
+ (($# >= 2)) || { usage; exit 2; }
+ output="$2"
+ shift 2
+ ;;
+ --force)
+ force=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ usage
+ exit 2
+ ;;
+ esac
+done
+
+ffmpeg_bin="${FFMPEG_BIN:-$(command -v ffmpeg || true)}"
+if [[ -z "$ffmpeg_bin" || ! -x "$ffmpeg_bin" ]]; then
+ echo "ffmpeg is required; install version $FFMPEG_VERSION_REQUIRED" >&2
+ exit 1
+fi
+
+ffmpeg_version="$("$ffmpeg_bin" -version | sed -n '1s/^ffmpeg version \([^ ]*\).*/\1/p')"
+if [[ "$ffmpeg_version" != "$FFMPEG_VERSION_REQUIRED" ]]; then
+ echo "Unsupported ffmpeg version '$ffmpeg_version'; expected $FFMPEG_VERSION_REQUIRED" >&2
+ exit 1
+fi
+
+case "$output" in
+ "$MODULE_ROOT"/src/main/assets/player-fixtures|"$REPO_ROOT"/player-test-fixtures/src/main/assets/player-fixtures)
+ ;;
+ *)
+ echo "Refusing to write outside the fixture output directory: $output" >&2
+ exit 1
+ ;;
+esac
+
+if [[ -e "$output" ]]; then
+ if ((force == 0)); then
+ echo "$output already exists; pass --force for an explicit regeneration" >&2
+ exit 1
+ fi
+ rm -rf -- "$output"
+fi
+
+mkdir -p "$output/hls"
+temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/puber-player-fixtures.XXXXXX")"
+trap 'rm -rf -- "$temp_dir"' EXIT
+
+common_video_args=(
+ -hide_banner -loglevel error -y
+ -fflags +bitexact
+ -threads 1
+)
+
+"$ffmpeg_bin" "${common_video_args[@]}" \
+ -f lavfi -i "testsrc2=size=640x360:rate=30" \
+ -f lavfi -i "sine=frequency=440:sample_rate=48000" \
+ -t 4 -shortest \
+ -map_metadata -1 \
+ -c:v libx264 -preset veryfast -profile:v baseline -level 3.0 \
+ -pix_fmt yuv420p -r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
+ -b:v 800k -c:a aac -b:a 96k -ar 48000 \
+ -movflags +faststart \
+ "$output/progressive.mp4"
+
+generate_video_playlist() {
+ local width="$1"
+ local height="$2"
+ local bitrate="$3"
+ local name="$4"
+ "$ffmpeg_bin" "${common_video_args[@]}" \
+ -i "$output/progressive.mp4" -map 0:v:0 -an \
+ -vf "scale=${width}:${height}:flags=bilinear" \
+ -map_metadata -1 \
+ -c:v libx264 -preset veryfast -profile:v baseline -level 3.0 \
+ -pix_fmt yuv420p -r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
+ -b:v "$bitrate" -force_key_frames "expr:gte(t,n_forced*2)" \
+ -f hls -hls_time 2 -hls_playlist_type vod \
+ -hls_flags independent_segments \
+ -hls_segment_filename "$temp_dir/${name}_%03d.ts" \
+ "$temp_dir/${name}.m3u8"
+ cp "$temp_dir/${name}.m3u8" "$output/hls/${name}.m3u8"
+ cp "$temp_dir/${name}"_*.ts "$output/hls/"
+}
+
+generate_video_playlist 640 360 700k video_360
+generate_video_playlist 1280 720 1.4M video_720
+
+generate_audio_playlist() {
+ local frequency="$1"
+ local name="$2"
+ "$ffmpeg_bin" "${common_video_args[@]}" \
+ -f lavfi -i "sine=frequency=${frequency}:sample_rate=48000" \
+ -t 4 -vn -map_metadata -1 -c:a aac -b:a 96k -ar 48000 \
+ -f hls -hls_time 2 -hls_playlist_type vod \
+ -hls_flags independent_segments \
+ -hls_segment_filename "$temp_dir/${name}_%03d.ts" \
+ "$temp_dir/${name}.m3u8"
+ cp "$temp_dir/${name}.m3u8" "$output/hls/${name}.m3u8"
+ cp "$temp_dir/${name}"_*.ts "$output/hls/"
+}
+
+generate_audio_playlist 440 audio_english
+generate_audio_playlist 660 audio_spanish
+
+(
+ cd "$output/hls"
+ {
+ echo '#EXTM3U'
+ echo '#EXT-X-VERSION:3'
+ echo '#EXT-X-INDEPENDENT-SEGMENTS'
+ echo '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="audio_english.m3u8"'
+ echo '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Español",LANGUAGE="es",DEFAULT=NO,AUTOSELECT=YES,URI="audio_spanish.m3u8"'
+ echo '#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"'
+ echo 'video_360.m3u8'
+ echo '#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"'
+ echo 'video_720.m3u8'
+ } > master.m3u8
+)
+
+cat > "$output/subtitle.vtt" <<'EOF'
+WEBVTT
+
+00:00:01.000 --> 00:00:02.000
+Synthetic player fixture cue
+EOF
+
+hash_for() {
+ shasum -a 256 "$output/$1" | awk '{print $1}'
+}
+
+{
+ echo "# Generated by tools/generate-player-test-fixtures.sh"
+ echo
+ echo "Tool: ffmpeg $ffmpeg_version"
+ echo "Inputs: lavfi testsrc2 video pattern and lavfi sine tones at 440 Hz and 660 Hz."
+ echo "Container: progressive H.264/AAC MP4; HLS MPEG-TS VOD playlists with two video variants and two AAC renditions."
+ echo "Duration: 4 seconds; segment target: 2 seconds."
+ echo "The generated bytes are committed. Gradle and tests verify hashes and do not transcode."
+ echo "Regeneration is a maintainer action and requires the exact ffmpeg version above."
+} > "$MODULE_ROOT/fixtures-provenance.md"
+
+{
+ echo "# Generated by tools/generate-player-test-fixtures.sh"
+ echo "fixture.ProgressiveMp4.duration_ms=4000"
+ echo "fixture.ProgressiveMp4.sha256=$(hash_for progressive.mp4)"
+ echo "fixture.HlsMaster.sha256=$(hash_for hls/master.m3u8)"
+ echo "fixture.HlsVideo360Playlist.duration_ms=4000"
+ echo "fixture.HlsVideo360Playlist.sha256=$(hash_for hls/video_360.m3u8)"
+ echo "fixture.HlsVideo720Playlist.duration_ms=4000"
+ echo "fixture.HlsVideo720Playlist.sha256=$(hash_for hls/video_720.m3u8)"
+ echo "fixture.HlsAudioEnglishPlaylist.duration_ms=4000"
+ echo "fixture.HlsAudioEnglishPlaylist.language=en"
+ echo "fixture.HlsAudioEnglishPlaylist.label=English"
+ echo "fixture.HlsAudioEnglishPlaylist.sha256=$(hash_for hls/audio_english.m3u8)"
+ echo "fixture.HlsAudioSpanishPlaylist.duration_ms=4000"
+ echo "fixture.HlsAudioSpanishPlaylist.language=es"
+ echo "fixture.HlsAudioSpanishPlaylist.label=Español"
+ echo "fixture.HlsAudioSpanishPlaylist.sha256=$(hash_for hls/audio_spanish.m3u8)"
+ echo "fixture.SubtitleWebVtt.cue_start_ms=1000"
+ echo "fixture.SubtitleWebVtt.cue_end_ms=2000"
+ echo "fixture.SubtitleWebVtt.sha256=$(hash_for subtitle.vtt)"
+} > "$output/fixture-manifest.properties"
+
+{
+ echo "# Generated by tools/generate-player-test-fixtures.sh"
+ echo "# Paths are relative to player-test-fixtures/src/main/assets/player-fixtures"
+ find "$output" -type f ! -name SHA256SUMS -print |
+ sed "s#^$output/##" |
+ sort |
+ while read -r path; do
+ printf '%s %s\n' "$(hash_for "$path")" "$path"
+ done
+} > "$output/SHA256SUMS"
+
+echo "Generated fixture pack at $output"
diff --git a/tools/resolve-android-test-apk-pair b/tools/resolve-android-test-apk-pair
new file mode 100755
index 00000000..5e5947b1
--- /dev/null
+++ b/tools/resolve-android-test-apk-pair
@@ -0,0 +1,171 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+pairing_contract=""
+expected_target_id=""
+expected_test_id=""
+target_metadata=""
+test_metadata=""
+built_after=""
+
+die() {
+ echo "resolve-android-test-apk-pair: $*" >&2
+ exit 1
+}
+
+if [[ -n "${APKANALYZER:-}" ]]; then
+ analyzer="$APKANALYZER"
+else
+ analyzer=""
+ for candidate in \
+ "${ANDROID_HOME:-}/cmdline-tools/latest/bin/apkanalyzer" \
+ "${ANDROID_HOME:-}/cmdline-tools/17.0/bin/apkanalyzer" \
+ "$(command -v apkanalyzer 2>/dev/null || true)"; do
+ if [[ -x "$candidate" ]]; then
+ analyzer="$candidate"
+ break
+ fi
+ done
+ [[ -n "$analyzer" ]] || die "apkanalyzer is not available"
+fi
+
+usage() {
+ echo "Usage: $0 --pairing-contract CONTRACT [--expected-target-application-id ID] [--expected-test-application-id ID] --target-metadata FILE --test-metadata FILE --built-after EPOCH_SECONDS" >&2
+}
+
+while (($# > 0)); do
+ case "$1" in
+ --pairing-contract) pairing_contract="${2:?missing value}"; shift 2 ;;
+ --expected-target-application-id) expected_target_id="${2:?missing value}"; shift 2 ;;
+ --expected-test-application-id) expected_test_id="${2:?missing value}"; shift 2 ;;
+ --target-metadata) target_metadata="${2:?missing value}"; shift 2 ;;
+ --test-metadata) test_metadata="${2:?missing value}"; shift 2 ;;
+ --built-after) built_after="${2:?missing value}"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) usage; exit 2 ;;
+ esac
+done
+
+[[ "$pairing_contract" == "app-android-test" || "$pairing_contract" == "standalone-self-target" ]] ||
+ die "unsupported pairing contract"
+[[ "$built_after" =~ ^[0-9]+$ ]] || die "--built-after must be epoch seconds"
+[[ -n "$target_metadata" && -n "$test_metadata" ]] || die "both metadata paths are required"
+if [[ "$pairing_contract" == "standalone-self-target" ]]; then
+ [[ -n "$expected_target_id" && -n "$expected_test_id" ]] ||
+ die "standalone-self-target requires expected application IDs"
+fi
+
+file_mtime() {
+ stat -f '%m' "$1" 2>/dev/null || stat -c '%Y' "$1"
+}
+
+regular_non_symlink() {
+ [[ -f "$1" && ! -L "$1" ]]
+}
+
+validate_metadata() {
+ local path="$1"
+ regular_non_symlink "$path" || die "metadata is not a regular non-symlink file: $path"
+ (( $(file_mtime "$path") >= built_after )) || die "metadata is stale: $path"
+ jq empty "$path" >/dev/null || die "metadata is not valid JSON: $path"
+}
+
+resolve_apk() {
+ local metadata="$1"
+ local dir output candidate
+ dir="$(cd -- "$(dirname -- "$metadata")" && pwd -P)"
+ output="$(jq -r '
+ if (.elements | type) != "array" then error("elements is not an array") else . end
+ | if (.elements | length) != 1 then error("expected exactly one output element") else . end
+ | .elements[0]
+ | if .type != "SINGLE" or ((.filters // []) | length) != 0
+ then error("expected one unfiltered SINGLE output") else .outputFile end
+ ' "$metadata")" || die "metadata contains no unique unfiltered SINGLE output: $metadata"
+ [[ -n "$output" && "$output" != "null" ]] || die "metadata outputFile is missing: $metadata"
+ [[ "$output" != /* && "$output" != *$'\n'* && "$output" != *$'\r'* ]] ||
+ die "outputFile must be a relative path: $output"
+ case "/$output/" in
+ */../*|*/..|../*) die "outputFile escapes metadata directory: $output" ;;
+ esac
+ candidate="$dir/$output"
+ regular_non_symlink "$candidate" || die "APK is not a regular non-symlink file: $candidate"
+ (( $(file_mtime "$candidate") >= built_after )) || die "APK is stale: $candidate"
+ printf '%s\n' "$candidate"
+}
+
+manifest_print() {
+ "$analyzer" manifest print "$1" 2>/dev/null || die "apkanalyzer could not inspect $1"
+}
+
+manifest_attr() {
+ local manifest="$1"
+ local element="$2"
+ local attribute="$3"
+ local flattened
+ flattened="$(printf '%s\n' "$manifest" | tr '\n' ' ')"
+ printf '%s\n' "$flattened" |
+ sed -nE "s#.*<$element([^>]*)/?>.*#\1#p" |
+ sed -nE "s#.*android:$attribute=\"([^\"]+)\".*#\1#p"
+}
+
+application_id() {
+ "$analyzer" manifest application-id "$1" 2>/dev/null || die "application-id inspection failed: $1"
+}
+
+validate_metadata "$target_metadata"
+validate_metadata "$test_metadata"
+target_apk="$(resolve_apk "$target_metadata")"
+test_apk="$(resolve_apk "$test_metadata")"
+target_manifest="$(manifest_print "$target_apk")"
+test_manifest="$(manifest_print "$test_apk")"
+[[ -n "$target_manifest" ]] || die "target manifest is empty: $target_apk"
+[[ -n "$test_manifest" ]] || die "test manifest is empty: $test_apk"
+target_id="$(application_id "$target_apk")"
+test_id="$(application_id "$test_apk")"
+runner="$(manifest_attr "$test_manifest" instrumentation name)"
+target_package="$(manifest_attr "$test_manifest" instrumentation targetPackage)"
+
+[[ "$runner" == "androidx.test.runner.AndroidJUnitRunner" ]] ||
+ die "unexpected instrumentation runner: $runner"
+[[ -n "$target_package" ]] || die "instrumentation targetPackage is missing"
+
+case "$pairing_contract" in
+ app-android-test)
+ [[ "$target_package" == "$target_id" ]] ||
+ die "app Android-test targetPackage '$target_package' does not match target '$target_id'"
+ ;;
+ standalone-self-target)
+ [[ "$target_id" == "$expected_target_id" ]] ||
+ die "target application ID '$target_id' does not match expected '$expected_target_id'"
+ [[ "$test_id" == "$expected_test_id" ]] ||
+ die "test application ID '$test_id' does not match expected '$expected_test_id'"
+ [[ "$target_package" == "$test_id" ]] ||
+ die "standalone targetPackage '$target_package' does not match test '$test_id'"
+ ;;
+esac
+
+jq -cn \
+ --arg contract "$pairing_contract" \
+ --arg target_path "$target_apk" \
+ --arg test_path "$test_apk" \
+ --arg target_application_id "$target_id" \
+ --arg test_application_id "$test_id" \
+ --arg target_package "$target_package" \
+ --arg runner "$runner" \
+ --arg target_sha256 "$(shasum -a 256 "$target_apk" | awk '{print $1}')" \
+ --arg test_sha256 "$(shasum -a 256 "$test_apk" | awk '{print $1}')" \
+ '{
+ pairing_contract: $contract,
+ target: {
+ apk_path: $target_path,
+ application_id: $target_application_id,
+ sha256: $target_sha256
+ },
+ test: {
+ apk_path: $test_path,
+ application_id: $test_application_id,
+ target_package: $target_package,
+ runner: $runner,
+ sha256: $test_sha256
+ }
+ }'
diff --git a/tools/test-resolve-android-test-apk-pair b/tools/test-resolve-android-test-apk-pair
new file mode 100755
index 00000000..80a4babf
--- /dev/null
+++ b/tools/test-resolve-android-test-apk-pair
@@ -0,0 +1,159 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+readonly ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly RESOLVER="$ROOT/tools/resolve-android-test-apk-pair"
+readonly TMP="$(mktemp -d "${TMPDIR:-/tmp}/puber-apk-pair-test.XXXXXX")"
+trap 'rm -rf -- "$TMP"' EXIT
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+assert_rejected() {
+ if "$@" >/dev/null 2>&1; then
+ fail "expected rejection: $*"
+ fi
+}
+
+make_analyzer() {
+ local path="$TMP/apkanalyzer"
+ cat > "$path" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ "$1 $2" == "manifest application-id" ]]; then
+ case "$3" in
+ *target.apk) echo "com.kino.puber.instrumentation" ;;
+ *app-test.apk) echo "com.kino.puber.instrumentation" ;;
+ *wrong-target-package.apk) echo "com.kino.puber.instrumentation" ;;
+ *wrong-runner.apk) echo "com.kino.puber.instrumentation" ;;
+ *baseline-test.apk) echo "com.kino.puber.baselineprofile" ;;
+ *) exit 1 ;;
+ esac
+else
+ case "$3" in
+ *target.apk) echo '' ;;
+ *app-test.apk) echo '' ;;
+ *wrong-target-package.apk) echo '' ;;
+ *wrong-runner.apk) echo '' ;;
+ *baseline-test.apk) echo '' ;;
+ *) exit 1 ;;
+ esac
+fi
+EOF
+ chmod +x "$path"
+ echo "$path"
+}
+
+write_metadata() {
+ local metadata="$1"
+ local apk_name="$2"
+ mkdir -p "$(dirname "$metadata")"
+ : > "$(dirname "$metadata")/$apk_name"
+ jq -n --arg output "$apk_name" \
+ '{elements: [{type: "SINGLE", filters: [], outputFile: $output}]}' > "$metadata"
+}
+
+analyzer="$(make_analyzer)"
+target_meta="$TMP/target/output-metadata.json"
+app_test_meta="$TMP/app-test/output-metadata.json"
+baseline_test_meta="$TMP/baseline-test/output-metadata.json"
+wrong_target_package_meta="$TMP/wrong-target-package/output-metadata.json"
+wrong_runner_meta="$TMP/wrong-runner/output-metadata.json"
+build_started="$(date +%s)"
+write_metadata "$target_meta" target.apk
+write_metadata "$app_test_meta" app-test.apk
+write_metadata "$baseline_test_meta" baseline-test.apk
+write_metadata "$wrong_target_package_meta" wrong-target-package.apk
+write_metadata "$wrong_runner_meta" wrong-runner.apk
+
+mkdir -p "$TMP/no-tools"
+missing_analyzer_output="$TMP/missing-analyzer.txt"
+if env -u APKANALYZER \
+ ANDROID_HOME="$TMP/missing-android-home" \
+ PATH="$TMP/no-tools" \
+ /bin/bash "$RESOLVER" >"$missing_analyzer_output" 2>&1; then
+ fail "missing apkanalyzer was accepted"
+else
+ missing_analyzer_status=$?
+fi
+[[ "$missing_analyzer_status" -eq 1 ]] ||
+ fail "missing apkanalyzer exited with $missing_analyzer_status instead of 1"
+[[ "$(cat "$missing_analyzer_output")" == \
+ "resolve-android-test-apk-pair: apkanalyzer is not available" ]] ||
+ fail "missing apkanalyzer diagnostic was not bounded"
+
+valid_app="$(env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$target_meta" --test-metadata "$app_test_meta" \
+ --built-after "$build_started")" || fail "valid app pair rejected"
+[[ "$(jq -r .pairing_contract <<<"$valid_app")" == "app-android-test" ]] ||
+ fail "app contract missing from output"
+
+valid_baseline="$(env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract standalone-self-target \
+ --expected-target-application-id com.kino.puber.instrumentation \
+ --expected-test-application-id com.kino.puber.baselineprofile \
+ --target-metadata "$target_meta" --test-metadata "$baseline_test_meta" \
+ --built-after "$build_started")" || fail "valid standalone pair rejected"
+[[ "$(jq -r .test.target_package <<<"$valid_baseline")" == "com.kino.puber.baselineprofile" ]] ||
+ fail "standalone target package missing"
+
+zero="$TMP/zero.json"
+jq '.elements = []' "$target_meta" > "$zero"
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$zero" --test-metadata "$app_test_meta" --built-after "$build_started"
+
+multi="$TMP/multi.json"
+jq '.elements += [{type: "SINGLE", filters: [], outputFile: "other.apk"}]' "$target_meta" > "$multi"
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$multi" --test-metadata "$app_test_meta" --built-after "$build_started"
+
+stale="$TMP/stale.json"
+cp "$target_meta" "$stale"
+touch -t 202001010000 "$stale"
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$stale" --test-metadata "$app_test_meta" --built-after "$build_started"
+
+split="$TMP/split.json"
+jq '.elements[0].type = "SPLIT"' "$target_meta" > "$split"
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$split" --test-metadata "$app_test_meta" --built-after "$build_started"
+
+escape="$TMP/escape.json"
+jq '.elements[0].outputFile = "../target.apk"' "$target_meta" > "$escape"
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$escape" --test-metadata "$app_test_meta" --built-after "$build_started"
+
+metadata_link="$TMP/metadata-link.json"
+ln -s "$target_meta" "$metadata_link"
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$metadata_link" --test-metadata "$app_test_meta" --built-after "$build_started"
+
+stale_apk="$TMP/target/stale-apk.json"
+cp "$target_meta" "$stale_apk"
+touch -t 202001010000 "$TMP/target/target.apk"
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$stale_apk" --test-metadata "$app_test_meta" --built-after "$build_started"
+touch "$TMP/target/target.apk"
+
+apk_link="$TMP/target/apk-link.json"
+jq '.elements[0].outputFile = "linked.apk"' "$target_meta" > "$apk_link"
+ln -s target.apk "$TMP/target/linked.apk"
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$apk_link" --test-metadata "$app_test_meta" --built-after "$build_started"
+
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$target_meta" --test-metadata "$wrong_target_package_meta" --built-after "$build_started"
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract app-android-test \
+ --target-metadata "$target_meta" --test-metadata "$wrong_runner_meta" --built-after "$build_started"
+
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract standalone-self-target \
+ --expected-target-application-id com.kino.puber.instrumentation \
+ --expected-test-application-id com.kino.puber.baselineprofile \
+ --target-metadata "$target_meta" --test-metadata "$app_test_meta" --built-after "$build_started"
+assert_rejected env APKANALYZER="$analyzer" "$RESOLVER" --pairing-contract standalone-self-target \
+ --expected-target-application-id com.example.wrong \
+ --expected-test-application-id com.kino.puber.baselineprofile \
+ --target-metadata "$target_meta" --test-metadata "$baseline_test_meta" --built-after "$build_started"
+
+echo "APK pair resolver tests passed"