Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ private object EmptyCatalogFixture : app.muxtv.catalog.ChannelBrowseRepository {
override fun pages(query: app.muxtv.catalog.ChannelBrowseQuery):
kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<app.muxtv.catalog.ChannelBrowseItem>> =
kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty())

override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery):
kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<app.muxtv.catalog.ChannelManagementItem>> =
kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty())
}

private fun SemanticsNodeInteraction.press(
Expand Down
8 changes: 8 additions & 0 deletions app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ class HomeJourneyTest {
val EmptyBrowseRepository = object : ChannelBrowseRepository {
override fun pages(query: ChannelBrowseQuery): Flow<androidx.paging.PagingData<ChannelBrowseItem>> =
flowOf(androidx.paging.PagingData.empty())

override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery):
Flow<androidx.paging.PagingData<app.muxtv.catalog.ChannelManagementItem>> =
flowOf(androidx.paging.PagingData.empty())
}

val FavoritesBrowseRepository = object : ChannelBrowseRepository {
Expand All @@ -257,6 +261,10 @@ class HomeJourneyTest {
),
),
)

override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery):
Flow<androidx.paging.PagingData<app.muxtv.catalog.ChannelManagementItem>> =
flowOf(androidx.paging.PagingData.empty())
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import app.muxtv.catalog.ChannelBrowseFilter
import app.muxtv.catalog.ChannelBrowseItem
import app.muxtv.catalog.ChannelBrowseQuery
import app.muxtv.catalog.ChannelBrowseRepository
import app.muxtv.catalog.ChannelManagementItem
import app.muxtv.catalog.ChannelManagementQuery
import app.muxtv.catalog.ChannelNowNext
import app.muxtv.catalog.ChannelQuery
import app.muxtv.catalog.EpgGuideRepository
Expand All @@ -18,6 +20,7 @@ import app.muxtv.catalog.RecentChannelsQuery
import app.muxtv.catalog.RecentChannelsRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest

@OptIn(ExperimentalCoroutinesApi::class)
Expand Down Expand Up @@ -59,6 +62,9 @@ internal class TestChannelBrowseRepository(
}
}

override fun managementPages(query: ChannelManagementQuery): Flow<PagingData<ChannelManagementItem>> =
flowOf(PagingData.empty())

