Skip to content

Commit d2b451b

Browse files
author
Arvin
committed
fix(iptv): stabilize guide navigation and bound provider requests
1 parent 14543b9 commit d2b451b

16 files changed

Lines changed: 724 additions & 136 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.arflix.tv.ui.screens.tv.live
2+
3+
import android.os.Bundle
4+
import android.os.SystemClock
5+
import androidx.test.platform.app.InstrumentationRegistry
6+
import com.arflix.tv.di.GuideAuditEntryPoint
7+
import com.arflix.tv.di.RepositoryAccessEntryPoint
8+
import com.arflix.tv.data.repository.IptvPlaylistEntry
9+
import dagger.hilt.android.EntryPointAccessors
10+
import java.net.URI
11+
import kotlinx.coroutines.runBlocking
12+
import kotlinx.coroutines.flow.first
13+
import org.junit.Test
14+
15+
/** Explicitly invoked local audits; never ship in the release APK. */
16+
class GuideConfiguredDeviceTest {
17+
@Test fun removeExplicitTestProfile() = runBlocking {
18+
val instrumentation = InstrumentationRegistry.getInstrumentation()
19+
val id = InstrumentationRegistry.getArguments().getString("testProfileId")
20+
?: return@runBlocking
21+
val access = EntryPointAccessors.fromApplication(instrumentation.targetContext, RepositoryAccessEntryPoint::class.java)
22+
val profiles = access.profileRepository()
23+
val target = profiles.getProfiles().single { it.id == id && it.name == "IPTV Navigation Test" }
24+
val original = profiles.getProfiles().single { it.name == "Arvind" }
25+
profiles.setActiveProfile(original.id)
26+
access.profileManager().setCurrentProfileId(original.id)
27+
profiles.deleteProfile(target.id)
28+
check(profiles.getProfiles().none { it.id == target.id })
29+
instrumentation.sendStatus(0, Bundle().apply { putString("stream", "Original profile restored; temporary profile removed.\n") })
30+
}
31+
32+
@Test fun setupExplicitlyProvidedPlaylist() = runBlocking {
33+
val instrumentation = InstrumentationRegistry.getInstrumentation()
34+
val url = InstrumentationRegistry.getArguments().getString("playlistUrl")
35+
?: return@runBlocking
36+
require(url.startsWith("http://") || url.startsWith("https://"))
37+
val context = instrumentation.targetContext
38+
val access = EntryPointAccessors.fromApplication(context, RepositoryAccessEntryPoint::class.java)
39+
val profiles = access.profileRepository()
40+
val profile = profiles.getProfiles().firstOrNull { it.name == "IPTV Navigation Test" }
41+
?: profiles.createProfile("IPTV Navigation Test", 0xFF447777)
42+
profiles.setActiveProfile(profile.id)
43+
access.profileManager().setCurrentProfileId(profile.id)
44+
val repository = EntryPointAccessors.fromApplication(context, GuideAuditEntryPoint::class.java).iptvRepository()
45+
repository.savePlaylists(listOf(IptvPlaylistEntry("guide-audit", "TREX test", url,
46+
importVod = false, importSeries = false)))
47+
instrumentation.sendStatus(0, Bundle().apply { putString("stream", "Test profile ready: ${profile.id}\n") })
48+
}
49+
50+
@Test fun reportConfiguredGuide() = runBlocking {
51+
val instrumentation = InstrumentationRegistry.getInstrumentation()
52+
val repository = EntryPointAccessors.fromApplication(instrumentation.targetContext,
53+
GuideAuditEntryPoint::class.java).iptvRepository()
54+
val config = repository.observeConfig().first()
55+
val start = SystemClock.elapsedRealtime()
56+
repository.warmupFromCacheOnly()
57+
val message = buildString {
58+
append("cache_ms=${SystemClock.elapsedRealtime() - start} channels=${repository.pagedChannelCount(null)}\n")
59+
config.playlists.forEach { playlist ->
60+
val host = runCatching { URI(playlist.m3uUrl).host }.getOrNull()
61+
append("playlist=${playlist.name} host=$host enabled=${playlist.enabled} id=${playlist.id}\n")
62+
}
63+
append("favorites=${repository.observeFavoriteChannels().first().size} hidden=${repository.observeHiddenGroups().first().size}\n")
64+
repository.pagedPlaylistGroupCounts().filter { it.second.contains("NL", true) }.take(12).forEach {
65+
append("group=$it\n")
66+
}
67+
}
68+
instrumentation.sendStatus(0, Bundle().apply { putString("stream", message) })
69+
}
70+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package com.arflix.tv.ui.screens.tv.live
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.height
5+
import androidx.compose.foundation.layout.width
6+
import androidx.compose.foundation.lazy.rememberLazyListState
7+
import androidx.compose.runtime.mutableStateOf
8+
import androidx.compose.runtime.LaunchedEffect
9+
import androidx.activity.OnBackPressedDispatcher
10+
import androidx.activity.compose.BackHandler
11+
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
12+
import androidx.compose.ui.Modifier
13+
import androidx.compose.ui.input.key.Key
14+
import androidx.compose.ui.test.*
15+
import androidx.compose.ui.test.junit4.createComposeRule
16+
import androidx.compose.ui.unit.dp
17+
import androidx.test.ext.junit.runners.AndroidJUnit4
18+
import com.arflix.tv.data.model.IptvChannel
19+
import org.junit.Assert.*
20+
import org.junit.Rule
21+
import org.junit.Test
22+
import org.junit.runner.RunWith
23+
import kotlinx.coroutines.delay
24+
25+
@RunWith(AndroidJUnit4::class)
26+
@OptIn(ExperimentalTestApi::class)
27+
class GuideNavigationDeviceTest {
28+
@get:Rule val compose = createComposeRule()
29+
private val rows by lazy { (0 until 55_000).map { index ->
30+
IptvChannel(id = "test:$index", name = "Channel $index", group = "News",
31+
streamUrl = "https://example.test/live").enrichForFastStartup(index + 1)
32+
} }
33+
34+
@Test fun programmeNavigationRevealsOffscreenRowsWithoutLosingFocus() {
35+
val mode = mutableStateOf(EpgGridFocusMode.ChannelList)
36+
var focused = ""
37+
compose.setContent {
38+
Box(Modifier.width(900.dp).height(400.dp)) {
39+
EpgGrid(channels = rows.take(144), clockTickMillis = 1_783_000_000_000L,
40+
nowNext = emptyMap(), selectedChannelId = "test:0", focusSelectedChannelSignal = 1,
41+
scrollResetKey = "all", onChannelSelect = {}, favorites = emptySet(),
42+
focusMode = mode.value, onEnterEpg = { mode.value = EpgGridFocusMode.Epg },
43+
onChannelFocused = { focused = it.id })
44+
}
45+
}
46+
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
47+
repeat(30) { compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) } }
48+
compose.runOnIdle { assertEquals("test:30", focused) }
49+
repeat(20) { compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } }
50+
compose.runOnIdle { assertEquals("test:10", focused) }
51+
}
52+
53+
@Test fun backClosesMenuInsteadOfMovingTheGuideBehindIt() {
54+
val menuOpen = mutableStateOf(true)
55+
var guideBacks = 0
56+
lateinit var dispatcher: OnBackPressedDispatcher
57+
compose.setContent {
58+
dispatcher = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher
59+
BackHandler(enabled = menuOpen.value) { menuOpen.value = false }
60+
EpgGrid(channels = rows.take(8), clockTickMillis = 1_783_000_000_000L,
61+
nowNext = emptyMap(), selectedChannelId = "test:0", focusSelectedChannelSignal = 1,
62+
scrollResetKey = "all", onChannelSelect = {}, favorites = emptySet(),
63+
gridFocused = true, backHandlingEnabled = !menuOpen.value,
64+
onMoveLeftFromChannels = { guideBacks++ })
65+
}
66+
compose.runOnIdle { dispatcher.onBackPressed() }
67+
compose.runOnIdle { assertFalse(menuOpen.value); assertEquals(0, guideBacks) }
68+
compose.runOnIdle { dispatcher.onBackPressed() }
69+
compose.runOnIdle { assertEquals(1, guideBacks) }
70+
}
71+
72+
@Test fun remoteScrollCrossesPageBoundariesAndCanReverseWithoutReset() {
73+
val channels = mutableStateOf(rows.take(144))
74+
val requested = mutableStateOf(144)
75+
var focused = ""
76+
compose.setContent {
77+
LaunchedEffect(requested.value) {
78+
delay(100L)
79+
channels.value = rows.take(requested.value)
80+
}
81+
Box(Modifier.width(900.dp).height(400.dp)) {
82+
EpgGrid(channels = channels.value, totalChannelCount = rows.size,
83+
clockTickMillis = 1_783_000_000_000L, nowNext = emptyMap(),
84+
selectedChannelId = "test:0", focusSelectedChannelSignal = 1,
85+
scrollResetKey = "all", onChannelSelect = {}, favorites = emptySet(),
86+
onChannelFocused = { focused = it.id },
87+
onRequestNextChannels = {
88+
requested.value = nextGuidePageLimit(channels.value.size, requested.value, rows.size)
89+
})
90+
}
91+
}
92+
compose.waitForIdle()
93+
repeat(170) {
94+
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
95+
}
96+
compose.runOnIdle { assertEquals("test:170", focused) }
97+
repeat(20) { compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } }
98+
compose.runOnIdle { assertEquals("test:150", focused) }
99+
compose.onNodeWithTag("iptv-channel:test:150").assertIsDisplayed()
100+
}
101+
102+
@Test fun touchScrollSurvivesAppendAndMetadataRefresh() {
103+
val channels = mutableStateOf(rows.take(144))
104+
var firstVisible = 0
105+
compose.setContent {
106+
Box(Modifier.width(380.dp).height(620.dp)) {
107+
EpgGrid(channels = channels.value, totalChannelCount = rows.size,
108+
clockTickMillis = 1_783_000_000_000L, nowNext = emptyMap(), compact = true,
109+
selectedChannelId = null, focusSelectedChannelSignal = 0,
110+
scrollResetKey = "all", onChannelSelect = {}, favorites = emptySet(),
111+
onVisibleChannelRange = { first, _ -> firstVisible = first })
112+
}
113+
}
114+
compose.onNodeWithTag("iptv-guide").performScrollToIndex(90)
115+
compose.waitForIdle()
116+
var before = 0
117+
compose.runOnIdle { before = firstVisible; channels.value = rows.take(336) }
118+
compose.waitForIdle()
119+
compose.runOnIdle { assertEquals(before, firstVisible) }
120+
compose.onNodeWithTag("iptv-guide").performTouchInput { swipeUp() }
121+
compose.waitForIdle()
122+
compose.runOnIdle { assertTrue(firstVisible > before) }
123+
}
124+
125+
@Test fun favoriteReorderKeepsStableFocusedChannel() {
126+
val channels = mutableStateOf(rows.take(8))
127+
compose.setContent {
128+
EpgGrid(channels = channels.value, clockTickMillis = 1_783_000_000_000L,
129+
nowNext = emptyMap(), selectedChannelId = "test:3", focusSelectedChannelSignal = 1,
130+
scrollResetKey = "fav", onChannelSelect = {}, favorites = setOf("test:3"))
131+
}
132+
compose.waitForIdle()
133+
compose.runOnIdle {
134+
channels.value = channels.value.toMutableList().apply { add(2, removeAt(3)) }
135+
}
136+
compose.waitForIdle()
137+
compose.onNodeWithTag("iptv-channel:test:3").assertIsFocused()
138+
}
139+
140+
@Test fun removingFocusedFavoriteFocusesTheNextChannel() {
141+
val channels = mutableStateOf(rows.take(8))
142+
compose.setContent {
143+
EpgGrid(channels = channels.value, clockTickMillis = 1_783_000_000_000L,
144+
nowNext = emptyMap(), selectedChannelId = "test:3", focusSelectedChannelSignal = 1,
145+
scrollResetKey = "fav", onChannelSelect = {}, favorites = setOf("test:3"),
146+
gridFocused = true)
147+
}
148+
compose.waitForIdle()
149+
compose.runOnIdle { channels.value = channels.value.filterNot { it.id == "test:3" } }
150+
compose.waitForIdle()
151+
compose.onNodeWithTag("iptv-channel:test:4").assertIsFocused()
152+
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
153+
compose.onNodeWithTag("iptv-channel:test:5").assertIsFocused()
154+
}
155+
156+
@Test fun touchCategoryMenuHidesGroupWithoutLeavingHiddenShortcut() {
157+
val hidden = mutableStateOf(emptySet<String>())
158+
compose.setContent {
159+
val state = buildPagedStartupChannelState(
160+
channels = rows.take(1).map { it.source }, totalChannelCount = 55_000,
161+
playlistGroupCounts = listOf(Triple("test", "News", 54_990), Triple("test", "Movies", 10)),
162+
favorites = emptySet(), recents = emptySet(), hiddenGroups = hidden.value)
163+
CategorySidebar(tree = state.tree, selectedId = "all", expanded = true,
164+
listState = rememberLazyListState(), onSelect = {}, onOpenSearch = {},
165+
isTouchDevice = true,
166+
onHideCategory = { playlist, group -> hidden.value = setOf("$playlist|$group") })
167+
}
168+
compose.onNodeWithText("News").performTouchInput { longClick() }
169+
compose.onNodeWithText("Hide category").performClick()
170+
compose.waitForIdle()
171+
compose.runOnIdle { assertEquals(setOf("test|News"), hidden.value) }
172+
compose.onNodeWithText("News").assertDoesNotExist()
173+
compose.onNodeWithText("Movies").assertIsDisplayed()
174+
}
175+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.arflix.tv.di
2+
3+
import com.arflix.tv.data.repository.IptvRepository
4+
import dagger.hilt.EntryPoint
5+
import dagger.hilt.InstallIn
6+
import dagger.hilt.components.SingletonComponent
7+
8+
@EntryPoint
9+
@InstallIn(SingletonComponent::class)
10+
interface GuideAuditEntryPoint {
11+
fun iptvRepository(): IptvRepository
12+
}

