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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,10 @@ dependencies {

// region Kotlin
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.guava)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.kotlinx.coroutines.test)
// endregion

// region Stateless
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 STRATO GmbH.
* SPDX-License-Identifier: GPL-2.0
*/

package com.nextcloud.client.player.media3

import android.content.Context
import androidx.test.annotation.UiThreadTest
import androidx.test.core.app.ApplicationProvider
import com.nextcloud.client.player.media3.common.MediaItemFactory
import com.nextcloud.client.player.media3.common.TestPlayerFactory
import com.nextcloud.client.player.media3.controller.TestMediaControllerFactory
import com.nextcloud.client.player.media3.session.MediaSessionHolder
import com.nextcloud.client.player.media3.session.TestMediaSessionFactory
import com.nextcloud.client.player.model.PlaybackModel
import com.nextcloud.client.player.model.PlaybackSettings
import com.nextcloud.client.player.model.error.DefaultPlaybackErrorStrategy
import com.nextcloud.client.player.model.file.PlaybackFile
import com.nextcloud.client.player.model.file.PlaybackFiles
import com.nextcloud.client.player.model.file.PlaybackFilesComparator
import com.nextcloud.client.player.model.state.PlayerState
import com.nextcloud.client.player.model.state.RepeatMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test

class Media3PlaybackModelTest {
private lateinit var settings: PlaybackSettings
private lateinit var model: PlaybackModel

private val testDispatcher = StandardTestDispatcher()

@Before
@UiThreadTest
fun setup() {
val context: Context = ApplicationProvider.getApplicationContext()
val playerFactory = TestPlayerFactory()
val sessionFactory = TestMediaSessionFactory(context, playerFactory)

settings = PlaybackSettings(context)

model = Media3PlaybackModel(
PlaybackStateFactory(),
sessionFactory,
TestMediaControllerFactory(context) { model as MediaSessionHolder },
settings,
MediaItemFactory(),
DefaultPlaybackErrorStrategy()
)

Dispatchers.setMain(testDispatcher)
}

@After
fun tearDown() {
Dispatchers.resetMain()
}

@Test
@UiThreadTest
fun start_initialState() = runModelTest { model ->
model.state.get().let { state ->
assertTrue(state.currentFiles.isEmpty())
assertNull(state.currentItemState)
assertEquals(settings.repeatMode, state.repeatMode)
assertEquals(settings.isShuffle, state.shuffle)
}
}

@Test
@UiThreadTest
fun setFiles_initialQueue() = runModelTest { model ->
val inputFiles = playbackFiles(3)

model.setFiles(inputFiles)

model.state.get().let { state ->
assertEquals(3, state.currentFiles.size)
assertNotEquals(PlayerState.PLAYING, state.currentItemState!!.playerState)
}

model.play()

model.state.get().let { state ->
assertEquals(PlayerState.PLAYING, state.currentItemState!!.playerState)
assertEquals(inputFiles.list.first().id, state.currentItemState.file.id)
}
}

@Test
@UiThreadTest
fun setFiles_updateRetainCurrentItem() = runModelTest { model ->
val inputFiles = playbackFiles(3)
val initialFileId = inputFiles.list.first().id

model.setFiles(inputFiles)
model.play()

model.state.get().let { state ->
assertEquals(3, state.currentFiles.size)
assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file))
assertEquals(initialFileId, state.currentItemState.file.id)
}

model.setFiles(inputFiles.copy(list = listOf(file(99)) + inputFiles.list))

model.state.get().let { state ->
assertEquals(4, state.currentFiles.size)
assertEquals(1, state.currentFiles.indexOf(state.currentItemState!!.file))
assertEquals(initialFileId, state.currentItemState.file.id)
}
}

@Test
@UiThreadTest
fun setFiles_repositionWhenCurrentRemoved() = runModelTest { model ->
val inputFiles = playbackFiles(3)
val initialFileId = inputFiles.list.first().id

model.setFiles(inputFiles)
model.play()

model.state.get().let { state ->
assertEquals(3, state.currentFiles.size)
assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file))
assertEquals(initialFileId, state.currentItemState.file.id)
}

model.setFiles(inputFiles.copy(list = inputFiles.list.filter { it.id != initialFileId }))

model.state.get().let { state ->
assertEquals(2, state.currentFiles.size)
assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file))
assertNotEquals(initialFileId, state.currentItemState.file.id)
}
}

@Test
@UiThreadTest
fun playback_playPauseStop() = runModelTest { model ->
model.setFiles(playbackFiles(2))

model.play()
assertEquals(PlayerState.PLAYING, model.state.get().currentItemState!!.playerState)

model.pause()
assertEquals(PlayerState.PAUSED, model.state.get().currentItemState!!.playerState)
}

@Test
@UiThreadTest
fun playback_nextPrevious() = runModelTest { model ->
model.setFiles(playbackFiles(3))

model.play()
model.state.get().let { state ->
assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file))
}

model.playNext()
model.state.get().let { state ->
assertEquals(1, state.currentFiles.indexOf(state.currentItemState!!.file))
}

model.playPrevious()
model.state.get().let { state ->
assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file))
}
}

@Test
@UiThreadTest
fun playback_seekToPosition() = runModelTest { model ->
model.setFiles(playbackFiles(1))
model.seekToPosition(5000L)
model.play()
assertEquals(5000L, model.state.get().currentItemState!!.currentTimeInMilliseconds)
}