private companion object {
val COMPLETED_LOAD_STATES = LoadStates(
refresh = LoadState.NotLoading(endOfPaginationReached = true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,51 @@ data class ChannelBrowseItem(
"guideState=$guideState)"
}

enum class ChannelManagementVisibility {
ALL,
VISIBLE,
HIDDEN,
}

data class ChannelManagementQuery(
val profileId: String,
val visibility: ChannelManagementVisibility,
) {
init {
require(profileId.isNotBlank())
}

override fun toString(): String =
"ChannelManagementQuery(profileId=<redacted>, visibility=$visibility)"
}

data class ChannelManagementItem(
val channelId: String,
val canonicalDisplayName: String,
val effectiveDisplayName: String,
val defaultChannelNumber: String?,
val customChannelNumber: Int?,
val effectiveChannelNumber: String?,
val isFavorite: Boolean,
val isHidden: Boolean,
val variantCount: Int,
) {
init {
require(channelId.isNotBlank())
require(canonicalDisplayName.isNotBlank())
require(effectiveDisplayName.isNotBlank())
require(variantCount > 0)
}

override fun toString(): String =
"ChannelManagementItem(channelId=<redacted>, canonicalDisplayName=<redacted>, " +
"effectiveDisplayName=<redacted>, defaultChannelNumberPresent=${defaultChannelNumber != null}, " +
"customChannelNumber=$customChannelNumber, effectiveChannelNumberPresent=${effectiveChannelNumber != null}, " +
"isFavorite=$isFavorite, isHidden=$isHidden, variantCount=$variantCount)"
}

interface ChannelBrowseRepository {
fun pages(query: ChannelBrowseQuery): Flow<PagingData<ChannelBrowseItem>>

fun managementPages(query: ChannelManagementQuery): Flow<PagingData<ChannelManagementItem>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package app.muxtv.catalog

import com.google.common.truth.Truth.assertThat
import org.junit.Assert.assertThrows
import org.junit.Test

class ChannelManagementContractTest {
@Test
fun queryRequiresProfileAndRedactsItFromDiagnostics() {
assertThrows(IllegalArgumentException::class.java) {
ChannelManagementQuery(
profileId = " ",
visibility = ChannelManagementVisibility.ALL,
)
}

val query = ChannelManagementQuery(
profileId = "private-profile-id",
visibility = ChannelManagementVisibility.HIDDEN,
)

assertThat(query.toString()).doesNotContain("private-profile-id")
assertThat(query.toString()).contains("HIDDEN")
}

@Test
fun visibilityModesAreExplicitAndStable() {
assertThat(ChannelManagementVisibility.entries)
.containsExactly(
ChannelManagementVisibility.ALL,
ChannelManagementVisibility.VISIBLE,
ChannelManagementVisibility.HIDDEN,
)
.inOrder()
}

@Test
fun managementItemKeepsCanonicalAndEffectiveValuesDistinct() {
val item = ChannelManagementItem(
channelId = "channel-1",
canonicalDisplayName = "Discovery Channel HD",
effectiveDisplayName = "Discovery",
defaultChannelNumber = "501",
customChannelNumber = 7,
effectiveChannelNumber = "7",
isFavorite = true,
isHidden = true,
variantCount = 2,
)

assertThat(item.canonicalDisplayName).isEqualTo("Discovery Channel HD")
assertThat(item.effectiveDisplayName).isEqualTo("Discovery")
assertThat(item.defaultChannelNumber).isEqualTo("501")
assertThat(item.customChannelNumber).isEqualTo(7)
assertThat(item.effectiveChannelNumber).isEqualTo("7")
assertThat(item.toString()).doesNotContain("Discovery Channel HD")
assertThat(item.toString()).doesNotContain("Discovery")
}

@Test
fun managementItemRequiresAtLeastOneActiveVariant() {
assertThrows(IllegalArgumentException::class.java) {
ChannelManagementItem(
channelId = "channel-1",
canonicalDisplayName = "Canonical",
effectiveDisplayName = "Effective",
defaultChannelNumber = null,
customChannelNumber = null,
effectiveChannelNumber = null,
isFavorite = false,
isHidden = false,
variantCount = 0,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.muxtv.catalog.ChannelBrowseFilter
import app.muxtv.catalog.ChannelBrowseQuery
import app.muxtv.catalog.ChannelManagementQuery
import app.muxtv.catalog.ChannelManagementVisibility
import app.muxtv.catalog.ChannelNowNext
import app.muxtv.catalog.EpgGuideRepository
import app.muxtv.catalog.NowNextQuery
Expand Down Expand Up @@ -106,6 +108,69 @@ class ChannelBrowseRepositoryTest {
.inOrder()
}

@Test
fun managementVisibilityCanRecoverHiddenChannelsWithoutWeakeningBrowse() = runTest {
activateRevision(revisionNumber = 1L, channelCount = 2)
database.catalogDao().insertOverlay(
UserChannelOverlayEntity(
profileId = PROFILE_ID,
canonicalChannelId = "channel-00002",
isHidden = true,
),
)

val browse = repository.pages(query(ChannelBrowseFilter.ALL)).asSnapshot()
val all = repository.managementPages(managementQuery(ChannelManagementVisibility.ALL)).asSnapshot()
val visible = repository.managementPages(managementQuery(ChannelManagementVisibility.VISIBLE)).asSnapshot()
val hidden = repository.managementPages(managementQuery(ChannelManagementVisibility.HIDDEN)).asSnapshot()

assertThat(browse.map { it.channelId }).containsExactly("channel-00001")
assertThat(all.map { it.channelId })
.containsExactly("channel-00001", "channel-00002")
.inOrder()
assertThat(visible.map { it.channelId }).containsExactly("channel-00001")
assertThat(hidden.map { it.channelId }).containsExactly("channel-00002")
assertThat(hidden.single().isHidden).isTrue()
}

@Test
fun managementProjectionKeepsProviderDefaultsSeparateFromUserOverrides() = runTest {
activateRevision(revisionNumber = 1L, channelCount = 1)
database.catalogDao().insertOverlay(
UserChannelOverlayEntity(
profileId = PROFILE_ID,
canonicalChannelId = "channel-00001",
isFavorite = true,
customName = "Renamed channel",
channelNumber = 7,
),
)

val item = repository.managementPages(managementQuery(ChannelManagementVisibility.ALL))
.asSnapshot()
.single()

assertThat(item.canonicalDisplayName).isEqualTo("Channel 00001")
assertThat(item.effectiveDisplayName).isEqualTo("Renamed channel")
assertThat(item.defaultChannelNumber).isEqualTo("1")
assertThat(item.customChannelNumber).isEqualTo(7)
assertThat(item.effectiveChannelNumber).isEqualTo("7")
assertThat(item.isFavorite).isTrue()
assertThat(item.isHidden).isFalse()
assertThat(item.variantCount).isEqualTo(1)
}

@Test
fun managementProjectionReadsOnlyActiveSourceRevision() = runTest {
activateRevision(revisionNumber = 1L, channelCount = 2)
activateRevision(revisionNumber = 2L, channelCount = 1)

val rows = repository.managementPages(managementQuery(ChannelManagementVisibility.ALL)).asSnapshot()

assertThat(rows.map { it.channelId }).containsExactly("channel-00001")
assertThat(rows.single().variantCount).isEqualTo(1)
}

@Test
fun activeRevisionInvalidatesExistingPagingSource() = runTest {
activateRevision(revisionNumber = 1L, channelCount = 1)
Expand All @@ -131,6 +196,9 @@ class ChannelBrowseRepositoryTest {

private fun query(filter: ChannelBrowseFilter) = ChannelBrowseQuery(PROFILE_ID, filter)

private fun managementQuery(visibility: ChannelManagementVisibility) =
ChannelManagementQuery(PROFILE_ID, visibility)

private fun refresh(
key: Int? = null,
loadSize: Int,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package app.muxtv.database

import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ChannelManagementInvalidationTest {
private lateinit var database: MuxTvDatabase
private lateinit var revisionStore: SourceRevisionStore

@Before
fun setUp() = runTest {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
MuxTvDatabase::class.java,
).build()
revisionStore = RoomSourceRevisionStore(database.sourceRevisionDao())
database.profileDao().insert(ProfileEntity(PROFILE_ID, "Primary", isPrimary = true))
activateCatalog()
}

@After
fun tearDown() {
database.close()
}

@Test
fun overlayChangeInvalidatesManagementPagingSource() = runTest {
val source = database.channelBrowseDao().pageManagedChannels(
profileId = PROFILE_ID,
hiddenState = null,
)
val first = source.load(refresh()) as PagingSource.LoadResult.Page
assertThat(first.data.single().isHidden).isFalse()

val invalidated = CompletableDeferred<Unit>()
source.registerInvalidatedCallback { invalidated.complete(Unit) }

database.catalogDao().insertOverlay(
UserChannelOverlayEntity(
profileId = PROFILE_ID,
canonicalChannelId = CHANNEL_ID,
isHidden = true,
),
)
database.invalidationTracker.refresh("user_channel_overlays")

withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(10_000L) { invalidated.await() }
}
assertThat(source.invalid).isTrue()

val replacement = database.channelBrowseDao().pageManagedChannels(
profileId = PROFILE_ID,
hiddenState = null,
)
val second = replacement.load(refresh()) as PagingSource.LoadResult.Page
assertThat(second.data.single().isHidden).isTrue()
}

private suspend fun activateCatalog() {
revisionStore.upsertSource(SourceDefinition(SOURCE_ID, "Provider"))
revisionStore.beginRevision(
sourceId = SOURCE_ID,
revisionNumber = 1L,
startedAtEpochMillis = 1_000L,
)
revisionStore.stageBatch(
sourceId = SOURCE_ID,
revisionNumber = 1L,
entries = listOf(
StagedCatalogEntry(
providerChannelId = "provider-channel-1",
providerKey = "tvg:1",
rawName = "Channel 1",
canonicalChannelId = CHANNEL_ID,
canonicalDisplayName = "Channel 1",
streamVariantId = "variant-1",
locator = "https://example.invalid/1.m3u8",
channelNumber = "1",
),
),
)
revisionStore.activate(
sourceId = SOURCE_ID,
revisionNumber = 1L,
activatedAtEpochMillis = 1_500L,
statistics = SourceRevisionStatistics(
parsedEntries = 1,
skippedEntries = 0,
warningCount = 0,
),
)
}

private fun refresh() = PagingSource.LoadParams.Refresh<Int>(
key = null,
loadSize = 64,
placeholdersEnabled = false,
)

private companion object {
const val PROFILE_ID = "profile-main"
const val SOURCE_ID = "source-main"
const val CHANNEL_ID = "channel-00001"
}
}
Loading