From 50fa39c8941a94994f028df55f1b637ac5d9a919 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 08:35:23 +0500 Subject: [PATCH 01/43] test(catalog): define channel control mutation contract --- .../ChannelPreferencesApiContractTest.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 core/database/src/test/kotlin/app/muxtv/database/ChannelPreferencesApiContractTest.kt 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", + ) + } +} From f9f590b24d44cd47b33d7813fc9fd0c775c02a74 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 08:36:49 +0500 Subject: [PATCH 02/43] feat(catalog): expose channel control mutations --- .../catalog/ChannelPreferencesRepository.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 } From dde1c64f294646bef990d78314f1e41ed785ed5a Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 08:36:57 +0500 Subject: [PATCH 03/43] feat(catalog): scaffold channel control repository methods --- .../RoomChannelPreferencesRepository.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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..a039f79c5 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,27 @@ internal class RoomChannelPreferencesRepository( FavoriteWriteResult.Unchanged -> ChannelFavoriteMutationResult.Unchanged FavoriteWriteResult.NotFound -> ChannelFavoriteMutationResult.NotFound } + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.InvalidInput + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.InvalidInput + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.InvalidInput + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.InvalidInput } From 2469bf9abb9659d48d04ff362bc51db759e3443e Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 08:37:37 +0500 Subject: [PATCH 04/43] test(catalog): specify channel control mutation behavior --- .../ChannelPreferencesRepositoryTest.kt | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) 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..20685d644 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) } @@ -186,6 +318,7 @@ class ChannelPreferencesRepositoryTest { revisionStore.stageBatch( sourceId = SOURCE_ID, revisionNumber = 2, + startedAtEpochMillis = 3_000, entries = listOf( stagedEntry( providerChannelId = "provider-replacement", From e251df9bc2d92809fd7917353a44570ca8b54835 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 08:38:20 +0500 Subject: [PATCH 05/43] test(catalog): keep channel control fixtures on active revision API --- .../app/muxtv/database/ChannelPreferencesRepositoryTest.kt | 1 - 1 file changed, 1 deletion(-) 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 20685d644..9565493c2 100644 --- a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelPreferencesRepositoryTest.kt +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelPreferencesRepositoryTest.kt @@ -318,7 +318,6 @@ class ChannelPreferencesRepositoryTest { revisionStore.stageBatch( sourceId = SOURCE_ID, revisionNumber = 2, - startedAtEpochMillis = 3_000, entries = listOf( stagedEntry( providerChannelId = "provider-replacement", From c01e2f06325281783fd6118787299068949e5e7f Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 08:38:58 +0500 Subject: [PATCH 06/43] feat(catalog): persist channel control overlays --- .../muxtv/database/ChannelPreferencesDao.kt | 229 +++++++++++++++++- 1 file changed, 223 insertions(+), 6 deletions(-) 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 From cc2dae9094a6cabc393554b186b1b718a904ef8a Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 08:39:09 +0500 Subject: [PATCH 07/43] feat(catalog): wire channel control mutations to room --- .../database/RoomChannelPreferencesRepository.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 a039f79c5..2fdb93da7 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelPreferencesRepository.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelPreferencesRepository.kt @@ -21,22 +21,29 @@ internal class RoomChannelPreferencesRepository( profileId: String, channelId: String, isHidden: Boolean, - ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.InvalidInput + ): ChannelPreferenceMutationResult = dao.setHidden(profileId, channelId, isHidden).toApiResult() override suspend fun setCustomName( profileId: String, channelId: String, customName: String?, - ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.InvalidInput + ): ChannelPreferenceMutationResult = dao.setCustomName(profileId, channelId, customName).toApiResult() override suspend fun setChannelNumber( profileId: String, channelId: String, channelNumber: Int?, - ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.InvalidInput + ): ChannelPreferenceMutationResult = dao.setChannelNumber(profileId, channelId, channelNumber).toApiResult() override suspend fun resetCustomization( profileId: String, channelId: String, - ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.InvalidInput + ): 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 } From 6dbd918a71efdaf8e714cb0caa61952875d5c8bc Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 09:25:41 +0500 Subject: [PATCH 08/43] test(tv): update channel preferences fake --- .../muxtv/TestChannelPreferencesRepository.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 } From 8ca1037db90a52581a4ee83f9682bad1d1983277 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 09:27:17 +0500 Subject: [PATCH 09/43] test(catalog): define channel management projection contract --- .../catalog/ChannelManagementContractTest.kt | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt diff --git a/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt b/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt new file mode 100644 index 000000000..cd104e08e --- /dev/null +++ b/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt @@ -0,0 +1,59 @@ +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") + } +} From 612befb330528076432d760ed1b828f3cff1cb8e Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 09:28:22 +0500 Subject: [PATCH 10/43] test(catalog): require active variant in management row --- .../catalog/ChannelManagementContractTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt b/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt index cd104e08e..219e551d1 100644 --- a/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt +++ b/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt @@ -56,4 +56,21 @@ class ChannelManagementContractTest { 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, + ) + } + } } From e0d757832c666d4fffc3287acb2ae21baebaa434 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 09:33:39 +0500 Subject: [PATCH 11/43] feat(catalog): define channel management projection types --- .../muxtv/catalog/ChannelBrowseRepository.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt index 6ab9df107..996a16f36 100644 --- a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt +++ b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt @@ -58,6 +58,49 @@ 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=, 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=, canonicalDisplayName=, " + + "effectiveDisplayName=, defaultChannelNumberPresent=${defaultChannelNumber != null}, " + + "customChannelNumber=$customChannelNumber, effectiveChannelNumberPresent=${effectiveChannelNumber != null}, " + + "isFavorite=$isFavorite, isHidden=$isHidden, variantCount=$variantCount)" +} + interface ChannelBrowseRepository { fun pages(query: ChannelBrowseQuery): Flow> } From 8866213d0a5696c9573ca2a1509c1f150af7d5da Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 09:36:12 +0500 Subject: [PATCH 12/43] test(database): define channel management projection behavior --- .../database/ChannelBrowseRepositoryTest.kt | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt index 21a3ef8c8..f7c6cc082 100644 --- a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt @@ -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 @@ -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) @@ -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, From 708f24f7d08d165d1cb67dc5a92acca1adfa44ae Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 09:40:34 +0500 Subject: [PATCH 13/43] feat(database): add channel management paging --- .../muxtv/catalog/ChannelBrowseRepository.kt | 2 + .../app/muxtv/database/ChannelBrowseDao.kt | 56 +++++++++++++++++++ .../database/RoomChannelBrowseRepository.kt | 35 ++++++++++++ 3 files changed, 93 insertions(+) diff --git a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt index 996a16f36..26d72294e 100644 --- a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt +++ b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt @@ -103,4 +103,6 @@ data class ChannelManagementItem( interface ChannelBrowseRepository { fun pages(query: ChannelBrowseQuery): Flow> + + fun managementPages(query: ChannelManagementQuery): Flow> } diff --git a/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt b/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt index c65f3a075..4ea8e22b2 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt @@ -15,6 +15,18 @@ internal data class ActiveChannelBrowseRow( val variantCount: Int, ) +internal data class ActiveChannelManagementRow( + 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, +) + @Dao @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) internal interface ChannelBrowseDao { @@ -59,6 +71,50 @@ internal interface ChannelBrowseDao { favoritesOnly: Boolean, ): PagingSource + @Query( + """ + SELECT canonical_channels.id AS channelId, + canonical_channels.displayName AS canonicalDisplayName, + COALESCE(user_channel_overlays.customName, canonical_channels.displayName) AS effectiveDisplayName, + MIN(provider_channels.channelNumber) AS defaultChannelNumber, + user_channel_overlays.channelNumber AS customChannelNumber, + COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) AS effectiveChannelNumber, + COALESCE(user_channel_overlays.isFavorite, 0) AS isFavorite, + COALESCE(user_channel_overlays.isHidden, 0) AS isHidden, + COUNT(DISTINCT stream_variants.id) AS variantCount + FROM canonical_channels + INNER JOIN stream_variants + ON stream_variants.canonicalChannelId = canonical_channels.id + INNER JOIN provider_channels + ON provider_channels.id = stream_variants.providerChannelId + INNER JOIN sources + ON sources.id = provider_channels.sourceId + LEFT JOIN user_channel_overlays + ON user_channel_overlays.profileId = :profileId + AND user_channel_overlays.canonicalChannelId = canonical_channels.id + WHERE provider_channels.revisionNumber = sources.activeRevision + AND (:hiddenState IS NULL OR COALESCE(user_channel_overlays.isHidden, 0) = :hiddenState) + GROUP BY canonical_channels.id, + canonical_channels.displayName, + user_channel_overlays.customName, + user_channel_overlays.channelNumber, + user_channel_overlays.isFavorite, + user_channel_overlays.isHidden + ORDER BY CASE + WHEN COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) <> '' + AND COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) NOT GLOB '*[^0-9]*' + THEN CAST(COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) AS INTEGER) + ELSE 2147483647 + END, + effectiveDisplayName COLLATE NOCASE, + canonical_channels.id COLLATE BINARY + """, + ) + fun pageManagedChannels( + profileId: String, + hiddenState: Int?, + ): PagingSource + @Query( """ SELECT recent_channels.canonicalChannelId AS channelId, diff --git a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt index 242d00f58..81cee32b6 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt @@ -5,10 +5,14 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.PagingSource import androidx.paging.PagingState +import androidx.paging.map 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.ChannelManagementVisibility import app.muxtv.catalog.ChannelNowNext import app.muxtv.catalog.EpgGuideRepository import app.muxtv.catalog.GuideProjectionState @@ -17,6 +21,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.CancellationException @@ -64,6 +69,23 @@ internal class RoomChannelBrowseRepository( }, ).flow } + + override fun managementPages(query: ChannelManagementQuery): Flow> = + Pager( + config = CHANNEL_BROWSE_PAGING_CONFIG, + pagingSourceFactory = { + dao.pageManagedChannels( + profileId = query.profileId, + hiddenState = when (query.visibility) { + ChannelManagementVisibility.ALL -> null + ChannelManagementVisibility.VISIBLE -> 0 + ChannelManagementVisibility.HIDDEN -> 1 + }, + ) + }, + ).flow.map { pagingData -> + pagingData.map(ActiveChannelManagementRow::toModel) + } } internal val CHANNEL_BROWSE_PAGING_CONFIG = PagingConfig( @@ -168,3 +190,16 @@ private fun ActiveChannelBrowseRow.toModel(guide: ChannelNowNext?): ChannelBrows variantCount = variantCount, guideState = guide?.state ?: GuideProjectionState.NO_GUIDE, ) + +private fun ActiveChannelManagementRow.toModel(): ChannelManagementItem = + ChannelManagementItem( + channelId = channelId, + canonicalDisplayName = canonicalDisplayName, + effectiveDisplayName = effectiveDisplayName, + defaultChannelNumber = defaultChannelNumber, + customChannelNumber = customChannelNumber, + effectiveChannelNumber = effectiveChannelNumber, + isFavorite = isFavorite, + isHidden = isHidden, + variantCount = variantCount, + ) From 26b9045ca9c43f9c844dbecfdc19eafdc7ad4c4f Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 09:42:02 +0500 Subject: [PATCH 14/43] test(tv): update channel browse fake for management API --- .../kotlin/app/muxtv/TestChannelBrowseRepository.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt b/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt index 15651e0a8..b2a60a03a 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt @@ -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 @@ -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) @@ -59,6 +62,9 @@ internal class TestChannelBrowseRepository( } } + override fun managementPages(query: ChannelManagementQuery): Flow> = + flowOf(PagingData.empty()) + private companion object { val COMPLETED_LOAD_STATES = LoadStates( refresh = LoadState.NotLoading(endOfPaginationReached = true), From 5b30305b3196b212205aee1bccb53a429fc2268a Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 09:42:26 +0500 Subject: [PATCH 15/43] test(channels): update browse fake for management API --- .../app/muxtv/feature/channels/ChannelsViewModelTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt index 6800767b5..cd79d202e 100644 --- a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt @@ -9,6 +9,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.EpgGuideRepository import app.muxtv.catalog.GuideProjectionState @@ -212,6 +214,9 @@ class ChannelsViewModelTest { } return flowOf(PagingData.from(listOf(item(id)))) } + + override fun managementPages(query: ChannelManagementQuery): Flow> = + flowOf(PagingData.empty()) } private class FakePlaybackStateSource : PlaybackSessionStateSource { From 7803f3f6d2df73425b9b6fbf8ca09ce5d9d1f049 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 09:43:33 +0500 Subject: [PATCH 16/43] test(database): harden channel management projection --- .../ChannelManagementRepositoryTest.kt | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementRepositoryTest.kt diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementRepositoryTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementRepositoryTest.kt new file mode 100644 index 000000000..f14bee164 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementRepositoryTest.kt @@ -0,0 +1,144 @@ +package app.muxtv.database + +import androidx.paging.testing.asSnapshot +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +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 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChannelManagementRepositoryTest { + 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)) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun managementPagingDoesNotObserveOrQueryGuide() = runTest { + activateSource( + sourceId = "source-a", + providerChannelId = "provider-a", + streamVariantId = "variant-a", + channelNumber = "10", + ) + val repository = RoomChannelBrowseRepository( + dao = database.channelBrowseDao(), + guideRepository = ExplodingGuideRepository, + nowEpochMillis = { 1_000L }, + ) + + val rows = repository.managementPages(query()).asSnapshot() + + assertThat(rows.map { it.channelId }).containsExactly(CANONICAL_CHANNEL_ID) + } + + @Test + fun managementPagingAggregatesActiveVariantsAcrossProvidersIntoOneCanonicalRow() = runTest { + activateSource( + sourceId = "source-a", + providerChannelId = "provider-a", + streamVariantId = "variant-a", + channelNumber = "10", + ) + activateSource( + sourceId = "source-b", + providerChannelId = "provider-b", + streamVariantId = "variant-b", + channelNumber = "20", + ) + val repository = RoomChannelBrowseRepository( + dao = database.channelBrowseDao(), + guideRepository = ExplodingGuideRepository, + nowEpochMillis = { 1_000L }, + ) + + val rows = repository.managementPages(query()).asSnapshot() + + assertThat(rows).hasSize(1) + assertThat(rows.single().channelId).isEqualTo(CANONICAL_CHANNEL_ID) + assertThat(rows.single().variantCount).isEqualTo(2) + assertThat(rows.single().defaultChannelNumber).isEqualTo("10") + } + + private fun query() = ChannelManagementQuery( + profileId = PROFILE_ID, + visibility = ChannelManagementVisibility.ALL, + ) + + private suspend fun activateSource( + sourceId: String, + providerChannelId: String, + streamVariantId: String, + channelNumber: String, + ) { + revisionStore.upsertSource(SourceDefinition(sourceId, sourceId)) + revisionStore.beginRevision( + sourceId = sourceId, + revisionNumber = 1L, + startedAtEpochMillis = 1_000L, + ) + revisionStore.stageBatch( + sourceId = sourceId, + revisionNumber = 1L, + entries = listOf( + StagedCatalogEntry( + providerChannelId = providerChannelId, + providerKey = "provider:$providerChannelId", + rawName = "Canonical Channel", + canonicalChannelId = CANONICAL_CHANNEL_ID, + canonicalDisplayName = "Canonical Channel", + streamVariantId = streamVariantId, + locator = "https://example.invalid/$streamVariantId.m3u8", + channelNumber = channelNumber, + ), + ), + ) + revisionStore.activate( + sourceId = sourceId, + revisionNumber = 1L, + activatedAtEpochMillis = 1_500L, + statistics = SourceRevisionStatistics( + parsedEntries = 1, + skippedEntries = 0, + warningCount = 0, + ), + ) + } + + private object ExplodingGuideRepository : EpgGuideRepository { + override suspend fun getNowNext(query: NowNextQuery): List = + error("Management paging must not query guide data") + + override fun observeDataChanges(): Flow = + error("Management paging must not observe guide data") + } + + private companion object { + const val PROFILE_ID = "profile-main" + const val CANONICAL_CHANNEL_ID = "channel-shared" + } +} From 1f0453b237eace6d426645b276fee8d9009841d8 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:19:56 +0500 Subject: [PATCH 17/43] test(tv): support channel management projection in accessibility fixture --- .../androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt index d1d08b3a9..8e7160c68 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt @@ -164,6 +164,10 @@ private object EmptyCatalogFixture : app.muxtv.catalog.ChannelBrowseRepository { override fun pages(query: app.muxtv.catalog.ChannelBrowseQuery): kotlinx.coroutines.flow.Flow> = kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty()) + + override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery): + kotlinx.coroutines.flow.Flow> = + kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty()) } private fun SemanticsNodeInteraction.press( From 960dff536c0aced7ac71f8b2448c0b562f9d2706 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:20:31 +0500 Subject: [PATCH 18/43] test(tv): support channel management projection in home fixtures --- .../src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt index 9e2bd4167..310b1738a 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt @@ -233,6 +233,10 @@ class HomeJourneyTest { val EmptyBrowseRepository = object : ChannelBrowseRepository { override fun pages(query: ChannelBrowseQuery): Flow> = flowOf(androidx.paging.PagingData.empty()) + + override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery): + Flow> = + flowOf(androidx.paging.PagingData.empty()) } val FavoritesBrowseRepository = object : ChannelBrowseRepository { @@ -257,6 +261,10 @@ class HomeJourneyTest { ), ), ) + + override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery): + Flow> = + flowOf(androidx.paging.PagingData.empty()) } } } From 995921d2d7f212913428acee87ad3d952bc28c7c Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:24:48 +0500 Subject: [PATCH 19/43] test(database): verify management paging overlay invalidation --- .../ChannelManagementInvalidationTest.kt | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementInvalidationTest.kt diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementInvalidationTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementInvalidationTest.kt new file mode 100644 index 000000000..965cc4bc3 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementInvalidationTest.kt @@ -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() + 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( + 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" + } +} From 9680c6f14e95c37c54548f766cf775ec1935630f Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:54:33 +0500 Subject: [PATCH 20/43] test(channels): define manage channels view model contract --- .../channels/ManageChannelsViewModelTest.kt | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt new file mode 100644 index 000000000..a1fb58ced --- /dev/null +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt @@ -0,0 +1,183 @@ +package app.muxtv.feature.channels + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.paging.PagingData +import app.muxtv.catalog.ChannelBrowseItem +import app.muxtv.catalog.ChannelBrowseQuery +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.yield +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ManageChannelsViewModelTest { + @Before + fun setUp() = Dispatchers.setMain(Dispatchers.Unconfined) + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun managementFiltersCreateExplicitHiddenAwareQueriesLazily() = runBlocking { + val browse = RecordingBrowseRepository() + val preferences = RecordingPreferencesRepository() + + withViewModel(browse, preferences) { viewModel -> + assertThat(browse.managementQueries).isEmpty() + + val allRows = viewModel.rowsFor(ManageChannelsFilter.ALL) + awaitManagementQueryCount(browse, 1) + assertThat(viewModel.rowsFor(ManageChannelsFilter.ALL)).isSameInstanceAs(allRows) + + viewModel.setFilter(ManageChannelsFilter.VISIBLE) + viewModel.rowsFor(viewModel.filter.value) + awaitManagementQueryCount(browse, 2) + + viewModel.setFilter(ManageChannelsFilter.HIDDEN) + viewModel.rowsFor(viewModel.filter.value) + awaitManagementQueryCount(browse, 3) + } + + assertThat(browse.managementQueries.map { it.visibility }) + .containsExactly( + ChannelManagementVisibility.ALL, + ChannelManagementVisibility.VISIBLE, + ChannelManagementVisibility.HIDDEN, + ).inOrder() + assertThat(browse.managementQueries.all { it.profileId == PROFILE_ID }).isTrue() + } + + @Test + fun mutationsDelegateToProfileScopedPreferencesRepository() = runBlocking { + val browse = RecordingBrowseRepository() + val preferences = RecordingPreferencesRepository() + + withViewModel(browse, preferences) { viewModel -> + viewModel.setHidden("channel-a", true) + viewModel.setCustomName("channel-a", " News HD ") + viewModel.setChannelNumber("channel-a", 42) + viewModel.resetCustomization("channel-a") + repeat(20) { + if (preferences.calls.size >= 4) return@repeat + yield() + } + } + + assertThat(preferences.calls).containsExactly( + "hidden:$PROFILE_ID:channel-a:true", + "name:$PROFILE_ID:channel-a: News HD ", + "number:$PROFILE_ID:channel-a:42", + "reset:$PROFILE_ID:channel-a", + ).inOrder() + } + + private suspend fun awaitManagementQueryCount(repository: RecordingBrowseRepository, count: Int) { + repeat(100) { + if (repository.managementQueries.size >= count) return + yield() + } + error("Expected $count management queries, got ${repository.managementQueries.size}") + } + + private suspend fun withViewModel( + browse: ChannelBrowseRepository, + preferences: ChannelPreferencesRepository, + block: suspend (ManageChannelsViewModel) -> Unit, + ) { + val store = ViewModelStore() + val factory = viewModelFactory { + initializer { + ManageChannelsViewModel( + channelBrowseRepository = browse, + channelPreferencesRepository = preferences, + profileId = PROFILE_ID, + ) + } + } + val viewModel = ViewModelProvider.create(store, factory)[ManageChannelsViewModel::class] + try { + block(viewModel) + } finally { + store.clear() + } + } + + private class RecordingBrowseRepository : ChannelBrowseRepository { + val managementQueries = mutableListOf() + + override fun pages(query: ChannelBrowseQuery): Flow> = + flowOf(PagingData.empty()) + + override fun managementPages(query: ChannelManagementQuery): Flow> { + managementQueries += query + return flowOf(PagingData.empty()) + } + } + + private class RecordingPreferencesRepository : ChannelPreferencesRepository { + val calls = mutableListOf() + + override suspend fun setFavorite( + profileId: String, + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult = ChannelFavoriteMutationResult.Unchanged + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult { + calls += "hidden:$profileId:$channelId:$isHidden" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult { + calls += "name:$profileId:$channelId:$customName" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult { + calls += "number:$profileId:$channelId:$channelNumber" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult { + calls += "reset:$profileId:$channelId" + return ChannelPreferenceMutationResult.Applied + } + } + + private companion object { + const val PROFILE_ID = "profile-main" + } +} From e95e7d62bb9abf9aad3d41f56356a2070259aa1f Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:55:59 +0500 Subject: [PATCH 21/43] test(channels): pin manage channel filters --- .../channels/ManageChannelsFilterContractTest.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsFilterContractTest.kt diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsFilterContractTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsFilterContractTest.kt new file mode 100644 index 000000000..1b1abbfe8 --- /dev/null +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsFilterContractTest.kt @@ -0,0 +1,16 @@ +package app.muxtv.feature.channels + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ManageChannelsFilterContractTest { + @Test + fun managementSurfaceExposesOnlyExplicitVisibilityFilters() { + assertThat(ManageChannelsFilter.entries) + .containsExactly( + ManageChannelsFilter.ALL, + ManageChannelsFilter.VISIBLE, + ManageChannelsFilter.HIDDEN, + ).inOrder() + } +} From 40d734b93822b98c07da00bd3ee332fc70c7f656 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 11:06:40 +0500 Subject: [PATCH 22/43] feat(channels): add manage channels view model --- .../channels/ManageChannelsViewModel.kt | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt new file mode 100644 index 000000000..927c4b1ff --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt @@ -0,0 +1,99 @@ +package app.muxtv.feature.channels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class ManageChannelsFilter { + ALL, + VISIBLE, + HIDDEN, +} + +class ManageChannelsViewModel( + private val channelBrowseRepository: ChannelBrowseRepository, + private val channelPreferencesRepository: ChannelPreferencesRepository, + private val profileId: String, +) : ViewModel() { + init { + require(profileId.isNotBlank()) { "profileId must not be blank." } + } + + private val mutableFilter = MutableStateFlow(ManageChannelsFilter.ALL) + val filter: StateFlow = mutableFilter.asStateFlow() + + private val rowsByFilter = + mutableMapOf>>() + + fun setFilter(filter: ManageChannelsFilter) { + mutableFilter.value = filter + } + + fun rowsFor(filter: ManageChannelsFilter): Flow> = + rowsByFilter.getOrPut(filter) { + channelBrowseRepository + .managementPages( + ChannelManagementQuery( + profileId = profileId, + visibility = filter.toManagementVisibility(), + ), + ) + .cachedIn(viewModelScope) + } + + suspend fun setHidden( + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult = + channelPreferencesRepository.setHidden( + profileId = profileId, + channelId = channelId, + isHidden = isHidden, + ) + + suspend fun setCustomName( + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = + channelPreferencesRepository.setCustomName( + profileId = profileId, + channelId = channelId, + customName = customName, + ) + + suspend fun setChannelNumber( + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = + channelPreferencesRepository.setChannelNumber( + profileId = profileId, + channelId = channelId, + channelNumber = channelNumber, + ) + + suspend fun resetCustomization( + channelId: String, + ): ChannelPreferenceMutationResult = + channelPreferencesRepository.resetCustomization( + profileId = profileId, + channelId = channelId, + ) + + private fun ManageChannelsFilter.toManagementVisibility(): ChannelManagementVisibility = + when (this) { + ManageChannelsFilter.ALL -> ChannelManagementVisibility.ALL + ManageChannelsFilter.VISIBLE -> ChannelManagementVisibility.VISIBLE + ManageChannelsFilter.HIDDEN -> ChannelManagementVisibility.HIDDEN + } +} From 47acace6a3c7cae2d539231725e768151800d746 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 11:10:29 +0500 Subject: [PATCH 23/43] feat(channels): add manage channels TV surface --- .../feature/channels/ManageChannelsRoute.kt | 606 ++++++++++++++++++ 1 file changed, 606 insertions(+) create mode 100644 feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt new file mode 100644 index 000000000..873793900 --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt @@ -0,0 +1,606 @@ +package app.muxtv.feature.channels + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import app.muxtv.designsystem.TvTokens +import app.muxtv.designsystem.component.MuxTvActionButton +import app.muxtv.designsystem.component.MuxTvChannelLogo +import app.muxtv.designsystem.component.MuxTvScreenScaffold +import kotlinx.coroutines.launch + +@Composable +fun ManageChannelsRoute( + channelBrowseRepository: ChannelBrowseRepository, + channelPreferencesRepository: ChannelPreferencesRepository, + profileId: String, + modifier: Modifier = Modifier, + railFocusRequester: FocusRequester? = null, +) { + val factory = remember(channelBrowseRepository, channelPreferencesRepository, profileId) { + viewModelFactory { + initializer { + ManageChannelsViewModel( + channelBrowseRepository = channelBrowseRepository, + channelPreferencesRepository = channelPreferencesRepository, + profileId = profileId, + ) + } + } + } + val screenViewModel: ManageChannelsViewModel = viewModel(factory = factory) + val filter by screenViewModel.filter.collectAsStateWithLifecycle() + val rowsFlow = remember(screenViewModel, filter) { screenViewModel.rowsFor(filter) } + val rows = rowsFlow.collectAsLazyPagingItems() + val scope = rememberCoroutineScope() + var selectedChannel by remember { mutableStateOf(null) } + var editor by remember { mutableStateOf(null) } + var mutationMessage by remember { mutableStateOf(null) } + + fun consumeMutationResult( + result: ChannelPreferenceMutationResult, + invalidMessage: String, + ) { + mutationMessage = when (result) { + ChannelPreferenceMutationResult.Applied, + ChannelPreferenceMutationResult.Unchanged, + -> null + + ChannelPreferenceMutationResult.NotFound -> "Канал больше недоступен в активном каталоге." + ChannelPreferenceMutationResult.InvalidInput -> invalidMessage + } + if (result == ChannelPreferenceMutationResult.Applied || result == ChannelPreferenceMutationResult.Unchanged) { + editor = null + } + if (result == ChannelPreferenceMutationResult.NotFound) { + selectedChannel = null + editor = null + } + } + + ManageChannelsContent( + rows = rows, + filter = filter, + selectedChannel = selectedChannel, + editor = editor, + mutationMessage = mutationMessage, + onFilterChanged = { next -> + selectedChannel = null + editor = null + mutationMessage = null + screenViewModel.setFilter(next) + }, + onSelectChannel = { channel -> + selectedChannel = channel + editor = null + mutationMessage = null + }, + onCloseActions = { + selectedChannel = null + editor = null + mutationMessage = null + }, + onToggleHidden = { channel -> + scope.launch { + consumeMutationResult( + result = screenViewModel.setHidden(channel.channelId, !channel.isHidden), + invalidMessage = "Не удалось изменить видимость канала.", + ) + } + }, + onRename = { channel -> + editor = ManageChannelEditor.Name( + value = channel.effectiveDisplayName, + ) + mutationMessage = null + }, + onEditNumber = { channel -> + editor = ManageChannelEditor.Number( + value = channel.customChannelNumber?.toString().orEmpty(), + ) + mutationMessage = null + }, + onEditorValueChanged = { value -> + editor = when (val current = editor) { + is ManageChannelEditor.Name -> current.copy(value = value) + is ManageChannelEditor.Number -> current.copy(value = value) + null -> null + } + mutationMessage = null + }, + onSaveEditor = save@{ + val channel = selectedChannel ?: return@save + when (val current = editor) { + is ManageChannelEditor.Name -> scope.launch { + consumeMutationResult( + result = screenViewModel.setCustomName(channel.channelId, current.value), + invalidMessage = "Введите имя от 1 до 128 символов без управляющих знаков.", + ) + } + + is ManageChannelEditor.Number -> { + val raw = current.value.trim() + val parsed = raw.takeIf(String::isNotEmpty)?.toIntOrNull() + if (raw.isNotEmpty() && parsed == null) { + mutationMessage = "Введите номер от 1 до 9999." + return@save + } + scope.launch { + consumeMutationResult( + result = screenViewModel.setChannelNumber(channel.channelId, parsed), + invalidMessage = "Введите номер от 1 до 9999.", + ) + } + } + + null -> Unit + } + }, + onCancelEditor = { + editor = null + mutationMessage = null + }, + onReset = { channel -> + scope.launch { + consumeMutationResult( + result = screenViewModel.resetCustomization(channel.channelId), + invalidMessage = "Не удалось сбросить настройки канала.", + ) + } + }, + railFocusRequester = railFocusRequester, + modifier = modifier, + ) +} + +@Composable +private fun ManageChannelsContent( + rows: LazyPagingItems, + filter: ManageChannelsFilter, + selectedChannel: ChannelManagementItem?, + editor: ManageChannelEditor?, + mutationMessage: String?, + onFilterChanged: (ManageChannelsFilter) -> Unit, + onSelectChannel: (ChannelManagementItem) -> Unit, + onCloseActions: () -> Unit, + onToggleHidden: (ChannelManagementItem) -> Unit, + onRename: (ChannelManagementItem) -> Unit, + onEditNumber: (ChannelManagementItem) -> Unit, + onEditorValueChanged: (String) -> Unit, + onSaveEditor: () -> Unit, + onCancelEditor: () -> Unit, + onReset: (ChannelManagementItem) -> Unit, + railFocusRequester: FocusRequester?, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val allFocusRequester = remember { FocusRequester() } + val visibleFocusRequester = remember { FocusRequester() } + val hiddenFocusRequester = remember { FocusRequester() } + val firstActionFocusRequester = remember { FocusRequester() } + val editorFocusRequester = remember { FocusRequester() } + + LaunchedEffect(selectedChannel?.channelId, editor) { + when { + editor != null -> editorFocusRequester.requestFocus() + selectedChannel != null -> firstActionFocusRequester.requestFocus() + } + } + + MuxTvScreenScaffold( + title = "Управление каналами", + modifier = modifier, + titleTestTag = MANAGE_CHANNELS_TITLE_TEST_TAG, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + MuxTvActionButton( + text = "Все", + onClick = { onFilterChanged(ManageChannelsFilter.ALL) }, + selected = filter == ManageChannelsFilter.ALL, + modifier = Modifier + .testTag(MANAGE_CHANNELS_FILTER_ALL_TEST_TAG) + .focusProperties { + left = railFocusRequester ?: FocusRequester.Default + right = visibleFocusRequester + } + .focusRequester(allFocusRequester), + ) + MuxTvActionButton( + text = "Видимые", + onClick = { onFilterChanged(ManageChannelsFilter.VISIBLE) }, + selected = filter == ManageChannelsFilter.VISIBLE, + modifier = Modifier + .testTag(MANAGE_CHANNELS_FILTER_VISIBLE_TEST_TAG) + .focusProperties { + left = allFocusRequester + right = hiddenFocusRequester + } + .focusRequester(visibleFocusRequester), + ) + MuxTvActionButton( + text = "Скрытые", + onClick = { onFilterChanged(ManageChannelsFilter.HIDDEN) }, + selected = filter == ManageChannelsFilter.HIDDEN, + modifier = Modifier + .testTag(MANAGE_CHANNELS_FILTER_HIDDEN_TEST_TAG) + .focusProperties { left = visibleFocusRequester } + .focusRequester(hiddenFocusRequester), + ) + } + + Spacer(Modifier.height(TvTokens.Spacing.small)) + Text( + text = filter.summary(rows.itemCount), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (selectedChannel != null) { + Spacer(Modifier.height(TvTokens.Spacing.small)) + ManageChannelActions( + channel = selectedChannel, + editor = editor, + mutationMessage = mutationMessage, + firstActionFocusRequester = firstActionFocusRequester, + editorFocusRequester = editorFocusRequester, + onToggleHidden = { onToggleHidden(selectedChannel) }, + onRename = { onRename(selectedChannel) }, + onEditNumber = { onEditNumber(selectedChannel) }, + onEditorValueChanged = onEditorValueChanged, + onSaveEditor = onSaveEditor, + onCancelEditor = onCancelEditor, + onReset = { onReset(selectedChannel) }, + onClose = onCloseActions, + ) + } else if (mutationMessage != null) { + Spacer(Modifier.height(TvTokens.Spacing.small)) + Text( + text = mutationMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(Modifier.height(TvTokens.Spacing.small)) + when { + rows.loadState.refresh is LoadState.Loading && rows.itemCount == 0 -> { + Text("Загрузка каналов…", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + rows.loadState.refresh is LoadState.Error && rows.itemCount == 0 -> { + Column(verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + Text("Не удалось прочитать список каналов.", color = MaterialTheme.colorScheme.error) + MuxTvActionButton(text = "Повторить", onClick = rows::retry) + } + } + + rows.loadState.refresh is LoadState.NotLoading && rows.itemCount == 0 -> { + Text( + text = filter.emptyMessage(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(MANAGE_CHANNELS_EMPTY_TEST_TAG), + ) + } + + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + ) { + items( + count = rows.itemCount, + key = rows.itemKey(ChannelManagementItem::channelId), + contentType = rows.itemContentType { "manage-channel-row" }, + ) { index -> + val channel = rows[index] + if (channel == null) { + Text("Загрузка…") + } else { + ManageChannelRow( + channel = channel, + selected = selectedChannel?.channelId == channel.channelId, + onClick = { onSelectChannel(channel) }, + modifier = Modifier + .fillMaxWidth() + .testTag("$MANAGE_CHANNEL_ROW_TEST_TAG_PREFIX${channel.channelId}") + .focusProperties { + if (index == 0) { + up = when (filter) { + ManageChannelsFilter.ALL -> allFocusRequester + ManageChannelsFilter.VISIBLE -> visibleFocusRequester + ManageChannelsFilter.HIDDEN -> hiddenFocusRequester + } + left = railFocusRequester ?: FocusRequester.Default + } + }, + ) + } + } + if (rows.loadState.append is LoadState.Error) { + item(key = "manage-append-error") { + MuxTvActionButton(text = "Повторить загрузку", onClick = rows::retry) + } + } + } + } + } +} + +@Composable +private fun ManageChannelActions( + channel: ChannelManagementItem, + editor: ManageChannelEditor?, + mutationMessage: String?, + firstActionFocusRequester: FocusRequester, + editorFocusRequester: FocusRequester, + onToggleHidden: () -> Unit, + onRename: () -> Unit, + onEditNumber: () -> Unit, + onEditorValueChanged: (String) -> Unit, + onSaveEditor: () -> Unit, + onCancelEditor: () -> Unit, + onReset: () -> Unit, + onClose: () -> Unit, +) { + val shape = RoundedCornerShape(TvTokens.Shape.rowCorner) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(TvTokens.Color.surfaceRaised) + .border(1.dp, MaterialTheme.colorScheme.borderVariant, shape) + .padding(TvTokens.Spacing.medium) + .testTag(MANAGE_CHANNELS_ACTIONS_TEST_TAG), + verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + ) { + Text( + text = channel.effectiveDisplayName, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + when (editor) { + null -> Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + MuxTvActionButton( + text = if (channel.isHidden) "Показать" else "Скрыть", + onClick = onToggleHidden, + modifier = Modifier.focusRequester(firstActionFocusRequester), + ) + MuxTvActionButton(text = "Переименовать", onClick = onRename) + MuxTvActionButton(text = "Номер", onClick = onEditNumber) + MuxTvActionButton(text = "Сбросить", onClick = onReset) + MuxTvActionButton(text = "Закрыть", onClick = onClose) + } + + is ManageChannelEditor.Name -> EditorRow( + label = "Название", + value = editor.value, + keyboardType = KeyboardType.Text, + focusRequester = editorFocusRequester, + onValueChanged = onEditorValueChanged, + onSave = onSaveEditor, + onCancel = onCancelEditor, + ) + + is ManageChannelEditor.Number -> EditorRow( + label = "Номер · пустое поле вернёт номер источника", + value = editor.value, + keyboardType = KeyboardType.Number, + focusRequester = editorFocusRequester, + onValueChanged = onEditorValueChanged, + onSave = onSaveEditor, + onCancel = onCancelEditor, + ) + } + + if (mutationMessage != null) { + Text( + text = mutationMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(MANAGE_CHANNELS_ERROR_TEST_TAG), + ) + } + } +} + +@Composable +private fun EditorRow( + label: String, + value: String, + keyboardType: KeyboardType, + focusRequester: FocusRequester, + onValueChanged: (String) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.xSmall)) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row( + horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicTextField( + value = value, + onValueChange = onValueChanged, + modifier = Modifier + .width(420.dp) + .height(48.dp) + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(TvTokens.Shape.rowCorner)) + .border(1.dp, MaterialTheme.colorScheme.borderVariant, RoundedCornerShape(TvTokens.Shape.rowCorner)) + .padding(horizontal = TvTokens.Spacing.small) + .focusRequester(focusRequester) + .testTag(MANAGE_CHANNELS_EDITOR_TEST_TAG), + textStyle = MaterialTheme.typography.titleMedium.copy(color = MaterialTheme.colorScheme.onSurface), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + ) + MuxTvActionButton(text = "Сохранить", onClick = onSave) + MuxTvActionButton(text = "Отмена", onClick = onCancel) + } + } +} + +@Composable +private fun ManageChannelRow( + channel: ChannelManagementItem, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember(channel.channelId) { mutableStateOf(false) } + val shape = RoundedCornerShape(TvTokens.Shape.rowCorner) + val customizedName = channel.effectiveDisplayName != channel.canonicalDisplayName + val customizedNumber = channel.customChannelNumber != null + val originalParts = buildList { + if (customizedName) add("исходное имя: ${channel.canonicalDisplayName}") + if (customizedNumber) { + channel.defaultChannelNumber?.takeIf(String::isNotBlank)?.let { add("номер источника: $it") } + } + } + val stateParts = buildList { + add(if (channel.isHidden) "Скрыт" else "Видим") + if (channel.isFavorite) add("Избранное") + if (customizedName || customizedNumber) add("Изменён") + if (channel.variantCount > 1) add("Источников: ${channel.variantCount}") + } + + Row( + modifier = modifier + .height(MANAGE_CHANNEL_ROW_HEIGHT) + .clip(shape) + .background( + when { + focused -> TvTokens.Color.surfaceRaised + selected -> MaterialTheme.colorScheme.surfaceVariant + else -> MaterialTheme.colorScheme.surface + }, + ) + .border( + width = if (focused) TvTokens.Focus.outlineWidth else 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.borderVariant, + shape = shape, + ) + .onFocusChanged { focused = it.isFocused } + .clickable(role = Role.Button, onClick = onClick) + .focusable() + .padding(horizontal = TvTokens.Spacing.medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.width(64.dp), contentAlignment = Alignment.Center) { + Text( + text = channel.effectiveChannelNumber?.takeIf(String::isNotBlank) ?: "—", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + Spacer(Modifier.width(TvTokens.Spacing.small)) + MuxTvChannelLogo(name = channel.effectiveDisplayName) + Spacer(Modifier.width(TvTokens.Spacing.small)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = channel.effectiveDisplayName, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = originalParts.joinToString(" · ").ifBlank { "Без пользовательских изменений" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(TvTokens.Spacing.medium)) + Text( + text = stateParts.joinToString(" · "), + style = MaterialTheme.typography.bodyMedium, + color = if (channel.isHidden) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } +} + +private sealed interface ManageChannelEditor { + val value: String + + data class Name(override val value: String) : ManageChannelEditor + data class Number(override val value: String) : ManageChannelEditor +} + +private fun ManageChannelsFilter.summary(count: Int): String = when (this) { + ManageChannelsFilter.ALL -> "Каналов в управлении: $count" + ManageChannelsFilter.VISIBLE -> "Видимых каналов: $count" + ManageChannelsFilter.HIDDEN -> "Скрытых каналов: $count" +} + +private fun ManageChannelsFilter.emptyMessage(): String = when (this) { + ManageChannelsFilter.ALL -> "Активных каналов пока нет." + ManageChannelsFilter.VISIBLE -> "Нет видимых каналов. Скрытые можно восстановить во вкладке «Скрытые»." + ManageChannelsFilter.HIDDEN -> "Скрытых каналов нет." +} + +private const val MANAGE_CHANNELS_TITLE_TEST_TAG = "manage-channels-title" +private const val MANAGE_CHANNELS_FILTER_ALL_TEST_TAG = "manage-channels-filter-all" +private const val MANAGE_CHANNELS_FILTER_VISIBLE_TEST_TAG = "manage-channels-filter-visible" +private const val MANAGE_CHANNELS_FILTER_HIDDEN_TEST_TAG = "manage-channels-filter-hidden" +private const val MANAGE_CHANNELS_EMPTY_TEST_TAG = "manage-channels-empty" +private const val MANAGE_CHANNELS_ACTIONS_TEST_TAG = "manage-channels-actions" +private const val MANAGE_CHANNELS_EDITOR_TEST_TAG = "manage-channels-editor" +private const val MANAGE_CHANNELS_ERROR_TEST_TAG = "manage-channels-error" +private const val MANAGE_CHANNEL_ROW_TEST_TAG_PREFIX = "manage-channel-row-" +private val MANAGE_CHANNEL_ROW_HEIGHT = 88.dp From f8a702b58c775122a835dcd4ae657da5ebdda053 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 11:11:58 +0500 Subject: [PATCH 24/43] feat(channels): expose manage channels entry --- .../channels/ChannelsManageEntryRoute.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt new file mode 100644 index 000000000..0c5322bd3 --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt @@ -0,0 +1,55 @@ +package app.muxtv.feature.channels + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.EpgGuideRepository +import app.muxtv.designsystem.TvTokens +import app.muxtv.designsystem.component.MuxTvActionButton +import app.muxtv.player.PlaybackSessionStateSource + +/** + * Adds the dedicated channel-management entry without changing the normal + * Channels browse/filter surface or its focus-restoration implementation. + */ +@Composable +fun ChannelsRoute( + channelBrowseRepository: ChannelBrowseRepository, + epgGuideRepository: EpgGuideRepository, + playbackSessionStateSource: PlaybackSessionStateSource, + profileId: String, + onOpenChannel: (String) -> Unit, + onManageChannels: () -> Unit, + modifier: Modifier = Modifier, + railFocusRequester: FocusRequester? = null, +) { + Box(modifier = modifier.fillMaxSize()) { + ChannelsRoute( + channelBrowseRepository = channelBrowseRepository, + epgGuideRepository = epgGuideRepository, + playbackSessionStateSource = playbackSessionStateSource, + profileId = profileId, + onOpenChannel = onOpenChannel, + modifier = Modifier.fillMaxSize(), + railFocusRequester = railFocusRequester, + ) + MuxTvActionButton( + text = "Управление", + contentDescription = "Управление каналами", + onClick = onManageChannels, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 88.dp, end = TvTokens.Spacing.screenInset) + .testTag(CHANNELS_MANAGE_TEST_TAG), + ) + } +} + +internal const val CHANNELS_MANAGE_TEST_TAG = "channels-manage" From e130743c32adee217587e64b99b9956c9f216893 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 11:12:06 +0500 Subject: [PATCH 25/43] feat(tv): add manage channels destination --- app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt b/app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt index b2cb0106f..34277f607 100644 --- a/app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt +++ b/app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt @@ -11,6 +11,9 @@ sealed interface AppDestination : NavKey { @Serializable data object Channels : AppDestination + @Serializable + data object ManageChannels : AppDestination + @Serializable data object Guide : AppDestination From bda528dae469149a205d6caea703f3c2f89adc2a Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 11:12:36 +0500 Subject: [PATCH 26/43] feat(tv): wire manage channels navigation --- .../kotlin/app/muxtv/navigation/AppNavigation.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt b/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt index d6cf05e4d..68857e17c 100644 --- a/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt +++ b/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt @@ -36,6 +36,7 @@ import app.muxtv.designsystem.component.MuxTvNavigationRail import app.muxtv.designsystem.component.MuxTvNavigationRailItem import app.muxtv.designsystem.icon.MuxTvIcons import app.muxtv.feature.channels.ChannelsRoute +import app.muxtv.feature.channels.ManageChannelsRoute import app.muxtv.feature.doctor.DoctorExportStatus import app.muxtv.feature.doctor.DoctorRoute import app.muxtv.feature.guide.GuideRoute @@ -144,6 +145,14 @@ fun AppNavigation( onOpenChannel = { channelId -> open(AppDestination.Player(channelId)) }, + onManageChannels = { open(AppDestination.ManageChannels) }, + railFocusRequester = railFocusRequester, + ) + + AppDestination.ManageChannels -> ManageChannelsRoute( + channelBrowseRepository = channelBrowseRepository, + channelPreferencesRepository = channelPreferencesRepository, + profileId = DatabaseDefaults.PRIMARY_PROFILE_ID, railFocusRequester = railFocusRequester, ) @@ -248,6 +257,7 @@ private fun AppDestination.navigationKey(): String = when (this) { AppDestination.Guide -> "guide" AppDestination.Search -> "search" AppDestination.Settings -> "settings" + AppDestination.ManageChannels -> error("ManageChannels is not a top-level destination.") AppDestination.Sources -> error("Sources is not a top-level destination.") AppDestination.Doctor -> error("Doctor is not a top-level destination.") AppDestination.AddSource -> error("AddSource is not a top-level destination.") @@ -269,6 +279,7 @@ private fun AppDestination.navigationLabel(): String = when (this) { AppDestination.Guide -> "Программа" AppDestination.Search -> "Поиск" AppDestination.Settings -> "Настройки" + AppDestination.ManageChannels -> error("ManageChannels is not a top-level destination.") AppDestination.Sources -> error("Sources is not a top-level destination.") AppDestination.Doctor -> error("Doctor is not a top-level destination.") AppDestination.AddSource -> error("AddSource is not a top-level destination.") @@ -281,6 +292,7 @@ private fun AppDestination.navigationIcon() = when (this) { AppDestination.Guide -> MuxTvIcons.Guide AppDestination.Search -> MuxTvIcons.Search AppDestination.Settings -> MuxTvIcons.Settings + AppDestination.ManageChannels -> error("ManageChannels is not a top-level destination.") AppDestination.Sources -> error("Sources is not a top-level destination.") AppDestination.Doctor -> error("Doctor is not a top-level destination.") AppDestination.AddSource -> error("AddSource is not a top-level destination.") @@ -293,6 +305,7 @@ private fun AppDestination.navigationTestTag(): String = when (this) { AppDestination.Guide -> "nav-guide" AppDestination.Search -> "nav-search" AppDestination.Settings -> "nav-settings" + AppDestination.ManageChannels -> error("ManageChannels is not a top-level destination.") AppDestination.Sources -> error("Sources is not a top-level destination.") AppDestination.Doctor -> error("Doctor is not a top-level destination.") AppDestination.AddSource -> error("AddSource is not a top-level destination.") @@ -300,6 +313,7 @@ private fun AppDestination.navigationTestTag(): String = when (this) { } private fun AppDestination.topLevelDestination(): AppDestination = when (this) { + AppDestination.ManageChannels -> AppDestination.Channels AppDestination.Sources, AppDestination.Doctor, AppDestination.AddSource -> AppDestination.Settings is AppDestination.Player -> AppDestination.Channels else -> this From 04121aeb9974dc1ca4ecad1c8ade444a29db7366 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 11:28:26 +0500 Subject: [PATCH 27/43] fix(channels): compile manage channels entry --- .../app/muxtv/feature/channels/ChannelsManageEntryRoute.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt index 0c5322bd3..df0e844b5 100644 --- a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt @@ -42,7 +42,6 @@ fun ChannelsRoute( ) MuxTvActionButton( text = "Управление", - contentDescription = "Управление каналами", onClick = onManageChannels, modifier = Modifier .align(Alignment.TopEnd) From 8c7fb3869d2e1e907e4eb1f31a6ab0bab33ffffb Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 14:22:14 +0500 Subject: [PATCH 28/43] test(channels): cover manage mutation dismissal contract --- .../ManageChannelsMutationUiContractTest.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsMutationUiContractTest.kt diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsMutationUiContractTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsMutationUiContractTest.kt new file mode 100644 index 000000000..512d2bfa3 --- /dev/null +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsMutationUiContractTest.kt @@ -0,0 +1,15 @@ +package app.muxtv.feature.channels + +import app.muxtv.catalog.ChannelPreferenceMutationResult +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ManageChannelsMutationUiContractTest { + @Test + fun onlyInvalidInputKeepsTheCurrentActionSnapshotOpen() { + assertThat(ChannelPreferenceMutationResult.Applied.shouldDismissManageChannelActions()).isTrue() + assertThat(ChannelPreferenceMutationResult.Unchanged.shouldDismissManageChannelActions()).isTrue() + assertThat(ChannelPreferenceMutationResult.NotFound.shouldDismissManageChannelActions()).isTrue() + assertThat(ChannelPreferenceMutationResult.InvalidInput.shouldDismissManageChannelActions()).isFalse() + } +} From 33df0fc6d908b4a607443255036f10ef19f7cd89 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 14:25:26 +0500 Subject: [PATCH 29/43] fix(channels): restore focus after manage mutations --- .../feature/channels/ManageChannelsRoute.kt | 168 ++++++++++++++++-- 1 file changed, 151 insertions(+), 17 deletions(-) diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt index 873793900..a0dc7863e 100644 --- a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt @@ -20,12 +20,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -57,6 +61,8 @@ import app.muxtv.designsystem.TvTokens import app.muxtv.designsystem.component.MuxTvActionButton import app.muxtv.designsystem.component.MuxTvChannelLogo import app.muxtv.designsystem.component.MuxTvScreenScaffold +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @Composable @@ -86,10 +92,25 @@ fun ManageChannelsRoute( var selectedChannel by remember { mutableStateOf(null) } var editor by remember { mutableStateOf(null) } var mutationMessage by remember { mutableStateOf(null) } + var selectionFocusAnchor by remember { mutableStateOf(null) } + var focusReturnRequest by remember { mutableStateOf(null) } + + fun dismissActions(waitForAnchorRemoval: Boolean) { + selectionFocusAnchor?.let { anchor -> + focusReturnRequest = ManageChannelsFocusReturnRequest( + anchor = anchor, + waitForAnchorRemoval = waitForAnchorRemoval, + ) + } + selectionFocusAnchor = null + selectedChannel = null + editor = null + } fun consumeMutationResult( result: ChannelPreferenceMutationResult, invalidMessage: String, + waitForAnchorRemoval: Boolean = false, ) { mutationMessage = when (result) { ChannelPreferenceMutationResult.Applied, @@ -99,12 +120,8 @@ fun ManageChannelsRoute( ChannelPreferenceMutationResult.NotFound -> "Канал больше недоступен в активном каталоге." ChannelPreferenceMutationResult.InvalidInput -> invalidMessage } - if (result == ChannelPreferenceMutationResult.Applied || result == ChannelPreferenceMutationResult.Unchanged) { - editor = null - } - if (result == ChannelPreferenceMutationResult.NotFound) { - selectedChannel = null - editor = null + if (result.shouldDismissManageChannelActions()) { + dismissActions(waitForAnchorRemoval = waitForAnchorRemoval) } } @@ -114,27 +131,35 @@ fun ManageChannelsRoute( selectedChannel = selectedChannel, editor = editor, mutationMessage = mutationMessage, + focusReturnRequest = focusReturnRequest, onFilterChanged = { next -> + selectionFocusAnchor = null + focusReturnRequest = null selectedChannel = null editor = null mutationMessage = null screenViewModel.setFilter(next) }, - onSelectChannel = { channel -> + onSelectChannel = { channel, anchor -> selectedChannel = channel + selectionFocusAnchor = anchor + focusReturnRequest = null editor = null mutationMessage = null }, onCloseActions = { - selectedChannel = null - editor = null + dismissActions(waitForAnchorRemoval = false) mutationMessage = null }, + onFocusReturnConsumed = { + focusReturnRequest = null + }, onToggleHidden = { channel -> scope.launch { consumeMutationResult( result = screenViewModel.setHidden(channel.channelId, !channel.isHidden), invalidMessage = "Не удалось изменить видимость канала.", + waitForAnchorRemoval = filter != ManageChannelsFilter.ALL, ) } }, @@ -195,6 +220,7 @@ fun ManageChannelsRoute( consumeMutationResult( result = screenViewModel.resetCustomization(channel.channelId), invalidMessage = "Не удалось сбросить настройки канала.", + waitForAnchorRemoval = filter == ManageChannelsFilter.HIDDEN && channel.isHidden, ) } }, @@ -210,9 +236,11 @@ private fun ManageChannelsContent( selectedChannel: ChannelManagementItem?, editor: ManageChannelEditor?, mutationMessage: String?, + focusReturnRequest: ManageChannelsFocusReturnRequest?, onFilterChanged: (ManageChannelsFilter) -> Unit, - onSelectChannel: (ChannelManagementItem) -> Unit, + onSelectChannel: (ChannelManagementItem, FocusAnchor) -> Unit, onCloseActions: () -> Unit, + onFocusReturnConsumed: () -> Unit, onToggleHidden: (ChannelManagementItem) -> Unit, onRename: (ChannelManagementItem) -> Unit, onEditNumber: (ChannelManagementItem) -> Unit, @@ -224,11 +252,19 @@ private fun ManageChannelsContent( modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() + val rowFocusRequesters = remember { mutableStateMapOf() } val allFocusRequester = remember { FocusRequester() } val visibleFocusRequester = remember { FocusRequester() } val hiddenFocusRequester = remember { FocusRequester() } val firstActionFocusRequester = remember { FocusRequester() } val editorFocusRequester = remember { FocusRequester() } + val selectedFilterFocusRequester = when (filter) { + ManageChannelsFilter.ALL -> allFocusRequester + ManageChannelsFilter.VISIBLE -> visibleFocusRequester + ManageChannelsFilter.HIDDEN -> hiddenFocusRequester + } + val refreshState = rows.loadState.refresh + val appendState = rows.loadState.append LaunchedEffect(selectedChannel?.channelId, editor) { when { @@ -237,6 +273,71 @@ private fun ManageChannelsContent( } } + LaunchedEffect( + focusReturnRequest, + rows, + refreshState, + appendState, + rows.itemCount, + ) { + val request = focusReturnRequest ?: return@LaunchedEffect + if (refreshState !is LoadState.NotLoading) return@LaunchedEffect + + if (rows.itemCount == 0) { + withFrameNanos { } + if (selectedFilterFocusRequester.requestFocus()) { + onFocusReturnConsumed() + } + return@LaunchedEffect + } + + val requestedIndex = request.anchor.previousIndex.coerceIn(0, rows.itemCount - 1) + listState.scrollToItem(requestedIndex, request.anchor.scrollOffset) + + val target = snapshotFlow { + val anchoredIndex = findLoadedManageChannelIndex(rows, request.anchor.itemKey) + when { + request.waitForAnchorRemoval && anchoredIndex != null -> null + !request.waitForAnchorRemoval && anchoredIndex != null -> { + request.anchor.itemKey.let { anchoredIndex to it } + } + + appendState is LoadState.NotLoading && appendState.endOfPaginationReached -> { + val loadedIds = (0 until rows.itemCount) + .mapNotNull { index -> rows.peek(index)?.channelId } + if (loadedIds.size == rows.itemCount) { + request.anchor.resolveAgainst(loadedIds)?.let { resolved -> + resolved.index to resolved.itemKey + } + } else { + null + } + } + + else -> null + } + } + .filterNotNull() + .first() + + val (targetIndex, targetId) = target + if (targetIndex != requestedIndex) { + listState.scrollToItem(targetIndex, request.anchor.scrollOffset) + } + + val requester = snapshotFlow { + val placed = listState.layoutInfo.visibleItemsInfo.any { item -> item.index == targetIndex } + if (placed) rowFocusRequesters[targetId] else null + } + .filterNotNull() + .first() + + withFrameNanos { } + if (requester.requestFocus()) { + onFocusReturnConsumed() + } + } + MuxTvScreenScaffold( title = "Управление каналами", modifier = modifier, @@ -346,23 +447,38 @@ private fun ManageChannelsContent( if (channel == null) { Text("Загрузка…") } else { + val focusRequester = remember(channel.channelId) { FocusRequester() } + DisposableEffect(channel.channelId, focusRequester) { + rowFocusRequesters[channel.channelId] = focusRequester + onDispose { + if (rowFocusRequesters[channel.channelId] === focusRequester) { + rowFocusRequesters.remove(channel.channelId) + } + } + } ManageChannelRow( channel = channel, selected = selectedChannel?.channelId == channel.channelId, - onClick = { onSelectChannel(channel) }, + onClick = { + onSelectChannel( + channel, + FocusAnchor( + itemKey = channel.channelId, + previousIndex = index, + scrollOffset = listState.firstVisibleItemScrollOffset, + ), + ) + }, modifier = Modifier .fillMaxWidth() .testTag("$MANAGE_CHANNEL_ROW_TEST_TAG_PREFIX${channel.channelId}") .focusProperties { if (index == 0) { - up = when (filter) { - ManageChannelsFilter.ALL -> allFocusRequester - ManageChannelsFilter.VISIBLE -> visibleFocusRequester - ManageChannelsFilter.HIDDEN -> hiddenFocusRequester - } + up = selectedFilterFocusRequester left = railFocusRequester ?: FocusRequester.Default } - }, + } + .focusRequester(focusRequester), ) } } @@ -376,6 +492,16 @@ private fun ManageChannelsContent( } } +private fun findLoadedManageChannelIndex( + rows: LazyPagingItems, + channelId: String, +): Int? { + for (index in 0 until rows.itemCount) { + if (rows.peek(index)?.channelId == channelId) return index + } + return null +} + @Composable private fun ManageChannelActions( channel: ChannelManagementItem, @@ -575,6 +701,11 @@ private fun ManageChannelRow( } } +private data class ManageChannelsFocusReturnRequest( + val anchor: FocusAnchor, + val waitForAnchorRemoval: Boolean, +) + private sealed interface ManageChannelEditor { val value: String @@ -582,6 +713,9 @@ private sealed interface ManageChannelEditor { data class Number(override val value: String) : ManageChannelEditor } +internal fun ChannelPreferenceMutationResult.shouldDismissManageChannelActions(): Boolean = + this != ChannelPreferenceMutationResult.InvalidInput + private fun ManageChannelsFilter.summary(count: Int): String = when (this) { ManageChannelsFilter.ALL -> "Каналов в управлении: $count" ManageChannelsFilter.VISIBLE -> "Видимых каналов: $count" From 9cc9b3ec71737f5065d7c044e81c01eddd571999 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 14:35:47 +0500 Subject: [PATCH 30/43] test(channels): define quick action contract --- .../ChannelQuickActionsContractTest.kt | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelQuickActionsContractTest.kt diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelQuickActionsContractTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelQuickActionsContractTest.kt new file mode 100644 index 000000000..ae62b9d58 --- /dev/null +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelQuickActionsContractTest.kt @@ -0,0 +1,103 @@ +package app.muxtv.feature.channels + +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class ChannelQuickActionsContractTest { + @Test + fun `visible channel exposes bounded quick actions in stable order`() { + assertThat(quickActionsFor(isFavorite = false).map(ChannelQuickAction::kind)) + .containsExactly( + ChannelQuickActionKind.FAVORITE, + ChannelQuickActionKind.HIDE, + ChannelQuickActionKind.RENAME, + ChannelQuickActionKind.CHANNEL_NUMBER, + ChannelQuickActionKind.RESET, + ) + .inOrder() + assertThat(quickActionsFor(isFavorite = false).first().label).isEqualTo("В избранное") + assertThat(quickActionsFor(isFavorite = true).first().label).isEqualTo("Убрать из избранного") + } + + @Test + fun `quick action mutations remain profile scoped`() = runBlocking { + val preferences = RecordingPreferencesRepository() + val controller = ChannelQuickActionsController( + channelPreferencesRepository = preferences, + profileId = PROFILE_ID, + ) + + controller.setFavorite(CHANNEL_ID, true) + controller.hide(CHANNEL_ID) + controller.setCustomName(CHANNEL_ID, " News ") + controller.setChannelNumber(CHANNEL_ID, 77) + controller.resetCustomization(CHANNEL_ID) + + assertThat(preferences.calls) + .containsExactly( + "favorite:$PROFILE_ID:$CHANNEL_ID:true", + "hidden:$PROFILE_ID:$CHANNEL_ID:true", + "name:$PROFILE_ID:$CHANNEL_ID: News ", + "number:$PROFILE_ID:$CHANNEL_ID:77", + "reset:$PROFILE_ID:$CHANNEL_ID", + ) + .inOrder() + } + + private class RecordingPreferencesRepository : ChannelPreferencesRepository { + val calls = mutableListOf() + + override suspend fun setFavorite( + profileId: String, + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult { + calls += "favorite:$profileId:$channelId:$isFavorite" + return ChannelFavoriteMutationResult.Applied + } + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult { + calls += "hidden:$profileId:$channelId:$isHidden" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult { + calls += "name:$profileId:$channelId:$customName" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult { + calls += "number:$profileId:$channelId:$channelNumber" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult { + calls += "reset:$profileId:$channelId" + return ChannelPreferenceMutationResult.Applied + } + } + + private companion object { + const val PROFILE_ID = "profile-main" + const val CHANNEL_ID = "channel-a" + } +} From 077304ee891353075259dfbea70ec6c1e4dcf2d3 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 14:36:16 +0500 Subject: [PATCH 31/43] feat(channels): add quick action controller --- .../feature/channels/ChannelQuickActions.kt | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelQuickActions.kt diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelQuickActions.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelQuickActions.kt new file mode 100644 index 000000000..5d2a0451f --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelQuickActions.kt @@ -0,0 +1,78 @@ +package app.muxtv.feature.channels + +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository + +internal enum class ChannelQuickActionKind { + FAVORITE, + HIDE, + RENAME, + CHANNEL_NUMBER, + RESET, +} + +internal data class ChannelQuickAction( + val kind: ChannelQuickActionKind, + val label: String, +) + +internal fun quickActionsFor(isFavorite: Boolean): List = listOf( + ChannelQuickAction( + kind = ChannelQuickActionKind.FAVORITE, + label = if (isFavorite) "Убрать из избранного" else "В избранное", + ), + ChannelQuickAction(ChannelQuickActionKind.HIDE, "Скрыть"), + ChannelQuickAction(ChannelQuickActionKind.RENAME, "Переименовать"), + ChannelQuickAction(ChannelQuickActionKind.CHANNEL_NUMBER, "Номер"), + ChannelQuickAction(ChannelQuickActionKind.RESET, "Сбросить"), +) + +internal class ChannelQuickActionsController( + private val channelPreferencesRepository: ChannelPreferencesRepository, + private val profileId: String, +) { + init { + require(profileId.isNotBlank()) + } + + suspend fun setFavorite( + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult = channelPreferencesRepository.setFavorite( + profileId = profileId, + channelId = channelId, + isFavorite = isFavorite, + ) + + suspend fun hide(channelId: String): ChannelPreferenceMutationResult = + channelPreferencesRepository.setHidden( + profileId = profileId, + channelId = channelId, + isHidden = true, + ) + + suspend fun setCustomName( + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = channelPreferencesRepository.setCustomName( + profileId = profileId, + channelId = channelId, + customName = customName, + ) + + suspend fun setChannelNumber( + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = channelPreferencesRepository.setChannelNumber( + profileId = profileId, + channelId = channelId, + channelNumber = channelNumber, + ) + + suspend fun resetCustomization(channelId: String): ChannelPreferenceMutationResult = + channelPreferencesRepository.resetCustomization( + profileId = profileId, + channelId = channelId, + ) +} From c2c14d1519cd6e310af97d32c1c47ebcb56ddfc1 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 15:53:28 +0500 Subject: [PATCH 32/43] feat(channels): wire long-OK quick actions --- .../muxtv/feature/channels/ChannelsRoute.kt | 575 +++++++++++++++--- 1 file changed, 483 insertions(+), 92 deletions(-) diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsRoute.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsRoute.kt index 83d97e49a..1575f6c39 100644 --- a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsRoute.kt +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsRoute.kt @@ -3,6 +3,7 @@ package app.muxtv.feature.channels import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,6 +20,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Star @@ -30,6 +33,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -44,6 +48,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -59,6 +64,9 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository import app.muxtv.catalog.EpgGuideRepository import app.muxtv.designsystem.TvTokens import app.muxtv.designsystem.component.MuxTvActionButton @@ -68,6 +76,7 @@ import app.muxtv.designsystem.component.MuxTvScreenScaffold import app.muxtv.player.PlaybackSessionStateSource import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch @Composable fun ChannelsRoute( @@ -78,6 +87,7 @@ fun ChannelsRoute( onOpenChannel: (String) -> Unit, modifier: Modifier = Modifier, railFocusRequester: FocusRequester? = null, + channelPreferencesRepository: ChannelPreferencesRepository? = null, ) { val factory = remember( channelBrowseRepository, @@ -96,6 +106,14 @@ fun ChannelsRoute( } } } + val quickActionsController = remember(channelPreferencesRepository, profileId) { + channelPreferencesRepository?.let { repository -> + ChannelQuickActionsController( + channelPreferencesRepository = repository, + profileId = profileId, + ) + } + } val screenViewModel: ChannelsViewModel = viewModel(factory = factory) val filter by screenViewModel.filter.collectAsStateWithLifecycle() val rowsFlow = remember(screenViewModel, filter) { @@ -146,6 +164,7 @@ fun ChannelsRoute( filter = filter, listState = listState, focusAnchor = focusAnchor, + quickActionsController = quickActionsController, onFilterChanged = screenViewModel::setFilter, onFocusAnchorChanged = { anchor -> focusedChannelId = anchor.itemKey @@ -201,16 +220,20 @@ private fun ChannelsContent( filter: ChannelsFilter, listState: LazyListState, focusAnchor: FocusAnchor?, + quickActionsController: ChannelQuickActionsController?, onFilterChanged: (ChannelsFilter) -> Unit, onFocusAnchorChanged: (FocusAnchor) -> Unit, onOpenChannel: (String) -> Unit, modifier: Modifier, railFocusRequester: FocusRequester? = null, ) { + val scope = rememberCoroutineScope() val focusRequesters = remember { mutableStateMapOf() } val allFilterFocusRequester = remember { FocusRequester() } val favoritesFilterFocusRequester = remember { FocusRequester() } val recentFilterFocusRequester = remember { FocusRequester() } + val firstQuickActionFocusRequester = remember { FocusRequester() } + val quickEditorFocusRequester = remember { FocusRequester() } val selectedFilterFocusRequester = when (filter) { ChannelsFilter.ALL -> allFilterFocusRequester ChannelsFilter.FAVORITES -> favoritesFilterFocusRequester @@ -218,9 +241,47 @@ private fun ChannelsContent( } var restorationCompleted by remember(filter) { mutableStateOf(false) } var observedFocusedChannelId by remember(filter) { mutableStateOf(null) } + var quickActionChannel by remember(filter) { mutableStateOf(null) } + var quickActionAnchor by remember(filter) { mutableStateOf(null) } + var quickEditor by remember(filter) { mutableStateOf(null) } + var quickMutationMessage by remember(filter) { mutableStateOf(null) } + var quickFocusReturnRequest by remember(filter) { mutableStateOf(null) } val refreshState = rows.loadState.refresh val appendState = rows.loadState.append + fun dismissQuickActions(waitForAnchorRemoval: Boolean) { + quickActionAnchor?.let { anchor -> + quickFocusReturnRequest = ChannelQuickFocusReturnRequest( + anchor = anchor, + waitForAnchorRemoval = waitForAnchorRemoval, + ) + } + quickActionChannel = null + quickActionAnchor = null + quickEditor = null + quickMutationMessage = null + } + + fun consumePreferenceResult( + result: ChannelPreferenceMutationResult, + invalidMessage: String, + waitForAnchorRemoval: Boolean = false, + ) { + when (result) { + ChannelPreferenceMutationResult.Applied, + ChannelPreferenceMutationResult.Unchanged, + ChannelPreferenceMutationResult.NotFound, + -> dismissQuickActions( + waitForAnchorRemoval = waitForAnchorRemoval && + result != ChannelPreferenceMutationResult.NotFound, + ) + + ChannelPreferenceMutationResult.InvalidInput -> { + quickMutationMessage = invalidMessage + } + } + } + LaunchedEffect( rows, refreshState, @@ -294,111 +355,283 @@ private fun ChannelsContent( restorationCompleted = true } - MuxTvScreenScaffold( - title = filter.title(), - modifier = modifier, + LaunchedEffect(quickActionChannel?.channelId, quickEditor) { + if (quickActionChannel == null) return@LaunchedEffect + withFrameNanos { } + when (quickEditor) { + null -> firstQuickActionFocusRequester.requestFocus() + else -> quickEditorFocusRequester.requestFocus() + } + } + + LaunchedEffect( + quickFocusReturnRequest, + rows, + refreshState, + appendState, + rows.itemCount, ) { - Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { - MuxTvActionButton( - text = "Все каналы", - onClick = { onFilterChanged(ChannelsFilter.ALL) }, - selected = filter == ChannelsFilter.ALL, - modifier = Modifier.testTag(CHANNELS_ALL_FILTER_TEST_TAG) - .focusProperties { - left = railFocusRequester ?: FocusRequester.Default - right = favoritesFilterFocusRequester - } - .focusRequester(allFilterFocusRequester), - ) - MuxTvActionButton( - text = "Избранное", - onClick = { onFilterChanged(ChannelsFilter.FAVORITES) }, - selected = filter == ChannelsFilter.FAVORITES, - modifier = Modifier.testTag(CHANNELS_FAVORITES_FILTER_TEST_TAG) - .focusProperties { - left = allFilterFocusRequester - right = recentFilterFocusRequester - } - .focusRequester(favoritesFilterFocusRequester), - ) - MuxTvActionButton( - text = "Недавние", - onClick = { onFilterChanged(ChannelsFilter.RECENT) }, - selected = filter == ChannelsFilter.RECENT, - modifier = Modifier.testTag(CHANNELS_RECENT_FILTER_TEST_TAG) - .focusProperties { - left = favoritesFilterFocusRequester + val request = quickFocusReturnRequest ?: return@LaunchedEffect + if (refreshState !is LoadState.NotLoading || rows.itemCount == 0) return@LaunchedEffect + + val requestedIndex = request.anchor.previousIndex.coerceIn(0, rows.itemCount - 1) + listState.scrollToItem(requestedIndex, request.anchor.scrollOffset) + + val target = snapshotFlow { + val anchoredIndex = findLoadedIndex(rows, request.anchor.itemKey) + when { + request.waitForAnchorRemoval && anchoredIndex != null -> null + !request.waitForAnchorRemoval && anchoredIndex != null -> { + request.anchor.itemKey.let { anchoredIndex to it } + } + + appendState is LoadState.NotLoading && appendState.endOfPaginationReached -> { + val loadedIds = (0 until rows.itemCount) + .mapNotNull { index -> rows.peek(index)?.channelId } + if (loadedIds.size == rows.itemCount) { + request.anchor.resolveAgainst(loadedIds)?.let { resolved -> + resolved.index to resolved.itemKey + } + } else { + null } - .focusRequester(recentFilterFocusRequester), - ) + } + + else -> null + } } - Text( - text = filter.countLabel(rows.itemCount), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - LazyColumn( + .filterNotNull() + .first() + + val (targetIndex, targetId) = target + if (targetIndex != requestedIndex) { + listState.scrollToItem(targetIndex, request.anchor.scrollOffset) + } + + val requester = snapshotFlow { + val placed = listState.layoutInfo.visibleItemsInfo.any { item -> item.index == targetIndex } + if (placed) focusRequesters[targetId] else null + } + .filterNotNull() + .first() + + withFrameNanos { } + if (requester.requestFocus()) { + quickFocusReturnRequest = null + } + } + + Box(modifier = modifier.fillMaxSize()) { + MuxTvScreenScaffold( + title = filter.title(), modifier = Modifier.fillMaxSize(), - state = listState, - verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), ) { - items( - count = rows.itemCount, - key = rows.itemKey(ChannelRowUiModel::channelId), - contentType = rows.itemContentType { "channel-row" }, - ) { index -> - val row = rows[index] - if (row == null) { - Text("Загрузка…", modifier = Modifier.testTag("channel-loading-$index")) - } else { - val focusRequester = remember(row.channelId) { FocusRequester() } - DisposableEffect(row.channelId, focusRequester) { - focusRequesters[row.channelId] = focusRequester - onDispose { - if (focusRequesters[row.channelId] === focusRequester) { - focusRequesters.remove(row.channelId) - } + Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + MuxTvActionButton( + text = "Все каналы", + onClick = { onFilterChanged(ChannelsFilter.ALL) }, + selected = filter == ChannelsFilter.ALL, + modifier = Modifier.testTag(CHANNELS_ALL_FILTER_TEST_TAG) + .focusProperties { + left = railFocusRequester ?: FocusRequester.Default + right = favoritesFilterFocusRequester } - } - fun captureFocusAnchor() = onFocusAnchorChanged( - FocusAnchor( - itemKey = row.channelId, - previousIndex = index, - scrollOffset = listState.firstVisibleItemScrollOffset, - ), - ) - ChannelRow( - row = row, - onClick = { - captureFocusAnchor() - onOpenChannel(row.channelId) - }, - modifier = Modifier.fillMaxWidth() - .testTag("$CHANNEL_ROW_TEST_TAG_PREFIX$index") - .focusProperties { - if (index == 0) { - up = selectedFilterFocusRequester - left = railFocusRequester ?: FocusRequester.Default + .focusRequester(allFilterFocusRequester), + ) + MuxTvActionButton( + text = "Избранное", + onClick = { onFilterChanged(ChannelsFilter.FAVORITES) }, + selected = filter == ChannelsFilter.FAVORITES, + modifier = Modifier.testTag(CHANNELS_FAVORITES_FILTER_TEST_TAG) + .focusProperties { + left = allFilterFocusRequester + right = recentFilterFocusRequester + } + .focusRequester(favoritesFilterFocusRequester), + ) + MuxTvActionButton( + text = "Недавние", + onClick = { onFilterChanged(ChannelsFilter.RECENT) }, + selected = filter == ChannelsFilter.RECENT, + modifier = Modifier.testTag(CHANNELS_RECENT_FILTER_TEST_TAG) + .focusProperties { + left = favoritesFilterFocusRequester + } + .focusRequester(recentFilterFocusRequester), + ) + } + Text( + text = filter.countLabel(rows.itemCount), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + ) { + items( + count = rows.itemCount, + key = rows.itemKey(ChannelRowUiModel::channelId), + contentType = rows.itemContentType { "channel-row" }, + ) { index -> + val row = rows[index] + if (row == null) { + Text("Загрузка…", modifier = Modifier.testTag("channel-loading-$index")) + } else { + val focusRequester = remember(row.channelId) { FocusRequester() } + DisposableEffect(row.channelId, focusRequester) { + focusRequesters[row.channelId] = focusRequester + onDispose { + if (focusRequesters[row.channelId] === focusRequester) { + focusRequesters.remove(row.channelId) } } - .focusRequester(focusRequester) - .onFocusChanged { state -> - if (state.isFocused) { - observedFocusedChannelId = row.channelId - captureFocusAnchor() - } else if (observedFocusedChannelId == row.channelId) { - observedFocusedChannelId = null + } + fun captureFocusAnchor(): FocusAnchor { + val anchor = FocusAnchor( + itemKey = row.channelId, + previousIndex = index, + scrollOffset = listState.firstVisibleItemScrollOffset, + ) + onFocusAnchorChanged(anchor) + return anchor + } + ChannelRow( + row = row, + onClick = { + captureFocusAnchor() + onOpenChannel(row.channelId) + }, + onLongClick = quickActionsController?.let { + { + quickActionAnchor = captureFocusAnchor() + quickActionChannel = row + quickEditor = null + quickMutationMessage = null + quickFocusReturnRequest = null } }, - ) + modifier = Modifier.fillMaxWidth() + .testTag("$CHANNEL_ROW_TEST_TAG_PREFIX$index") + .focusProperties { + if (index == 0) { + up = selectedFilterFocusRequester + left = railFocusRequester ?: FocusRequester.Default + } + } + .focusRequester(focusRequester) + .onFocusChanged { state -> + if (state.isFocused) { + observedFocusedChannelId = row.channelId + captureFocusAnchor() + } else if (observedFocusedChannelId == row.channelId) { + observedFocusedChannelId = null + } + }, + ) + } } - } - if (rows.loadState.append is LoadState.Error) { - item(key = "append-error", contentType = "append-error") { - MuxTvActionButton(text = "Повторить загрузку", onClick = rows::retry) + if (rows.loadState.append is LoadState.Error) { + item(key = "append-error", contentType = "append-error") { + MuxTvActionButton(text = "Повторить загрузку", onClick = rows::retry) + } } } } + + val selected = quickActionChannel + val controller = quickActionsController + if (selected != null && controller != null) { + ChannelQuickActionsPanel( + channel = selected, + editor = quickEditor, + mutationMessage = quickMutationMessage, + firstActionFocusRequester = firstQuickActionFocusRequester, + editorFocusRequester = quickEditorFocusRequester, + onFavorite = { + scope.launch { + when (controller.setFavorite(selected.channelId, !selected.isFavorite)) { + ChannelFavoriteMutationResult.Applied, + ChannelFavoriteMutationResult.Unchanged, + -> dismissQuickActions( + waitForAnchorRemoval = filter == ChannelsFilter.FAVORITES && selected.isFavorite, + ) + + ChannelFavoriteMutationResult.NotFound -> dismissQuickActions(waitForAnchorRemoval = false) + } + } + }, + onHide = { + scope.launch { + consumePreferenceResult( + result = controller.hide(selected.channelId), + invalidMessage = "Не удалось скрыть канал.", + waitForAnchorRemoval = true, + ) + } + }, + onRename = { + quickEditor = ChannelQuickEditor.Name(selected.displayName) + quickMutationMessage = null + }, + onEditNumber = { + quickEditor = ChannelQuickEditor.Number("") + quickMutationMessage = null + }, + onEditorValueChanged = { value -> + quickEditor = when (val current = quickEditor) { + is ChannelQuickEditor.Name -> current.copy(value = value) + is ChannelQuickEditor.Number -> current.copy(value = value) + null -> null + } + quickMutationMessage = null + }, + onSaveEditor = save@{ + when (val current = quickEditor) { + is ChannelQuickEditor.Name -> scope.launch { + consumePreferenceResult( + result = controller.setCustomName(selected.channelId, current.value), + invalidMessage = "Введите имя от 1 до 128 символов без управляющих знаков.", + ) + } + + is ChannelQuickEditor.Number -> { + val raw = current.value.trim() + val parsed = raw.takeIf(String::isNotEmpty)?.toIntOrNull() + if (raw.isNotEmpty() && parsed == null) { + quickMutationMessage = "Введите номер от 1 до 9999." + return@save + } + scope.launch { + consumePreferenceResult( + result = controller.setChannelNumber(selected.channelId, parsed), + invalidMessage = "Введите номер от 1 до 9999.", + ) + } + } + + null -> Unit + } + }, + onCancelEditor = { + quickEditor = null + quickMutationMessage = null + }, + onReset = { + scope.launch { + consumePreferenceResult( + result = controller.resetCustomization(selected.channelId), + invalidMessage = "Не удалось сбросить настройки канала.", + ) + } + }, + onClose = { dismissQuickActions(waitForAnchorRemoval = false) }, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = TvTokens.Spacing.screenInset), + ) + } } } @@ -409,15 +642,158 @@ private fun findLoadedIndex(rows: LazyPagingItems, channelId: return null } +@Composable +private fun ChannelQuickActionsPanel( + channel: ChannelRowUiModel, + editor: ChannelQuickEditor?, + mutationMessage: String?, + firstActionFocusRequester: FocusRequester, + editorFocusRequester: FocusRequester, + onFavorite: () -> Unit, + onHide: () -> Unit, + onRename: () -> Unit, + onEditNumber: () -> Unit, + onEditorValueChanged: (String) -> Unit, + onSaveEditor: () -> Unit, + onCancelEditor: () -> Unit, + onReset: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(TvTokens.Shape.rowCorner) + Column( + modifier = modifier + .width(420.dp) + .clip(shape) + .background(TvTokens.Color.surfaceRaised) + .border(1.dp, MaterialTheme.colorScheme.borderVariant, shape) + .padding(TvTokens.Spacing.medium) + .testTag(CHANNEL_QUICK_ACTIONS_TEST_TAG), + verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + ) { + Text( + text = channel.displayName, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "Долгое OK · быстрые действия", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + when (editor) { + null -> Column(verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.xSmall)) { + quickActionsFor(channel.isFavorite).forEachIndexed { index, action -> + MuxTvActionButton( + text = action.label, + onClick = when (action.kind) { + ChannelQuickActionKind.FAVORITE -> onFavorite + ChannelQuickActionKind.HIDE -> onHide + ChannelQuickActionKind.RENAME -> onRename + ChannelQuickActionKind.CHANNEL_NUMBER -> onEditNumber + ChannelQuickActionKind.RESET -> onReset + }, + modifier = if (index == 0) { + Modifier.fillMaxWidth().focusRequester(firstActionFocusRequester) + } else { + Modifier.fillMaxWidth() + }, + ) + } + MuxTvActionButton( + text = "Закрыть", + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) + } + + is ChannelQuickEditor.Name -> ChannelQuickEditorRow( + label = "Название", + value = editor.value, + keyboardType = KeyboardType.Text, + focusRequester = editorFocusRequester, + onValueChanged = onEditorValueChanged, + onSave = onSaveEditor, + onCancel = onCancelEditor, + ) + + is ChannelQuickEditor.Number -> ChannelQuickEditorRow( + label = "Номер · пустое поле вернёт номер источника", + value = editor.value, + keyboardType = KeyboardType.Number, + focusRequester = editorFocusRequester, + onValueChanged = onEditorValueChanged, + onSave = onSaveEditor, + onCancel = onCancelEditor, + ) + } + + if (mutationMessage != null) { + Text( + text = mutationMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(CHANNEL_QUICK_ACTIONS_ERROR_TEST_TAG), + ) + } + } +} + +@Composable +private fun ChannelQuickEditorRow( + label: String, + value: String, + keyboardType: KeyboardType, + focusRequester: FocusRequester, + onValueChanged: (String) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.xSmall)) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + BasicTextField( + value = value, + onValueChange = onValueChanged, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(TvTokens.Shape.rowCorner)) + .border(1.dp, MaterialTheme.colorScheme.borderVariant, RoundedCornerShape(TvTokens.Shape.rowCorner)) + .padding(horizontal = TvTokens.Spacing.small) + .focusRequester(focusRequester) + .testTag(CHANNEL_QUICK_ACTIONS_EDITOR_TEST_TAG), + textStyle = MaterialTheme.typography.titleMedium.copy(color = MaterialTheme.colorScheme.onSurface), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + ) + Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + MuxTvActionButton(text = "Сохранить", onClick = onSave) + MuxTvActionButton(text = "Отмена", onClick = onCancel) + } + } +} + /** Lounge channel row: fixed geometry, no focus scale. */ @Composable private fun ChannelRow( row: ChannelRowUiModel, onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { var focused by remember { mutableStateOf(false) } val shape = RoundedCornerShape(TvTokens.Shape.rowCorner) + val interactionModifier = if (onLongClick == null) { + Modifier.clickable(role = Role.Button, onClick = onClick) + } else { + Modifier.combinedClickable( + role = Role.Button, + onClick = onClick, + onLongClick = onLongClick, + ) + } Row( modifier = modifier .height(TvTokens.Size.channelRowHeight) @@ -429,7 +805,7 @@ private fun ChannelRow( shape = shape, ) .onFocusChanged { focused = it.isFocused } - .clickable(role = Role.Button, onClick = onClick) + .then(interactionModifier) .focusable() .padding(horizontal = TvTokens.Spacing.medium), verticalAlignment = Alignment.CenterVertically, @@ -548,6 +924,18 @@ private fun MessageRoute( } } +private sealed interface ChannelQuickEditor { + val value: String + + data class Name(override val value: String) : ChannelQuickEditor + data class Number(override val value: String) : ChannelQuickEditor +} + +private data class ChannelQuickFocusReturnRequest( + val anchor: FocusAnchor, + val waitForAnchorRemoval: Boolean, +) + private fun ChannelsFilter.title() = when (this) { ChannelsFilter.ALL -> "Эфир" ChannelsFilter.FAVORITES -> "Избранное" @@ -574,5 +962,8 @@ private const val CHANNEL_ROW_TEST_TAG_PREFIX = "channel-row-" private const val CHANNELS_ALL_FILTER_TEST_TAG = "channels-filter-all" private const val CHANNELS_FAVORITES_FILTER_TEST_TAG = "channels-filter-favorites" private const val CHANNELS_RECENT_FILTER_TEST_TAG = "channels-filter-recent" +internal const val CHANNEL_QUICK_ACTIONS_TEST_TAG = "channel-quick-actions" +internal const val CHANNEL_QUICK_ACTIONS_EDITOR_TEST_TAG = "channel-quick-actions-editor" +internal const val CHANNEL_QUICK_ACTIONS_ERROR_TEST_TAG = "channel-quick-actions-error" private val ROW_PROGRAMME_WIDTH = 340.dp private val ROW_PROGRESS_WIDTH = 120.dp \ No newline at end of file From 7d427da9daf663c71af6fa5ab5fe0c28f5df13ab Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 15:57:49 +0500 Subject: [PATCH 33/43] test(channels): cover long-OK quick action journey --- .../muxtv/ChannelQuickActionsJourneyTest.kt | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt new file mode 100644 index 000000000..711c76a0f --- /dev/null +++ b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt @@ -0,0 +1,255 @@ +package app.muxtv + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertDoesNotExist +import androidx.compose.ui.test.assertExists +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import app.muxtv.catalog.ChannelQuery +import app.muxtv.catalog.PlayableChannel +import app.muxtv.catalog.PlayableChannelSummary +import app.muxtv.catalog.PlaybackAccessMutationResult +import app.muxtv.catalog.PlaybackCatalog +import app.muxtv.catalog.PlaybackVariantResolution +import app.muxtv.catalog.RecentChannel +import app.muxtv.catalog.RecentChannelWriteResult +import app.muxtv.catalog.RecentChannelsQuery +import app.muxtv.catalog.RecentChannelsRepository +import app.muxtv.designsystem.MuxTvTheme +import app.muxtv.feature.channels.ChannelsRoute +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.junit.Rule +import org.junit.Test + +class ChannelQuickActionsJourneyTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun normalOkStillOpensPlaybackWhenQuickActionsAreEnabled() { + val catalog = QuickActionPlaybackCatalog() + var openedChannelId: String? = null + + composeRule.setContent { + MuxTvTheme { + channelsRoute( + catalog = catalog, + preferences = HidingPreferencesRepository(catalog), + onOpenChannel = { openedChannelId = it }, + ) + } + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("channel-row-0") + .assertIsFocused() + .pressEnter() + composeRule.waitForIdle() + + assert(openedChannelId == "channel-a") + composeRule.onNodeWithTag("channel-quick-actions").assertDoesNotExist() + } + + @Test + fun longClickOpensQuickActionsWithoutStartingPlayback() { + val catalog = QuickActionPlaybackCatalog() + var openedChannelId: String? = null + + composeRule.setContent { + MuxTvTheme { + channelsRoute( + catalog = catalog, + preferences = HidingPreferencesRepository(catalog), + onOpenChannel = { openedChannelId = it }, + ) + } + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("channel-row-0") + .performSemanticsAction(SemanticsActions.OnLongClick) + composeRule.waitForIdle() + + assert(openedChannelId == null) + composeRule.onNodeWithTag("channel-quick-actions").assertExists() + composeRule.onNodeWithText("В избранное").assertIsFocused() + } + + @Test + fun hidingFocusedChannelReturnsFocusToNearestPreviousRow() { + val catalog = QuickActionPlaybackCatalog() + val preferences = HidingPreferencesRepository(catalog) + + composeRule.setContent { + MuxTvTheme { + channelsRoute( + catalog = catalog, + preferences = preferences, + ) + } + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("channel-row-0").performKeyInput { + keyDown(Key.DirectionDown) + keyUp(Key.DirectionDown) + } + composeRule.onNodeWithTag("channel-row-1").assertIsFocused() + composeRule.onNodeWithTag("channel-row-1") + .performSemanticsAction(SemanticsActions.OnLongClick) + composeRule.waitForIdle() + composeRule.onNodeWithText("Скрыть").performClick() + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText("Второй", substring = false).fetchSemanticsNodes().isEmpty() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("channel-quick-actions").assertDoesNotExist() + composeRule.onNodeWithText("Первый", substring = false).assertIsFocused() + } + + @androidx.compose.runtime.Composable + private fun channelsRoute( + catalog: QuickActionPlaybackCatalog, + preferences: ChannelPreferencesRepository, + onOpenChannel: (String) -> Unit = {}, + ) { + ChannelsRoute( + channelBrowseRepository = TestChannelBrowseRepository( + playbackCatalog = catalog, + recentChannelsRepository = EmptyRecentChannelsRepository, + epgGuideRepository = NoGuideEpgGuideRepository, + ), + epgGuideRepository = NoGuideEpgGuideRepository, + playbackSessionStateSource = NoPlaybackSessionStateSource, + profileId = PROFILE_ID, + onOpenChannel = onOpenChannel, + channelPreferencesRepository = preferences, + ) + } + + private companion object { + const val PROFILE_ID = "profile-main" + } +} + +private class HidingPreferencesRepository( + private val catalog: QuickActionPlaybackCatalog, +) : ChannelPreferencesRepository { + override suspend fun setFavorite( + profileId: String, + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult = ChannelFavoriteMutationResult.Applied + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult { + if (isHidden) catalog.remove(channelId) + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.Applied + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.Applied + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.Applied +} + +private class QuickActionPlaybackCatalog : PlaybackCatalog { + private val channels = MutableStateFlow( + listOf( + quickActionChannel("channel-a", "Первый"), + quickActionChannel("channel-b", "Второй"), + quickActionChannel("channel-c", "Третий"), + ), + ) + + fun remove(channelId: String) { + channels.value = channels.value.filterNot { it.channelId == channelId } + } + + override fun observeChannels(query: ChannelQuery): Flow> = + channels.map { rows -> + rows.filter { row -> !query.favoritesOnly || row.isFavorite } + .take(query.limit) + } + + override suspend fun getChannel( + profileId: String, + channelId: String, + ): PlayableChannel? = null + + override suspend fun resolveVariant( + profileId: String, + channelId: String, + preferredVariantId: String?, + ): PlaybackVariantResolution? = null + + override suspend fun approveInsecurePlayback( + profileId: String, + channelId: String, + variantId: String, + ): PlaybackAccessMutationResult = PlaybackAccessMutationResult.NotFound + + override suspend fun revokeInsecurePlayback( + profileId: String, + channelId: String, + variantId: String, + ): PlaybackAccessMutationResult = PlaybackAccessMutationResult.NotFound +} + +private object EmptyRecentChannelsRepository : RecentChannelsRepository { + override fun observeRecent(query: RecentChannelsQuery): Flow> = flowOf(emptyList()) + + override suspend fun recordSuccessfulPlayback( + profileId: String, + channelId: String, + successfulAtEpochMillis: Long, + ): RecentChannelWriteResult = RecentChannelWriteResult.Applied +} + +private fun quickActionChannel( + id: String, + name: String, +): PlayableChannelSummary = PlayableChannelSummary( + channelId = id, + displayName = name, + logoUrl = null, + groupTitle = "Тест", + channelNumber = null, + isFavorite = false, + variantCount = 1, +) + +private fun androidx.compose.ui.test.SemanticsNodeInteraction.pressEnter() = performKeyInput { + keyDown(Key.Enter) + keyUp(Key.Enter) +} From 09eb348d03acd672558a2f374dff7b8007e2c208 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 15:58:12 +0500 Subject: [PATCH 34/43] feat(channels): enable quick actions from channels entry --- .../app/muxtv/feature/channels/ChannelsManageEntryRoute.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt index df0e844b5..f47234c26 100644 --- a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelPreferencesRepository import app.muxtv.catalog.EpgGuideRepository import app.muxtv.designsystem.TvTokens import app.muxtv.designsystem.component.MuxTvActionButton @@ -22,6 +23,7 @@ import app.muxtv.player.PlaybackSessionStateSource @Composable fun ChannelsRoute( channelBrowseRepository: ChannelBrowseRepository, + channelPreferencesRepository: ChannelPreferencesRepository, epgGuideRepository: EpgGuideRepository, playbackSessionStateSource: PlaybackSessionStateSource, profileId: String, @@ -39,6 +41,7 @@ fun ChannelsRoute( onOpenChannel = onOpenChannel, modifier = Modifier.fillMaxSize(), railFocusRequester = railFocusRequester, + channelPreferencesRepository = channelPreferencesRepository, ) MuxTvActionButton( text = "Управление", From 1e99064554b9027017a418f78ff6b37d0cedbfdf Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 15:59:35 +0500 Subject: [PATCH 35/43] feat(navigation): provide channel preferences to quick actions --- app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt b/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt index 68857e17c..d650980c1 100644 --- a/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt +++ b/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt @@ -139,6 +139,7 @@ fun AppNavigation( AppDestination.Channels -> ChannelsRoute( channelBrowseRepository = channelBrowseRepository, + channelPreferencesRepository = channelPreferencesRepository, epgGuideRepository = epgGuideRepository, playbackSessionStateSource = controllerConnector, profileId = DatabaseDefaults.PRIMARY_PROFILE_ID, From d458d7caf9a6b41ead3490083f20987abed7bbb9 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 16:01:27 +0500 Subject: [PATCH 36/43] test(channels): use effective quick action assertions --- .../kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt index 711c76a0f..1deed6dc2 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt @@ -2,9 +2,9 @@ package app.muxtv import androidx.compose.ui.input.key.Key import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertDoesNotExist import androidx.compose.ui.test.assertExists +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag @@ -27,6 +27,7 @@ import app.muxtv.catalog.RecentChannelsQuery import app.muxtv.catalog.RecentChannelsRepository import app.muxtv.designsystem.MuxTvTheme import app.muxtv.feature.channels.ChannelsRoute +import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -59,7 +60,7 @@ class ChannelQuickActionsJourneyTest { .pressEnter() composeRule.waitForIdle() - assert(openedChannelId == "channel-a") + assertThat(openedChannelId).isEqualTo("channel-a") composeRule.onNodeWithTag("channel-quick-actions").assertDoesNotExist() } @@ -83,7 +84,7 @@ class ChannelQuickActionsJourneyTest { .performSemanticsAction(SemanticsActions.OnLongClick) composeRule.waitForIdle() - assert(openedChannelId == null) + assertThat(openedChannelId).isNull() composeRule.onNodeWithTag("channel-quick-actions").assertExists() composeRule.onNodeWithText("В избранное").assertIsFocused() } From 285e7609272ef0999201d3d3b09442de26764bb1 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 21:56:06 +0500 Subject: [PATCH 37/43] test(channels): fix compose test assertion imports --- .../kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt index 1deed6dc2..35cebdbb0 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt @@ -2,8 +2,6 @@ package app.muxtv import androidx.compose.ui.input.key.Key import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.assertDoesNotExist -import androidx.compose.ui.test.assertExists import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithText From aa84529cef49b3ea2b372182745c1b4d7116fbc7 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 22:33:13 +0500 Subject: [PATCH 38/43] test(channels): synchronize hide quick action journey --- .../kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt index 35cebdbb0..7cb4e39ef 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt @@ -111,13 +111,14 @@ class ChannelQuickActionsJourneyTest { .performSemanticsAction(SemanticsActions.OnLongClick) composeRule.waitForIdle() composeRule.onNodeWithText("Скрыть").performClick() + composeRule.waitForIdle() + composeRule.onNodeWithTag("channel-quick-actions").assertDoesNotExist() composeRule.waitUntil(timeoutMillis = 5_000) { composeRule.onAllNodesWithText("Второй", substring = false).fetchSemanticsNodes().isEmpty() } composeRule.waitForIdle() - composeRule.onNodeWithTag("channel-quick-actions").assertDoesNotExist() composeRule.onNodeWithText("Первый", substring = false).assertIsFocused() } From 5ef51d8b9901105399f4a66d7b10d42c5a8e6fd0 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 23:06:49 +0500 Subject: [PATCH 39/43] test(channels): await quick action dismissal --- .../kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt index 7cb4e39ef..ef9aa6a13 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt @@ -111,9 +111,13 @@ class ChannelQuickActionsJourneyTest { .performSemanticsAction(SemanticsActions.OnLongClick) composeRule.waitForIdle() composeRule.onNodeWithText("Скрыть").performClick() - composeRule.waitForIdle() - composeRule.onNodeWithTag("channel-quick-actions").assertDoesNotExist() + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText( + "Долгое OK · быстрые действия", + substring = false, + ).fetchSemanticsNodes().isEmpty() + } composeRule.waitUntil(timeoutMillis = 5_000) { composeRule.onAllNodesWithText("Второй", substring = false).fetchSemanticsNodes().isEmpty() } From 96df5d5d6248eff4236f2394561ff7915a2091c6 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 23:19:31 +0500 Subject: [PATCH 40/43] test(channel-control): add manage channels device acceptance --- .../ManageChannelsAcceptanceJourneyTest.kt | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt new file mode 100644 index 000000000..f43101466 --- /dev/null +++ b/app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt @@ -0,0 +1,290 @@ +package app.muxtv + +import androidx.paging.PagingData +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import app.muxtv.catalog.ChannelBrowseItem +import app.muxtv.catalog.ChannelBrowseQuery +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import app.muxtv.designsystem.MuxTvTheme +import app.muxtv.feature.channels.ManageChannelsRoute +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.junit.Rule +import org.junit.Test + +class ManageChannelsAcceptanceJourneyTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun hiddenChannelCanBeRecoveredAndFocusFallsBackToNearestPreviousHiddenRow() { + val fixture = ManageChannelsFixture( + initialRows = listOf( + managementChannel( + id = "channel-a", + canonicalName = "Первый", + number = "1", + isHidden = true, + ), + managementChannel( + id = "channel-b", + canonicalName = "Второй", + number = "2", + isHidden = true, + ), + managementChannel( + id = "channel-c", + canonicalName = "Третий", + number = "3", + isHidden = false, + ), + ), + ) + + composeRule.setContent { + MuxTvTheme { + ManageChannelsRoute( + channelBrowseRepository = fixture, + channelPreferencesRepository = fixture, + profileId = PROFILE_ID, + ) + } + } + + composeRule.onNodeWithTag("manage-channels-filter-hidden").performClick() + composeRule.waitUntilTag("manage-channel-row-channel-b") + composeRule.onNodeWithTag("manage-channel-row-channel-b").performClick() + composeRule.onNodeWithText("Показать").assertIsFocused().performClick() + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag("manage-channels-actions").fetchSemanticsNodes().isEmpty() + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag("manage-channel-row-channel-b").fetchSemanticsNodes().isEmpty() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("manage-channel-row-channel-a").assertIsFocused() + composeRule.onNodeWithTag("manage-channel-row-channel-c").assertDoesNotExist() + } + + @Test + fun renameNumberAndResetRoundTripKeepsFavoriteState() { + val fixture = ManageChannelsFixture( + initialRows = listOf( + managementChannel( + id = "channel-news", + canonicalName = "Новости", + number = "10", + isFavorite = true, + ), + ), + ) + + composeRule.setContent { + MuxTvTheme { + ManageChannelsRoute( + channelBrowseRepository = fixture, + channelPreferencesRepository = fixture, + profileId = PROFILE_ID, + ) + } + } + composeRule.waitUntilTag("manage-channel-row-channel-news") + + composeRule.onNodeWithTag("manage-channel-row-channel-news").performClick() + composeRule.onNodeWithText("Переименовать").performClick() + composeRule.onNodeWithTag("manage-channels-editor") + .assertIsFocused() + .performTextClearance() + .performTextInput("Мои новости") + composeRule.onNodeWithText("Сохранить").performClick() + composeRule.waitUntilText("Мои новости") + + composeRule.onNodeWithTag("manage-channel-row-channel-news").performClick() + composeRule.onNodeWithText("Номер").performClick() + composeRule.onNodeWithTag("manage-channels-editor") + .assertIsFocused() + .performTextInput("77") + composeRule.onNodeWithText("Сохранить").performClick() + composeRule.waitUntilText("77") + composeRule.onNodeWithText("Видим · Избранное · Изменён").assertExists() + + composeRule.onNodeWithTag("manage-channel-row-channel-news").performClick() + composeRule.onNodeWithText("Сбросить").performClick() + composeRule.waitUntilText("Новости") + composeRule.waitForIdle() + + composeRule.onNodeWithText("10").assertExists() + composeRule.onNodeWithText("Видим · Избранное").assertExists() + composeRule.onNodeWithText("Изменён", substring = true).assertDoesNotExist() + composeRule.onNodeWithTag("manage-channel-row-channel-news").assertIsFocused() + } + + private fun androidx.compose.ui.test.junit4.ComposeContentTestRule.waitUntilTag(tag: String) { + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithTag(tag).fetchSemanticsNodes().size == 1 + } + } + + private fun androidx.compose.ui.test.junit4.ComposeContentTestRule.waitUntilText(text: String) { + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithText(text, substring = false).fetchSemanticsNodes().isNotEmpty() + } + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithTag("manage-channels-actions").fetchSemanticsNodes().isEmpty() + } + } + + private companion object { + const val PROFILE_ID = "profile-main" + } +} + +private class ManageChannelsFixture( + initialRows: List, +) : ChannelBrowseRepository, ChannelPreferencesRepository { + private val rows = MutableStateFlow(initialRows) + + override fun pages(query: ChannelBrowseQuery): Flow> = + flowOf(PagingData.empty()) + + override fun managementPages(query: ChannelManagementQuery): Flow> = + rows.map { current -> + val filtered = when (query.visibility) { + ChannelManagementVisibility.ALL -> current + ChannelManagementVisibility.VISIBLE -> current.filterNot(ChannelManagementItem::isHidden) + ChannelManagementVisibility.HIDDEN -> current.filter(ChannelManagementItem::isHidden) + } + PagingData.from(filtered) + } + + override suspend fun setFavorite( + profileId: String, + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult = + updateVisible(channelId) { row -> row.copy(isFavorite = isFavorite) } + .toFavoriteResult() + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult = + update(channelId) { row -> row.copy(isHidden = isHidden) } + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult { + val normalized = customName?.trim() + if (normalized != null && normalized.isBlank()) return ChannelPreferenceMutationResult.InvalidInput + return update(channelId) { row -> + row.copy(effectiveDisplayName = normalized ?: row.canonicalDisplayName) + } + } + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult { + if (channelNumber != null && channelNumber !in 1..9999) { + return ChannelPreferenceMutationResult.InvalidInput + } + return update(channelId) { row -> + row.copy( + customChannelNumber = channelNumber, + effectiveChannelNumber = channelNumber?.toString() ?: row.defaultChannelNumber, + ) + } + } + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult = + update(channelId) { row -> + row.copy( + effectiveDisplayName = row.canonicalDisplayName, + customChannelNumber = null, + effectiveChannelNumber = row.defaultChannelNumber, + isHidden = false, + ) + } + + private fun updateVisible( + channelId: String, + transform: (ChannelManagementItem) -> ChannelManagementItem, + ): ChannelPreferenceMutationResult { + val current = rows.value + val index = current.indexOfFirst { row -> row.channelId == channelId && !row.isHidden } + if (index < 0) return ChannelPreferenceMutationResult.NotFound + return replaceAt(index, transform) + } + + private fun update( + channelId: String, + transform: (ChannelManagementItem) -> ChannelManagementItem, + ): ChannelPreferenceMutationResult { + val current = rows.value + val index = current.indexOfFirst { row -> row.channelId == channelId } + if (index < 0) return ChannelPreferenceMutationResult.NotFound + return replaceAt(index, transform) + } + + private fun replaceAt( + index: Int, + transform: (ChannelManagementItem) -> ChannelManagementItem, + ): ChannelPreferenceMutationResult { + val current = rows.value + val before = current[index] + val after = transform(before) + if (before == after) return ChannelPreferenceMutationResult.Unchanged + rows.value = current.toMutableList().also { it[index] = after } + return ChannelPreferenceMutationResult.Applied + } + + private fun ChannelPreferenceMutationResult.toFavoriteResult(): ChannelFavoriteMutationResult = when (this) { + ChannelPreferenceMutationResult.Applied -> ChannelFavoriteMutationResult.Applied + ChannelPreferenceMutationResult.Unchanged -> ChannelFavoriteMutationResult.Unchanged + ChannelPreferenceMutationResult.NotFound -> ChannelFavoriteMutationResult.NotFound + ChannelPreferenceMutationResult.InvalidInput -> ChannelFavoriteMutationResult.NotFound + } +} + +private fun managementChannel( + id: String, + canonicalName: String, + number: String, + isFavorite: Boolean = false, + isHidden: Boolean = false, +): ChannelManagementItem = ChannelManagementItem( + channelId = id, + canonicalDisplayName = canonicalName, + effectiveDisplayName = canonicalName, + defaultChannelNumber = number, + customChannelNumber = null, + effectiveChannelNumber = number, + isFavorite = isFavorite, + isHidden = isHidden, + variantCount = 1, +) From 57f020031ee339472e8528dbc3971076143af694 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 23:20:28 +0500 Subject: [PATCH 41/43] test(channel-control): cover browse projection acceptance --- .../ChannelControlBrowseAcceptanceTest.kt | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 core/database/src/androidTest/kotlin/app/muxtv/database/ChannelControlBrowseAcceptanceTest.kt diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelControlBrowseAcceptanceTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelControlBrowseAcceptanceTest.kt new file mode 100644 index 000000000..7cf860f91 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelControlBrowseAcceptanceTest.kt @@ -0,0 +1,161 @@ +package app.muxtv.database + +import androidx.paging.testing.asSnapshot +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.muxtv.catalog.ChannelBrowseFilter +import app.muxtv.catalog.ChannelBrowseQuery +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChannelControlBrowseAcceptanceTest { + private lateinit var database: MuxTvDatabase + private lateinit var revisionStore: SourceRevisionStore + private lateinit var browse: RoomChannelBrowseRepository + private lateinit var recent: RoomRecentChannelsRepository + + @Before + fun setUp() = runTest { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MuxTvDatabase::class.java, + ).build() + revisionStore = RoomSourceRevisionStore(database.sourceRevisionDao()) + browse = RoomChannelBrowseRepository( + dao = database.channelBrowseDao(), + guideRepository = RoomEpgGuideRepository(database.epgGuideDao()), + nowEpochMillis = { 5_000L }, + ) + recent = RoomRecentChannelsRepository(database.recentChannelsDao()) + database.profileDao().insert(ProfileEntity(PROFILE_ID, "Primary", isPrimary = true)) + activateCatalog() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun oneOverlayDrivesAllFavoritesAndRecentThenHideSuppressesEveryBrowseProjection() = runTest { + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = CHANNEL_A, + isFavorite = true, + customName = "Мои новости", + channelNumber = 77, + ), + ) + recent.recordSuccessfulPlayback(PROFILE_ID, CHANNEL_B, 1_000L) + recent.recordSuccessfulPlayback(PROFILE_ID, CHANNEL_A, 2_000L) + + val all = browse.pages(query(ChannelBrowseFilter.ALL)).asSnapshot() + val favorites = browse.pages(query(ChannelBrowseFilter.FAVORITES)).asSnapshot() + val recentRows = browse.pages(query(ChannelBrowseFilter.RECENT)).asSnapshot() + + assertThat(all.map { it.channelId }).containsExactly(CHANNEL_B, CHANNEL_A).inOrder() + assertEffectiveOverlay(all.single { it.channelId == CHANNEL_A }) + assertThat(favorites.map { it.channelId }).containsExactly(CHANNEL_A) + assertEffectiveOverlay(favorites.single()) + assertThat(recentRows.map { it.channelId }).containsExactly(CHANNEL_A, CHANNEL_B).inOrder() + assertEffectiveOverlay(recentRows.first()) + + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = CHANNEL_A, + isFavorite = true, + customName = "Мои новости", + channelNumber = 77, + isHidden = true, + ), + ) + + assertThat(browse.pages(query(ChannelBrowseFilter.ALL)).asSnapshot().map { it.channelId }) + .containsExactly(CHANNEL_B) + assertThat(browse.pages(query(ChannelBrowseFilter.FAVORITES)).asSnapshot()).isEmpty() + assertThat(browse.pages(query(ChannelBrowseFilter.RECENT)).asSnapshot().map { it.channelId }) + .containsExactly(CHANNEL_B) + } + + private fun assertEffectiveOverlay(item: app.muxtv.catalog.ChannelBrowseItem) { + assertThat(item.displayName).isEqualTo("Мои новости") + assertThat(item.channelNumber).isEqualTo("77") + assertThat(item.isFavorite).isTrue() + } + + private fun query(filter: ChannelBrowseFilter) = ChannelBrowseQuery(PROFILE_ID, filter) + + 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( + stagedEntry( + providerChannelId = "provider-a", + canonicalChannelId = CHANNEL_A, + displayName = "Новости", + channelNumber = "1", + variantId = "variant-a", + ), + stagedEntry( + providerChannelId = "provider-b", + canonicalChannelId = CHANNEL_B, + displayName = "Спорт", + channelNumber = "2", + variantId = "variant-b", + ), + ), + ) + val result = revisionStore.activate( + sourceId = SOURCE_ID, + revisionNumber = 1L, + activatedAtEpochMillis = 1_500L, + statistics = SourceRevisionStatistics( + parsedEntries = 2, + skippedEntries = 0, + warningCount = 0, + ), + ) + assertThat(result).isInstanceOf(SourceRevisionActivationResult.Activated::class.java) + } + + private fun stagedEntry( + providerChannelId: String, + canonicalChannelId: String, + displayName: String, + channelNumber: String, + variantId: String, + ) = StagedCatalogEntry( + providerChannelId = providerChannelId, + providerKey = "tvg:$canonicalChannelId", + rawName = displayName, + canonicalChannelId = canonicalChannelId, + canonicalDisplayName = displayName, + streamVariantId = variantId, + locator = "https://example.invalid/$variantId.m3u8", + tvgId = canonicalChannelId, + groupTitle = "Тест", + channelNumber = channelNumber, + ) + + private companion object { + const val PROFILE_ID = "profile-main" + const val SOURCE_ID = "source-main" + const val CHANNEL_A = "channel-a" + const val CHANNEL_B = "channel-b" + } +} From 235023ca7c95f26d60b3a46de4f4d9d0767d90c1 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 23:25:07 +0500 Subject: [PATCH 42/43] test(channels): sync deterministic compose coroutine rule --- .../kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt index ef9aa6a13..1137b7f04 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt @@ -3,7 +3,7 @@ package app.muxtv import androidx.compose.ui.input.key.Key import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -111,6 +111,7 @@ class ChannelQuickActionsJourneyTest { .performSemanticsAction(SemanticsActions.OnLongClick) composeRule.waitForIdle() composeRule.onNodeWithText("Скрыть").performClick() + composeRule.waitForIdle() composeRule.waitUntil(timeoutMillis = 5_000) { composeRule.onAllNodesWithText( From fb0a599c02cd2146706886b3f440333c7e313c59 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 23:25:54 +0500 Subject: [PATCH 43/43] test(channel-control): use deterministic compose rule in acceptance --- .../muxtv/ManageChannelsAcceptanceJourneyTest.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt index f43101466..a5589ef8d 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt @@ -1,8 +1,7 @@ package app.muxtv -import androidx.paging.PagingData import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag @@ -10,6 +9,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput +import androidx.paging.PagingData import app.muxtv.catalog.ChannelBrowseItem import app.muxtv.catalog.ChannelBrowseQuery import app.muxtv.catalog.ChannelBrowseRepository @@ -71,6 +71,7 @@ class ManageChannelsAcceptanceJourneyTest { composeRule.waitUntilTag("manage-channel-row-channel-b") composeRule.onNodeWithTag("manage-channel-row-channel-b").performClick() composeRule.onNodeWithText("Показать").assertIsFocused().performClick() + composeRule.waitForIdle() composeRule.waitUntil(timeoutMillis = 5_000) { composeRule.onAllNodesWithTag("manage-channels-actions").fetchSemanticsNodes().isEmpty() @@ -115,6 +116,7 @@ class ManageChannelsAcceptanceJourneyTest { .performTextClearance() .performTextInput("Мои новости") composeRule.onNodeWithText("Сохранить").performClick() + composeRule.waitForIdle() composeRule.waitUntilText("Мои новости") composeRule.onNodeWithTag("manage-channel-row-channel-news").performClick() @@ -123,13 +125,14 @@ class ManageChannelsAcceptanceJourneyTest { .assertIsFocused() .performTextInput("77") composeRule.onNodeWithText("Сохранить").performClick() + composeRule.waitForIdle() composeRule.waitUntilText("77") composeRule.onNodeWithText("Видим · Избранное · Изменён").assertExists() composeRule.onNodeWithTag("manage-channel-row-channel-news").performClick() composeRule.onNodeWithText("Сбросить").performClick() - composeRule.waitUntilText("Новости") composeRule.waitForIdle() + composeRule.waitUntilText("Новости") composeRule.onNodeWithText("10").assertExists() composeRule.onNodeWithText("Видим · Избранное").assertExists() @@ -169,8 +172,8 @@ private class ManageChannelsFixture( rows.map { current -> val filtered = when (query.visibility) { ChannelManagementVisibility.ALL -> current - ChannelManagementVisibility.VISIBLE -> current.filterNot(ChannelManagementItem::isHidden) - ChannelManagementVisibility.HIDDEN -> current.filter(ChannelManagementItem::isHidden) + ChannelManagementVisibility.VISIBLE -> current.filterNot { it.isHidden } + ChannelManagementVisibility.HIDDEN -> current.filter { it.isHidden } } PagingData.from(filtered) }