@Test
@UiThreadTest
fun settings_repeatAndShuffle() = runModelTest { model ->
model.setFiles(playbackFiles(2))
model.setRepeatMode(RepeatMode.ALL)
model.setShuffle(true)

model.state.get().let { state ->
assertEquals(RepeatMode.ALL, state.repeatMode)
assertTrue(state.shuffle)
}

model.setRepeatMode(RepeatMode.SINGLE)
model.setShuffle(false)

model.state.get().let { state ->
assertEquals(RepeatMode.SINGLE, state.repeatMode)
assertFalse(state.shuffle)
}
}

@Test
@UiThreadTest
fun switchToFile_changesCurrentItem() = runModelTest { model ->
val inputFiles = playbackFiles(4)

model.setFiles(inputFiles)
model.play()

model.state.get().let { state ->
assertEquals(0, state.currentFiles.indexOf(state.currentItemState!!.file))
assertEquals(inputFiles.list.first().id, state.currentItemState.file.id)
}

model.switchToFile(inputFiles.list.last())

model.state.get().let { state ->
assertEquals(3, state.currentFiles.indexOf(state.currentItemState!!.file))
assertEquals(inputFiles.list.last().id, state.currentItemState.file.id)
}
}

@Test
@UiThreadTest
fun setFilesFlow_emitsAndUpdates() = runModelTest { model ->
val filesFlow = MutableStateFlow(playbackFiles(2))
model.setFilesFlow(filesFlow)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(2, model.state.get().currentFiles.size)

filesFlow.tryEmit(playbackFiles(3))
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(3, model.state.get().currentFiles.size)
}

private fun runModelTest(test: suspend TestScope.(PlaybackModel) -> Unit) = runTest {
model.start()
test(model)
model.release()
settings.reset()
}

private fun file(id: Int) = PlaybackFile(
id = "id$id",
uri = "https://example.com/media$id.mp3",
name = "media$id.mp3",
mimeType = "audio/mpeg",
contentLength = 123456,
lastModified = System.currentTimeMillis() + id,
isFavorite = false
)

private fun playbackFiles(count: Int): PlaybackFiles {
val list = (0 until count).map { file(it) }
return PlaybackFiles(list, PlaybackFilesComparator.NONE)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 STRATO GmbH.
* SPDX-License-Identifier: GPL-2.0
*/

package com.nextcloud.client.player.media3

import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import com.nextcloud.client.player.media3.common.MediaItemFactory
import com.nextcloud.client.player.media3.common.setExtras
import com.nextcloud.client.player.model.file.PlaybackFile
import com.nextcloud.client.player.model.state.PlayerState
import com.nextcloud.client.player.model.state.RepeatMode
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import androidx.media3.common.VideoSize as ExoVideoSize

class PlaybackStateFactoryTest {

private val stateFactory = PlaybackStateFactory()
private val itemFactory = MediaItemFactory()

@Test
fun create_builds_PlaybackState_with_expected_fields() {
val player = mockk<Player>(relaxed = true)

val file1 = createPlaybackFile("1")
val file2 = createPlaybackFile("2")
val item1 = itemFactory.create(file1)
val item2 = itemFactory.create(file2)

val playerMetadata = MediaMetadata.Builder()
.setTitle("") // force fallback to file name without extension
.setArtist("Artist")
.setAlbumTitle("Album")
.setGenre("Rock")
.setRecordingYear(1999)
.setDescription("Desc")
.setArtworkData(byteArrayOf(1, 2, 3), 0)
.setExtras(file2)
.build()

every { player.mediaItemCount } returns 2
every { player.getMediaItemAt(0) } returns item1
every { player.getMediaItemAt(1) } returns item2
every { player.shuffleModeEnabled } returns true
every { player.repeatMode } returns Player.REPEAT_MODE_ALL
every { player.currentMediaItem } returns item2
every { player.mediaMetadata } returns playerMetadata
every { player.currentPosition } returns 12_345L
every { player.duration } returns 54_321L
every { player.videoSize } returns ExoVideoSize(1920, 1080)
every { player.playbackState } returns Player.STATE_READY
every { player.playWhenReady } returns false

val state = stateFactory.create(player).orElseThrow()
val currentItemState = state.currentItemState!!
val currentItemMetadata = currentItemState.metadata!!
val currentItemVideoSize = currentItemState.videoSize!!

assertEquals(listOf(file1, file2), state.currentFiles)
assertEquals(RepeatMode.ALL, state.repeatMode)
assertTrue(state.shuffle)
assertEquals(file2, currentItemState.file)
assertEquals(PlayerState.PAUSED, currentItemState.playerState)
assertEquals("name2", currentItemMetadata.title) // fallback from empty title
assertEquals("Artist", currentItemMetadata.artist)
assertEquals("Album", currentItemMetadata.album)
assertEquals("Rock", currentItemMetadata.genre)
assertEquals(1999, currentItemMetadata.year)
assertEquals("Desc", currentItemMetadata.description)
assertArrayEquals(byteArrayOf(1, 2, 3), currentItemMetadata.artworkData)
assertNull(currentItemMetadata.artworkUri)
assertEquals(1920, currentItemVideoSize.width)
assertEquals(1080, currentItemVideoSize.height)
assertEquals(12_345L, currentItemState.currentTimeInMilliseconds)
assertEquals(54_321L, currentItemState.maxTimeInMilliseconds)
}

private fun createPlaybackFile(id: String) = PlaybackFile(
id = id,
uri = "uri$id",
name = "name$id.mp3",
mimeType = "audio/mpeg",
contentLength = 0,
lastModified = 0,
isFavorite = false
)
}
Loading