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"