diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelPreferencesRepository.kt b/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelPreferencesRepository.kt index ea0c1e556..adaf4dbce 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelPreferencesRepository.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelPreferencesRepository.kt @@ -1,6 +1,7 @@ package app.muxtv import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult import app.muxtv.catalog.ChannelPreferencesRepository internal object NoChannelPreferencesRepository : ChannelPreferencesRepository { @@ -9,4 +10,27 @@ internal object NoChannelPreferencesRepository : ChannelPreferencesRepository { channelId: String, isFavorite: Boolean, ): ChannelFavoriteMutationResult = ChannelFavoriteMutationResult.NotFound + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.NotFound + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.NotFound + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.NotFound + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.NotFound } diff --git a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelPreferencesRepository.kt b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelPreferencesRepository.kt index 1f5ecd14b..288f75cde 100644 --- a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelPreferencesRepository.kt +++ b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelPreferencesRepository.kt @@ -6,10 +6,40 @@ enum class ChannelFavoriteMutationResult { NotFound, } +enum class ChannelPreferenceMutationResult { + Applied, + Unchanged, + NotFound, + InvalidInput, +} + interface ChannelPreferencesRepository { suspend fun setFavorite( profileId: String, channelId: String, isFavorite: Boolean, ): ChannelFavoriteMutationResult + + suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult + + suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult + + suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult + + suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult } diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelPreferencesRepositoryTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelPreferencesRepositoryTest.kt index 6fc30f5cb..9565493c2 100644 --- a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelPreferencesRepositoryTest.kt +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelPreferencesRepositoryTest.kt @@ -4,6 +4,7 @@ import androidx.room3.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult import app.muxtv.catalog.ChannelQuery import app.muxtv.catalog.RejectAllPlaybackAccessPolicyResolver import app.muxtv.catalog.UnhandledPlaybackReferenceResolver @@ -98,11 +99,130 @@ class ChannelPreferencesRepositoryTest { assertThat(channel.summary.channelNumber).isEqualTo("7") } + @Test + fun hiddenMutationCreatesOverlayAndSupportsUnhide() = runTest { + assertThat( + channelPreferences.setHidden(PROFILE_ID, CHANNEL_ID, true), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + assertThat(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)).isNull() + + assertThat( + channelPreferences.setHidden(PROFILE_ID, CHANNEL_ID, true), + ).isEqualTo(ChannelPreferenceMutationResult.Unchanged) + + assertThat( + channelPreferences.setHidden(PROFILE_ID, CHANNEL_ID, false), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + assertThat(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)).isNotNull() + } + + @Test + fun customNameMutationTrimsAndPreservesOtherOverlayFields() = runTest { + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = CHANNEL_ID, + isFavorite = true, + channelNumber = 7, + ), + ) + + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, " My News "), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + + val channel = requireNotNull(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)) + assertThat(channel.summary.displayName).isEqualTo("My News") + assertThat(channel.summary.channelNumber).isEqualTo("7") + assertThat(channel.summary.isFavorite).isTrue() + + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, "My News"), + ).isEqualTo(ChannelPreferenceMutationResult.Unchanged) + } + + @Test + fun customNameMutationRejectsInvalidInputWithoutCreatingOverlay() = runTest { + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, " "), + ).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, "News\u0000HD"), + ).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, "N".repeat(129)), + ).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + assertThat(database.catalogDao().countOverlays(PROFILE_ID)).isEqualTo(0) + } + + @Test + fun channelNumberMutationPersistsValidNumberAndSupportsReset() = runTest { + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, CHANNEL_ID, 7), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + assertThat( + requireNotNull(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)).summary.channelNumber, + ).isEqualTo("7") + + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, CHANNEL_ID, 0), + ).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, CHANNEL_ID, 10_000), + ).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, CHANNEL_ID, null), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + assertThat( + requireNotNull(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)).summary.channelNumber, + ).isEqualTo("10") + } + + @Test + fun resetCustomizationRestoresProviderPresentationAndPreservesFavorite() = runTest { + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = CHANNEL_ID, + isFavorite = true, + isHidden = true, + customName = "Hidden News", + channelNumber = 77, + ), + ) + + assertThat( + channelPreferences.resetCustomization(PROFILE_ID, CHANNEL_ID), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + + val channel = requireNotNull(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)) + assertThat(channel.summary.displayName).isEqualTo("News") + assertThat(channel.summary.channelNumber).isEqualTo("10") + assertThat(channel.summary.isFavorite).isTrue() + + assertThat( + channelPreferences.resetCustomization(PROFILE_ID, CHANNEL_ID), + ).isEqualTo(ChannelPreferenceMutationResult.Unchanged) + } + @Test fun missingChannelReturnsNotFoundWithoutCreatingOverlay() = runTest { assertThat( channelPreferences.setFavorite(PROFILE_ID, "missing-channel", true), ).isEqualTo(ChannelFavoriteMutationResult.NotFound) + assertThat( + channelPreferences.setHidden(PROFILE_ID, "missing-channel", true), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.setCustomName(PROFILE_ID, "missing-channel", "Missing"), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, "missing-channel", 1), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.resetCustomization(PROFILE_ID, "missing-channel"), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) assertThat(database.catalogDao().countOverlays(PROFILE_ID)).isEqualTo(0) } @@ -134,6 +254,18 @@ class ChannelPreferencesRepositoryTest { assertThat( channelPreferences.setFavorite(PROFILE_ID, CHANNEL_ID, true), ).isEqualTo(ChannelFavoriteMutationResult.NotFound) + assertThat( + channelPreferences.setHidden(PROFILE_ID, CHANNEL_ID, true), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, "Old News"), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, CHANNEL_ID, 7), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.resetCustomization(PROFILE_ID, CHANNEL_ID), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) assertThat(database.catalogDao().countOverlays(PROFILE_ID)).isEqualTo(0) } diff --git a/core/database/src/main/kotlin/app/muxtv/database/ChannelPreferencesDao.kt b/core/database/src/main/kotlin/app/muxtv/database/ChannelPreferencesDao.kt index 90f4d0a14..de5d37228 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/ChannelPreferencesDao.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/ChannelPreferencesDao.kt @@ -6,8 +6,12 @@ import androidx.room3.OnConflictStrategy import androidx.room3.Query import androidx.room3.Transaction -internal data class ActiveChannelFavoriteRow( +internal data class ActiveChannelPreferenceRow( + val hasOverlay: Boolean, val isFavorite: Boolean, + val customName: String?, + val channelNumber: Int?, + val isHidden: Boolean, ) internal enum class FavoriteWriteResult { @@ -16,11 +20,23 @@ internal enum class FavoriteWriteResult { NotFound, } +internal enum class PreferenceWriteResult { + Applied, + Unchanged, + NotFound, + InvalidInput, +} + @Dao internal abstract class ChannelPreferencesDao { @Query( """ - SELECT COALESCE(user_channel_overlays.isFavorite, 0) AS isFavorite + SELECT + CASE WHEN user_channel_overlays.canonicalChannelId IS NULL THEN 0 ELSE 1 END AS hasOverlay, + COALESCE(user_channel_overlays.isFavorite, 0) AS isFavorite, + user_channel_overlays.customName AS customName, + user_channel_overlays.channelNumber AS channelNumber, + COALESCE(user_channel_overlays.isHidden, 0) AS isHidden FROM canonical_channels INNER JOIN stream_variants ON stream_variants.canonicalChannelId = canonical_channels.id @@ -33,14 +49,13 @@ internal abstract class ChannelPreferencesDao { AND user_channel_overlays.canonicalChannelId = canonical_channels.id WHERE canonical_channels.id = :channelId AND provider_channels.revisionNumber = sources.activeRevision - AND COALESCE(user_channel_overlays.isHidden, 0) = 0 LIMIT 1 """, ) - protected abstract suspend fun findActiveFavorite( + protected abstract suspend fun findActivePreferences( profileId: String, channelId: String, - ): ActiveChannelFavoriteRow? + ): ActiveChannelPreferenceRow? @Query( """ @@ -56,6 +71,79 @@ internal abstract class ChannelPreferencesDao { isFavorite: Boolean, ): Int + @Query( + """ + UPDATE user_channel_overlays + SET isHidden = :isHidden + WHERE profileId = :profileId + AND canonicalChannelId = :channelId + """, + ) + protected abstract suspend fun updateHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): Int + + @Query( + """ + UPDATE user_channel_overlays + SET customName = :customName + WHERE profileId = :profileId + AND canonicalChannelId = :channelId + """, + ) + protected abstract suspend fun updateCustomName( + profileId: String, + channelId: String, + customName: String?, + ): Int + + @Query( + """ + UPDATE user_channel_overlays + SET channelNumber = :channelNumber + WHERE profileId = :profileId + AND canonicalChannelId = :channelId + """, + ) + protected abstract suspend fun updateChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): Int + + @Query( + """ + UPDATE user_channel_overlays + SET customName = NULL, + channelNumber = NULL, + isHidden = 0 + WHERE profileId = :profileId + AND canonicalChannelId = :channelId + """, + ) + protected abstract suspend fun resetPresentation( + profileId: String, + channelId: String, + ): Int + + @Query( + """ + DELETE FROM user_channel_overlays + WHERE profileId = :profileId + AND canonicalChannelId = :channelId + AND isFavorite = 0 + AND customName IS NULL + AND channelNumber IS NULL + AND isHidden = 0 + """, + ) + protected abstract suspend fun deleteDefaultOverlay( + profileId: String, + channelId: String, + ): Int + @Insert(onConflict = OnConflictStrategy.ABORT) protected abstract suspend fun insertOverlay(overlay: UserChannelOverlayEntity) @@ -68,8 +156,11 @@ internal abstract class ChannelPreferencesDao { require(profileId.isNotBlank()) require(channelId.isNotBlank()) - val current = findActiveFavorite(profileId, channelId) + val current = findActivePreferences(profileId, channelId) ?: return FavoriteWriteResult.NotFound + if (current.isHidden) { + return FavoriteWriteResult.NotFound + } if (current.isFavorite == isFavorite) { return FavoriteWriteResult.Unchanged } @@ -85,4 +176,130 @@ internal abstract class ChannelPreferencesDao { } return FavoriteWriteResult.Applied } + + @Transaction + open suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): PreferenceWriteResult { + require(profileId.isNotBlank()) + require(channelId.isNotBlank()) + + val current = findActivePreferences(profileId, channelId) + ?: return PreferenceWriteResult.NotFound + if (current.isHidden == isHidden) { + return PreferenceWriteResult.Unchanged + } + + if (updateHidden(profileId, channelId, isHidden) == 0) { + insertOverlay( + UserChannelOverlayEntity( + profileId = profileId, + canonicalChannelId = channelId, + isHidden = isHidden, + ), + ) + } else if (!isHidden) { + deleteDefaultOverlay(profileId, channelId) + } + return PreferenceWriteResult.Applied + } + + @Transaction + open suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): PreferenceWriteResult { + require(profileId.isNotBlank()) + require(channelId.isNotBlank()) + + val normalizedName = normalizeCustomChannelName(customName) + ?: if (customName == null) null else return PreferenceWriteResult.InvalidInput + val current = findActivePreferences(profileId, channelId) + ?: return PreferenceWriteResult.NotFound + if (current.customName == normalizedName) { + return PreferenceWriteResult.Unchanged + } + + if (updateCustomName(profileId, channelId, normalizedName) == 0) { + insertOverlay( + UserChannelOverlayEntity( + profileId = profileId, + canonicalChannelId = channelId, + customName = normalizedName, + ), + ) + } else if (normalizedName == null) { + deleteDefaultOverlay(profileId, channelId) + } + return PreferenceWriteResult.Applied + } + + @Transaction + open suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): PreferenceWriteResult { + require(profileId.isNotBlank()) + require(channelId.isNotBlank()) + + if (channelNumber != null && channelNumber !in MIN_CUSTOM_CHANNEL_NUMBER..MAX_CUSTOM_CHANNEL_NUMBER) { + return PreferenceWriteResult.InvalidInput + } + val current = findActivePreferences(profileId, channelId) + ?: return PreferenceWriteResult.NotFound + if (current.channelNumber == channelNumber) { + return PreferenceWriteResult.Unchanged + } + + if (updateChannelNumber(profileId, channelId, channelNumber) == 0) { + insertOverlay( + UserChannelOverlayEntity( + profileId = profileId, + canonicalChannelId = channelId, + channelNumber = channelNumber, + ), + ) + } else if (channelNumber == null) { + deleteDefaultOverlay(profileId, channelId) + } + return PreferenceWriteResult.Applied + } + + @Transaction + open suspend fun resetCustomization( + profileId: String, + channelId: String, + ): PreferenceWriteResult { + require(profileId.isNotBlank()) + require(channelId.isNotBlank()) + + val current = findActivePreferences(profileId, channelId) + ?: return PreferenceWriteResult.NotFound + if (current.customName == null && current.channelNumber == null && !current.isHidden) { + return PreferenceWriteResult.Unchanged + } + + resetPresentation(profileId, channelId) + if (!current.isFavorite) { + deleteDefaultOverlay(profileId, channelId) + } + return PreferenceWriteResult.Applied + } } + +private fun normalizeCustomChannelName(value: String?): String? { + if (value == null) return null + val normalized = value.trim() + if (normalized.isEmpty()) return null + if (normalized.codePointCount(0, normalized.length) > MAX_CUSTOM_CHANNEL_NAME_CODE_POINTS) return null + if (normalized.any(Char::isISOControl)) return null + return normalized +} + +private const val MAX_CUSTOM_CHANNEL_NAME_CODE_POINTS = 128 +private const val MIN_CUSTOM_CHANNEL_NUMBER = 1 +private const val MAX_CUSTOM_CHANNEL_NUMBER = 9_999 diff --git a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelPreferencesRepository.kt b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelPreferencesRepository.kt index 95af20c2f..2fdb93da7 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelPreferencesRepository.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelPreferencesRepository.kt @@ -1,6 +1,7 @@ package app.muxtv.database import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult import app.muxtv.catalog.ChannelPreferencesRepository internal class RoomChannelPreferencesRepository( @@ -15,4 +16,34 @@ internal class RoomChannelPreferencesRepository( FavoriteWriteResult.Unchanged -> ChannelFavoriteMutationResult.Unchanged FavoriteWriteResult.NotFound -> ChannelFavoriteMutationResult.NotFound } + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult = dao.setHidden(profileId, channelId, isHidden).toApiResult() + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = dao.setCustomName(profileId, channelId, customName).toApiResult() + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = dao.setChannelNumber(profileId, channelId, channelNumber).toApiResult() + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult = dao.resetCustomization(profileId, channelId).toApiResult() +} + +private fun PreferenceWriteResult.toApiResult(): ChannelPreferenceMutationResult = when (this) { + PreferenceWriteResult.Applied -> ChannelPreferenceMutationResult.Applied + PreferenceWriteResult.Unchanged -> ChannelPreferenceMutationResult.Unchanged + PreferenceWriteResult.NotFound -> ChannelPreferenceMutationResult.NotFound + PreferenceWriteResult.InvalidInput -> ChannelPreferenceMutationResult.InvalidInput } diff --git a/core/database/src/test/kotlin/app/muxtv/database/ChannelPreferencesApiContractTest.kt b/core/database/src/test/kotlin/app/muxtv/database/ChannelPreferencesApiContractTest.kt new file mode 100644 index 000000000..bc1a897bb --- /dev/null +++ b/core/database/src/test/kotlin/app/muxtv/database/ChannelPreferencesApiContractTest.kt @@ -0,0 +1,22 @@ +package app.muxtv.database + +import app.muxtv.catalog.ChannelPreferencesRepository +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ChannelPreferencesApiContractTest { + @Test + fun channelControlMutationsAreExposedByCatalogPort() { + val methodNames = ChannelPreferencesRepository::class.java.methods + .map { method -> method.name } + .toSet() + + assertThat(methodNames).containsAtLeast( + "setFavorite", + "setHidden", + "setCustomName", + "setChannelNumber", + "resetCustomization", + ) + } +}