app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.database.sqlite.SQLiteDatabase
55
import android.database.sqlite.SQLiteOpenHelper
66
import com.arflix.tv.data.model.DrmInfo
77
import com.arflix.tv.data.model.IptvChannel
8+
import com.arflix.tv.data.model.PlaylistGroupKey
89
import com.google.gson.Gson
910

1011
/**
@@ -231,7 +232,8 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper(
231232
playlistId: String?,
232233
groupTitle: String?,
233234
offset: Int,
234-
limit: Int
235+
limit: Int,
236+
excludedGroups: Set<String> = emptySet(),
235237
): List<IptvChannel> {
236238
if (sourceKey.isBlank()) return emptyList()
237239
fun query(normalizedGroup: Boolean): List<IptvChannel> {
@@ -245,6 +247,9 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper(
245247
if (byGroup) {
246248
if (normalizedGroup) append(" AND trim(group_title) = ?") else append(" AND group_title = ?")
247249
}
250+
excludedGroups.forEach { _ ->
251+
append(" AND NOT (trim(group_title) = ? AND (id LIKE ? OR id LIKE ?))")
252+
}
248253
append(" ORDER BY ord")
249254
if (limit >= 0) append(" LIMIT ").append(limit).append(" OFFSET ").append(offset.coerceAtLeast(0))
250255
}
@@ -255,6 +260,12 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper(
255260
add("stalker:${playlistId}:%")
256261
}
257262
if (byGroup) add(if (normalizedGroup) groupTitle!!.trim() else groupTitle!!)
263+
excludedGroups.forEach { rawKey ->
264+
val key = PlaylistGroupKey(rawKey)
265+
add(key.groupName.trim())
266+
add("${key.playlistId}:%")
267+
add("stalker:${key.playlistId}:%")
268+
}
258269
}.toTypedArray()
259270
return readableDatabase.rawQuery(sql, args).use { cursor ->
260271
val out = ArrayList<IptvChannel>(if (limit in 1..100_000) limit else cursor.count)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.arflix.tv.data.repository
2+
3+
import kotlinx.coroutines.delay
4+
import kotlinx.coroutines.sync.Mutex
5+
import kotlinx.coroutines.sync.Semaphore
6+
import kotlinx.coroutines.sync.withLock
7+
import kotlinx.coroutines.sync.withPermit
8+
import java.net.URI
9+
10+
/** Shared across all EPG batches; a new viewport must not create a new request budget. */
11+
internal class IptvGuideRequestBudget(private val clock: () -> Long = System::currentTimeMillis) {
12+
private class Provider {
13+
val permits = Semaphore(2)
14+
val lock = Mutex()
15+
var nextStart = 0L
16+
@Volatile var blockedUntil = 0L
17+
}
18+
19+
private val providers = HashMap<String, Provider>()
20+
private fun provider(url: String): Provider = synchronized(providers) {
21+
val uri = URI(url)
22+
providers.getOrPut("${uri.scheme}://${uri.authority}") { Provider() }
23+
}
24+
25+
suspend fun <T> request(url: String, block: suspend () -> T?): T? {
26+
val provider = provider(url)
27+
return provider.permits.withPermit {
28+
val allowed = provider.lock.withLock {
29+
if (clock() < provider.blockedUntil) return@withLock false
30+
delay((provider.nextStart - clock()).coerceAtLeast(0))
31+
if (clock() < provider.blockedUntil) return@withLock false
32+
provider.nextStart = clock() + 250L
33+
true
34+
}
35+
if (allowed) block() else null
36+
}
37+
}
38+
39+
fun onResponse(url: String, status: Int, retryAfterSeconds: Long? = null) {
40+
val cooldown = when (status) {
41+
401, 403 -> 5 * 60_000L
42+
429, 503, 513 -> (retryAfterSeconds?.coerceIn(30, 900) ?: 60) * 1000L
43+
else -> return
44+
}
45+
val provider = provider(url)
46+
provider.blockedUntil = maxOf(provider.blockedUntil, clock() + cooldown)
47+
}
48+
}

0 commit comments

Comments
 (0)