diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt b/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt index aaf0cce5..838b0ed7 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt @@ -41,7 +41,14 @@ val streams by lazy { TypedSource.Builder("https://livesim.dashif.org/livesim/testpic_2s/Manifest.mpd") .build() ).build() - ) + ), + Stream( + title = "Test card (with CEA tracks)", + source = SourceDescription.Builder( + TypedSource.Builder("https://livesim2.dashif.org/vod/testpic_2s/cea608.mpd") + .build() + ).build() + ), ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e1932a3c..230bf242 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,13 +10,14 @@ activity-compose = "1.10.1" appcompat = "1.7.1" compose-bom = "2025.08.01" junit4 = "4.13.2" +mockk = "1.14.9" playServices-castFramework = "21.5.0" ui-test-junit4 = "1.9.0" # ...not in BOM for some reason? androidx-junit = "1.3.0" androidx-espresso = "3.7.0" androidx-mediarouter = "1.8.1" dokka = "2.0.0" -theoplayer = { prefer="10.11.0", strictly = "[7.6.0, 11.0)" } +theoplayer = { prefer="10.13.0", strictly = "[7.6.0, 11.0)" } [libraries] androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" } @@ -35,6 +36,7 @@ androidx-compose-ui-toolingPreview = { group = "androidx.compose.ui", name = "ui androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } androidx-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso" } androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "androidx-mediarouter" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } playServices-castFramework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServices-castFramework" } gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "gradle" } dokka-base = { group = "org.jetbrains.dokka", name = "dokka-base", version.ref = "dokka" } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index af840ffc..918d0db7 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -78,7 +78,9 @@ dependencies { implementation(libs.androidx.compose.ui.toolingPreview) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.iconsExtended) + testImplementation(libs.junit4) + testImplementation(libs.mockk) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso) androidTestImplementation(libs.androidx.compose.ui.testJunit4) diff --git a/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt index a430fbf5..855fcb89 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt @@ -29,7 +29,7 @@ fun AudioTrackList( ) { val audioTrack = audioTracks[it] ListItem( - headlineContent = { Text(text = formatTrackLabel(audioTrack)) }, + headlineContent = { Text(text = rememberTrackLabel(audioTrack)) }, leadingContent = { RadioButton( selected = (activeAudioTrack == audioTrack), diff --git a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt index cef88a0c..16e8c557 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -1,9 +1,11 @@ package com.theoplayer.android.ui +import android.content.res.Resources import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalResources import com.theoplayer.android.api.player.track.Track -import java.util.Locale +import com.theoplayer.android.ui.util.constructLabel import kotlin.math.absoluteValue /** @@ -63,19 +65,9 @@ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = fals * @param track the media track or text track */ @Composable -fun formatTrackLabel(track: Track): String { - val label = track.label - if (!label.isNullOrEmpty()) { - return label - } - val languageCode = track.language - if (!languageCode.isNullOrEmpty()) { - val locale = Locale.forLanguageTag(languageCode) - val languageName = locale.getDisplayName(locale) - if (languageName.isNotEmpty()) { - return languageName - } - return languageCode - } - return stringResource(R.string.theoplayer_ui_track_unknown) -} \ No newline at end of file +fun rememberTrackLabel( + track: Track, + resources: Resources = LocalResources.current, +): String = remember(key1 = track.id, key2 = track.uid) { + constructLabel(track) ?: resources.getString(R.string.theoplayer_ui_track_unknown) +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt index c3824d49..93d60b76 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt @@ -86,7 +86,7 @@ fun MenuScope.LanguageMenuCompact() { ) { Text( modifier = Modifier.weight(1f), - text = player?.activeAudioTrack?.let { formatTrackLabel(it) } + text = player?.activeAudioTrack?.let { rememberTrackLabel(it) } ?: stringResource( R.string.theoplayer_ui_audio_none ), @@ -115,7 +115,7 @@ fun MenuScope.LanguageMenuCompact() { ) { Text( modifier = Modifier.weight(1f), - text = player?.activeSubtitleTrack?.let { formatTrackLabel(it) } ?: stringResource( + text = player?.activeSubtitleTrack?.let { rememberTrackLabel(it) } ?: stringResource( R.string.theoplayer_ui_subtitles_off ), textAlign = TextAlign.Center diff --git a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt index 8e70f194..7fbfdfd7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt @@ -45,7 +45,7 @@ fun SubtitleTrackList( ) { val audioTrack = subtitleTracks[it] ListItem( - headlineContent = { Text(text = formatTrackLabel(audioTrack)) }, + headlineContent = { Text(text = rememberTrackLabel(audioTrack)) }, leadingContent = { RadioButton( selected = (activeSubtitleTrack == audioTrack), diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt new file mode 100644 index 00000000..a6fd6857 --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt @@ -0,0 +1,45 @@ +package com.theoplayer.android.ui.util + +import androidx.annotation.CheckResult +import androidx.annotation.IntRange + +private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex() + +/** + * Checks whether a provided label is CEA-608 or CEA-708 formed. + */ +@CheckResult +internal fun isLabelCeaFormatted(label: String?): Boolean { + if (label.isNullOrEmpty()) { + return false + } + + val matchResult = CEA_FORMATTING_REGEX.find(label) ?: return false + val groupValues = matchResult.groupValues + // There is one group we want to match with the channel number. + if (groupValues.size != 2) { + return false + } + + val rawChannelNumber = groupValues[1] + val channelNumber = rawChannelNumber.toIntOrNull() + return !rawChannelNumber.startsWith("0") && channelNumber in 1..63 +} + +/** + * Creates a text track label for CEA-608 and CEA-708 formats. + * + * @return an optional string composed of a [channelNumber] and a prepended + * "CC" suffix, or `null` if the channel number is invalid. + */ +@CheckResult +internal fun getLabelForChannelNumber( + @IntRange(from = 0L, to = 63L) channelNumber: Int, +): String? { + // CEA-608 only supports channel numbers in [1, 4], + // while CEA-708 support service numbers in [1, 63]. + if (channelNumber !in 1..63) { + return null + } + return "CC${channelNumber}" +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt b/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt new file mode 100644 index 00000000..164ed51b --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt @@ -0,0 +1,32 @@ +package com.theoplayer.android.ui.util + +import androidx.annotation.DoNotInline +import com.theoplayer.android.api.player.track.texttrack.TextTrack + +/** + * Returns [TextTrack.getCaptionChannel], if available. + */ +internal val TextTrack.captionChannelCompat: Int? + get() { + // TextTrack.getCaptionChannel was added in THEOplayer 10.13.0. + return if (THEOplayerGlobalExt.version >= version1013) { + TheoPlayer1013Impl.getTextTrackCaptionChannel(this) + } else null + } + +private val version1013 = Version(major = 10, minor = 13, patchAndPrerelease = "0") + +/** + * This class must be loaded **only** with THEOplayer 10.13.0 or higher. + * + * This uses the same pattern as AndroidX AppCompat, + * see e.g. [androidx.appcompat.app.AppCompatDelegate.Api33Impl] + * and the docs about [API-specific implementations](https://github.com/androidx/androidx/blob/androidx-main/docs/api_guidelines/compat.md#delegating-to-api-specific-implementations-delegating-to-api-specific-implementations) + * and [static shims](https://github.com/androidx/androidx/blob/androidx-main/docs/api_guidelines/platform_compat.md#static-shims-ex-viewcompat-static-shim). + */ +private class TheoPlayer1013Impl private constructor() { + companion object { + @DoNotInline + fun getTextTrackCaptionChannel(track: TextTrack): Int? = track.captionChannel + } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt new file mode 100644 index 00000000..54bb6c93 --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -0,0 +1,88 @@ +package com.theoplayer.android.ui.util + +import androidx.annotation.CheckResult +import com.theoplayer.android.api.player.track.Track +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackType +import java.util.Locale + +private const val LANGUAGE_UNDEFINED = "und" + +/** + * Returns a name for the [Track.language] in the + * [Locale.Category.DISPLAY] locale that is appropriate + * for display to the user. + * If such conversion is not possible, for instance + * when [Track.language] is `null`, blank, or `"und"`, + * returns `null`. + */ +@get:CheckResult +internal val Track.localizedLanguageName: String? + get() { + val languageCode = this.language + ?.takeUnless { it.isBlank() || it == LANGUAGE_UNDEFINED } + ?: return null + val locale = Locale.forLanguageTag(languageCode) + val localisedLanguage: String? = locale.getDisplayName(locale) + return localisedLanguage?.takeUnless { it.isBlank() } + } + +/** + * Constructs a label for the given [Track] instance. + * The method works slightly different for different player version. + * + * On version 10 and below the logic checks the following and condition + * and the first not `null` entry from the list: + * 1. Track label if is not a language code + * or a CEA-prefixed string. + * 2. Track language display name + * 3. Track caption channel if a text CEA-608 track + * 4. Track label if was either a language code or a CEA-prefixed string + * + * If none of the above is satisfied, returns `null`. + * + * On version 11 and later the logic has slightly changed as + * the player no longer constructs the [Track.getLabel] internally: + * 1. Track label + * 2. Track language display name + * 3. Track caption channel + */ +internal fun constructLabel( + track: Track, +): String? { + val label: String? = if ( + (track is TextTrack) && + THEOplayerGlobalExt.version.major < 11 && + (isLabelCeaFormatted(track.label) || (track.label != null && track.language == track.label)) + ) { + // If we are below 11th major release + // and the label is CEA-formatted we + // can safely assume it was the last resort + // option to produce a meaningful label, given + // we cannot localize the language code in the player. + null + } else { + // With 11 release, the player will no longer + // prefix text tracks with "CC" for CEA-608 and CEA-708, + // if [Track.label] is `null`. + track.label + } + + if (!label.isNullOrBlank()) { + return label + } + + track.localizedLanguageName?.let { return it } + + if ((track is TextTrack) && track.type == TextTrackType.CEA608) { + track.captionChannelCompat + ?.let { getLabelForChannelNumber(it) } + ?.let { return it } + + track.label + ?.takeUnless { it.isBlank() } + ?.let { return it } + } + + return null +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt new file mode 100644 index 00000000..d654e984 --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -0,0 +1,62 @@ +package com.theoplayer.android.ui.util + +import com.theoplayer.android.api.THEOplayerGlobal + +private const val VERSION_DELIMITER = '.' + +/** + * A [semver](https://semver.org/) version. + */ +internal data class Version( + /** + * The major version. + */ + val major: Int, + /** + * The minor version. + */ + val minor: Int, + /** + * The patch (and prerelease) version. + */ + val patchAndPrerelease: String, +) : Comparable { + override fun toString() = buildString { + append(major) + append(VERSION_DELIMITER) + append(minor) + append(VERSION_DELIMITER) + append(patchAndPrerelease) + } + + override fun compareTo(other: Version): Int { + return compareBy { it.major } + .thenBy { it.minor } + .thenBy { it.patchAndPrerelease } + .compare(this, other) + } + + companion object { + fun parse(version: String): Version { + try { + val versionParts = version.split(VERSION_DELIMITER, limit = 3) + require(versionParts.size == 3) + val (major, minor, patchAndPrerelease) = versionParts + return Version( + major = major.toInt(), + minor = minor.toInt(), + patchAndPrerelease = patchAndPrerelease + ) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid version", e) + } + } + } +} + +internal object THEOplayerGlobalExt { + /** + * Returns the version of THEOplayer, as a [Version]. + */ + val version: Version by lazy { Version.parse(THEOplayerGlobal.getVersion()) } +} diff --git a/ui/src/test/java/com/theoplayer/android/ui/ExampleUnitTest.kt b/ui/src/test/java/com/theoplayer/android/ui/ExampleUnitTest.kt deleted file mode 100644 index b281c162..00000000 --- a/ui/src/test/java/com/theoplayer/android/ui/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.theoplayer.android.ui - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt new file mode 100644 index 00000000..0f95e156 --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt @@ -0,0 +1,181 @@ +package com.theoplayer.android.ui.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Enclosed::class) +class CeaUtilTest { + + @RunWith(Parameterized::class) + class IsLabelCeaFormattedTest( + private val args: Args, + ) { + + @Test + fun `WHEN provided with a label THEN returns whether CEA formatted`() { + assertEquals( + args.expectedIsCeaFormatted, + isLabelCeaFormatted(args.label), + ) + } + + data class Args( + val label: String?, + val expectedIsCeaFormatted: Boolean, + ) + + private companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + // False. + Args( + label = null, + expectedIsCeaFormatted = false, + ), + Args( + label = "", + expectedIsCeaFormatted = false, + ), + Args( + label = "abc", + expectedIsCeaFormatted = false, + ), + Args( + label = "Some label", + expectedIsCeaFormatted = false, + ), + Args( + label = "Text with cc1 inlined", + expectedIsCeaFormatted = false, + ), + Args( + label = "cC1", + expectedIsCeaFormatted = false, + ), + Args( + label = "Cc1", + expectedIsCeaFormatted = false, + ), + Args( + label = "CC0", + expectedIsCeaFormatted = false, + ), + Args( + label = "CC01", + expectedIsCeaFormatted = false, + ), + Args( + label = "CC64", + expectedIsCeaFormatted = false, + ), + Args( + label = "CC128", + expectedIsCeaFormatted = false, + ), + + // True. + Args( + label = "CC1", + expectedIsCeaFormatted = true, + ), + Args( + label = "CC2", + expectedIsCeaFormatted = true, + ), + Args( + label = "CC3", + expectedIsCeaFormatted = true, + ), + Args( + label = "CC4", + expectedIsCeaFormatted = true, + ), + Args( + label = "CC22", + expectedIsCeaFormatted = true, + ), + Args( + label = "CC63", + expectedIsCeaFormatted = true, + ), + ) + } + } + + @RunWith(Parameterized::class) + class GetLabelForChannelNumberTest( + private val args: Args, + ) { + + @Test + fun `WHEN provided with a channel number THEN returns an expected label`() { + assertEquals( + args.expectedLabel, + getLabelForChannelNumber(args.channelNumber), + ) + } + + data class Args( + val channelNumber: Int, + val expectedLabel: String?, + ) + + private companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + // Boundary checks. + Args( + channelNumber = -1, + expectedLabel = null, + ), + Args( + channelNumber = -100, + expectedLabel = null, + ), + Args( + channelNumber = 100, + expectedLabel = null, + ), + Args( + channelNumber = 64, + expectedLabel = null, + ), + Args( + channelNumber = 0, + expectedLabel = null, + ), + + // Regular checks. + Args( + channelNumber = 1, + expectedLabel = "CC1", + ), + Args( + channelNumber = 2, + expectedLabel = "CC2", + ), + Args( + channelNumber = 3, + expectedLabel = "CC3", + ), + Args( + channelNumber = 4, + expectedLabel = "CC4", + ), + Args( + channelNumber = 22, + expectedLabel = "CC22", + ), + Args( + channelNumber = 63, + expectedLabel = "CC63", + ), + ) + } + } +} diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt new file mode 100644 index 00000000..a4befdaf --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt @@ -0,0 +1,235 @@ +package com.theoplayer.android.ui.util + +import com.theoplayer.android.api.player.track.Track +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackType +import io.mockk.clearMocks +import io.mockk.clearStaticMockk +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import org.junit.After +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.Parameterized +import java.util.Locale + +@RunWith(Enclosed::class) +class TrackExtsTest { + + @RunWith(JUnit4::class) + class LocalisedLanguageNameTest { + + private val track = mockk() + private val locale = mockk() + + @Before + fun setUp() { + mockkStatic(Locale::class) + } + + @After + fun tearDown() { + clearStaticMockk(Locale::class) + } + + @Test + fun `GIVEN language is null THEN localised language is also null`() { + every { track.language } returns null + Assert.assertNull(track.localizedLanguageName) + } + + @Test + fun `GIVEN language is und THEN localised language is null`() { + every { track.language } returns LANGUAGE_CODE_UNDEFINED + Assert.assertNull(track.localizedLanguageName) + } + + @Test + fun `GIVEN language is blank THEN localised language is null`() { + every { track.language } returns TEST_BLANK_STRING + Assert.assertNull(track.localizedLanguageName) + } + + @Test + fun `GIVEN locale returns null as displayLanguage THEN localised language is null`() { + every { track.language } returns LANGUAGE_CODE_ENGLISH + every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale + every { locale.getDisplayName(any()) } returns null + + Assert.assertNull(track.localizedLanguageName) + } + + @Test + fun `GIVEN locale returns a blank string as displayLanguage THEN localised language is null`() { + every { track.language } returns LANGUAGE_CODE_ENGLISH + every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale + every { locale.getDisplayName(any()) } returns TEST_BLANK_STRING + + Assert.assertNull(track.localizedLanguageName) + } + + @Test + fun `GIVEN locale returns a valid display name THEN returns localised name`() { + every { track.language } returns LANGUAGE_CODE_ENGLISH + every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale + every { locale.getDisplayName(any()) } returns LOCALISED_ENGLISH_CODE_NAME + + assertEquals(LOCALISED_ENGLISH_CODE_NAME, track.localizedLanguageName) + } + + private companion object { + const val LANGUAGE_CODE_UNDEFINED = "und" + const val LANGUAGE_CODE_ENGLISH = "en" + const val LOCALISED_ENGLISH_CODE_NAME = "English" + const val TEST_BLANK_STRING = " " + } + } + + @RunWith(Parameterized::class) + class ConstructLabelTest( + private val args: Args, + ) { + + private val track = mockk() + + @Before + fun setUp() { + mockkObject(THEOplayerGlobalExt) + every { THEOplayerGlobalExt.version } returns Version.parse(args.playerVersion) + + every { track.type } returns TextTrackType.CEA608 + every { track.label } returns args.label + every { track.language } returns args.language + every { track.captionChannel } returns args.captionChannel + + mockkStatic(Track::localizedLanguageName) + every { any().localizedLanguageName } returns args.localizedLanguageName + } + + @After + fun tearDown() { + clearMocks(THEOplayerGlobalExt) + clearStaticMockk(Track::localizedLanguageName) + } + + @Test + fun `WHEN a valid track provided THEN returns a correct label`() { + assertEquals( + args.expectedLabel, + constructLabel(track), + ) + } + + data class Args( + val label: String?, + val language: String?, + val localizedLanguageName: String?, + val captionChannel: Int?, + val playerVersion: String, + val expectedLabel: String?, + ) + + private companion object { + + const val TEST_PLAYER_VERSION_10 = "10.1.1" + const val TEST_PLAYER_VERSION_11 = "11.0.10" + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + // Boundary checks. + Args( + label = null, + language = null, + localizedLanguageName = null, + captionChannel = null, + playerVersion = "0.0.0", + expectedLabel = null, + ), + + // v10 checks. + Args( + label = "Hello world", + language = null, + localizedLanguageName = null, + captionChannel = null, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "Hello world", + ), + Args( + label = null, + language = "en", + localizedLanguageName = "English", + captionChannel = null, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "English", + ), + Args( + label = "en", + language = "en", + localizedLanguageName = "English", + captionChannel = null, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "English", + ), + Args( + label = "en", + language = null, + localizedLanguageName = null, + captionChannel = null, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "en", + ), + Args( + label = "CC1", + language = "en", + localizedLanguageName = "English", + captionChannel = null, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "English", + ), + Args( + label = null, + language = null, + localizedLanguageName = null, + captionChannel = 1, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "CC1", + ), + + // v11 checks. + Args( + label = "Hello world", + language = null, + localizedLanguageName = null, + captionChannel = null, + playerVersion = TEST_PLAYER_VERSION_11, + expectedLabel = "Hello world", + ), + Args( + label = "en", + language = "en", + localizedLanguageName = "English", + captionChannel = null, + playerVersion = TEST_PLAYER_VERSION_11, + expectedLabel = "en", + ), + Args( + label = null, + language = null, + localizedLanguageName = null, + captionChannel = 4, + playerVersion = TEST_PLAYER_VERSION_11, + expectedLabel = "CC4", + ), + ) + } + } +} diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt new file mode 100644 index 00000000..621fdcd6 --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt @@ -0,0 +1,119 @@ +package com.theoplayer.android.ui.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Enclosed::class) +internal class VersionUtilTest { + + @RunWith(Parameterized::class) + class ParseVersionTest( + private val args: Args, + ) { + + @Test + fun `WHEN a version string provided THEN returns a correct major version`() { + assertEquals( + args.expected, + Version.parse(args.version), + ) + } + + data class Args( + val version: String, + val expected: Version, + ) { + override fun toString(): String = version + } + + private companion object { + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + Args( + version = "11.0.0", + expected = Version(major = 11, minor = 0, patchAndPrerelease = "0"), + ), + Args( + version = "1.2.3", + expected = Version(major = 1, minor = 2, patchAndPrerelease = "3"), + ), + Args( + version = "9.8.7", + expected = Version(major = 9, minor = 8, patchAndPrerelease = "7"), + ), + Args( + version = "1.1.0-beta01", + expected = Version(major = 1, minor = 1, patchAndPrerelease = "0-beta01"), + ), + Args( + version = "2.1.0-beta.1.0", + expected = Version(major = 2, minor = 1, patchAndPrerelease = "0-beta.1.0"), + ), + Args( + version = "16.8.2+01", + expected = Version(major = 16, minor = 8, patchAndPrerelease = "2+01"), + ), + ) + } + } + + @RunWith(Parameterized::class) + class InvalidVersionTest( + private val version: String + ) { + + @Test + fun `WHEN an invalid version string provided THEN throws an error`() { + assertThrows(IllegalArgumentException::class.java) { + Version.parse(version) + } + } + + private companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + "", + "not a version string", + "1.00" + ) + } + } + + class CompareVersionTest { + @Test + fun `WHEN left major is less than right major THEN left is less than right`() { + assertEquals(Version.parse("3.0.0") compareTo Version.parse("4.0.0"), -1) + assertEquals(Version.parse("3.9.1") compareTo Version.parse("4.0.0"), -1) + assertEquals(Version.parse("10.5.3") compareTo Version.parse("11.0.0"), -1) + } + + @Test + fun `WHEN majors are equal and left minor is less than right major THEN left is less than right`() { + assertEquals(Version.parse("3.0.0") compareTo Version.parse("3.1.0"), -1) + assertEquals(Version.parse("3.9.1") compareTo Version.parse("3.10.0"), -1) + assertEquals(Version.parse("10.5.3") compareTo Version.parse("10.6.0"), -1) + } + + @Test + fun `WHEN majors and minors are equal and left patch is less than right patch THEN left is less than right`() { + assertEquals(Version.parse("3.0.0") compareTo Version.parse("3.0.1"), -1) + assertEquals(Version.parse("3.9.1") compareTo Version.parse("3.9.10"), -1) + assertEquals(Version.parse("10.5.3") compareTo Version.parse("10.5.4"), -1) + } + + @Test + fun `WHEN majors, minors and patches are equal THEN left is equal to right`() { + assertEquals(Version.parse("3.0.0") compareTo Version.parse("3.0.0"), 0) + assertEquals(Version.parse("3.9.1") compareTo Version.parse("3.9.1"), 0) + assertEquals(Version.parse("10.5.3") compareTo Version.parse("10.5.3"), 0) + } + } + +}