From 69d797add365fbb3cf2220d321a9d1d5a8d2badc Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 23 Aug 2026 08:48:30 +0000 Subject: [PATCH 01/16] feat(iptv): make Stalker portal groups configurable like M3U playlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat the Stalker/Ministra portal as a pseudo-playlist (id "stalker") so its categories appear and can be managed in the Settings UI the same way M3U playlist groups are: - Stalker gets its own playlist row (TV + mobile) with action chips for categories / edit config / remove, instead of only a config dialog. - The categories screen loads Stalker genres (already persisted via the `stalker:` channel id prefix) and supports per-group hide/show, move up/down, and reset order — reusing the existing M3U group infrastructure. - A "Show all / Hide all" bulk-toggle button is shown for Stalker only, flipping every category in one operation. - Groups are only hidden/shown, never deleted. Also fixes a pre-existing bug where the TV grid ignored hidden groups and group order: hiddenGroups and groupOrder are stored as `playlistId|groupName` keys (PlaylistGroupKey), but TvViewModel compared them against plain group names, so hiding/ordering had no effect. Both buildPreparedGroups and currentVisiblePlaylistGroups now extract the group name from each key. Removing a Stalker portal now also clears its persisted group preferences (hidden groups + order) via clearGroupPreferences so re-adding a portal starts clean. Co-authored-by: openhands --- .../tv/data/repository/IptvRepository.kt | 56 +++++ .../tv/ui/screens/settings/SettingsScreen.kt | 213 ++++++++++++++++-- .../ui/screens/settings/SettingsViewModel.kt | 14 ++ .../arflix/tv/ui/screens/tv/TvViewModel.kt | 21 +- app/src/main/res/values/strings.xml | 10 + 5 files changed, 291 insertions(+), 23 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 61154847e..67e99ce21 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -638,6 +638,8 @@ class IptvRepository @Inject constructor( prefs.remove(stalkerPortalUrlKey()) prefs.remove(stalkerMacAddressKey()) } + // Drop Stalker group preferences so re-adding a portal starts clean. + clearGroupPreferences(STALKER_PLAYLIST_ID) cachedStalkerApi = null groupOrderLocallyDirty = true if (sourceChanged) { @@ -1519,6 +1521,57 @@ class IptvRepository @Inject constructor( invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "toggle hidden group") } + /** + * Show or hide all groups of a playlist in one operation. Used by the + * "show all / hide all" bulk toggle in the categories screen. A group is + * hidden when its playlistId|groupName key is present in the hidden set; + * `hidden=true` adds the missing keys, `hidden=false` removes them. + */ + suspend fun setGroupsHidden(playlistId: String, groups: List, hidden: Boolean) { + val trimmedId = playlistId.trim() + if (trimmedId.isEmpty()) return + val targetKeys = groups + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { PlaylistGroupKey.build(trimmedId, it) } + .toHashSet() + if (targetKeys.isEmpty()) return + context.settingsDataStore.edit { prefs -> + val existing = decodeHiddenGroups(prefs).toMutableList() + if (hidden) { + existing.addAll(targetKeys) + } else { + existing.removeAll { it in targetKeys } + } + prefs[hiddenGroupsKey()] = gson.toJson(existing.distinct()) + } + invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "set groups hidden") + } + + /** + * Remove every hidden-group and group-order entry that belongs to the + * given playlist. Called when a Stalker portal is removed so no stale + * `stalker|...` preferences linger after the source is gone. + */ + suspend fun clearGroupPreferences(playlistId: String) { + val trimmedId = playlistId.trim() + if (trimmedId.isEmpty()) return + context.settingsDataStore.edit { prefs -> + val retainedHidden = decodeHiddenGroups(prefs) + .filterNot { PlaylistGroupKey(it).playlistId == trimmedId } + if (retainedHidden.isEmpty()) prefs.remove(hiddenGroupsKey()) + else prefs[hiddenGroupsKey()] = gson.toJson(retainedHidden) + + val retainedOrder = decodeGroupOrder(prefs) + .filterNot { PlaylistGroupKey(it).playlistId == trimmedId } + if (retainedOrder.isEmpty()) prefs.remove(groupOrderKey()) + else prefs[groupOrderKey()] = gson.toJson(retainedOrder) + prefs[groupOrderSchemaKey()] = IPTV_GROUP_ORDER_SCHEMA.toString() + } + groupOrderLocallyDirty = true + invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "clear group preferences") + } + suspend fun moveGroupUp(playlistId: String, groupName: String, currentGroups: List = emptyList()) { val target = PlaylistGroupKey.build(playlistId, groupName.trim()) if (groupName.trim().isEmpty()) return @@ -8737,6 +8790,9 @@ class IptvRepository @Inject constructor( const val IPTV_USER_AGENT = "VLC/3.0.20 LibVLC/3.0.20" const val BROWSER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + /** Pseudo playlist id for the Stalker/Ministra portal source. */ + const val STALKER_PLAYLIST_ID = "stalker" + val BRACKET_CONTENT_REGEX = Regex("""\[[^\]]*]""") val PAREN_CONTENT_REGEX = Regex("""\([^\)]*\)""") val YEAR_PAREN_REGEX = Regex("""\((19|20)\d{2}\)""") diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 93756425d..e885092cc 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -77,6 +77,8 @@ import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.filled.LiveTv +import androidx.compose.material.icons.filled.Tv +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Widgets import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete @@ -236,6 +238,15 @@ val LocalSettingsFocusTracker = compositionLocalOf { null private const val ACCOUNT_DELETION_URL = "https://auth.arvio.tv/delete" private const val PRIVACY_POLICY_URL = "https://arvio.tv/privacy" +/** + * Pseudo playlist id used for the Stalker/Ministra portal source. Stalker + * channels are stored with the `stalker:` id prefix, so the paged channel + * store reports them under this playlist id. Treating Stalker as a virtual + * playlist lets it reuse the same group-management UI as M3U playlists + * (categories / hide / sort / bulk toggle) without a separate code path. + */ +private const val STALKER_PLAYLIST_ID = "stalker" + private val tvGeneralSectionIds = setOf( "language", "subtitles", @@ -274,6 +285,23 @@ internal fun orderedIptvGroups( return (explicitOrder + available).distinct() } +/** + * Highest sub-focus action index of the Stalker pseudo-playlist row in the TV + * settings grid: 0 = categories, 1 = edit config, 2 = remove (only present + * when a portal is configured). Used by the D-pad left/right guards so the + * focus does not run past the last available action chip. + */ +internal fun stalkerRowMaxAction(configured: Boolean): Int = if (configured) 2 else 1 + +/** + * Focus index of the first category row inside the IPTV categories screen. + * The Stalker source gets a "show all / hide all" bulk-toggle row at index 1 + * (between Reset and the categories), so its categories start at 2; every + * other source starts at 1. + */ +internal fun firstIptvGroupIndex(playlistId: String, orderedGroups: List): Int = + if (playlistId == STALKER_PLAYLIST_ID && orderedGroups.isNotEmpty()) 2 else 1 + private fun openExternalUrl(context: Context, url: String) { runCatching { context.startActivity( @@ -470,11 +498,13 @@ fun SettingsScreen( when (section) { in tvGeneralSectionIds -> (tvGeneralRowsForSection(section).size - 1).coerceAtLeast(0) "iptv" -> if (showIptvCategoriesSettings) { - orderedIptvGroups( + val groups = orderedIptvGroups( playlistId = uiState.iptvSelectedPlaylistId.orEmpty(), availableGroups = uiState.iptvAvailableGroups, groupOrder = uiState.iptvGroupOrder - ).size // Reset row + category rows + ) + // Reset row + (Stalker bulk-toggle row) + category rows. + groups.size + (firstIptvGroupIndex(uiState.iptvSelectedPlaylistId.orEmpty(), groups) - 1) } else { 4 + uiState.iptvPlaylists.size // Add + rows + stalker + order + refresh + clear } @@ -818,7 +848,7 @@ fun SettingsScreen( iptvActionIndex > 0 && ( showIptvCategoriesSettings && contentFocusIndex > 0 || - !showIptvCategoriesSettings && contentFocusIndex in 2..(uiState.iptvPlaylists.size + 1) + !showIptvCategoriesSettings && (contentFocusIndex == 1 || contentFocusIndex in 2..(uiState.iptvPlaylists.size + 1)) ) ) { iptvActionIndex-- @@ -861,7 +891,9 @@ fun SettingsScreen( addonActionIndex < focusedStremioAddonMaxAction ) { addonActionIndex++ - } else if (currentSection == "iptv" && showIptvCategoriesSettings && contentFocusIndex > 0 && iptvActionIndex < 2) { + } else if (currentSection == "iptv" && showIptvCategoriesSettings && contentFocusIndex >= firstIptvGroupIndex(uiState.iptvSelectedPlaylistId.orEmpty(), orderedIptvGroups(uiState.iptvSelectedPlaylistId.orEmpty(), uiState.iptvAvailableGroups, uiState.iptvGroupOrder)) && iptvActionIndex < 2) { + iptvActionIndex++ + } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex == 1 && iptvActionIndex < stalkerRowMaxAction(uiState.iptvStalkerUrl.isNotBlank())) { iptvActionIndex++ } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex in 2..(uiState.iptvPlaylists.size + 1) && iptvActionIndex < 5) { iptvActionIndex++ @@ -1006,8 +1038,14 @@ fun SettingsScreen( contentFocusIndex == 0 -> { viewModel.resetIptvGroupOrder(playlistId) } - contentFocusIndex in 1..orderedGroups.size -> { - val group = orderedGroups.getOrNull(contentFocusIndex - 1) + contentFocusIndex == 1 && playlistId == STALKER_PLAYLIST_ID && orderedGroups.isNotEmpty() -> { + val allHidden = orderedGroups.all { + com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, it) in uiState.iptvHiddenGroups + } + viewModel.setAllIptvGroupsVisible(playlistId, visible = allHidden) + } + contentFocusIndex in firstIptvGroupIndex(playlistId, orderedGroups)..orderedGroups.size + (firstIptvGroupIndex(playlistId, orderedGroups) - 1) -> { + val group = orderedGroups.getOrNull(contentFocusIndex - firstIptvGroupIndex(playlistId, orderedGroups)) if (!group.isNullOrBlank()) { when (iptvActionIndex) { 0 -> viewModel.toggleIptvHiddenGroup(playlistId, group) @@ -1023,9 +1061,21 @@ fun SettingsScreen( showIptvInput = true } contentFocusIndex == 1 -> { - stalkerEditPortal = uiState.iptvStalkerUrl - stalkerEditMac = uiState.iptvStalkerMac - showStalkerInput = true + when (iptvActionIndex) { + 0 -> { + if (uiState.iptvStalkerUrl.isNotBlank()) { + openIptvCategories(STALKER_PLAYLIST_ID) + } + } + 2 -> { + viewModel.removeStalkerConfig() + } + else -> { + stalkerEditPortal = uiState.iptvStalkerUrl + stalkerEditMac = uiState.iptvStalkerMac + showStalkerInput = true + } + } } contentFocusIndex in 2..(uiState.iptvPlaylists.size + 1) -> { val idx = contentFocusIndex - 2 @@ -1580,7 +1630,9 @@ fun SettingsScreen( onToggleHidden = { viewModel.toggleIptvHiddenGroup(uiState.iptvSelectedPlaylistId ?: "", it) }, onMoveUp = { viewModel.moveIptvGroupUp(uiState.iptvSelectedPlaylistId ?: "", it) }, onMoveDown = { viewModel.moveIptvGroupDown(uiState.iptvSelectedPlaylistId ?: "", it) }, - onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") } + onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") }, + showBulkToggle = (uiState.iptvSelectedPlaylistId ?: "") == STALKER_PLAYLIST_ID, + onBulkToggle = { visible -> viewModel.setAllIptvGroupsVisible(uiState.iptvSelectedPlaylistId ?: "", visible) } ) } else IptvSettings( playlists = uiState.iptvPlaylists, @@ -1595,7 +1647,10 @@ fun SettingsScreen( focusedActionIndex = iptvActionIndex, onConfigure = { editingIptvIndex = -1; showIptvInput = true }, onConfigureStalker = { stalkerEditPortal = uiState.iptvStalkerUrl; stalkerEditMac = uiState.iptvStalkerMac; showStalkerInput = true }, + onRemoveStalker = { viewModel.removeStalkerConfig() }, + onManageStalkerCategories = { openIptvCategories(STALKER_PLAYLIST_ID) }, stalkerSubtitle = if (uiState.iptvStalkerUrl.isNotBlank()) uiState.iptvStalkerUrl else stringResource(R.string.settings_stalker_middleware_hint), + stalkerConfigured = uiState.iptvStalkerUrl.isNotBlank(), onEditPlaylist = { idx -> editingIptvIndex = idx; showIptvInput = true }, onTogglePlaylist = { idx -> val updated = uiState.iptvPlaylists.toMutableList() @@ -1643,7 +1698,10 @@ fun SettingsScreen( focusedActionIndex = iptvActionIndex, onConfigure = { editingIptvIndex = -1; showIptvInput = true }, onConfigureStalker = { stalkerEditPortal = uiState.iptvStalkerUrl; stalkerEditMac = uiState.iptvStalkerMac; showStalkerInput = true }, + onRemoveStalker = { viewModel.removeStalkerConfig() }, + onManageStalkerCategories = { openIptvCategories(STALKER_PLAYLIST_ID) }, stalkerSubtitle = if (uiState.iptvStalkerUrl.isNotBlank()) uiState.iptvStalkerUrl else stringResource(R.string.settings_stalker_middleware_hint), + stalkerConfigured = uiState.iptvStalkerUrl.isNotBlank(), onEditPlaylist = { idx -> editingIptvIndex = idx; showIptvInput = true }, onTogglePlaylist = { idx -> val updated = uiState.iptvPlaylists.toMutableList() @@ -4584,6 +4642,12 @@ private fun MobileSettingsSubPage( viewModel.setIptvSelectedPlaylistId(playlistId) onNavigate("IPTV_CATEGORIES") }, + onRemoveStalker = { viewModel.removeStalkerConfig() }, + onManageStalkerCategories = { + viewModel.setIptvSelectedPlaylistId(STALKER_PLAYLIST_ID) + onNavigate("IPTV_CATEGORIES") + }, + stalkerConfigured = uiState.iptvStalkerUrl.isNotBlank(), sortOrder = uiState.iptvSortOrder, onSortOrderChange = { viewModel.setIptvSortOrder(it) } ) @@ -4599,7 +4663,9 @@ private fun MobileSettingsSubPage( onToggleHidden = { viewModel.toggleIptvHiddenGroup(uiState.iptvSelectedPlaylistId ?: "", it) }, onMoveUp = { viewModel.moveIptvGroupUp(uiState.iptvSelectedPlaylistId ?: "", it) }, onMoveDown = { viewModel.moveIptvGroupDown(uiState.iptvSelectedPlaylistId ?: "", it) }, - onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") } + onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") }, + showBulkToggle = (uiState.iptvSelectedPlaylistId ?: "") == STALKER_PLAYLIST_ID, + onBulkToggle = { visible -> viewModel.setAllIptvGroupsVisible(uiState.iptvSelectedPlaylistId ?: "", visible) } ) } "Home Server" -> { @@ -6755,7 +6821,10 @@ private fun IptvSettings( sortOrder: String = "provider", onSortOrderChange: (String) -> Unit = {}, onConfigureStalker: () -> Unit = {}, - stalkerSubtitle: String = "" + onRemoveStalker: () -> Unit = {}, + onManageStalkerCategories: () -> Unit = {}, + stalkerSubtitle: String = "", + stalkerConfigured: Boolean = false ) { val isMobile = LocalDeviceType.current.isTouchDevice() var selectionMode by remember { mutableStateOf(false) } @@ -6791,7 +6860,45 @@ private fun IptvSettings( } MobileSettingsCategory(title = stringResource(R.string.settings_section_playlists)) { MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_playlist), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_add_tv_lists_hint) else stringResource(R.string.settings_create_another_tv), value = if (playlists.size >= 3) stringResource(R.string.settings_badge_full_short) else "", isFocused = false, showDivider = true, onClick = onConfigure) - MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.settings_add_stalker_portal), subtitle = stalkerSubtitle, value = "", isFocused = false, showDivider = playlists.isNotEmpty(), onClick = onConfigureStalker) + // Stalker pseudo-playlist row: behaves like an M3U playlist row + // (categories / edit / remove) rather than a plain config entry. + Column { + Row( + modifier = Modifier.fillMaxWidth() + .clickable { onConfigureStalker() } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = Icons.Default.Tv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.settings_add_stalker_portal), style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(stalkerSubtitle, style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Icon( + imageVector = Icons.Default.List, + contentDescription = stringResource(R.string.settings_cd_manage_stalker_categories), + tint = if (stalkerConfigured) TextSecondary else TextSecondary.copy(alpha = 0.4f), + modifier = Modifier + .size(36.dp) + .clickable(enabled = stalkerConfigured) { onManageStalkerCategories() } + .padding(6.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Box(modifier = Modifier.size(36.dp).clickable { onConfigureStalker() }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.settings_cd_edit_stalker_config), tint = Color.White, modifier = Modifier.size(20.dp)) + } + if (stalkerConfigured) { + Spacer(modifier = Modifier.width(12.dp)) + Box(modifier = Modifier.size(36.dp).clickable { onRemoveStalker() }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.settings_stalker_remove), tint = Color.White, modifier = Modifier.size(20.dp)) + } + } + } + if (playlists.isNotEmpty()) { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).padding(horizontal = 16.dp).background(Color.White.copy(alpha = 0.05f))) + } + } playlists.forEachIndexed { index, playlist -> val isSelected = selectedIndices.contains(index) val epgSourceCount = playlist.settingsEpgInput().lineSequence().count { it.isNotBlank() } @@ -6884,7 +6991,38 @@ private fun IptvSettings( Column { SettingsRow(icon = Icons.Default.LiveTv, title = stringResource(R.string.add_playlist), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_add_iptv_lists_hint) else stringResource(R.string.settings_create_another_iptv), value = if (playlists.size >= 3) stringResource(R.string.settings_badge_full) else stringResource(R.string.settings_badge_add), isFocused = focusedIndex == 0, onClick = onConfigure, modifier = Modifier.settingsFocusSlot(0)) Spacer(modifier = Modifier.height(6.dp)) - SettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.settings_add_stalker_portal), subtitle = stalkerSubtitle, value = stringResource(R.string.settings_badge_add), isFocused = focusedIndex == 1, onClick = onConfigureStalker, modifier = Modifier.settingsFocusSlot(1)) + // Stalker pseudo-playlist row (TV): same action-chip layout as an + // M3U playlist row. Sub-focus 0 = categories, 1 = edit config, + // 2 = remove (only when configured). + val stalkerRingColor = resolveAccentColor(fallback = Pink) + Row(modifier = Modifier.settingsFocusSlot(1).fillMaxWidth().background(if (focusedIndex == 1) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == 1) 2.dp else 0.dp, color = if (focusedIndex == 1) stalkerRingColor else Color.Transparent, shape = RoundedCornerShape(12.dp)).clickable { onConfigureStalker() }.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.settings_add_stalker_portal), style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = if (focusedIndex == 1) TextPrimary else TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(modifier = Modifier.height(4.dp)) + Text(stalkerSubtitle, style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + CatalogActionChip( + icon = Icons.Default.List, + isFocused = focusedIndex == 1 && focusedActionIndex == 0, + enabled = stalkerConfigured, + onClick = { onManageStalkerCategories() } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.Edit, + isFocused = focusedIndex == 1 && focusedActionIndex == 1, + onClick = onConfigureStalker + ) + if (stalkerConfigured) { + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.Delete, + isFocused = focusedIndex == 1 && focusedActionIndex == 2, + isDestructive = true, + onClick = onRemoveStalker + ) + } + } Spacer(modifier = Modifier.height(16.dp)) playlists.forEachIndexed { index, playlist -> val rowIndex = index + 2 @@ -10880,7 +11018,9 @@ private fun IptvCategoriesSettings( onToggleHidden: (String) -> Unit, onMoveUp: (String) -> Unit, onMoveDown: (String) -> Unit, - onReset: () -> Unit + onReset: () -> Unit, + showBulkToggle: Boolean = false, + onBulkToggle: (visible: Boolean) -> Unit = {} ) { val isMobile = LocalDeviceType.current.isTouchDevice() val orderedGroups = remember(groupOrder, availableGroups, playlistId) { @@ -10891,10 +11031,20 @@ private fun IptvCategoriesSettings( ) } val categoryListState = rememberLazyListState() + // When the bulk toggle is shown it occupies focus index 1, so the first + // category row shifts to index 2 (was 1). Reset stays at index 0. + val firstGroupIndex = if (showBulkToggle) 2 else 1 + + // Bulk button reflects the current state: when every category is hidden + // it offers "Show all", otherwise "Hide all". + val allCategoriesHidden = remember(orderedGroups, hiddenGroups, playlistId) { + if (orderedGroups.isEmpty()) false + else orderedGroups.all { com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, it) in hiddenGroups } + } LaunchedEffect(isMobile, focusedIndex, orderedGroups.size) { - if (!isMobile && focusedIndex > 0 && orderedGroups.isNotEmpty()) { - categoryListState.animateScrollToItem((focusedIndex - 1).coerceIn(0, orderedGroups.lastIndex)) + if (!isMobile && focusedIndex >= firstGroupIndex && orderedGroups.isNotEmpty()) { + categoryListState.animateScrollToItem((focusedIndex - firstGroupIndex).coerceIn(0, orderedGroups.lastIndex)) } } @@ -10918,6 +11068,33 @@ private fun IptvCategoriesSettings( modifier = Modifier.settingsFocusSlot(0) ) + if (showBulkToggle && orderedGroups.isNotEmpty()) { + Spacer(modifier = Modifier.height(10.dp)) + val bulkTitle = if (allCategoriesHidden) stringResource(R.string.settings_iptv_show_all) else stringResource(R.string.settings_iptv_hide_all) + val bulkSubtitle = if (allCategoriesHidden) stringResource(R.string.settings_iptv_show_all_desc) else stringResource(R.string.settings_iptv_hide_all_desc) + val bulkIcon = if (allCategoriesHidden) Icons.Default.Visibility else Icons.Default.VisibilityOff + if (isMobile) { + MobileSettingsRow( + icon = bulkIcon, + title = bulkTitle, + subtitle = bulkSubtitle, + value = "", + onClick = { onBulkToggle(allCategoriesHidden) }, + showDivider = true + ) + } else { + SettingsRow( + icon = bulkIcon, + title = bulkTitle, + subtitle = bulkSubtitle, + value = stringResource(R.string.settings_badge_reset), + isFocused = focusedIndex == 1, + onClick = { onBulkToggle(allCategoriesHidden) }, + modifier = Modifier.settingsFocusSlot(1) + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) if (isMobile) { @@ -10965,7 +11142,7 @@ private fun IptvCategoriesSettings( items = orderedGroups, key = { _, group -> com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, group) } ) { index, group -> - val rowFocusIndex = index + 1 + val rowFocusIndex = index + firstGroupIndex val isRowFocused = focusedIndex == rowFocusIndex val groupKey = com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, group) val isHidden = hiddenGroups.contains(groupKey) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 3dc49da2f..12aec66c0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -929,6 +929,20 @@ class SettingsViewModel @Inject constructor( } } + /** + * Bulk-show or bulk-hide all groups of a playlist at once. Drives the + * "show all / hide all" button in the categories screen. The current + * available groups are taken from the UI state so the operation only + * touches groups that actually belong to the selected playlist. + */ + fun setAllIptvGroupsVisible(playlistId: String, visible: Boolean) { + viewModelScope.launch { + val groups = _uiState.value.iptvAvailableGroups + if (groups.isEmpty()) return@launch + iptvRepository.setGroupsHidden(playlistId, groups, hidden = !visible) + } + } + // ========== App Updates ========== fun performFullSync(silent: Boolean = false) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index d19a7f076..cf08c60b2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt @@ -7,6 +7,7 @@ import com.arflix.tv.R import com.arflix.tv.data.model.IptvChannel import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.data.model.IptvSnapshot +import com.arflix.tv.data.model.PlaylistGroupKey import com.arflix.tv.data.repository.CloudSyncRepository import com.arflix.tv.data.repository.IptvConfig import com.arflix.tv.ui.screens.tv.live.LiveTvGuideSources @@ -1886,7 +1887,10 @@ class TvViewModel @Inject constructor( private fun currentVisiblePlaylistGroups(playlistId: String? = null): List { val targetPlaylistId = playlistId?.trim().orEmpty() if (targetPlaylistId.isNotBlank() && iptvRepository.pagedChannelsReady()) { - val hidden = _uiState.value.snapshot.hiddenGroups.mapTo(HashSet()) { it.trim() } + // hiddenGroups are `playlistId|groupName` keys; narrow to this + // playlist's hidden names so cross-source groups are unaffected. + val hidden = _uiState.value.snapshot.hiddenGroups + .mapTo(HashSet()) { PlaylistGroupKey(it).groupName.trim() } return iptvRepository.pagedPlaylistGroupCounts() .asSequence() .filter { (id, _, _) -> id == targetPlaylistId } @@ -1896,7 +1900,8 @@ class TvViewModel @Inject constructor( .toList() } val snapshot = _uiState.value.snapshot - val hidden = snapshot.hiddenGroups.mapTo(HashSet()) { it.trim() } + val hidden = snapshot.hiddenGroups + .mapTo(HashSet()) { PlaylistGroupKey(it).groupName.trim() } return snapshot.grouped.keys .asSequence() .map { it.trim() } @@ -2215,12 +2220,18 @@ private fun setPreparedContent(state: TvUiState): TvUiState { private fun buildPreparedGroups(snapshot: IptvSnapshot): List { val dynamicGroups = snapshot.grouped.keys.toList() - val hiddenSet = snapshot.hiddenGroups.toHashSet() - val visibleGroups = dynamicGroups.filterNot { hiddenSet.contains(it) } + // hiddenGroups are stored as `playlistId|groupName` keys (PlaylistGroupKey), + // but the TV grid merges channels from every source under plain group + // names. A group is therefore hidden when any source marks it hidden. + val hiddenGroupNames = snapshot.hiddenGroups + .mapTo(HashSet()) { PlaylistGroupKey(it).groupName.trim() } + val visibleGroups = dynamicGroups.filterNot { hiddenGroupNames.contains(it.trim()) } val favorites = snapshot.favoriteGroups.filter { visibleGroups.contains(it) } val others = visibleGroups.filterNot { snapshot.favoriteGroups.contains(it) } val baseOrdered = if (snapshot.groupOrder.isNotEmpty()) { - val orderMap = snapshot.groupOrder.withIndex().associate { (i, groupName) -> groupName to i } + val orderMap = snapshot.groupOrder + .mapIndexed { i, key -> PlaylistGroupKey(key).groupName to i } + .associate { (groupName, i) -> groupName to i } (favorites + others).sortedBy { orderMap[it] ?: Int.MAX_VALUE } } else { favorites + others diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01c0af6c8..78039a48a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -737,6 +737,16 @@ No categories available Hidden Visible + Show all + Hide all + Unhide every category of this source + Hide every category of this source + Stalker categories + Configured + Not configured + Remove portal + Manage Stalker categories + Edit Stalker portal Default Enter %1$s... 12-hour From cede92740874e0ff77668dc0929dad9f4855180f Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 23 Aug 2026 09:07:12 +0000 Subject: [PATCH 02/16] ci: restore test-APK workflow (debug build, no secrets needed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wiederhergestellt aus den alten Branches (beim Fork-Aufräumen 22.08. verloren gegangen). Baut eine DEBUG-APK via assembleSideloadDebug mit Application-ID-Suffix .stalker-live (parallel installierbar neben produktiver ARVIO). Braucht keine Secrets. Wird vor dem upstream-PR wieder entfernt. Co-authored-by: openhands --- .github/workflows/test-apk.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/test-apk.yml diff --git a/.github/workflows/test-apk.yml b/.github/workflows/test-apk.yml new file mode 100644 index 000000000..b01b91533 --- /dev/null +++ b/.github/workflows/test-apk.yml @@ -0,0 +1,34 @@ +name: Build test APK (debug, parallel app id) + +on: + push: + branches: + - feat/stalker-live-config + workflow_dispatch: + +jobs: + debug-apk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Placeholder config + run: cp secrets.defaults.properties secrets.properties + + - name: Parallel-install app id (.stalker-live) + run: sed -i 's/ debug {/ debug {\n applicationIdSuffix = ".stalker-live"/' app/build.gradle.kts + + - name: Build debug APK + run: ./gradlew assembleSideloadDebug --no-daemon + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: arvio-stalker-live-test + path: app/build/outputs/apk/sideload/debug/*.apk From 2b0b3dfc213e9f084fc14e91721bb9d37d7e64b5 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 23 Aug 2026 09:11:56 +0000 Subject: [PATCH 03/16] ci: fix app-id suffix (no hyphens allowed in Android package names) Co-authored-by: openhands --- .github/workflows/test-apk.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-apk.yml b/.github/workflows/test-apk.yml index b01b91533..f3866eaf5 100644 --- a/.github/workflows/test-apk.yml +++ b/.github/workflows/test-apk.yml @@ -21,8 +21,8 @@ jobs: - name: Placeholder config run: cp secrets.defaults.properties secrets.properties - - name: Parallel-install app id (.stalker-live) - run: sed -i 's/ debug {/ debug {\n applicationIdSuffix = ".stalker-live"/' app/build.gradle.kts + - name: Parallel-install app id (.stalkerlive) + run: sed -i 's/ debug {/ debug {\n applicationIdSuffix = ".stalkerlive"/' app/build.gradle.kts - name: Build debug APK run: ./gradlew assembleSideloadDebug --no-daemon @@ -30,5 +30,5 @@ jobs: - name: Upload APK uses: actions/upload-artifact@v4 with: - name: arvio-stalker-live-test + name: arvio-stalkerlive-test path: app/build/outputs/apk/sideload/debug/*.apk From 0858c223b0878dac91a2355aa5ff138f3c8b13e1 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 10:06:06 +0000 Subject: [PATCH 04/16] feat(iptv): convert Stalker to a portal list (backend foundation) Reworks Stalker from a single config object (stalkerPortalUrl / stalkerMacAddress) into a real list of up to 3 StalkerPortalEntry items, mirroring the M3U playlists model. This is the invisible backend foundation for the multi-portal UI; the existing single Stalker settings row stays functional via the first portal. Data model & persistence: - IptvConfig.stalkerPortals: List - IptvCloudProfileState.stalkerPortals replaces the two legacy fields - DataStore stalkerPortalsKey() stores JSON (analogous to playlistsKey()) - Legacy migration: old single-portal fields auto-become "Portal 1" (id stalker1) when the list store is empty Playback & loading: - cachedStalkerApi -> cachedStalkerApis Map - resolveStalkerStreamUrl(channelId, command) routes by stalker:: prefix; falls back to the first portal for legacy callers - Channel ids prefixed stalker:: so startsWith("stalker:") checks keep matching; all enabled portals load in parallel and merge Signatures & sync: - buildConfigSignature/buildSourceSignature and cloud/sync signatures derived from the portal list (version bumped to invalidate stale caches) - Cloud sync exports/imports the portal list; legacy keys cleared on import Compatibility: SettingsUiState exposes iptvStalkerUrl/iptvStalkerMac as derived getters over portals[0] so the Session-1 settings UI is unchanged. Unit tests cover migration, multi-portal prefix extraction, JSON round-trip, max-portal cap, and blank-field filtering. Co-authored-by: openhands --- .../tv/data/repository/IptvRepository.kt | 318 +++++++++++++----- .../data/repository/StalkerPortalSupport.kt | 68 ++++ .../ui/screens/settings/SettingsViewModel.kt | 49 ++- .../com/arflix/tv/ui/screens/tv/TvScreen.kt | 5 +- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 25 +- .../repository/StalkerPortalSupportTest.kt | 141 ++++++++ 6 files changed, 493 insertions(+), 113 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 67e99ce21..4062af3d9 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -161,8 +161,7 @@ data class IptvConfig( val m3uUrl: String = "", val epgUrl: String = "", val playlists: List = emptyList(), - val stalkerPortalUrl: String = "", - val stalkerMacAddress: String = "", + val stalkerPortals: List = emptyList(), val sortOrder: String = "provider" ) @@ -175,6 +174,20 @@ data class IptvPlaylistEntry( val epgUrls: List = emptyList() ) +/** + * A single Stalker/Ministra portal configuration. Mirrors [IptvPlaylistEntry] + * so Stalker portals can be managed (add/edit/remove/reorder/toggle) like M3U + * playlists. Channel ids are prefixed `stalker::` so the existing + * `startsWith("stalker:")` checks keep matching. + */ +data class StalkerPortalEntry( + val id: String, + val name: String, + val portalUrl: String, + val macAddress: String, + val enabled: Boolean = true +) + data class IptvLoadProgress( val message: String, val percent: Int? = null @@ -183,8 +196,7 @@ data class IptvLoadProgress( data class IptvCloudProfileState( val m3uUrl: String = "", val epgUrl: String = "", - val stalkerPortalUrl: String = "", - val stalkerMacAddress: String = "", + val stalkerPortals: List = emptyList(), val favoriteGroups: List = emptyList(), val favoriteChannels: List = emptyList(), val hiddenGroups: List = emptyList(), @@ -228,7 +240,30 @@ class IptvRepository @Inject constructor( private var cachedChannelsById: Map = emptyMap() @Volatile private var cachedGroupedChannels: Map> = emptyMap() - @Volatile var cachedStalkerApi: com.arflix.tv.data.api.StalkerApi? = null + @Volatile + private var cachedStalkerApis: Map = emptyMap() + + /** + * Public accessor kept for compatibility with code that previously read the + * single cached Stalker API instance. Returns the first cached portal API. + */ + val cachedStalkerApi: com.arflix.tv.data.api.StalkerApi? + get() = cachedStalkerApis.values.firstOrNull() + + internal fun portalIdFromChannelId(channelId: String): String? = + StalkerPortalSupport.portalIdFromChannelId(channelId) + + private suspend fun getOrCreateStalkerApi(portal: StalkerPortalEntry): com.arflix.tv.data.api.StalkerApi? { + cachedStalkerApis[portal.id]?.let { return it } + return stalkerApiMutex.withLock { + cachedStalkerApis[portal.id]?.let { return it } + com.arflix.tv.data.api.StalkerApi(portal.portalUrl, portal.macAddress) + .takeIf { it.handshake() } + ?.also { api -> + cachedStalkerApis = cachedStalkerApis + (portal.id to api) + } + } + } /** (store key, channel count) — see [pagedChannelStoreCount]. */ @Volatile @@ -337,7 +372,7 @@ class IptvRepository @Inject constructor( ) private fun hasAnyConfiguredSource(config: IptvConfig): Boolean = config.m3uUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + config.stalkerPortals.any { it.enabled && it.portalUrl.isNotBlank() } || config.playlists.any { it.enabled && it.m3uUrl.isNotBlank() } private fun activePlaylists(config: IptvConfig): List = config.playlists.filter { it.enabled }.ifEmpty { @@ -357,6 +392,11 @@ class IptvRepository @Inject constructor( emptyList() } } + + /** Enabled Stalker portals with a non-blank URL — the ones that load channels. */ + private fun activeStalkerPortals(config: IptvConfig): List = + config.stalkerPortals.filter { it.enabled && it.portalUrl.isNotBlank() } + @Volatile private var xtreamSeriesLoadedAtMs: Long = 0L @Volatile @@ -469,8 +509,7 @@ class IptvRepository @Inject constructor( m3uUrl = primary?.m3uUrl ?: legacyM3uUrl, epgUrl = primary?.epgUrl ?: legacyEpgUrls.firstOrNull().orEmpty(), playlists = playlists, - stalkerPortalUrl = decryptConfigValue(prefs[stalkerPortalUrlKey()].orEmpty()), - stalkerMacAddress = prefs[stalkerMacAddressKey()].orEmpty(), + stalkerPortals = readStalkerPortals(prefs), sortOrder = normalizeIptvSortOrder(prefs[sortOrderKey()]) ) } @@ -610,17 +649,26 @@ class IptvRepository @Inject constructor( val normalizedMac = macAddress.trim().uppercase().let { mac -> if (mac.isNotEmpty() && !mac.startsWith("00:1A:79:")) mac else mac } - val nextConfig = previousConfig.copy( - stalkerPortalUrl = normalizedUrl, - stalkerMacAddress = normalizedMac, - ) + // Legacy single-portal save: writes/updates the first portal entry. + val existing = previousConfig.stalkerPortals + val nextPortals = if (existing.isEmpty()) { + listOf(StalkerPortalEntry("stalker1", "Portal 1", normalizedUrl, normalizedMac)) + } else { + existing.mapIndexed { index, portal -> + if (index == 0) portal.copy(portalUrl = normalizedUrl, macAddress = normalizedMac) + else portal + } + } + val nextConfig = previousConfig.copy(stalkerPortals = nextPortals) val previousSourceKey = epgIndexKey(profileId, previousConfig) val sourceChanged = buildSourceSignature(previousConfig) != buildSourceSignature(nextConfig) context.settingsDataStore.edit { prefs -> - prefs[stalkerPortalUrlKey()] = encryptConfigValue(normalizedUrl) - prefs[stalkerMacAddressKey()] = normalizedMac + prefs[stalkerPortalsKey()] = gson.toJson(nextPortals) + // Clear legacy single-portal keys so the list store becomes the source of truth. + prefs.remove(stalkerPortalUrlKey()) + prefs.remove(stalkerMacAddressKey()) } - cachedStalkerApi = null + cachedStalkerApis = emptyMap() groupOrderLocallyDirty = true if (sourceChanged) { withContext(Dispatchers.IO) { deletePersistedSourceCaches(previousSourceKey) } @@ -633,14 +681,15 @@ class IptvRepository @Inject constructor( val profileId = profileManager.getProfileIdSync() val previousConfig = observeConfig().first() val previousSourceKey = epgIndexKey(profileId, previousConfig) - val sourceChanged = previousConfig.stalkerPortalUrl.isNotBlank() || previousConfig.stalkerMacAddress.isNotBlank() + val sourceChanged = previousConfig.stalkerPortals.isNotEmpty() context.settingsDataStore.edit { prefs -> + prefs.remove(stalkerPortalsKey()) prefs.remove(stalkerPortalUrlKey()) prefs.remove(stalkerMacAddressKey()) } // Drop Stalker group preferences so re-adding a portal starts clean. clearGroupPreferences(STALKER_PLAYLIST_ID) - cachedStalkerApi = null + cachedStalkerApis = emptyMap() groupOrderLocallyDirty = true if (sourceChanged) { withContext(Dispatchers.IO) { deletePersistedSourceCaches(previousSourceKey) } @@ -649,16 +698,22 @@ class IptvRepository @Inject constructor( invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "clear stalker config") } - suspend fun resolveStalkerStreamUrl(command: String): String? { + /** + * Resolves a Stalker `cmd` stream argument into an authenticated URL. When + * [channelId] carries the `stalker::` prefix the matching portal + * API is used; otherwise the first configured portal is used as a fallback + * (keeps the legacy single-portal behavior intact). + */ + suspend fun resolveStalkerStreamUrl(channelId: String?, command: String): String? { val config = observeConfig().first() - if (config.stalkerPortalUrl.isBlank() || config.stalkerMacAddress.isBlank()) return null - - val stalker = cachedStalkerApi ?: stalkerApiMutex.withLock { - cachedStalkerApi ?: com.arflix.tv.data.api.StalkerApi( - config.stalkerPortalUrl, - config.stalkerMacAddress, - ).takeIf { it.handshake() }?.also { cachedStalkerApi = it } - } + val portals = config.stalkerPortals + if (portals.isEmpty()) return null + val portalId = channelId?.let { portalIdFromChannelId(it) } + val portal = portalId + ?.let { id -> portals.firstOrNull { it.id == id } } + ?: portals.first() + if (portal.portalUrl.isBlank() || portal.macAddress.isBlank()) return null + val stalker = getOrCreateStalkerApi(portal) return stalker?.resolveStreamUrl(command) } @@ -1416,6 +1471,7 @@ class IptvRepository @Inject constructor( prefs.remove(m3uUrlKey()) prefs.remove(epgUrlKey()) prefs.remove(playlistsKey()) + prefs.remove(stalkerPortalsKey()) prefs.remove(stalkerPortalUrlKey()) prefs.remove(stalkerMacAddressKey()) prefs.remove(favoriteGroupsKey()) @@ -1426,6 +1482,7 @@ class IptvRepository @Inject constructor( prefs.remove(tvSessionKey()) } groupOrderLocallyDirty = true + cachedStalkerApis = emptyMap() withContext(Dispatchers.IO) { deletePersistedSourceCaches(previousSourceKey) } invalidateCache() invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "clear iptv config") @@ -1666,7 +1723,8 @@ class IptvRepository @Inject constructor( ensureCacheOwnership(profileId, config) cleanupIptvCacheDirectory() val activePlaylists = activePlaylists(config) - if (activePlaylists.isEmpty() && config.stalkerPortalUrl.isBlank()) { + val stalkerPortals = activeStalkerPortals(config) + if (activePlaylists.isEmpty() && stalkerPortals.isEmpty()) { return@withContext IptvSnapshot( channels = emptyList(), grouped = emptyMap(), @@ -1678,24 +1736,34 @@ class IptvRepository @Inject constructor( } // ── Stalker Portal path ── - // Load Stalker in parallel with M3U/Xtream playlists when both are configured (hybrid mode). - // Stalker-only (no playlists) keeps the legacy early-return behavior. - val stalkerConfigured = config.stalkerPortalUrl.isNotBlank() - val stalkerChannelsDeferred = if (stalkerConfigured) { + // Load every enabled Stalker portal in parallel with M3U/Xtream + // playlists when both are configured (hybrid mode). Stalker-only + // (no playlists) keeps the legacy early-return behavior. + val stalkerChannelsDeferred = if (stalkerPortals.isNotEmpty()) { async { - runCatching { - onProgress(IptvLoadProgress(context.getString(R.string.iptv_connecting_stalker), 10)) - val stalker = com.arflix.tv.data.api.StalkerApi(config.stalkerPortalUrl, config.stalkerMacAddress) - if (!stalker.handshake()) { - return@runCatching null to emptyList() + onProgress(IptvLoadProgress(context.getString(R.string.iptv_connecting_stalker), 10)) + stalkerPortals.map { portal -> + async { + runCatching { + val stalker = com.arflix.tv.data.api.StalkerApi(portal.portalUrl, portal.macAddress) + if (!stalker.handshake()) { + return@runCatching Triple>(portal.id, null, emptyList()) + } + stalker.getProfile() + val channels = stalker.getChannels() + // Prefix with stalker:: so the + // portal can be identified for playback routing. + Triple(portal.id, stalker, channels.map { it.copy(id = "stalker:${portal.id}:${it.id}") }) + }.getOrElse { Triple>(portal.id, null, emptyList()) } } - onProgress(IptvLoadProgress(context.getString(R.string.iptv_progress_loading_channels), 30)) - stalker.getProfile() - val channels = stalker.getChannels() - // Prefix IDs to avoid collisions with M3U/Xtream channel IDs. - stalker to channels.map { it.copy(id = "stalker:${it.id}") } - }.getOrElse { e -> - null to emptyList() + }.awaitAll().let { results -> + val apis = HashMap() + val channels = ArrayList() + for ((portalId, api, chs) in results) { + channels.addAll(chs) + api?.let { apis[portalId] = it } + } + apis to channels } } } else { @@ -1703,8 +1771,8 @@ class IptvRepository @Inject constructor( } // Stalker-only mode: no playlists configured → return Stalker channels directly. - if (activePlaylists.isEmpty() && stalkerConfigured) { - val (stalkerApi, stalkerChannels) = stalkerChannelsDeferred?.await() ?: (null to emptyList()) + if (activePlaylists.isEmpty() && stalkerPortals.isNotEmpty()) { + val (stalkerApis, stalkerChannels) = stalkerChannelsDeferred?.await() ?: (emptyMap() to emptyList()) if (stalkerChannels.isEmpty()) { return@withContext IptvSnapshot( epgWarning = "Stalker handshake failed. Check Portal URL and MAC.", @@ -1719,7 +1787,7 @@ class IptvRepository @Inject constructor( val groupOrder = observeGroupOrder().first() cachedChannels = stalkerChannels cachedGroupedChannels = grouped - cachedStalkerApi = stalkerApi + cachedStalkerApis = stalkerApis val snapshot = IptvSnapshot( channels = stalkerChannels, grouped = grouped, @@ -1770,9 +1838,9 @@ class IptvRepository @Inject constructor( ) // If Stalker is configured but not yet in cache, await and merge. if (stalkerChannelsDeferred != null) { - val (stalkerApi, stalkerChannels) = stalkerChannelsDeferred.await() - if (stalkerApi != null) { - cachedStalkerApi = stalkerApi + val (stalkerApis, stalkerChannels) = stalkerChannelsDeferred.await() + if (stalkerApis.isNotEmpty()) { + cachedStalkerApis = stalkerApis } if (stalkerChannels.isNotEmpty()) { // cachedChannels may already contain Stalker entries merged by a @@ -1823,9 +1891,9 @@ class IptvRepository @Inject constructor( } }.let { playlistChannels -> // Merge Stalker channels (if configured) into the final list. - val (stalkerApi, stalkerChannels) = stalkerChannelsDeferred?.await() ?: (null to emptyList()) - if (stalkerApi != null) { - cachedStalkerApi = stalkerApi + val (stalkerApis, stalkerChannels) = stalkerChannelsDeferred?.await() ?: (emptyMap() to emptyList()) + if (stalkerApis.isNotEmpty()) { + cachedStalkerApis = stalkerApis } val merged = playlistChannels + stalkerChannels cachedChannels = merged @@ -2861,29 +2929,50 @@ class IptvRepository @Inject constructor( return runCatching { epgIndex.countPrograms(indexKey) }.getOrDefault(0) } - private suspend fun fetchFreshChannelsForStartup(config: IptvConfig): Pair, com.arflix.tv.data.api.StalkerApi?>? { + private suspend fun fetchFreshChannelsForStartup(config: IptvConfig): Pair, Map>? { val activeLists = activePlaylists(config) - if (activeLists.isEmpty() && config.stalkerPortalUrl.isBlank()) return null + val stalkerPortals = activeStalkerPortals(config) + if (activeLists.isEmpty() && stalkerPortals.isEmpty()) return null // Stalker-only mode: no playlists configured. - if (activeLists.isEmpty() && config.stalkerPortalUrl.isNotBlank()) { - val stalker = com.arflix.tv.data.api.StalkerApi(config.stalkerPortalUrl, config.stalkerMacAddress) - if (!stalker.handshake()) return null - stalker.getProfile() - val channels = stalker.getChannels().map { it.copy(id = "stalker:${it.id}") } - return if (channels.isNotEmpty()) channels to stalker else null + if (activeLists.isEmpty() && stalkerPortals.isNotEmpty()) { + val apis = HashMap() + val channels = ArrayList() + for (portal in stalkerPortals) { + runCatching { + val stalker = com.arflix.tv.data.api.StalkerApi(portal.portalUrl, portal.macAddress) + if (!stalker.handshake()) return@runCatching + stalker.getProfile() + stalker.getChannels().map { it.copy(id = "stalker:${portal.id}:${it.id}") } + .also { channels.addAll(it) } + apis[portal.id] = stalker + } + } + return if (channels.isNotEmpty()) channels to apis else null } // Load playlists and Stalker in parallel when both are configured. - val (playlistChannels, stalkerApi, stalkerChannels) = coroutineScope { - val stalkerDeferred = if (config.stalkerPortalUrl.isNotBlank()) { + val (playlistChannels, stalkerApis, stalkerChannels) = coroutineScope { + val stalkerDeferred = if (stalkerPortals.isNotEmpty()) { async { - runCatching { - val stalker = com.arflix.tv.data.api.StalkerApi(config.stalkerPortalUrl, config.stalkerMacAddress) - if (!stalker.handshake()) return@runCatching null to emptyList() - stalker.getProfile() - stalker to stalker.getChannels().map { it.copy(id = "stalker:${it.id}") } - }.getOrElse { null to emptyList() } + stalkerPortals.map { portal -> + async { + runCatching { + val stalker = com.arflix.tv.data.api.StalkerApi(portal.portalUrl, portal.macAddress) + if (!stalker.handshake()) return@runCatching Triple>(portal.id, null, emptyList()) + stalker.getProfile() + Triple(portal.id, stalker, stalker.getChannels().map { it.copy(id = "stalker:${portal.id}:${it.id}") }) + }.getOrElse { Triple>(portal.id, null, emptyList()) } + } + }.awaitAll().let { results -> + val apis = HashMap() + val channels = ArrayList() + for ((portalId, api, chs) in results) { + channels.addAll(chs) + api?.let { apis[portalId] = it } + } + apis to channels + } } } else { null @@ -2899,25 +2988,25 @@ class IptvRepository @Inject constructor( } } }.awaitAll().flatten() - val (sa, sc) = stalkerDeferred?.await() ?: (null to emptyList()) + val (sa, sc) = stalkerDeferred?.await() ?: (emptyMap() to emptyList()) Triple(playlists, sa, sc) } val merged = playlistChannels + stalkerChannels - return if (merged.isNotEmpty()) merged to stalkerApi else null + return if (merged.isNotEmpty()) merged to stalkerApis else null } private suspend fun storeStartupChannels( config: IptvConfig, channels: List, - stalkerApi: com.arflix.tv.data.api.StalkerApi? + stalkerApis: Map ) { loadMutex.withLock { cachedChannels = channels cachedGroupedChannels = buildGroupedChannels(channels) cachedPlaylistAt = System.currentTimeMillis() - if (stalkerApi != null) { - cachedStalkerApi = stalkerApi + if (stalkerApis.isNotEmpty()) { + cachedStalkerApis = stalkerApis } val validIds = channels.asSequence().map { it.id }.toSet() if (cachedNowNext.isNotEmpty()) { @@ -3027,6 +3116,10 @@ class IptvRepository @Inject constructor( profileManager.profileStringKeyFor(profileId, "iptv_m3u_url") private fun epgUrlKey(): Preferences.Key = profileManager.profileStringKey("iptv_epg_url") private fun playlistsKey(): Preferences.Key = profileManager.profileStringKey("iptv_playlists_json") + private fun stalkerPortalsKey(): Preferences.Key = profileManager.profileStringKey("iptv_stalker_portals_json") + private fun stalkerPortalsKeyFor(profileId: String): Preferences.Key = + profileManager.profileStringKeyFor(profileId, "iptv_stalker_portals_json") + // Legacy single-portal keys kept only to read+ migrate pre-list data. private fun stalkerPortalUrlKey(): Preferences.Key = profileManager.profileStringKey("iptv_stalker_portal_url") private fun stalkerPortalUrlKeyFor(profileId: String): Preferences.Key = profileManager.profileStringKeyFor(profileId, "iptv_stalker_portal_url") @@ -3071,6 +3164,38 @@ class IptvRepository @Inject constructor( }.getOrDefault(emptyList()) } + private fun normalizeStalkerPortalEntry( + portal: StalkerPortalEntry, + index: Int + ): StalkerPortalEntry? = StalkerPortalSupport.normalizeStalkerPortalEntry(portal, index) + + private fun decodeStalkerPortals(raw: String): List = + StalkerPortalSupport.decodeStalkerPortals(raw, MAX_STALKER_PORTALS) + + /** + * Reads the persisted Stalker portal list. When the list store is empty but + * the legacy single-portal fields are set, migrates them into `Portal 1` + * (id `stalker1`) once so existing users keep their portal after the update. + */ + private fun readStalkerPortals(prefs: Preferences): List { + val listRaw = prefs[stalkerPortalsKey()].orEmpty() + if (listRaw.isNotBlank()) return decodeStalkerPortals(listRaw) + val legacyUrl = decryptConfigValue(prefs[stalkerPortalUrlKey()].orEmpty()).trim().trimEnd('/') + val legacyMac = prefs[stalkerMacAddressKey()].orEmpty().trim().uppercase() + return StalkerPortalSupport.migratedPortalFromLegacy(legacyUrl, legacyMac)?.let { listOf(it) } ?: emptyList() + } + + private fun readStalkerPortalsFor( + prefs: Preferences, + profileId: String + ): List { + val listRaw = prefs[stalkerPortalsKeyFor(profileId)].orEmpty() + if (listRaw.isNotBlank()) return decodeStalkerPortals(listRaw) + val legacyUrl = decryptConfigValue(prefs[stalkerPortalUrlKeyFor(profileId)].orEmpty()).trim().trimEnd('/') + val legacyMac = prefs[stalkerMacAddressKeyFor(profileId)].orEmpty().trim().uppercase() + return StalkerPortalSupport.migratedPortalFromLegacy(legacyUrl, legacyMac)?.let { listOf(it) } ?: emptyList() + } + private fun hiddenGroupsKey(): Preferences.Key = profileManager.profileStringKey("iptv_hidden_groups") private fun hiddenGroupsKeyFor(profileId: String): Preferences.Key = profileManager.profileStringKeyFor(profileId, "iptv_hidden_groups") @@ -3222,8 +3347,7 @@ class IptvRepository @Inject constructor( return IptvCloudProfileState( m3uUrl = primary?.m3uUrl ?: legacyM3uUrl, epgUrl = primary?.epgUrl ?: legacyEpgUrls.firstOrNull().orEmpty(), - stalkerPortalUrl = decryptConfigValue(prefs[stalkerPortalUrlKeyFor(safeProfileId)].orEmpty()), - stalkerMacAddress = prefs[stalkerMacAddressKeyFor(safeProfileId)].orEmpty(), + stalkerPortals = readStalkerPortalsFor(prefs, safeProfileId), favoriteGroups = decodeFavoriteGroups(prefs[favoriteGroupsKeyFor(safeProfileId)].orEmpty()), favoriteChannels = decodeFavoriteChannels(prefs[favoriteChannelsKeyFor(safeProfileId)].orEmpty()), hiddenGroups = if (hiddenRaw.isNotBlank()) { @@ -3250,8 +3374,10 @@ class IptvRepository @Inject constructor( val normalizedM3u = normalizeStoredIptvUrl(state.m3uUrl) val normalizedEpgUrls = normalizeStoredEpgInputs(state.epgUrl) val normalizedEpg = normalizedEpgUrls.firstOrNull().orEmpty() - val normalizedStalkerPortal = state.stalkerPortalUrl.trim().trimEnd('/') - val normalizedStalkerMac = state.stalkerMacAddress.trim().uppercase() + val normalizedStalkerPortals = state.stalkerPortals + .mapIndexed { index, portal -> normalizeStalkerPortalEntry(portal, index) } + .filterNotNull() + .take(MAX_STALKER_PORTALS) val normalizedPlaylists = state.playlists.mapIndexed { index, playlist -> normalizePlaylistEntry(playlist, index) }.filterNotNull().take(3) @@ -3276,10 +3402,14 @@ class IptvRepository @Inject constructor( context.settingsDataStore.edit { prefs -> prefs[m3uUrlKeyFor(safeProfileId)] = encryptConfigValue(normalizedM3u) prefs[epgUrlKeyFor(safeProfileId)] = encryptConfigValue(normalizedEpg) - if (normalizedStalkerPortal.isBlank()) prefs.remove(stalkerPortalUrlKeyFor(safeProfileId)) - else prefs[stalkerPortalUrlKeyFor(safeProfileId)] = encryptConfigValue(normalizedStalkerPortal) - if (normalizedStalkerMac.isBlank()) prefs.remove(stalkerMacAddressKeyFor(safeProfileId)) - else prefs[stalkerMacAddressKeyFor(safeProfileId)] = normalizedStalkerMac + if (normalizedStalkerPortals.isEmpty()) { + prefs.remove(stalkerPortalsKeyFor(safeProfileId)) + } else { + prefs[stalkerPortalsKeyFor(safeProfileId)] = gson.toJson(normalizedStalkerPortals) + } + // Clear legacy single-portal keys so the list store is authoritative. + prefs.remove(stalkerPortalUrlKeyFor(safeProfileId)) + prefs.remove(stalkerMacAddressKeyFor(safeProfileId)) prefs[favoriteGroupsKeyFor(safeProfileId)] = gson.toJson(state.favoriteGroups.distinct()) prefs[favoriteChannelsKeyFor(safeProfileId)] = gson.toJson(state.favoriteChannels.distinct()) if (state.hiddenGroups.isNotEmpty()) { @@ -3310,7 +3440,7 @@ class IptvRepository @Inject constructor( } groupOrderLocallyDirty = false if (profileManager.getProfileIdSync() == safeProfileId) { - cachedStalkerApi = null + cachedStalkerApis = emptyMap() invalidateCache() } } @@ -7845,6 +7975,17 @@ class IptvRepository @Inject constructor( } } + private fun stalkerPortalSignature(config: IptvConfig): String = + config.stalkerPortals.joinToString(separator = "||") { portal -> + listOf( + portal.id.trim(), + portal.name.trim(), + portal.portalUrl.trim(), + portal.macAddress.trim(), + portal.enabled.toString() + ).joinToString("|") + } + private fun buildConfigSignature(config: IptvConfig): String { val digest = MessageDigest.getInstance("SHA-256") val playlistSignature = config.playlists.joinToString(separator = "||") { playlist -> @@ -7858,11 +7999,10 @@ class IptvRepository @Inject constructor( ).joinToString("|") } val raw = listOf( - "playlist-group-prefix-v5-xtream-category-order-catchup-history-48h", + "playlist-group-prefix-v6-stalker-portal-list-xtream-category-order-catchup-history-48h", config.m3uUrl.trim(), config.epgUrl.trim(), - config.stalkerPortalUrl.trim(), - config.stalkerMacAddress.trim(), + stalkerPortalSignature(config), playlistSignature ).joinToString("||") return digest.digest(raw.toByteArray(StandardCharsets.UTF_8)) @@ -7880,10 +8020,9 @@ class IptvRepository @Inject constructor( ).joinToString("|") } val raw = listOf( - "playlist-sources-v4-xtream-category-order-catchup-history-48h", + "playlist-sources-v5-stalker-portal-list-xtream-category-order-catchup-history-48h", config.m3uUrl.trim(), - config.stalkerPortalUrl.trim(), - config.stalkerMacAddress.trim(), + stalkerPortalSignature(config), playlistSignature ).joinToString("||") return digest.digest(raw.toByteArray(StandardCharsets.UTF_8)) @@ -8793,6 +8932,9 @@ class IptvRepository @Inject constructor( /** Pseudo playlist id for the Stalker/Ministra portal source. */ const val STALKER_PLAYLIST_ID = "stalker" + /** Maximum number of Stalker portals a user can configure. */ + const val MAX_STALKER_PORTALS = 3 + val BRACKET_CONTENT_REGEX = Regex("""\[[^\]]*]""") val PAREN_CONTENT_REGEX = Regex("""\([^\)]*\)""") val YEAR_PAREN_REGEX = Regex("""\((19|20)\d{2}\)""") diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt new file mode 100644 index 000000000..c53394893 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt @@ -0,0 +1,68 @@ +package com.arflix.tv.data.repository + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +/** + * Pure helpers for the Stalker multi-portal model. Kept dependency-free so they + * can be unit-tested without an Android context. + */ +internal object StalkerPortalSupport { + + private val gson = Gson() + + /** + * Channel ids use the `stalker::` shape. Returns the + * portal id segment, or null when the id is not a Stalker channel or the + * portal segment is missing. + */ + fun portalIdFromChannelId(channelId: String): String? { + if (!channelId.startsWith("stalker:")) return null + val afterPrefix = channelId.removePrefix("stalker:") + val portalId = afterPrefix.substringBefore(':', missingDelimiterValue = "") + return portalId.takeIf { it.isNotBlank() } + } + + fun normalizeStalkerPortalEntry( + portal: StalkerPortalEntry, + index: Int + ): StalkerPortalEntry? { + val portalUrl = runCatching { portal.portalUrl }.getOrNull().orEmpty().trim().trimEnd('/') + val macAddress = runCatching { portal.macAddress }.getOrNull().orEmpty().trim().uppercase() + if (portalUrl.isBlank() || macAddress.isBlank()) return null + return StalkerPortalEntry( + id = runCatching { portal.id }.getOrNull().orEmpty().trim().ifBlank { "stalker${index + 1}" }, + name = runCatching { portal.name }.getOrNull().orEmpty().trim().ifBlank { "Portal ${index + 1}" }, + portalUrl = portalUrl, + macAddress = macAddress, + enabled = runCatching { portal.enabled }.getOrDefault(true) + ) + } + + fun decodeStalkerPortals(raw: String, maxPortals: Int): List { + if (raw.isBlank()) return emptyList() + return runCatching { + val type = TypeToken.getParameterized(List::class.java, StalkerPortalEntry::class.java).type + gson.fromJson>(raw, type) + ?.mapIndexed { index, portal -> normalizeStalkerPortalEntry(portal, index) } + ?.filterNotNull() + ?.take(maxPortals) + ?: emptyList() + }.getOrDefault(emptyList()) + } + + /** + * Builds the migrated Portal 1 entry from legacy single-portal fields. + */ + fun migratedPortalFromLegacy(portalUrl: String, macAddress: String): StalkerPortalEntry? { + val url = portalUrl.trim().trimEnd('/') + val mac = macAddress.trim().uppercase() + if (url.isBlank() || mac.isBlank()) return null + return StalkerPortalEntry( + id = "stalker1", + name = "Portal 1", + portalUrl = url, + macAddress = mac + ) + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 12aec66c0..6d7ad1285 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -36,6 +36,7 @@ import com.arflix.tv.data.repository.IptvConfig import com.arflix.tv.data.repository.IptvRepository import com.arflix.tv.data.repository.normalizeIptvSortOrder import com.arflix.tv.data.repository.IptvPlaylistEntry +import com.arflix.tv.data.repository.StalkerPortalEntry import com.arflix.tv.data.repository.LauncherContinueWatchingRepository import com.arflix.tv.data.repository.MediaRepository import com.arflix.tv.data.repository.ProfileManager @@ -172,8 +173,7 @@ data class SettingsUiState( val iptvM3uUrl: String = "", val iptvEpgUrl: String = "", val iptvPlaylists: List = emptyList(), - val iptvStalkerUrl: String = "", - val iptvStalkerMac: String = "", + val iptvStalkerPortals: List = emptyList(), val iptvSortOrder: String = "provider", val iptvChannelCount: Int = 0, val isIptvLoading: Boolean = false, @@ -239,7 +239,15 @@ data class SettingsUiState( val subtitleRemoveHearingImpaired: Boolean = true, val aiKeyServerState: AiKeyServerState = AiKeyServerState(), val smoothScrolling: Boolean = true -) +) { + /** + * Legacy compatibility: the first Stalker portal's URL. The Settings UI + * (still single-row in Session 1) reads this so it keeps working unchanged + * while the underlying model is now a list. Session 2 will replace these. + */ + val iptvStalkerUrl: String get() = iptvStalkerPortals.firstOrNull()?.portalUrl.orEmpty() + val iptvStalkerMac: String get() = iptvStalkerPortals.firstOrNull()?.macAddress.orEmpty() +} @HiltViewModel class SettingsViewModel @Inject constructor( @@ -1954,23 +1962,23 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { iptvRepository.observeConfig().collect { config -> val current = _uiState.value - if (current.iptvM3uUrl != config.m3uUrl || current.iptvEpgUrl != config.epgUrl || current.iptvStalkerUrl != config.stalkerPortalUrl || current.iptvStalkerMac != config.stalkerMacAddress || current.iptvPlaylists != config.playlists || current.iptvSortOrder != config.sortOrder) { + val stalkerConfigured = config.stalkerPortals.any { it.portalUrl.isNotBlank() } + if (current.iptvM3uUrl != config.m3uUrl || current.iptvEpgUrl != config.epgUrl || current.iptvStalkerPortals != config.stalkerPortals || current.iptvPlaylists != config.playlists || current.iptvSortOrder != config.sortOrder) { _uiState.value = current.copy( iptvM3uUrl = config.m3uUrl, iptvEpgUrl = config.epgUrl, iptvPlaylists = config.playlists, - iptvStalkerUrl = config.stalkerPortalUrl, - iptvStalkerMac = config.stalkerMacAddress, + iptvStalkerPortals = config.stalkerPortals, iptvSortOrder = config.sortOrder ) } if (!hasObservedIptvConfig) { hasObservedIptvConfig = true lastObservedIptvM3u = config.m3uUrl - lastObservedStalkerUrl = config.stalkerPortalUrl + lastObservedStalkerUrl = if (stalkerConfigured) "stalker" else "" lastObservedIptvConfigSignature = config.syncSignature() val hasAnyIptvConfig = config.m3uUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + stalkerConfigured || config.playlists.any { it.enabled && it.m3uUrl.isNotBlank() } if (!hasAnyIptvConfig) { _uiState.value = _uiState.value.copy( @@ -1987,12 +1995,12 @@ class SettingsViewModel @Inject constructor( } val hasAnyConfig = config.m3uUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + stalkerConfigured || config.playlists.any { it.enabled && it.m3uUrl.isNotBlank() } val configSignature = config.syncSignature() if (hasAnyConfig && configSignature != lastObservedIptvConfigSignature) { lastObservedIptvM3u = config.m3uUrl - lastObservedStalkerUrl = config.stalkerPortalUrl + lastObservedStalkerUrl = if (stalkerConfigured) "stalker" else "" lastObservedIptvConfigSignature = configSignature if (iptvLoadJob?.isActive != true) { refreshIptv(showToast = false, force = false) @@ -2331,8 +2339,7 @@ class SettingsViewModel @Inject constructor( private suspend fun removeStalkerConfigInternal() { iptvRepository.clearStalkerConfig() _uiState.value = _uiState.value.copy( - iptvStalkerUrl = "", - iptvStalkerMac = "", + iptvStalkerPortals = emptyList(), toastMessage = "Stalker portal removed", toastType = ToastType.SUCCESS ) @@ -2389,9 +2396,10 @@ class SettingsViewModel @Inject constructor( fun refreshIptv(showToast: Boolean = true, configured: Boolean = false, force: Boolean = true) { viewModelScope.launch { val currentConfig = iptvRepository.observeConfig().first() - // Check legacy m3uUrl, multi-playlist entries, and Stalker portal + // Check legacy m3uUrl, multi-playlist entries, and Stalker portals val hasPlaylists = currentConfig.playlists.any { it.m3uUrl.isNotBlank() && it.enabled } - if (currentConfig.m3uUrl.isBlank() && currentConfig.stalkerPortalUrl.isBlank() && !hasPlaylists) { + val hasStalker = currentConfig.stalkerPortals.any { it.portalUrl.isNotBlank() } + if (currentConfig.m3uUrl.isBlank() && !hasStalker && !hasPlaylists) { return@launch } @@ -3967,11 +3975,20 @@ private fun IptvConfig.syncSignature(): String { playlist.enabled.toString() ).joinToString("~") } + val stalkerSignature = stalkerPortals + .joinToString("|") { portal -> + listOf( + portal.id, + portal.name, + portal.portalUrl, + portal.macAddress, + portal.enabled.toString() + ).joinToString("~") + } return listOf( m3uUrl, epgUrl, - stalkerPortalUrl, - stalkerMacAddress, + stalkerSignature, playlistsSignature ).joinToString("||") } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index eb96a3e3d..11ec9e560 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -705,10 +705,11 @@ fun TvScreen( LaunchedEffect(playingChannelId, playingChannel?.streamUrl) { var stream = playingChannel?.streamUrl ?: return@LaunchedEffect + val playingId = playingChannel?.id if (isPlayerReleased) return@LaunchedEffect // Resolve Stalker portal cmd to an authenticated stream URL. - if (playingChannel?.id?.startsWith("stalker:") == true) { - val resolved = viewModel.iptvRepository.resolveStalkerStreamUrl(stream) + if (playingId?.startsWith("stalker:") == true) { + val resolved = viewModel.iptvRepository.resolveStalkerStreamUrl(playingId, stream) if (resolved != null) stream = resolved else return@LaunchedEffect } if (stream == lastPreparedStreamUrl) return@LaunchedEffect diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index cf08c60b2..5338ba410 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt @@ -76,7 +76,7 @@ data class TvUiState( ) { val isConfigured: Boolean get() = config.m3uUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + config.stalkerPortals.any { it.enabled && it.portalUrl.isNotBlank() } || config.playlists.any { it.enabled && it.m3uUrl.isNotBlank() } val hasPotentialGuideSource: Boolean get() = config.hasConfiguredEpgSource() @@ -273,7 +273,7 @@ class TvViewModel @Inject constructor( startFullEpgWarmup() val hasAnyIptvConfig = config.m3uUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + config.stalkerPortals.any { it.enabled && it.portalUrl.isNotBlank() } || config.playlists.any { it.enabled && it.m3uUrl.isNotBlank() } // Auto-heal cases where the app has IPTV config but an empty in-memory snapshot. @@ -1963,6 +1963,7 @@ class TvViewModel @Inject constructor( } val resolvedUrl = resolveStalkerStreamIfNeeded( rawUrl = rawUrl, + channelId = channel.id, isStalkerChannel = channel.id.startsWith("stalker:"), forceRefresh = forceRefresh, ) @@ -1975,6 +1976,7 @@ class TvViewModel @Inject constructor( private suspend fun resolveStalkerStreamIfNeeded( rawUrl: String, + channelId: String, isStalkerChannel: Boolean, forceRefresh: Boolean, ): String { @@ -1988,7 +1990,7 @@ class TvViewModel @Inject constructor( } val resolved = withContext(Dispatchers.IO) { - iptvRepository.resolveStalkerStreamUrl(trimmed) + iptvRepository.resolveStalkerStreamUrl(channelId, trimmed) }?.trim().orEmpty() val playable = resolved.ifBlank { trimmed.removePrefix("ffmpeg").trim() } if (playable.isNotBlank()) { @@ -2310,7 +2312,7 @@ private fun filterTvChannels( private fun hasNetworkEpgSource(config: IptvConfig): Boolean { return config.epgUrl.isNotBlank() || - config.stalkerPortalUrl.isNotBlank() || + config.stalkerPortals.any { it.portalUrl.isNotBlank() } || config.m3uUrl.isNotBlank() || looksLikeXtream(config.m3uUrl) || config.playlists.any { playlist -> @@ -2326,7 +2328,7 @@ private fun hasNetworkEpgSource(config: IptvConfig): Boolean { private fun IptvConfig.hasConfiguredEpgSource(): Boolean { return epgUrl.isNotBlank() || - stalkerPortalUrl.isNotBlank() || + stalkerPortals.any { it.portalUrl.isNotBlank() } || m3uUrl.isNotBlank() || looksLikeXtream(m3uUrl) || playlists.any { playlist -> @@ -2358,11 +2360,20 @@ internal fun IptvConfig.syncSignature(): String { playlist.enabled.toString() ).joinToString("~") } + val stalkerSignature = stalkerPortals + .joinToString("|") { portal -> + listOf( + portal.id, + portal.name, + portal.portalUrl, + portal.macAddress, + portal.enabled.toString() + ).joinToString("~") + } return listOf( m3uUrl, epgUrl, - stalkerPortalUrl, - stalkerMacAddress, + stalkerSignature, playlistsSignature ).joinToString("||") } diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt new file mode 100644 index 000000000..2f589c46c --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt @@ -0,0 +1,141 @@ +package com.arflix.tv.data.repository + +import com.google.common.truth.Truth.assertThat +import com.google.gson.Gson +import org.junit.Test + +class StalkerPortalSupportTest { + + private val gson = Gson() + + @Test + fun portalIdFromChannelIdExtractsPortalSegment() { + assertThat(StalkerPortalSupport.portalIdFromChannelId("stalker:stalker1:42")) + .isEqualTo("stalker1") + assertThat(StalkerPortalSupport.portalIdFromChannelId("stalker:stalker2:abc")) + .isEqualTo("stalker2") + } + + @Test + fun portalIdFromChannelIdReturnsNullForNonStalkerIds() { + assertThat(StalkerPortalSupport.portalIdFromChannelId("list_1:42")).isNull() + assertThat(StalkerPortalSupport.portalIdFromChannelId("plain")).isNull() + } + + @Test + fun portalIdFromChannelIdReturnsNullWhenPortalSegmentMissing() { + // Legacy single-portal id shape "stalker:42" has no portal segment. + assertThat(StalkerPortalSupport.portalIdFromChannelId("stalker:42")).isNull() + } + + @Test + fun startsWithStalkerStillMatchesNewPrefixedIds() { + // The existing checks rely on startsWith("stalker:") and must keep matching. + assertThat("stalker:stalker1:42".startsWith("stalker:")).isTrue() + assertThat("stalker:stalker2:abc".startsWith("stalker:")).isTrue() + } + + @Test + fun migratedPortalFromLegacyProducesPortal1() { + val portal = StalkerPortalSupport.migratedPortalFromLegacy( + "http://portal.example/stalker_portal/", + "00:1A:79:12:34:56" + ) + assertThat(portal).isNotNull() + assertThat(portal!!.id).isEqualTo("stalker1") + assertThat(portal.name).isEqualTo("Portal 1") + assertThat(portal.portalUrl).isEqualTo("http://portal.example/stalker_portal") + assertThat(portal.macAddress).isEqualTo("00:1A:79:12:34:56") + } + + @Test + fun migratedPortalFromLegacyReturnsNullWhenFieldsBlank() { + assertThat(StalkerPortalSupport.migratedPortalFromLegacy("", "00:1A:79:12:34:56")).isNull() + assertThat(StalkerPortalSupport.migratedPortalFromLegacy("http://portal", "")).isNull() + } + + @Test + fun migrateAndDecodeRoundTripsThroughJson() { + val migrated = StalkerPortalSupport.migratedPortalFromLegacy( + "http://portal.example/", + "00:1a:79:aa:bb:cc" + )!! + // MAC is uppercased during migration. + assertThat(migrated.macAddress).isEqualTo("00:1A:79:AA:BB:CC") + + val json = gson.toJson(listOf(migrated)) + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + assertThat(decoded).hasSize(1) + assertThat(decoded[0].id).isEqualTo("stalker1") + assertThat(decoded[0].portalUrl).isEqualTo("http://portal.example") + assertThat(decoded[0].macAddress).isEqualTo("00:1A:79:AA:BB:CC") + } + + @Test + fun decodeStalkerPortalsPreservesMultiplePortalsAndOrder() { + val portals = listOf( + StalkerPortalEntry("stalker1", "Portal 1", "http://a/", "00:1A:79:11:11:11"), + StalkerPortalEntry("stalker2", "Portal 2", "http://b/", "00:1A:79:22:22:22"), + StalkerPortalEntry("stalker3", "Portal 3", "http://c/", "00:1A:79:33:33:33") + ) + val json = gson.toJson(portals) + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + assertThat(decoded.map { it.id }) + .containsExactly("stalker1", "stalker2", "stalker3").inOrder() + assertThat(decoded.map { it.portalUrl }) + .containsExactly("http://a", "http://b", "http://c").inOrder() + } + + @Test + fun decodeStalkerPortalsCapsAtMaxPortals() { + val portals = listOf( + StalkerPortalEntry("stalker1", "Portal 1", "http://a/", "00:1A:79:11:11:11"), + StalkerPortalEntry("stalker2", "Portal 2", "http://b/", "00:1A:79:22:22:22"), + StalkerPortalEntry("stalker3", "Portal 3", "http://c/", "00:1A:79:33:33:33"), + StalkerPortalEntry("stalker4", "Portal 4", "http://d/", "00:1A:79:44:44:44") + ) + val json = gson.toJson(portals) + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + assertThat(decoded).hasSize(3) + assertThat(decoded.map { it.id }) + .containsExactly("stalker1", "stalker2", "stalker3").inOrder() + } + + @Test + fun decodeStalkerPortalsDropsEntriesWithBlankFields() { + val portals = listOf( + StalkerPortalEntry("stalker1", "Portal 1", "http://a/", "00:1A:79:11:11:11"), + StalkerPortalEntry("stalker2", "Portal 2", "", "00:1A:79:22:22:22"), + StalkerPortalEntry("stalker3", "Portal 3", "http://c/", "") + ) + val json = gson.toJson(portals) + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + assertThat(decoded).hasSize(1) + assertThat(decoded[0].id).isEqualTo("stalker1") + } + + @Test + fun decodeStalkerPortalsAssignsDefaultIdAndNameWhenBlank() { + val portals = listOf( + StalkerPortalEntry("", "", "http://a/", "00:1A:79:11:11:11") + ) + val json = gson.toJson(portals) + val decoded = StalkerPortalSupport.decodeStalkerPortals(json, maxPortals = 3) + assertThat(decoded).hasSize(1) + assertThat(decoded[0].id).isEqualTo("stalker1") + assertThat(decoded[0].name).isEqualTo("Portal 1") + } + + @Test + fun decodeStalkerPortalsReturnsEmptyForBlankOrMalformedInput() { + assertThat(StalkerPortalSupport.decodeStalkerPortals("", maxPortals = 3)).isEmpty() + assertThat(StalkerPortalSupport.decodeStalkerPortals("not json", maxPortals = 3)).isEmpty() + } + + @Test + fun multiPortalChannelIdsResolveToDistinctPortals() { + val ids = listOf("stalker:stalker1:10", "stalker:stalker2:20", "stalker:stalker3:30") + val portalIds = ids.mapNotNull { StalkerPortalSupport.portalIdFromChannelId(it) } + assertThat(portalIds).containsExactly("stalker1", "stalker2", "stalker3").inOrder() + } +} From b7197ad17981878bb0fb6fe8fe8ba461c0df95b7 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 10:10:16 +0000 Subject: [PATCH 05/16] fix(iptv): specify Triple/Pair type params for multi-portal merge Kotlin could not infer the type parameters for the empty fallback pairs and success-branch Triples in the parallel Stalker-portal merge paths. Make the Triple> and emptyMap/emptyList fallbacks explicit so compileSideloadDebugKotlin succeeds. Co-authored-by: openhands --- .../com/arflix/tv/data/repository/IptvRepository.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 4062af3d9..dc4112012 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -1753,7 +1753,7 @@ class IptvRepository @Inject constructor( val channels = stalker.getChannels() // Prefix with stalker:: so the // portal can be identified for playback routing. - Triple(portal.id, stalker, channels.map { it.copy(id = "stalker:${portal.id}:${it.id}") }) + Triple>(portal.id, stalker, channels.map { it.copy(id = "stalker:${portal.id}:${it.id}") }) }.getOrElse { Triple>(portal.id, null, emptyList()) } } }.awaitAll().let { results -> @@ -1772,7 +1772,8 @@ class IptvRepository @Inject constructor( // Stalker-only mode: no playlists configured → return Stalker channels directly. if (activePlaylists.isEmpty() && stalkerPortals.isNotEmpty()) { - val (stalkerApis, stalkerChannels) = stalkerChannelsDeferred?.await() ?: (emptyMap() to emptyList()) + val (stalkerApis, stalkerChannels) = stalkerChannelsDeferred?.await() + ?: (emptyMap() to emptyList()) if (stalkerChannels.isEmpty()) { return@withContext IptvSnapshot( epgWarning = "Stalker handshake failed. Check Portal URL and MAC.", @@ -1891,7 +1892,8 @@ class IptvRepository @Inject constructor( } }.let { playlistChannels -> // Merge Stalker channels (if configured) into the final list. - val (stalkerApis, stalkerChannels) = stalkerChannelsDeferred?.await() ?: (emptyMap() to emptyList()) + val (stalkerApis, stalkerChannels) = stalkerChannelsDeferred?.await() + ?: (emptyMap() to emptyList()) if (stalkerApis.isNotEmpty()) { cachedStalkerApis = stalkerApis } @@ -2961,7 +2963,7 @@ class IptvRepository @Inject constructor( val stalker = com.arflix.tv.data.api.StalkerApi(portal.portalUrl, portal.macAddress) if (!stalker.handshake()) return@runCatching Triple>(portal.id, null, emptyList()) stalker.getProfile() - Triple(portal.id, stalker, stalker.getChannels().map { it.copy(id = "stalker:${portal.id}:${it.id}") }) + Triple>(portal.id, stalker, stalker.getChannels().map { it.copy(id = "stalker:${portal.id}:${it.id}") }) }.getOrElse { Triple>(portal.id, null, emptyList()) } } }.awaitAll().let { results -> @@ -2988,7 +2990,8 @@ class IptvRepository @Inject constructor( } } }.awaitAll().flatten() - val (sa, sc) = stalkerDeferred?.await() ?: (emptyMap() to emptyList()) + val (sa, sc) = stalkerDeferred?.await() + ?: (emptyMap() to emptyList()) Triple(playlists, sa, sc) } From 98cf2b0dd033678ced6cbd8fb657c78ff9ee8875 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 10:28:08 +0000 Subject: [PATCH 06/16] ci: bump versionCode in test-apk builds so Android allows in-place update When the versionCode stayed at 310 Android refused to install a new test APK over the existing .stalkerlive build (same version), forcing a full reinstall that would wipe the saved Stalker portal and defeat the migration test. Each test build now derives a unique, monotonically increasing versionCode (310 + github.run_number) so updates install in place. Workflow-only change, removed before the upstream PR. Co-authored-by: openhands --- .github/workflows/test-apk.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test-apk.yml b/.github/workflows/test-apk.yml index f3866eaf5..d69a97eae 100644 --- a/.github/workflows/test-apk.yml +++ b/.github/workflows/test-apk.yml @@ -24,6 +24,12 @@ jobs: - name: Parallel-install app id (.stalkerlive) run: sed -i 's/ debug {/ debug {\n applicationIdSuffix = ".stalkerlive"/' app/build.gradle.kts + - name: Bump versionCode so Android allows in-place update + run: | + BUILD_NUMBER=${{ github.run_number }} + sed -i "s/versionCode = [0-9]*/versionCode = $((310 + BUILD_NUMBER))/" app/build.gradle.kts + echo "Bumped versionCode to $((310 + BUILD_NUMBER)) for run $BUILD_NUMBER" + - name: Build debug APK run: ./gradlew assembleSideloadDebug --no-daemon From 4e09c3350361d870201b7bfc46d92df2837e3e94 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 10:43:26 +0000 Subject: [PATCH 07/16] ci: add one-off workflow to generate a stable debug keystore Debug APKs built in CI each get a different ephemeral signing key, so Android refuses in-place updates (signature mismatch). This generates a fixed debug keystore once, to be stored as a repository secret and reused by test-apk builds. Co-authored-by: openhands --- .github/workflows/generate-debug-key.yml | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/generate-debug-key.yml diff --git a/.github/workflows/generate-debug-key.yml b/.github/workflows/generate-debug-key.yml new file mode 100644 index 000000000..12135701f --- /dev/null +++ b/.github/workflows/generate-debug-key.yml @@ -0,0 +1,28 @@ +name: Generate debug keystore (one-off) + +on: + workflow_dispatch: + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - name: Generate debug keystore + run: | + mkdir -p tmp-keystore + keytool -genkeypair -v \ + -keystore tmp-keystore/debug.keystore \ + -storepass android \ + -keypass android \ + -alias arvio-stalker-debug \ + -keyalg RSA -keysize 2048 -validity 36500 \ + -dname "CN=ARVIO Stalker Test, OU=Dev, O=ReichiMD, L=Test, ST=Test, C=DE" + + - name: Base64 encode + run: base64 -w 0 tmp-keystore/debug.keystore > tmp-keystore/debug.keystore.b64 + + - name: Upload as artifact + uses: actions/upload-artifact@v4 + with: + name: arvio-debug-keystore + path: tmp-keystore/debug.keystore.b64 From f680f903613e4972c0faca6e0c1939d9d3f8ca26 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 10:46:14 +0000 Subject: [PATCH 08/16] ci: sign test APKs with a fixed debug keystore for in-place updates Debug APKs built in ephemeral CI runners each get a different signing key, so Android refuses to update one test build over another (signature mismatch), regardless of versionCode. Store a fixed debug keystore in the branch and copy it to ~/.android/debug.keystore before building so every test APK is signed with the same key and can update in place. The keystore uses the standard debug password and is removed (along with test-apk.yml) before the upstream PR. Co-authored-by: openhands --- .github/test-signing/debug.keystore | Bin 0 -> 2786 bytes .github/workflows/generate-debug-key.yml | 28 ----------------------- .github/workflows/test-apk.yml | 6 +++++ .gitignore | 3 +++ 4 files changed, 9 insertions(+), 28 deletions(-) create mode 100644 .github/test-signing/debug.keystore delete mode 100644 .github/workflows/generate-debug-key.yml diff --git a/.github/test-signing/debug.keystore b/.github/test-signing/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..5b74454de234bf793c8d0cdb49c72a7cbe274212 GIT binary patch literal 2786 zcma);X*kpi`^RU-ER1bBGfWQ2p)4c$4KmYYsT})~C1r^bX6!s1Xv8f+QDu1tuzS;4&?t`pEMg<_j|~7PevF z#qh(1`*2f{f;ck;u?0o-Fi<1}z^1SaIv{&J6o6u8h z(eBnz9kS|PMel)4!r&gSG@y-^c^USA4>91UVb;vJCSKNY9#^SVrsyc5dsPnBn^)U5 z*IW9HUh@~^ldV{6PEKe|U98^*j87OQOGVaco(Vf$9w`;V?nU&wvUXgMR!Qtg*$)%J zVw>jNi^$r)(XR3b$U|m#B(Z*WqJi-?#`TJpnQq)xxgS1fDDHIEW_#p>GYL}DV5v>M zzCL~r;vR=Z3)b#_{2=^c$I!1T-QPvp28)|0D1k<3oN}<8oJ1Q zB=6fbK2f9SF>Xhj+MDE+ufFCMJ+SSlE5vVSKb@`O!@OX<@8is{&ZJ9`5f+@rS@8`7 z-Tw7c?F|2p#pB{B$8w%<_nU#lyT*@A<&*=J>-r~!(XNrEfNx$B-&(Oy(#(J{f-Q`NJRL6p1A zW$VImLMDPEdVjFq&RR*7zEfHW+;GY2Szd_l2!j$B$9q&jYW9YRZ}0Md91E?xCx(9^ z`c&){DoCn|p_v&R#f;XIoyR=b{hEu|!6NvP5pTMH@a1Vab@x17P2ljMn=%nHXLG<6 zaFO zkgi+m#rMbuDe5iuX)C(P*R#D0!drGe)gaxY-j}Z#;@%tEQBJAfzzQF}_r|znkouG1 zSzj=XII@f_E0N58wthTeTxsr()kcu^Fy;%YPiUhX8_P^eEf3bvQ&1_pWFqksT7IDu zka>*6r?hXLOTTkQ0u5~unuufUrFEY~2$LL4rVQ$R)I|uQn-T+=aEn_jf{j!HQRq}R z5>f|@m1V2VNx|+?EtnaCFRav(O(OF2sf4P6!m*o4hOC3=>L{_0ZTc~i3&94}vF_b4 z)skIb(H7HLZx=aAV75y*x^x|AuxBKh+G^n3PcSv?@yEZI2ZhI_(@;5rM+_fxy|JB3ALkQmsvj9aV|j;N z3b>^N)K4bf4mj~EF8MHcc}ZE&Wl#Vp7~}!E0#X891qJT+-XJd!WuGd6T=&%(6a;bu z{`kixg2zCAJMZB`k-`&*gcIsS6@n^3^&~)p7XMm8z>8?m%zcy!0fY7>;hzckzXdo) z!`aH5tH!?h`Xq_Az_#c&g17xI0Zvi$Ow!VHC2CBj8&_wFe~hL11uNB(qS6Vj`2C;a1prM^=fc>;hcm=O|vrx|6h+r@H^ULLY@W_K?)%xL37M zCD}PIZi%Xrq3}1FfemmB+D%m$A_vMe-3Di@9V!n+>fCX#D!jE-3craR+dDa`nIXJf zxlU{;4DHgmaR`jvC0 znNtx)xiJ?Q4Wj4L`CaB{&UbIfTbh&k;bQIPow97R95Tznh*dpt@$kuO8cg1rtZ7svNA?2uAHS$6GdVRq&cF~d3lB+ko zb0Ot29dEYEy>tS~Rf&QRj@L*Ve_f(JX%bN@HVESpP}_#r9?H`2VWbsXwJW8(0xs{l z`abqoo|Aj$kQ5&i{mEdZ_E+wMI;1ZFYFH@oY35ysSJBw6WD=Ie+9G zCH&gR$0gsltHmR!zVNqfRj+p6&GGKjs4$@7j$dVT$k^4JHp|mPhlD zjk&@0j1j@*X*6=)M(-({{6+7Y!Z}(&w~{k4S*^x=8M=B{A^uxjNNV8p6Jn0BL35aV zmY{;XBnENe&`71C9$w`?wF+_OHX<;ygV0iCouV^(Z7?U(Ct zt;C*=-oiTT#=8S|<{jmN1DlRkCKu*{1am)TfJEJ zTYu{l-f475&|II}Jj?d`*(ZA8=hF zOE(_%lPWF?$PbxLZ^bKo*QrJfoSr6OjzkER*j}@}V>J0B*wlM6Wck6o30)*bMEQ}T zfZ1PrKVm;@zB(wg^@w`Y?J=A(Wx48owKbtf{cAFRmyo@>bkZ#gZ4Trx5I%7k{d*Sa z{xDx+rd{$?+RG2R%EPKSZg_yaZmCR7s#Sw(j6kS${9|ETNKP`B0GI-JK=9Y!2LZ!C zP^mYR?K_HQs{E3oBX)y#y;M;2UdcROAW(K^XKpuT)(wiO7Qas#@b_{GH3>BI2z%N! L@?25=|03yMs}$@i literal 0 HcmV?d00001 diff --git a/.github/workflows/generate-debug-key.yml b/.github/workflows/generate-debug-key.yml deleted file mode 100644 index 12135701f..000000000 --- a/.github/workflows/generate-debug-key.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Generate debug keystore (one-off) - -on: - workflow_dispatch: - -jobs: - generate: - runs-on: ubuntu-latest - steps: - - name: Generate debug keystore - run: | - mkdir -p tmp-keystore - keytool -genkeypair -v \ - -keystore tmp-keystore/debug.keystore \ - -storepass android \ - -keypass android \ - -alias arvio-stalker-debug \ - -keyalg RSA -keysize 2048 -validity 36500 \ - -dname "CN=ARVIO Stalker Test, OU=Dev, O=ReichiMD, L=Test, ST=Test, C=DE" - - - name: Base64 encode - run: base64 -w 0 tmp-keystore/debug.keystore > tmp-keystore/debug.keystore.b64 - - - name: Upload as artifact - uses: actions/upload-artifact@v4 - with: - name: arvio-debug-keystore - path: tmp-keystore/debug.keystore.b64 diff --git a/.github/workflows/test-apk.yml b/.github/workflows/test-apk.yml index d69a97eae..3823af58e 100644 --- a/.github/workflows/test-apk.yml +++ b/.github/workflows/test-apk.yml @@ -21,6 +21,12 @@ jobs: - name: Placeholder config run: cp secrets.defaults.properties secrets.properties + - name: Use committed debug keystore (stable signing key) + run: | + mkdir -p ~/.android + cp .github/test-signing/debug.keystore ~/.android/debug.keystore + echo "Using fixed debug keystore so test APKs can update in place." + - name: Parallel-install app id (.stalkerlive) run: sed -i 's/ debug {/ debug {\n applicationIdSuffix = ".stalkerlive"/' app/build.gradle.kts diff --git a/.gitignore b/.gitignore index 24dd7d59e..271e7f177 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,9 @@ release.keystore upload.keystore *.jks *.keystore +# Exception: the committed debug keystore for test APKs (stable signing key +# so CI test builds can update in place; removed before the upstream PR). +!.github/test-signing/debug.keystore # Temporary files *.tmp From 1f8b7bd34271ffb153aaf3697d54ce69afec69df Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 13:14:33 +0000 Subject: [PATCH 09/16] fix(iptv): populate hidden groups and order in cache snapshots getMemoryCachedSnapshot() and getCachedSnapshotOrNull() built the IptvSnapshot without hiddenGroups/groupOrder, leaving them at the emptyList() default. The TV grid reads these cached snapshots at startup (and onChannelsReady reuses the live snapshot's hiddenGroups), so the hidden-group filter had nothing to apply and every group became visible again. Read both prefs from the DataStore the same way loadSnapshot already does. --- .../com/arflix/tv/data/repository/IptvRepository.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index dc4112012..7682cf2ba 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -2328,6 +2328,8 @@ class IptvRepository @Inject constructor( val favoriteGroups = observeFavoriteGroups().first() val favoriteChannels = observeFavoriteChannels().first() + val hiddenGroups = observeHiddenGroups().first() + val groupOrder = observeGroupOrder().first() val grouped = cachedGroupedChannels.ifEmpty { buildGroupedChannels(cachedChannels).also { cachedGroupedChannels = it } } @@ -2339,6 +2341,8 @@ class IptvRepository @Inject constructor( nowNext = cachedNowNext, favoriteGroups = favoriteGroups, favoriteChannels = favoriteChannels, + hiddenGroups = hiddenGroups, + groupOrder = groupOrder, epgWarning = null, loadedAt = Instant.ofEpochMilli(loadedAtMillis) ) @@ -2368,6 +2372,8 @@ class IptvRepository @Inject constructor( if (channels.isEmpty()) return null val favoriteGroups = observeFavoriteGroups().first() val favoriteChannels = observeFavoriteChannels().first() + val hiddenGroups = observeHiddenGroups().first() + val groupOrder = observeGroupOrder().first() val grouped = cachedGroupedChannels.ifEmpty { buildGroupedChannels(channels).also { cachedGroupedChannels = it } } @@ -2378,6 +2384,8 @@ class IptvRepository @Inject constructor( nowNext = cachedNowNext, favoriteGroups = favoriteGroups, favoriteChannels = favoriteChannels, + hiddenGroups = hiddenGroups, + groupOrder = groupOrder, epgWarning = null, loadedAt = Instant.ofEpochMilli(loadedAtMillis) ) From dd174558b1d173efbce72064367407e648e6662c Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 14:28:11 +0000 Subject: [PATCH 10/16] feat(iptv): multi-portal Stalker UI with CRUD, reorder and per-portal categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session 2 — full Stalker portal UI (TV + Mobile): SettingsScreen: - Two separate sections (M3U Playlists + Stalker Portals) - Up to 3 Stalker portal rows with full chip row (categories / enable / edit / rename / up / down / delete) - 'Add Stalker portal' button disabled at max (3) - Rename dialog (name editable, subtitle 'Stalker', no EPG counter) - Per-portal categories dialog with per-portal bulk-toggle - Enable/disable as icon (same style as M3U, no slider) - Sort up/down within Stalker section only D-Pad navigation: - Recomputed index dispatch maps for variable M3U + Stalker row counts - iptvRowMaxAction() replaces stalkerRowMaxAction() (both rows = 5 chips) - firstIptvGroupIndex() accepts stalker portal ids for per-portal bulk-toggle - sectionMaxIndex includes Stalker portal count SettingsViewModel: - Removed compat getters (iptvStalkerUrl/iptvStalkerMac) - Real CRUD: onAddStalkerPortal, onEditStalkerPortal, onToggleStalkerPortal, onMoveStalkerPortalUp/Down, onRemoveStalkerPortal, onRenameStalkerPortal - loadIptvGroupsForPlaylist is stalker-portal-aware (stalker:: prefix) IptvRepository: - saveStalkerPortals() added (normalize, cap at 3, clear removed portal groups) - STALKER_PLAYLIST_ID / MAX_STALKER_PORTALS in public companion object strings.xml: new section title, portal-N template, content descriptions, bulk-toggle labels, rename dialog strings Tests: normalizeStalkerPortalEntry + D-Pad index helper unit tests Co-authored-by: openhands --- .../tv/data/repository/IptvRepository.kt | 57 +- .../tv/ui/screens/settings/SettingsScreen.kt | 579 ++++++++++++------ .../ui/screens/settings/SettingsViewModel.kt | 186 ++++-- app/src/main/res/values/strings.xml | 13 + .../repository/StalkerPortalSupportTest.kt | 37 ++ .../settings/SettingsIptvGroupOrderTest.kt | 44 ++ 6 files changed, 678 insertions(+), 238 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 7682cf2ba..f6a0d1e5d 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -698,6 +698,49 @@ class IptvRepository @Inject constructor( invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "clear stalker config") } + /** + * Persists the full Stalker portal list — the Stalker counterpart of + * [savePlaylists]. The caller (SettingsViewModel) builds the mutated list + * (add / edit / remove / reorder / toggle / rename) and hands it here. + * + * - Normalizes every entry (trims URL, uppercases MAC, assigns default + * id/name) and caps the list at [MAX_STALKER_PORTALS]. + * - Drops group preferences for portals that were removed so no stale + * `stalker|...` keys linger. + * - Invalidates the Stalker API cache and persisted source caches when the + * source signature changes. + */ + suspend fun saveStalkerPortals(portals: List) { + val profileId = profileManager.getProfileIdSync() + val previousConfig = observeConfig().first() + val normalized = portals.mapIndexed { index, portal -> + normalizeStalkerPortalEntry(portal, index) + }.filterNotNull().take(MAX_STALKER_PORTALS) + val nextConfig = previousConfig.copy(stalkerPortals = normalized) + val previousSourceKey = epgIndexKey(profileId, previousConfig) + val sourceChanged = buildSourceSignature(previousConfig) != buildSourceSignature(nextConfig) + + val removedIds = previousConfig.stalkerPortals + .map { it.id } + .filterNot { id -> normalized.any { it.id == id } } + + context.settingsDataStore.edit { prefs -> + prefs[stalkerPortalsKey()] = gson.toJson(normalized) + // Clear legacy single-portal keys so the list store stays authoritative. + prefs.remove(stalkerPortalUrlKey()) + prefs.remove(stalkerMacAddressKey()) + } + // Drop group preferences for removed portals (independent sets, decision #3). + removedIds.forEach { clearGroupPreferences(it) } + cachedStalkerApis = emptyMap() + groupOrderLocallyDirty = true + if (sourceChanged) { + withContext(Dispatchers.IO) { deletePersistedSourceCaches(previousSourceKey) } + } + invalidateCache() + invalidationBus.markDirty(CloudSyncScope.IPTV, profileManager.getProfileIdSync(), "save stalker portals") + } + /** * Resolves a Stalker `cmd` stream argument into an authenticated URL. When * [channelId] carries the `stalker::` prefix the matching portal @@ -8925,6 +8968,14 @@ class IptvRepository @Inject constructor( // ════════════════════════════════════════════════════════════════════════ + companion object { + /** Pseudo playlist id for the Stalker/Ministra portal source. */ + const val STALKER_PLAYLIST_ID = "stalker" + + /** Maximum number of Stalker portals a user can configure. */ + const val MAX_STALKER_PORTALS = 3 + } + private companion object { private val DURATION_SCALE_REGEX = Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""") private val URL_QUERY_SECRETS_REGEX = Regex("""(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""") @@ -8940,12 +8991,6 @@ class IptvRepository @Inject constructor( const val IPTV_USER_AGENT = "VLC/3.0.20 LibVLC/3.0.20" const val BROWSER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" - /** Pseudo playlist id for the Stalker/Ministra portal source. */ - const val STALKER_PLAYLIST_ID = "stalker" - - /** Maximum number of Stalker portals a user can configure. */ - const val MAX_STALKER_PORTALS = 3 - val BRACKET_CONTENT_REGEX = Regex("""\[[^\]]*]""") val PAREN_CONTENT_REGEX = Regex("""\([^\)]*\)""") val YEAR_PAREN_REGEX = Regex("""\((19|20)\d{2}\)""") diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index e885092cc..4b86694cd 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -91,6 +91,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.TextFields import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Speaker @@ -191,6 +192,8 @@ import com.arflix.tv.data.model.RuntimeKind import com.arflix.tv.data.repository.HomeServerConnection import com.arflix.tv.data.repository.HomeServerKind import com.arflix.tv.data.repository.IptvPlaylistEntry +import com.arflix.tv.data.repository.IptvRepository +import com.arflix.tv.data.repository.StalkerPortalEntry import com.arflix.tv.ui.components.AppTopBar import com.arflix.tv.ui.components.AppTopBarContentTopInset import com.arflix.tv.ui.components.CatalogueRowLayoutToggleButton @@ -245,7 +248,7 @@ private const val PRIVACY_POLICY_URL = "https://arvio.tv/privacy" * playlist lets it reuse the same group-management UI as M3U playlists * (categories / hide / sort / bulk toggle) without a separate code path. */ -private const val STALKER_PLAYLIST_ID = "stalker" +private const val STALKER_PLAYLIST_ID = IptvRepository.STALKER_PLAYLIST_ID private val tvGeneralSectionIds = setOf( "language", @@ -286,21 +289,22 @@ internal fun orderedIptvGroups( } /** - * Highest sub-focus action index of the Stalker pseudo-playlist row in the TV - * settings grid: 0 = categories, 1 = edit config, 2 = remove (only present - * when a portal is configured). Used by the D-pad left/right guards so the - * focus does not run past the last available action chip. + * Highest sub-focus action index of a content row in the IPTV settings grid. + * Both M3U playlist rows and Stalker portal rows carry the same full chip row + * (categories / toggle / edit / up / down / delete) → max action index 5. The + * "add" rows (index 0 = add M3U, add Stalker portal) have no sub-focus chips. */ -internal fun stalkerRowMaxAction(configured: Boolean): Int = if (configured) 2 else 1 +internal fun iptvRowMaxAction(): Int = 5 /** * Focus index of the first category row inside the IPTV categories screen. - * The Stalker source gets a "show all / hide all" bulk-toggle row at index 1 - * (between Reset and the categories), so its categories start at 2; every - * other source starts at 1. + * Every source that has a bulk-toggle "show all / hide all" row (all Stalker + * portals and the legacy single Stalker source) gets it at index 1, between + * Reset and the categories, so its categories start at 2; sources without a + * bulk toggle start at 1. */ -internal fun firstIptvGroupIndex(playlistId: String, orderedGroups: List): Int = - if (playlistId == STALKER_PLAYLIST_ID && orderedGroups.isNotEmpty()) 2 else 1 +internal fun firstIptvGroupIndex(playlistId: String, orderedGroups: List, stalkerPortalIds: Set = emptySet()): Int = + if (orderedGroups.isNotEmpty() && (playlistId == STALKER_PLAYLIST_ID || playlistId in stalkerPortalIds)) 2 else 1 private fun openExternalUrl(context: Context, url: String) { runCatching { @@ -460,6 +464,12 @@ fun SettingsScreen( var showStalkerInput by remember { mutableStateOf(false) } var stalkerEditPortal by remember { mutableStateOf("") } var stalkerEditMac by remember { mutableStateOf("") } + var stalkerEditName by remember { mutableStateOf("") } + // null = adding a new portal; non-null = editing the portal with this id. + var stalkerEditId by remember { mutableStateOf(null) } + var showStalkerRename by remember { mutableStateOf(false) } + var stalkerRenameId by remember { mutableStateOf("") } + var stalkerRenameName by remember { mutableStateOf("") } var showCatalogInput by remember { mutableStateOf(false) } var catalogInputUrl by remember { mutableStateOf("") } var showSubtitlePicker by remember { mutableStateOf(false) } @@ -498,15 +508,17 @@ fun SettingsScreen( when (section) { in tvGeneralSectionIds -> (tvGeneralRowsForSection(section).size - 1).coerceAtLeast(0) "iptv" -> if (showIptvCategoriesSettings) { + val stalkerIds = uiState.iptvStalkerPortals.map { it.id }.toSet() val groups = orderedIptvGroups( playlistId = uiState.iptvSelectedPlaylistId.orEmpty(), availableGroups = uiState.iptvAvailableGroups, groupOrder = uiState.iptvGroupOrder ) - // Reset row + (Stalker bulk-toggle row) + category rows. - groups.size + (firstIptvGroupIndex(uiState.iptvSelectedPlaylistId.orEmpty(), groups) - 1) + // Reset row + (bulk-toggle row) + category rows. + groups.size + (firstIptvGroupIndex(uiState.iptvSelectedPlaylistId.orEmpty(), groups, stalkerIds) - 1) } else { - 4 + uiState.iptvPlaylists.size // Add + rows + stalker + order + refresh + clear + // Add-M3U + M3U rows + Add-Stalker + Stalker rows + order + refresh + clear + 4 + uiState.iptvPlaylists.size + uiState.iptvStalkerPortals.size } "home_server" -> uiState.homeServerConnections.size + 3 "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs @@ -741,6 +753,7 @@ fun SettingsScreen( showCustomAddonInput || showIptvInput || showStalkerInput || + showStalkerRename || showHomeServerInput || showPlexHomeServerInput || showCatalogInput || @@ -842,13 +855,17 @@ fun SettingsScreen( Key.DirectionLeft -> { when (activeZone) { Zone.CONTENT -> { + val m3uCount = uiState.iptvPlaylists.size + val stalkerCount = uiState.iptvStalkerPortals.size + val stalkerStart = m3uCount + 2 + val stalkerEnd = m3uCount + 1 + stalkerCount if (currentSection == "stremio" && contentFocusIndex < stremioAddons.size && addonActionIndex > 0) { addonActionIndex-- } else if (currentSection == "iptv" && iptvActionIndex > 0 && ( showIptvCategoriesSettings && contentFocusIndex > 0 || - !showIptvCategoriesSettings && (contentFocusIndex == 1 || contentFocusIndex in 2..(uiState.iptvPlaylists.size + 1)) + !showIptvCategoriesSettings && (contentFocusIndex in 1..m3uCount || contentFocusIndex in stalkerStart..stalkerEnd) ) ) { iptvActionIndex-- @@ -886,16 +903,21 @@ fun SettingsScreen( catalogActionIndex = 0 } Zone.CONTENT -> { + val stalkerIds = uiState.iptvStalkerPortals.map { it.id }.toSet() + val m3uCount = uiState.iptvPlaylists.size + val stalkerCount = uiState.iptvStalkerPortals.size + val stalkerStart = m3uCount + 2 + val stalkerEnd = m3uCount + 1 + stalkerCount if (currentSection == "stremio" && contentFocusIndex in 0 until stremioAddons.size && addonActionIndex < focusedStremioAddonMaxAction ) { addonActionIndex++ - } else if (currentSection == "iptv" && showIptvCategoriesSettings && contentFocusIndex >= firstIptvGroupIndex(uiState.iptvSelectedPlaylistId.orEmpty(), orderedIptvGroups(uiState.iptvSelectedPlaylistId.orEmpty(), uiState.iptvAvailableGroups, uiState.iptvGroupOrder)) && iptvActionIndex < 2) { + } else if (currentSection == "iptv" && showIptvCategoriesSettings && contentFocusIndex >= firstIptvGroupIndex(uiState.iptvSelectedPlaylistId.orEmpty(), orderedIptvGroups(uiState.iptvSelectedPlaylistId.orEmpty(), uiState.iptvAvailableGroups, uiState.iptvGroupOrder), stalkerIds) && iptvActionIndex < 2) { iptvActionIndex++ - } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex == 1 && iptvActionIndex < stalkerRowMaxAction(uiState.iptvStalkerUrl.isNotBlank())) { + } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex in 1..m3uCount && iptvActionIndex < iptvRowMaxAction()) { iptvActionIndex++ - } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex in 2..(uiState.iptvPlaylists.size + 1) && iptvActionIndex < 5) { + } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex in stalkerStart..stalkerEnd && iptvActionIndex < iptvRowMaxAction()) { iptvActionIndex++ } else if (currentSection == "catalogs" && contentFocusIndex > 1 && catalogActionIndex < 5) { catalogActionIndex++ @@ -1027,6 +1049,7 @@ fun SettingsScreen( } } "iptv" -> { + val stalkerIds = uiState.iptvStalkerPortals.map { it.id }.toSet() if (showIptvCategoriesSettings) { val playlistId = uiState.iptvSelectedPlaylistId.orEmpty() val orderedGroups = orderedIptvGroups( @@ -1034,18 +1057,20 @@ fun SettingsScreen( availableGroups = uiState.iptvAvailableGroups, groupOrder = uiState.iptvGroupOrder ) + val firstGroupIdx = firstIptvGroupIndex(playlistId, orderedGroups, stalkerIds) + val hasBulkToggle = orderedGroups.isNotEmpty() && (playlistId == STALKER_PLAYLIST_ID || playlistId in stalkerIds) when { contentFocusIndex == 0 -> { viewModel.resetIptvGroupOrder(playlistId) } - contentFocusIndex == 1 && playlistId == STALKER_PLAYLIST_ID && orderedGroups.isNotEmpty() -> { + contentFocusIndex == 1 && hasBulkToggle -> { val allHidden = orderedGroups.all { com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, it) in uiState.iptvHiddenGroups } viewModel.setAllIptvGroupsVisible(playlistId, visible = allHidden) } - contentFocusIndex in firstIptvGroupIndex(playlistId, orderedGroups)..orderedGroups.size + (firstIptvGroupIndex(playlistId, orderedGroups) - 1) -> { - val group = orderedGroups.getOrNull(contentFocusIndex - firstIptvGroupIndex(playlistId, orderedGroups)) + contentFocusIndex in firstGroupIdx..orderedGroups.size + (firstGroupIdx - 1) -> { + val group = orderedGroups.getOrNull(contentFocusIndex - firstGroupIdx) if (!group.isNullOrBlank()) { when (iptvActionIndex) { 0 -> viewModel.toggleIptvHiddenGroup(playlistId, group) @@ -1055,79 +1080,91 @@ fun SettingsScreen( } } } - } else when { - contentFocusIndex == 0 -> { - editingIptvIndex = -1 - showIptvInput = true - } - contentFocusIndex == 1 -> { - when (iptvActionIndex) { - 0 -> { - if (uiState.iptvStalkerUrl.isNotBlank()) { - openIptvCategories(STALKER_PLAYLIST_ID) - } - } - 2 -> { - viewModel.removeStalkerConfig() - } - else -> { - stalkerEditPortal = uiState.iptvStalkerUrl - stalkerEditMac = uiState.iptvStalkerMac - showStalkerInput = true - } + } else { + val m3uCount = uiState.iptvPlaylists.size + val stalkerCount = uiState.iptvStalkerPortals.size + val stalkerStart = m3uCount + 2 + val stalkerEnd = m3uCount + 1 + stalkerCount + when { + contentFocusIndex == 0 -> { + editingIptvIndex = -1 + showIptvInput = true } - } - contentFocusIndex in 2..(uiState.iptvPlaylists.size + 1) -> { - val idx = contentFocusIndex - 2 - val updated = uiState.iptvPlaylists.toMutableList() - val playlist = updated.getOrNull(idx) - if (playlist != null) { - when (iptvActionIndex) { - 0 -> { - openIptvCategories(playlist.id) - } - 1 -> { - updated[idx] = playlist.copy(enabled = !playlist.enabled) - viewModel.saveIptvPlaylists(updated) - } - 2 -> { - editingIptvIndex = idx - showIptvInput = true - } - 3 -> { - if (idx > 0) { - val item = updated.removeAt(idx) - updated.add(idx - 1, item) + contentFocusIndex in 1..m3uCount -> { + val idx = contentFocusIndex - 1 + val updated = uiState.iptvPlaylists.toMutableList() + val playlist = updated.getOrNull(idx) + if (playlist != null) { + when (iptvActionIndex) { + 0 -> openIptvCategories(playlist.id) + 1 -> { + updated[idx] = playlist.copy(enabled = !playlist.enabled) viewModel.saveIptvPlaylists(updated) } - } - 4 -> { - if (idx < updated.lastIndex) { - val item = updated.removeAt(idx) - updated.add(idx + 1, item) + 2 -> { editingIptvIndex = idx; showIptvInput = true } + 3 -> { + if (idx > 0) { + val item = updated.removeAt(idx) + updated.add(idx - 1, item) + viewModel.saveIptvPlaylists(updated) + } + } + 4 -> { + if (idx < updated.lastIndex) { + val item = updated.removeAt(idx) + updated.add(idx + 1, item) + viewModel.saveIptvPlaylists(updated) + } + } + else -> { + updated.removeAt(idx) viewModel.saveIptvPlaylists(updated) } } - else -> { - updated.removeAt(idx) - viewModel.saveIptvPlaylists(updated) + } + } + contentFocusIndex == m3uCount + 1 -> { + // Add Stalker portal + stalkerEditId = null + stalkerEditPortal = "" + stalkerEditMac = "" + stalkerEditName = "" + showStalkerInput = true + } + contentFocusIndex in stalkerStart..stalkerEnd -> { + val sIdx = contentFocusIndex - stalkerStart + val portal = uiState.iptvStalkerPortals.getOrNull(sIdx) + if (portal != null) { + when (iptvActionIndex) { + 0 -> openIptvCategories(portal.id) + 1 -> viewModel.onToggleStalkerPortal(portal.id) + 2 -> { + stalkerEditId = portal.id + stalkerEditPortal = portal.portalUrl + stalkerEditMac = portal.macAddress + stalkerEditName = portal.name + showStalkerInput = true + } + 3 -> viewModel.onMoveStalkerPortalUp(portal.id) + 4 -> viewModel.onMoveStalkerPortalDown(portal.id) + else -> viewModel.onRemoveStalkerPortal(portal.id) } } } - } - contentFocusIndex == uiState.iptvPlaylists.size + 2 -> { - val next = when (uiState.iptvSortOrder) { - "provider" -> "number" - "number" -> "name" - else -> "provider" + contentFocusIndex == m3uCount + stalkerCount + 2 -> { + val next = when (uiState.iptvSortOrder) { + "provider" -> "number" + "number" -> "name" + else -> "provider" + } + viewModel.setIptvSortOrder(next) + } + contentFocusIndex == m3uCount + stalkerCount + 3 -> { + viewModel.refreshIptv(force = true) + } + contentFocusIndex == m3uCount + stalkerCount + 4 -> { + viewModel.clearIptvConfig() } - viewModel.setIptvSortOrder(next) - } - contentFocusIndex == uiState.iptvPlaylists.size + 3 -> { - viewModel.refreshIptv(force = true) - } - contentFocusIndex == uiState.iptvPlaylists.size + 4 -> { - viewModel.clearIptvConfig() } } } @@ -1336,7 +1373,6 @@ fun SettingsScreen( onSubtitleAiApiKeyClick = { showAiApiKeyDialog = true }, onSubtitleAiQrClick = { viewModel.startAiKeyServer() }, onAddIptvClick = { editingIptvIndex = -1; showIptvInput = true }, - onConfigureStalkerClick = { stalkerEditPortal = uiState.iptvStalkerUrl; stalkerEditMac = uiState.iptvStalkerMac; showStalkerInput = true }, onEditIptvClick = { idx -> editingIptvIndex = idx; showIptvInput = true }, onAddCatalogClick = { showCatalogInput = true }, onImportCatalogPackClick = { showCatalogPackInput = true }, @@ -1631,7 +1667,7 @@ fun SettingsScreen( onMoveUp = { viewModel.moveIptvGroupUp(uiState.iptvSelectedPlaylistId ?: "", it) }, onMoveDown = { viewModel.moveIptvGroupDown(uiState.iptvSelectedPlaylistId ?: "", it) }, onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") }, - showBulkToggle = (uiState.iptvSelectedPlaylistId ?: "") == STALKER_PLAYLIST_ID, + showBulkToggle = (uiState.iptvSelectedPlaylistId ?: "") == STALKER_PLAYLIST_ID || (uiState.iptvSelectedPlaylistId ?: "") in uiState.iptvStalkerPortals.map { it.id }, onBulkToggle = { visible -> viewModel.setAllIptvGroupsVisible(uiState.iptvSelectedPlaylistId ?: "", visible) } ) } else IptvSettings( @@ -1646,11 +1682,15 @@ fun SettingsScreen( focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, focusedActionIndex = iptvActionIndex, onConfigure = { editingIptvIndex = -1; showIptvInput = true }, - onConfigureStalker = { stalkerEditPortal = uiState.iptvStalkerUrl; stalkerEditMac = uiState.iptvStalkerMac; showStalkerInput = true }, - onRemoveStalker = { viewModel.removeStalkerConfig() }, - onManageStalkerCategories = { openIptvCategories(STALKER_PLAYLIST_ID) }, - stalkerSubtitle = if (uiState.iptvStalkerUrl.isNotBlank()) uiState.iptvStalkerUrl else stringResource(R.string.settings_stalker_middleware_hint), - stalkerConfigured = uiState.iptvStalkerUrl.isNotBlank(), + stalkerPortals = uiState.iptvStalkerPortals, + onAddStalkerPortal = { stalkerEditId = null; stalkerEditPortal = ""; stalkerEditMac = ""; stalkerEditName = ""; showStalkerInput = true }, + onEditStalkerPortal = { portal -> stalkerEditId = portal.id; stalkerEditPortal = portal.portalUrl; stalkerEditMac = portal.macAddress; stalkerEditName = portal.name; showStalkerInput = true }, + onToggleStalkerPortal = { id -> viewModel.onToggleStalkerPortal(id) }, + onMoveStalkerPortalUp = { id -> viewModel.onMoveStalkerPortalUp(id) }, + onMoveStalkerPortalDown = { id -> viewModel.onMoveStalkerPortalDown(id) }, + onRemoveStalkerPortal = { id -> viewModel.onRemoveStalkerPortal(id) }, + onManageStalkerCategories = { id -> openIptvCategories(id) }, + onRenameStalkerPortal = { portal -> stalkerRenameId = portal.id; stalkerRenameName = portal.name; showStalkerRename = true }, onEditPlaylist = { idx -> editingIptvIndex = idx; showIptvInput = true }, onTogglePlaylist = { idx -> val updated = uiState.iptvPlaylists.toMutableList() @@ -1697,11 +1737,15 @@ fun SettingsScreen( focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, focusedActionIndex = iptvActionIndex, onConfigure = { editingIptvIndex = -1; showIptvInput = true }, - onConfigureStalker = { stalkerEditPortal = uiState.iptvStalkerUrl; stalkerEditMac = uiState.iptvStalkerMac; showStalkerInput = true }, - onRemoveStalker = { viewModel.removeStalkerConfig() }, - onManageStalkerCategories = { openIptvCategories(STALKER_PLAYLIST_ID) }, - stalkerSubtitle = if (uiState.iptvStalkerUrl.isNotBlank()) uiState.iptvStalkerUrl else stringResource(R.string.settings_stalker_middleware_hint), - stalkerConfigured = uiState.iptvStalkerUrl.isNotBlank(), + stalkerPortals = uiState.iptvStalkerPortals, + onAddStalkerPortal = { stalkerEditId = null; stalkerEditPortal = ""; stalkerEditMac = ""; stalkerEditName = ""; showStalkerInput = true }, + onEditStalkerPortal = { portal -> stalkerEditId = portal.id; stalkerEditPortal = portal.portalUrl; stalkerEditMac = portal.macAddress; stalkerEditName = portal.name; showStalkerInput = true }, + onToggleStalkerPortal = { id -> viewModel.onToggleStalkerPortal(id) }, + onMoveStalkerPortalUp = { id -> viewModel.onMoveStalkerPortalUp(id) }, + onMoveStalkerPortalDown = { id -> viewModel.onMoveStalkerPortalDown(id) }, + onRemoveStalkerPortal = { id -> viewModel.onRemoveStalkerPortal(id) }, + onManageStalkerCategories = { id -> openIptvCategories(id) }, + onRenameStalkerPortal = { portal -> stalkerRenameId = portal.id; stalkerRenameName = portal.name; showStalkerRename = true }, onEditPlaylist = { idx -> editingIptvIndex = idx; showIptvInput = true }, onTogglePlaylist = { idx -> val updated = uiState.iptvPlaylists.toMutableList() @@ -2044,10 +2088,16 @@ fun SettingsScreen( ) } if (showStalkerInput) { + val isEditing = stalkerEditId != null InputModal( - title = stringResource(R.string.settings_stalker_title), - supportingText = if (uiState.iptvStalkerUrl.isNotBlank()) stringResource(R.string.settings_stalker_remove_hint) else null, + title = stringResource(if (isEditing) R.string.settings_cd_edit_stalker_config else R.string.settings_add_stalker_portal_button), fields = listOf( + InputField( + label = stringResource(R.string.settings_stalker_portal_name_label), + value = stalkerEditName, + placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), + onValueChange = { stalkerEditName = it } + ), InputField( label = stringResource(R.string.settings_stalker_label_portal_url), value = stalkerEditPortal, @@ -2062,12 +2112,35 @@ fun SettingsScreen( ) ), onConfirm = { - viewModel.saveStalkerConfig(stalkerEditPortal.trim(), stalkerEditMac.trim()) + val id = stalkerEditId + if (id != null) { + viewModel.onEditStalkerPortal(id, stalkerEditPortal, stalkerEditMac, stalkerEditName) + } else { + viewModel.onAddStalkerPortal(stalkerEditPortal, stalkerEditMac, stalkerEditName) + } showStalkerInput = false }, onDismiss = { showStalkerInput = false } ) } + if (showStalkerRename) { + InputModal( + title = stringResource(R.string.settings_stalker_rename_title), + fields = listOf( + InputField( + label = stringResource(R.string.settings_stalker_portal_name_label), + value = stalkerRenameName, + placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), + onValueChange = { stalkerRenameName = it } + ) + ), + onConfirm = { + viewModel.onRenameStalkerPortal(stalkerRenameId, stalkerRenameName) + showStalkerRename = false + }, + onDismiss = { showStalkerRename = false } + ) + } @@ -4224,6 +4297,14 @@ private fun MobileSettingsSubPage( ) { val scrollState = rememberScrollState() + var showStalkerInput by remember { mutableStateOf(false) } + var stalkerEditId by remember { mutableStateOf(null) } + var stalkerEditPortal by remember { mutableStateOf("") } + var stalkerEditMac by remember { mutableStateOf("") } + var stalkerEditName by remember { mutableStateOf("") } + var showStalkerRename by remember { mutableStateOf(false) } + var stalkerRenameId by remember { mutableStateOf("") } + var stalkerRenameName by remember { mutableStateOf("") } Column( modifier = Modifier .fillMaxSize() @@ -4606,8 +4687,15 @@ private fun MobileSettingsSubPage( focusedIndex = -1, focusedActionIndex = 0, onConfigure = onAddIptvClick, - onConfigureStalker = onConfigureStalkerClick, - stalkerSubtitle = if (uiState.iptvStalkerUrl.isNotBlank()) uiState.iptvStalkerUrl else stringResource(R.string.settings_stalker_middleware_hint), + stalkerPortals = uiState.iptvStalkerPortals, + onAddStalkerPortal = { stalkerEditId = null; stalkerEditPortal = ""; stalkerEditMac = ""; stalkerEditName = ""; showStalkerInput = true }, + onEditStalkerPortal = { portal -> stalkerEditId = portal.id; stalkerEditPortal = portal.portalUrl; stalkerEditMac = portal.macAddress; stalkerEditName = portal.name; showStalkerInput = true }, + onToggleStalkerPortal = { id -> viewModel.onToggleStalkerPortal(id) }, + onMoveStalkerPortalUp = { id -> viewModel.onMoveStalkerPortalUp(id) }, + onMoveStalkerPortalDown = { id -> viewModel.onMoveStalkerPortalDown(id) }, + onRemoveStalkerPortal = { id -> viewModel.onRemoveStalkerPortal(id) }, + onManageStalkerCategories = { id -> viewModel.setIptvSelectedPlaylistId(id); onNavigate("IPTV_CATEGORIES") }, + onRenameStalkerPortal = { portal -> stalkerRenameId = portal.id; stalkerRenameName = portal.name; showStalkerRename = true }, onEditPlaylist = onEditIptvClick, onTogglePlaylist = { idx -> val updated = uiState.iptvPlaylists.toMutableList() @@ -4642,12 +4730,6 @@ private fun MobileSettingsSubPage( viewModel.setIptvSelectedPlaylistId(playlistId) onNavigate("IPTV_CATEGORIES") }, - onRemoveStalker = { viewModel.removeStalkerConfig() }, - onManageStalkerCategories = { - viewModel.setIptvSelectedPlaylistId(STALKER_PLAYLIST_ID) - onNavigate("IPTV_CATEGORIES") - }, - stalkerConfigured = uiState.iptvStalkerUrl.isNotBlank(), sortOrder = uiState.iptvSortOrder, onSortOrderChange = { viewModel.setIptvSortOrder(it) } ) @@ -4664,7 +4746,7 @@ private fun MobileSettingsSubPage( onMoveUp = { viewModel.moveIptvGroupUp(uiState.iptvSelectedPlaylistId ?: "", it) }, onMoveDown = { viewModel.moveIptvGroupDown(uiState.iptvSelectedPlaylistId ?: "", it) }, onReset = { viewModel.resetIptvGroupOrder(uiState.iptvSelectedPlaylistId ?: "") }, - showBulkToggle = (uiState.iptvSelectedPlaylistId ?: "") == STALKER_PLAYLIST_ID, + showBulkToggle = (uiState.iptvSelectedPlaylistId ?: "") == STALKER_PLAYLIST_ID || (uiState.iptvSelectedPlaylistId ?: "") in uiState.iptvStalkerPortals.map { it.id }, onBulkToggle = { visible -> viewModel.setAllIptvGroupsVisible(uiState.iptvSelectedPlaylistId ?: "", visible) } ) } @@ -4835,6 +4917,61 @@ private fun MobileCloudAccountSubPage( ) } + if (showStalkerInput) { + val isEditing = stalkerEditId != null + InputModal( + title = stringResource(if (isEditing) R.string.settings_cd_edit_stalker_config else R.string.settings_add_stalker_portal_button), + fields = listOf( + InputField( + label = stringResource(R.string.settings_stalker_portal_name_label), + value = stalkerEditName, + placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), + onValueChange = { stalkerEditName = it } + ), + InputField( + label = stringResource(R.string.settings_stalker_label_portal_url), + value = stalkerEditPortal, + placeholder = stringResource(R.string.settings_stalker_ph_portal_url), + onValueChange = { stalkerEditPortal = it } + ), + InputField( + label = stringResource(R.string.settings_stalker_label_mac), + value = stalkerEditMac, + placeholder = stringResource(R.string.settings_stalker_ph_mac), + onValueChange = { stalkerEditMac = it } + ) + ), + onConfirm = { + val id = stalkerEditId + if (id != null) { + viewModel.onEditStalkerPortal(id, stalkerEditPortal, stalkerEditMac, stalkerEditName) + } else { + viewModel.onAddStalkerPortal(stalkerEditPortal, stalkerEditMac, stalkerEditName) + } + showStalkerInput = false + }, + onDismiss = { showStalkerInput = false } + ) + } + if (showStalkerRename) { + InputModal( + title = stringResource(R.string.settings_stalker_rename_title), + fields = listOf( + InputField( + label = stringResource(R.string.settings_stalker_portal_name_label), + value = stalkerRenameName, + placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), + onValueChange = { stalkerRenameName = it } + ) + ), + onConfirm = { + viewModel.onRenameStalkerPortal(stalkerRenameId, stalkerRenameName) + showStalkerRename = false + }, + onDismiss = { showStalkerRename = false } + ) + } + // Section 2: ACCOUNT PROFILE & STATUS MobileSettingsCategory(title = stringResource(R.string.cloud_account).uppercase()) { if (uiState.isLoggedIn) { @@ -6820,11 +6957,15 @@ private fun IptvSettings( onManageCategories: (String) -> Unit = {}, sortOrder: String = "provider", onSortOrderChange: (String) -> Unit = {}, - onConfigureStalker: () -> Unit = {}, - onRemoveStalker: () -> Unit = {}, - onManageStalkerCategories: () -> Unit = {}, - stalkerSubtitle: String = "", - stalkerConfigured: Boolean = false + stalkerPortals: List = emptyList(), + onAddStalkerPortal: () -> Unit = {}, + onEditStalkerPortal: (StalkerPortalEntry) -> Unit = {}, + onToggleStalkerPortal: (String) -> Unit = {}, + onMoveStalkerPortalUp: (String) -> Unit = {}, + onMoveStalkerPortalDown: (String) -> Unit = {}, + onRemoveStalkerPortal: (String) -> Unit = {}, + onManageStalkerCategories: (String) -> Unit = {}, + onRenameStalkerPortal: (StalkerPortalEntry) -> Unit = {} ) { val isMobile = LocalDeviceType.current.isTouchDevice() var selectionMode by remember { mutableStateOf(false) } @@ -6860,45 +7001,6 @@ private fun IptvSettings( } MobileSettingsCategory(title = stringResource(R.string.settings_section_playlists)) { MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_playlist), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_add_tv_lists_hint) else stringResource(R.string.settings_create_another_tv), value = if (playlists.size >= 3) stringResource(R.string.settings_badge_full_short) else "", isFocused = false, showDivider = true, onClick = onConfigure) - // Stalker pseudo-playlist row: behaves like an M3U playlist row - // (categories / edit / remove) rather than a plain config entry. - Column { - Row( - modifier = Modifier.fillMaxWidth() - .clickable { onConfigureStalker() } - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = Icons.Default.Tv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.settings_add_stalker_portal), style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(stalkerSubtitle, style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Icon( - imageVector = Icons.Default.List, - contentDescription = stringResource(R.string.settings_cd_manage_stalker_categories), - tint = if (stalkerConfigured) TextSecondary else TextSecondary.copy(alpha = 0.4f), - modifier = Modifier - .size(36.dp) - .clickable(enabled = stalkerConfigured) { onManageStalkerCategories() } - .padding(6.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Box(modifier = Modifier.size(36.dp).clickable { onConfigureStalker() }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { - Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.settings_cd_edit_stalker_config), tint = Color.White, modifier = Modifier.size(20.dp)) - } - if (stalkerConfigured) { - Spacer(modifier = Modifier.width(12.dp)) - Box(modifier = Modifier.size(36.dp).clickable { onRemoveStalker() }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { - Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.settings_stalker_remove), tint = Color.White, modifier = Modifier.size(20.dp)) - } - } - } - if (playlists.isNotEmpty()) { - Box(modifier = Modifier.fillMaxWidth().height(1.dp).padding(horizontal = 16.dp).background(Color.White.copy(alpha = 0.05f))) - } - } playlists.forEachIndexed { index, playlist -> val isSelected = selectedIndices.contains(index) val epgSourceCount = playlist.settingsEpgInput().lineSequence().count { it.isNotBlank() } @@ -6952,6 +7054,72 @@ private fun IptvSettings( } } } + MobileSettingsCategory(title = stringResource(R.string.settings_section_stalker_portals)) { + val stalkerFull = stalkerPortals.size >= 3 + MobileSettingsRow( + icon = Icons.Default.Add, + title = stringResource(R.string.settings_add_stalker_portal_button), + subtitle = if (stalkerPortals.isEmpty()) stringResource(R.string.settings_stalker_add_hint) else stringResource(R.string.settings_stalker_create_another), + value = if (stalkerFull) stringResource(R.string.settings_badge_full_short) else "", + isFocused = false, + showDivider = stalkerPortals.isNotEmpty(), + onClick = { if (!stalkerFull) onAddStalkerPortal() } + ) + stalkerPortals.forEachIndexed { index, portal -> + Column { + Row( + modifier = Modifier.fillMaxWidth() + .clickable { onEditStalkerPortal(portal) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = Icons.Default.Tv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(portal.name, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(stringResource(R.string.settings_stalker_subtitle), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Icon( + imageVector = Icons.Default.List, + contentDescription = stringResource(R.string.settings_cd_manage_stalker_categories), + tint = TextSecondary, + modifier = Modifier + .size(36.dp) + .clickable { onManageStalkerCategories(portal.id) } + .padding(6.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + // Enable/disable toggle chip (same style as M3U) + Box(modifier = Modifier.width(44.dp).height(24.dp).background(color = if (portal.enabled) SuccessGreen else Color.White.copy(alpha = 0.2f), shape = RoundedCornerShape(13.dp)).clickable { onToggleStalkerPortal(portal.id) }.padding(3.dp), contentAlignment = if (portal.enabled) Alignment.CenterEnd else Alignment.CenterStart) { + Box(modifier = Modifier.size(18.dp).background(color = Color.White, shape = RoundedCornerShape(10.dp))) + } + Spacer(modifier = Modifier.width(12.dp)) + Box(modifier = Modifier.size(36.dp).clickable { onEditStalkerPortal(portal) }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.settings_cd_edit_stalker_config), tint = Color.White, modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + Box(modifier = Modifier.size(36.dp).clickable { onRenameStalkerPortal(portal) }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Icon(Icons.Default.TextFields, contentDescription = stringResource(R.string.settings_stalker_rename_title), tint = Color.White, modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + Box(modifier = Modifier.size(36.dp).clickable { onMoveStalkerPortalUp(portal.id) }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Icon(Icons.Default.ArrowUpward, contentDescription = stringResource(R.string.settings_cd_move_stalker_up), tint = Color.White, modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + Box(modifier = Modifier.size(36.dp).clickable { onMoveStalkerPortalDown(portal.id) }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Icon(Icons.Default.ArrowDownward, contentDescription = stringResource(R.string.settings_cd_move_stalker_down), tint = Color.White, modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(12.dp)) + Box(modifier = Modifier.size(36.dp).clickable { onRemoveStalkerPortal(portal.id) }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.settings_cd_remove_stalker_portal), tint = Color.White, modifier = Modifier.size(20.dp)) + } + } + if (index < stalkerPortals.size - 1) { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).padding(horizontal = 16.dp).background(Color.White.copy(alpha = 0.05f))) + } + } + } + } MobileSettingsCategory(title = stringResource(R.string.settings_section_options)) { val sortDisplayValue = when (sortOrder) { "number" -> stringResource(R.string.settings_iptv_order_number) @@ -6991,41 +7159,8 @@ private fun IptvSettings( Column { SettingsRow(icon = Icons.Default.LiveTv, title = stringResource(R.string.add_playlist), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_add_iptv_lists_hint) else stringResource(R.string.settings_create_another_iptv), value = if (playlists.size >= 3) stringResource(R.string.settings_badge_full) else stringResource(R.string.settings_badge_add), isFocused = focusedIndex == 0, onClick = onConfigure, modifier = Modifier.settingsFocusSlot(0)) Spacer(modifier = Modifier.height(6.dp)) - // Stalker pseudo-playlist row (TV): same action-chip layout as an - // M3U playlist row. Sub-focus 0 = categories, 1 = edit config, - // 2 = remove (only when configured). - val stalkerRingColor = resolveAccentColor(fallback = Pink) - Row(modifier = Modifier.settingsFocusSlot(1).fillMaxWidth().background(if (focusedIndex == 1) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == 1) 2.dp else 0.dp, color = if (focusedIndex == 1) stalkerRingColor else Color.Transparent, shape = RoundedCornerShape(12.dp)).clickable { onConfigureStalker() }.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.settings_add_stalker_portal), style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = if (focusedIndex == 1) TextPrimary else TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) - Spacer(modifier = Modifier.height(4.dp)) - Text(stalkerSubtitle, style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - CatalogActionChip( - icon = Icons.Default.List, - isFocused = focusedIndex == 1 && focusedActionIndex == 0, - enabled = stalkerConfigured, - onClick = { onManageStalkerCategories() } - ) - Spacer(modifier = Modifier.width(6.dp)) - CatalogActionChip( - icon = Icons.Default.Edit, - isFocused = focusedIndex == 1 && focusedActionIndex == 1, - onClick = onConfigureStalker - ) - if (stalkerConfigured) { - Spacer(modifier = Modifier.width(6.dp)) - CatalogActionChip( - icon = Icons.Default.Delete, - isFocused = focusedIndex == 1 && focusedActionIndex == 2, - isDestructive = true, - onClick = onRemoveStalker - ) - } - } - Spacer(modifier = Modifier.height(16.dp)) playlists.forEachIndexed { index, playlist -> - val rowIndex = index + 2 + val rowIndex = index + 1 val epgSourceCount = playlist.settingsEpgInput().lineSequence().count { it.isNotBlank() } val focusRingColor = resolveAccentColor(fallback = Pink) Row(modifier = Modifier.settingsFocusSlot(rowIndex).fillMaxWidth().background(if (focusedIndex == rowIndex) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == rowIndex) 2.dp else 0.dp, color = if (focusedIndex == rowIndex) focusRingColor else Color.Transparent, shape = RoundedCornerShape(12.dp)).clickable { onEditPlaylist(index) }.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { @@ -7074,6 +7209,69 @@ private fun IptvSettings( Spacer(modifier = Modifier.height(10.dp)) } Spacer(modifier = Modifier.height(6.dp)) + // ── Stalker portals section (separate from M3U, decision #4) ── + val stalkerAddIndex = playlists.size + 1 + val stalkerFull = stalkerPortals.size >= 3 + SettingsRow( + icon = Icons.Default.Add, + title = stringResource(R.string.settings_add_stalker_portal_button), + subtitle = if (stalkerPortals.isEmpty()) stringResource(R.string.settings_stalker_add_hint) else stringResource(R.string.settings_stalker_create_another), + value = if (stalkerFull) stringResource(R.string.settings_badge_full) else stringResource(R.string.settings_badge_add), + isFocused = focusedIndex == stalkerAddIndex, + onClick = { if (!stalkerFull) onAddStalkerPortal() }, + modifier = Modifier.settingsFocusSlot(stalkerAddIndex) + ) + Spacer(modifier = Modifier.height(6.dp)) + stalkerPortals.forEachIndexed { index, portal -> + val rowIndex = playlists.size + 2 + index + val focusRingColor = resolveAccentColor(fallback = Pink) + Row(modifier = Modifier.settingsFocusSlot(rowIndex).fillMaxWidth().background(if (focusedIndex == rowIndex) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == rowIndex) 2.dp else 0.dp, color = if (focusedIndex == rowIndex) focusRingColor else Color.Transparent, shape = RoundedCornerShape(12.dp)).clickable { onEditStalkerPortal(portal) }.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(portal.name, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = if (focusedIndex == rowIndex) TextPrimary else TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(modifier = Modifier.height(4.dp)) + Text(stringResource(R.string.settings_stalker_subtitle), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + CatalogActionChip( + icon = Icons.Default.List, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 0, + onClick = { onManageStalkerCategories(portal.id) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = if (portal.enabled) Icons.Default.Check else Icons.Default.VisibilityOff, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 1, + onClick = { onToggleStalkerPortal(portal.id) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.Edit, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 2, + onClick = { onEditStalkerPortal(portal) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.ArrowUpward, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 3, + onClick = { onMoveStalkerPortalUp(portal.id) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.ArrowDownward, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 4, + onClick = { onMoveStalkerPortalDown(portal.id) } + ) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip( + icon = Icons.Default.Delete, + isFocused = focusedIndex == rowIndex && focusedActionIndex == 5, + isDestructive = true, + onClick = { onRemoveStalkerPortal(portal.id) } + ) + } + Spacer(modifier = Modifier.height(10.dp)) + } + Spacer(modifier = Modifier.height(6.dp)) + val trailingBase = playlists.size + stalkerPortals.size + 2 SettingsRow( icon = Icons.Default.List, title = stringResource(R.string.settings_iptv_channel_order), @@ -7083,7 +7281,7 @@ private fun IptvSettings( "name" -> stringResource(R.string.settings_iptv_order_name) else -> stringResource(R.string.settings_iptv_order_provider) }, - isFocused = focusedIndex == playlists.size + 2, + isFocused = focusedIndex == trailingBase, onClick = { val next = when (sortOrder) { "provider" -> "number" @@ -7092,13 +7290,14 @@ private fun IptvSettings( } onSortOrderChange(next) }, - modifier = Modifier.settingsFocusSlot(playlists.size + 2) + modifier = Modifier.settingsFocusSlot(trailingBase) ) Spacer(modifier = Modifier.height(16.dp)) val refreshSubtitle = when { isLoading -> stringResource(R.string.settings_refreshing_channels_epg); error != null -> error; playlists.none { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() } -> stringResource(R.string.settings_reload_playlists_now); else -> stringResource(R.string.settings_reload_playlist_epg_now) } - SettingsRow(icon = Icons.Default.Link, title = stringResource(R.string.refresh_iptv), subtitle = refreshSubtitle, value = if (isLoading) stringResource(R.string.settings_badge_loading) else stringResource(R.string.settings_badge_refresh), isFocused = focusedIndex == playlists.size + 3, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(playlists.size + 3)) + SettingsRow(icon = Icons.Default.Link, title = stringResource(R.string.refresh_iptv), subtitle = refreshSubtitle, value = if (isLoading) stringResource(R.string.settings_badge_loading) else stringResource(R.string.settings_badge_refresh), isFocused = focusedIndex == trailingBase + 1, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(trailingBase + 1)) Spacer(modifier = Modifier.height(16.dp)) - SettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (playlists.isEmpty()) stringResource(R.string.settings_no_playlists_configured) else stringResource(R.string.settings_remove_playlists_epg), value = if (playlists.isEmpty()) stringResource(R.string.settings_badge_empty) else stringResource(R.string.settings_badge_delete), isFocused = focusedIndex == playlists.size + 4, onClick = onDelete, modifier = Modifier.settingsFocusSlot(playlists.size + 4)) + val hasNoSources = playlists.isEmpty() && stalkerPortals.isEmpty() + SettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (hasNoSources) stringResource(R.string.settings_no_playlists_configured) else stringResource(R.string.settings_remove_playlists_epg), value = if (hasNoSources) stringResource(R.string.settings_badge_empty) else stringResource(R.string.settings_badge_delete), isFocused = focusedIndex == trailingBase + 2, onClick = onDelete, modifier = Modifier.settingsFocusSlot(trailingBase + 2)) if (isLoading && !progressText.isNullOrBlank()) { Spacer(modifier = Modifier.height(12.dp)) Text(stringResource(R.string.settings_progress_format, progressText, progressPercent.coerceIn(0, 100)), style = ArflixTypography.caption, color = TextSecondary) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 6d7ad1285..2c6012504 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -239,15 +239,7 @@ data class SettingsUiState( val subtitleRemoveHearingImpaired: Boolean = true, val aiKeyServerState: AiKeyServerState = AiKeyServerState(), val smoothScrolling: Boolean = true -) { - /** - * Legacy compatibility: the first Stalker portal's URL. The Settings UI - * (still single-row in Session 1) reads this so it keeps working unchanged - * while the underlying model is now a list. Session 2 will replace these. - */ - val iptvStalkerUrl: String get() = iptvStalkerPortals.firstOrNull()?.portalUrl.orEmpty() - val iptvStalkerMac: String get() = iptvStalkerPortals.firstOrNull()?.macAddress.orEmpty() -} +) @HiltViewModel class SettingsViewModel @Inject constructor( @@ -890,22 +882,33 @@ class SettingsViewModel @Inject constructor( } private suspend fun loadIptvGroupsForPlaylist(playlistId: String): List { - val pagedGroups = withContext(Dispatchers.IO) { - iptvRepository.pagedPlaylistGroupCounts() - .asSequence() - .filter { (id, _, count) -> id == playlistId && count > 0 } - .map { (_, group, _) -> group.trim().ifBlank { "Ungrouped" } } - .distinct() - .toList() + // Stalker channel ids use the `stalker::` shape, so the + // paged channel store reports them under playlist_id "stalker" (the text + // before the first colon) — not the individual portal id. For Stalker + // portals we therefore skip the paged path and read groups from the + // in-memory snapshot, filtering by the full `stalker::` prefix. + val stalkerPortalIds = _uiState.value.iptvStalkerPortals.map { it.id }.toSet() + val isStalkerPortal = playlistId in stalkerPortalIds + + if (!isStalkerPortal) { + val pagedGroups = withContext(Dispatchers.IO) { + iptvRepository.pagedPlaylistGroupCounts() + .asSequence() + .filter { (id, _, count) -> id == playlistId && count > 0 } + .map { (_, group, _) -> group.trim().ifBlank { "Ungrouped" } } + .distinct() + .toList() + } + if (pagedGroups.isNotEmpty()) return pagedGroups } - if (pagedGroups.isNotEmpty()) return pagedGroups val snapshot = iptvRepository.getMemoryCachedSnapshot() ?: iptvRepository.getCachedSnapshotOrNull() + val prefix = if (isStalkerPortal) "stalker:$playlistId:" else "$playlistId:" return withContext(Dispatchers.Default) { snapshot?.channels ?.asSequence() - ?.filter { it.id.startsWith("$playlistId:") } + ?.filter { it.id.startsWith(prefix) } ?.map { it.group.trim().ifBlank { "Ungrouped" } } ?.distinct() ?.toList() @@ -2314,37 +2317,136 @@ class SettingsViewModel @Inject constructor( } } - fun saveStalkerConfig(portalUrl: String, macAddress: String) { + /** + * Add a new Stalker portal at the end of the list (capped at + * [IptvRepository.MAX_STALKER_PORTALS]). Returns false (with a toast) when + * the limit is reached or the URL/MAC are blank. + */ + fun onAddStalkerPortal(portalUrl: String, macAddress: String, name: String? = null) { + val trimmedUrl = portalUrl.trim().trimEnd('/') + val trimmedMac = macAddress.trim().uppercase() + if (trimmedUrl.isBlank() || trimmedMac.isBlank()) { + _uiState.value = _uiState.value.copy( + toastMessage = "Portal URL and MAC address are required", + toastType = ToastType.ERROR + ) + return + } + val current = _uiState.value.iptvStalkerPortals + if (current.size >= IptvRepository.MAX_STALKER_PORTALS) { + _uiState.value = _uiState.value.copy( + toastMessage = "Maximum number of Stalker portals reached", + toastType = ToastType.ERROR + ) + return + } + val index = current.size + val portal = StalkerPortalEntry( + id = "stalker${index + 1}", + name = name?.trim()?.ifBlank { null } ?: "Portal ${index + 1}", + portalUrl = trimmedUrl, + macAddress = trimmedMac + ) + persistStalkerPortals(current + portal) + } + + /** + * Update an existing portal's URL/MAC (and optionally its name). The edit + * dialog calls this with the portal's id. + */ + fun onEditStalkerPortal(portalId: String, portalUrl: String, macAddress: String, name: String? = null) { + val trimmedUrl = portalUrl.trim().trimEnd('/') + val trimmedMac = macAddress.trim().uppercase() + if (trimmedUrl.isBlank() || trimmedMac.isBlank()) { + _uiState.value = _uiState.value.copy( + toastMessage = "Portal URL and MAC address are required", + toastType = ToastType.ERROR + ) + return + } + val updated = _uiState.value.iptvStalkerPortals.map { portal -> + if (portal.id == portalId) portal.copy( + portalUrl = trimmedUrl, + macAddress = trimmedMac, + name = name?.trim()?.ifBlank { portal.name } ?: portal.name + ) else portal + } + if (updated == _uiState.value.iptvStalkerPortals) return + persistStalkerPortals(updated) + } + + /** + * Rename a portal without touching its URL/MAC. + */ + fun onRenameStalkerPortal(portalId: String, name: String) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + val updated = _uiState.value.iptvStalkerPortals.map { portal -> + if (portal.id == portalId) portal.copy(name = trimmed) else portal + } + if (updated == _uiState.value.iptvStalkerPortals) return viewModelScope.launch { - val trimmedUrl = portalUrl.trim() - val trimmedMac = macAddress.trim() - if (trimmedUrl.isBlank() && trimmedMac.isBlank()) { - removeStalkerConfigInternal() - return@launch - } - if (trimmedUrl.isBlank() || trimmedMac.isBlank()) { - _uiState.value = _uiState.value.copy(toastMessage = "Portal URL and MAC address are required", toastType = ToastType.ERROR) - return@launch - } - iptvRepository.saveStalkerConfig(trimmedUrl, trimmedMac) + iptvRepository.saveStalkerPortals(updated) + _uiState.value = _uiState.value.copy(iptvStalkerPortals = updated) syncLocalStateToCloud(silent = true) - refreshIptv(showToast = true, configured = true, force = true) } } - fun removeStalkerConfig() { - viewModelScope.launch { removeStalkerConfigInternal() } + /** Enable / disable a portal. */ + fun onToggleStalkerPortal(portalId: String) { + val updated = _uiState.value.iptvStalkerPortals.map { portal -> + if (portal.id == portalId) portal.copy(enabled = !portal.enabled) else portal + } + if (updated == _uiState.value.iptvStalkerPortals) return + persistStalkerPortals(updated) } - private suspend fun removeStalkerConfigInternal() { - iptvRepository.clearStalkerConfig() - _uiState.value = _uiState.value.copy( - iptvStalkerPortals = emptyList(), - toastMessage = "Stalker portal removed", - toastType = ToastType.SUCCESS - ) - syncLocalStateToCloud(silent = true) - refreshIptv(showToast = false, configured = true, force = true) + fun onMoveStalkerPortalUp(portalId: String) { + val current = _uiState.value.iptvStalkerPortals.toMutableList() + val idx = current.indexOfFirst { it.id == portalId } + if (idx <= 0) return + val item = current.removeAt(idx) + current.add(idx - 1, item) + persistStalkerPortals(current) + } + + fun onMoveStalkerPortalDown(portalId: String) { + val current = _uiState.value.iptvStalkerPortals.toMutableList() + val idx = current.indexOfFirst { it.id == portalId } + if (idx !in 0 until current.lastIndex) return + val item = current.removeAt(idx) + current.add(idx + 1, item) + persistStalkerPortals(current) + } + + fun onRemoveStalkerPortal(portalId: String) { + val updated = _uiState.value.iptvStalkerPortals.filterNot { it.id == portalId } + if (updated == _uiState.value.iptvStalkerPortals) return + persistStalkerPortals(updated) + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + toastMessage = "Stalker portal removed", + toastType = ToastType.SUCCESS + ) + } + } + + /** + * Open the categories dialog for a specific Stalker portal. Each portal has + * its own independent group set (decision #3) — the portal id is used as + * the playlist id for [PlaylistGroupKey] and hidden-group filtering. + */ + fun onManageStalkerCategories(portalId: String) { + setIptvSelectedPlaylistId(portalId) + } + + private fun persistStalkerPortals(portals: List) { + viewModelScope.launch { + iptvRepository.saveStalkerPortals(portals) + _uiState.value = _uiState.value.copy(iptvStalkerPortals = portals) + syncLocalStateToCloud(silent = true) + refreshIptv(showToast = true, configured = true, force = true) + } } /** diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78039a48a..18b995582 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -747,6 +747,19 @@ Remove portal Manage Stalker categories Edit Stalker portal + STALKER PORTALS + Add Stalker portal + Add up to 3 Stalker / Ministra portals + Add another Stalker portal + Stalker + Portal name + e.g. Portal 1 + Enable or disable Stalker portal + Move Stalker portal up + Move Stalker portal down + Remove Stalker portal + Rename portal + Maximum of 3 Stalker portals reached Default Enter %1$s... 12-hour diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt index 2f589c46c..b2b8b6870 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/StalkerPortalSupportTest.kt @@ -138,4 +138,41 @@ class StalkerPortalSupportTest { val portalIds = ids.mapNotNull { StalkerPortalSupport.portalIdFromChannelId(it) } assertThat(portalIds).containsExactly("stalker1", "stalker2", "stalker3").inOrder() } + + @Test + fun normalizeStalkerPortalEntryTrimsUrlAndUppercasesMac() { + val portal = StalkerPortalEntry("stalker1", "Portal 1", " http://a/ ", " 00:1a:79:aa:bb:cc ") + val normalized = StalkerPortalSupport.normalizeStalkerPortalEntry(portal, 0) + assertThat(normalized).isNotNull() + assertThat(normalized!!.portalUrl).isEqualTo("http://a") + assertThat(normalized.macAddress).isEqualTo("00:1A:79:AA:BB:CC") + assertThat(normalized.enabled).isTrue() + } + + @Test + fun normalizeStalkerPortalEntryAssignsDefaultIdAndNameByIndex() { + val portal = StalkerPortalEntry("", "", "http://a/", "00:1A:79:AA:BB:CC") + val normalized = StalkerPortalSupport.normalizeStalkerPortalEntry(portal, 2) + assertThat(normalized).isNotNull() + assertThat(normalized!!.id).isEqualTo("stalker3") + assertThat(normalized.name).isEqualTo("Portal 3") + } + + @Test + fun normalizeStalkerPortalEntryReturnsNullForBlankFields() { + assertThat(StalkerPortalSupport.normalizeStalkerPortalEntry( + StalkerPortalEntry("stalker1", "Portal 1", "", "00:1A:79:AA:BB:CC"), 0 + )).isNull() + assertThat(StalkerPortalSupport.normalizeStalkerPortalEntry( + StalkerPortalEntry("stalker1", "Portal 1", "http://a/", ""), 0 + )).isNull() + } + + @Test + fun normalizeStalkerPortalEntryPreservesEnabledFlag() { + val portal = StalkerPortalEntry("stalker1", "Portal 1", "http://a/", "00:1A:79:AA:BB:CC", enabled = false) + val normalized = StalkerPortalSupport.normalizeStalkerPortalEntry(portal, 0) + assertThat(normalized).isNotNull() + assertThat(normalized!!.enabled).isFalse() + } } diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/settings/SettingsIptvGroupOrderTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/settings/SettingsIptvGroupOrderTest.kt index 667e16d34..51eb2ef4a 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/settings/SettingsIptvGroupOrderTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/settings/SettingsIptvGroupOrderTest.kt @@ -17,3 +17,47 @@ class SettingsIptvGroupOrderTest { assertThat(ordered).doesNotContain("[B] Kids") } } + +class StalkerDpadIndexTest { + + @Test + fun iptvRowMaxActionIsAlwaysFive() { + // Both M3U playlist rows and Stalker portal rows carry the same full + // chip row (categories / toggle / edit / up / down / delete). + assertThat(iptvRowMaxAction()).isEqualTo(5) + } + + @Test + fun firstIptvGroupIndexStartsAtOneForM3UPlaylists() { + assertThat( + firstIptvGroupIndex("list_1", listOf("Movies", "Kids")) + ).isEqualTo(1) + } + + @Test + fun firstIptvGroupIndexStartsAtTwoForStalkerPortals() { + // Each Stalker portal gets a bulk-toggle row at index 1. + assertThat( + firstIptvGroupIndex("stalker1", listOf("Movies", "Kids"), setOf("stalker1", "stalker2")) + ).isEqualTo(2) + assertThat( + firstIptvGroupIndex("stalker2", listOf("News"), setOf("stalker1", "stalker2")) + ).isEqualTo(2) + } + + @Test + fun firstIptvGroupIndexIsOneWhenGroupsEmptyEvenForStalker() { + // No bulk-toggle row when there are no groups to toggle. + assertThat( + firstIptvGroupIndex("stalker1", emptyList(), setOf("stalker1")) + ).isEqualTo(1) + } + + @Test + fun firstIptvGroupIndexIsOneForUnknownStalkerPlaylistId() { + // Legacy STALKER_PLAYLIST_ID ("stalker") still gets bulk toggle. + assertThat( + firstIptvGroupIndex("stalker", listOf("Movies"), emptySet()) + ).isEqualTo(2) + } +} From 00e6510a204937c88bba69773890fe167e763120 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 14:32:58 +0000 Subject: [PATCH 11/16] fix(iptv): move STALKER_PLAYLIST_ID/MAX_STALKER_PORTALS to top-level constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two companion objects in one class is illegal in Kotlin — the compiler silently dropped the private one, making MULTI_SPACE_REGEX/HTTP_URL_REGEX unresolved. Move the public constants to top-level in StalkerPortalSupport.kt and keep the private companion object intact. Co-authored-by: openhands --- .../com/arflix/tv/data/repository/IptvRepository.kt | 8 -------- .../com/arflix/tv/data/repository/StalkerPortalSupport.kt | 6 ++++++ .../com/arflix/tv/ui/screens/settings/SettingsScreen.kt | 3 +-- .../arflix/tv/ui/screens/settings/SettingsViewModel.kt | 5 +++-- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index f6a0d1e5d..28f895f7b 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -8968,14 +8968,6 @@ class IptvRepository @Inject constructor( // ════════════════════════════════════════════════════════════════════════ - companion object { - /** Pseudo playlist id for the Stalker/Ministra portal source. */ - const val STALKER_PLAYLIST_ID = "stalker" - - /** Maximum number of Stalker portals a user can configure. */ - const val MAX_STALKER_PORTALS = 3 - } - private companion object { private val DURATION_SCALE_REGEX = Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""") private val URL_QUERY_SECRETS_REGEX = Regex("""(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""") diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt index c53394893..1311e9eb1 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StalkerPortalSupport.kt @@ -3,6 +3,12 @@ package com.arflix.tv.data.repository import com.google.gson.Gson import com.google.gson.reflect.TypeToken +/** Pseudo playlist id for the Stalker/Ministra portal source. */ +const val STALKER_PLAYLIST_ID = "stalker" + +/** Maximum number of Stalker portals a user can configure. */ +const val MAX_STALKER_PORTALS = 3 + /** * Pure helpers for the Stalker multi-portal model. Kept dependency-free so they * can be unit-tested without an Android context. diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 4b86694cd..b4a18a3b7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -192,7 +192,7 @@ import com.arflix.tv.data.model.RuntimeKind import com.arflix.tv.data.repository.HomeServerConnection import com.arflix.tv.data.repository.HomeServerKind import com.arflix.tv.data.repository.IptvPlaylistEntry -import com.arflix.tv.data.repository.IptvRepository +import com.arflix.tv.data.repository.STALKER_PLAYLIST_ID import com.arflix.tv.data.repository.StalkerPortalEntry import com.arflix.tv.ui.components.AppTopBar import com.arflix.tv.ui.components.AppTopBarContentTopInset @@ -248,7 +248,6 @@ private const val PRIVACY_POLICY_URL = "https://arvio.tv/privacy" * playlist lets it reuse the same group-management UI as M3U playlists * (categories / hide / sort / bulk toggle) without a separate code path. */ -private const val STALKER_PLAYLIST_ID = IptvRepository.STALKER_PLAYLIST_ID private val tvGeneralSectionIds = setOf( "language", diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 2c6012504..b2f41820b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -34,6 +34,7 @@ import com.arflix.tv.data.repository.HomeServerRepository import com.arflix.tv.data.repository.PlexPinAuthSession import com.arflix.tv.data.repository.IptvConfig import com.arflix.tv.data.repository.IptvRepository +import com.arflix.tv.data.repository.MAX_STALKER_PORTALS import com.arflix.tv.data.repository.normalizeIptvSortOrder import com.arflix.tv.data.repository.IptvPlaylistEntry import com.arflix.tv.data.repository.StalkerPortalEntry @@ -2319,7 +2320,7 @@ class SettingsViewModel @Inject constructor( /** * Add a new Stalker portal at the end of the list (capped at - * [IptvRepository.MAX_STALKER_PORTALS]). Returns false (with a toast) when + * [MAX_STALKER_PORTALS]). Returns false (with a toast) when * the limit is reached or the URL/MAC are blank. */ fun onAddStalkerPortal(portalUrl: String, macAddress: String, name: String? = null) { @@ -2333,7 +2334,7 @@ class SettingsViewModel @Inject constructor( return } val current = _uiState.value.iptvStalkerPortals - if (current.size >= IptvRepository.MAX_STALKER_PORTALS) { + if (current.size >= MAX_STALKER_PORTALS) { _uiState.value = _uiState.value.copy( toastMessage = "Maximum number of Stalker portals reached", toastType = ToastType.ERROR From c9649c42f986f52aa75ab9a9b51d2887248cb90f Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 14:38:09 +0000 Subject: [PATCH 12/16] fix(iptv): place Stalker dialogs in correct composable scope MobileSettingsSubPage has its own local Stalker state vars; the input/rename dialogs must live in that function, not in MobileCloudAccountSubPage where the vars are unresolved. Co-authored-by: openhands --- .../tv/ui/screens/settings/SettingsScreen.kt | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index b4a18a3b7..f7e13ecc4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -4797,6 +4797,61 @@ private fun MobileSettingsSubPage( } } } + + if (showStalkerInput) { + val isEditing = stalkerEditId != null + InputModal( + title = stringResource(if (isEditing) R.string.settings_cd_edit_stalker_config else R.string.settings_add_stalker_portal_button), + fields = listOf( + InputField( + label = stringResource(R.string.settings_stalker_portal_name_label), + value = stalkerEditName, + placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), + onValueChange = { stalkerEditName = it } + ), + InputField( + label = stringResource(R.string.settings_stalker_label_portal_url), + value = stalkerEditPortal, + placeholder = stringResource(R.string.settings_stalker_ph_portal_url), + onValueChange = { stalkerEditPortal = it } + ), + InputField( + label = stringResource(R.string.settings_stalker_label_mac), + value = stalkerEditMac, + placeholder = stringResource(R.string.settings_stalker_ph_mac), + onValueChange = { stalkerEditMac = it } + ) + ), + onConfirm = { + val id = stalkerEditId + if (id != null) { + viewModel.onEditStalkerPortal(id, stalkerEditPortal, stalkerEditMac, stalkerEditName) + } else { + viewModel.onAddStalkerPortal(stalkerEditPortal, stalkerEditMac, stalkerEditName) + } + showStalkerInput = false + }, + onDismiss = { showStalkerInput = false } + ) + } + if (showStalkerRename) { + InputModal( + title = stringResource(R.string.settings_stalker_rename_title), + fields = listOf( + InputField( + label = stringResource(R.string.settings_stalker_portal_name_label), + value = stalkerRenameName, + placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), + onValueChange = { stalkerRenameName = it } + ) + ), + onConfirm = { + viewModel.onRenameStalkerPortal(stalkerRenameId, stalkerRenameName) + showStalkerRename = false + }, + onDismiss = { showStalkerRename = false } + ) + } } @Composable @@ -4916,61 +4971,6 @@ private fun MobileCloudAccountSubPage( ) } - if (showStalkerInput) { - val isEditing = stalkerEditId != null - InputModal( - title = stringResource(if (isEditing) R.string.settings_cd_edit_stalker_config else R.string.settings_add_stalker_portal_button), - fields = listOf( - InputField( - label = stringResource(R.string.settings_stalker_portal_name_label), - value = stalkerEditName, - placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), - onValueChange = { stalkerEditName = it } - ), - InputField( - label = stringResource(R.string.settings_stalker_label_portal_url), - value = stalkerEditPortal, - placeholder = stringResource(R.string.settings_stalker_ph_portal_url), - onValueChange = { stalkerEditPortal = it } - ), - InputField( - label = stringResource(R.string.settings_stalker_label_mac), - value = stalkerEditMac, - placeholder = stringResource(R.string.settings_stalker_ph_mac), - onValueChange = { stalkerEditMac = it } - ) - ), - onConfirm = { - val id = stalkerEditId - if (id != null) { - viewModel.onEditStalkerPortal(id, stalkerEditPortal, stalkerEditMac, stalkerEditName) - } else { - viewModel.onAddStalkerPortal(stalkerEditPortal, stalkerEditMac, stalkerEditName) - } - showStalkerInput = false - }, - onDismiss = { showStalkerInput = false } - ) - } - if (showStalkerRename) { - InputModal( - title = stringResource(R.string.settings_stalker_rename_title), - fields = listOf( - InputField( - label = stringResource(R.string.settings_stalker_portal_name_label), - value = stalkerRenameName, - placeholder = stringResource(R.string.settings_stalker_portal_name_placeholder), - onValueChange = { stalkerRenameName = it } - ) - ), - onConfirm = { - viewModel.onRenameStalkerPortal(stalkerRenameId, stalkerRenameName) - showStalkerRename = false - }, - onDismiss = { showStalkerRename = false } - ) - } - // Section 2: ACCOUNT PROFILE & STATUS MobileSettingsCategory(title = stringResource(R.string.cloud_account).uppercase()) { if (uiState.isLoggedIn) { From 2114198713152d21b530c073590ebf7ddadbce79 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 16:55:30 +0000 Subject: [PATCH 13/16] fix(iptv): match mobile Stalker portal row to M3U style The mobile Stalker portal row previously rendered the full TV chip row (categories, toggle, edit, rename, up, down, delete = 7 controls), causing overflow/clipping on touch devices. Mirror the M3U mobile row instead: show only the category selector and enable/disable toggle, with tap-to-edit and long-press selection mode for reorder (drag handle) and delete. TV rows and D-pad index maps are unchanged. --- .../tv/ui/screens/settings/SettingsScreen.kt | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index f7e13ecc4..1ff24a803 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -6969,6 +6969,8 @@ private fun IptvSettings( val isMobile = LocalDeviceType.current.isTouchDevice() var selectionMode by remember { mutableStateOf(false) } var selectedIndices by remember { mutableStateOf(setOf()) } + // Tracks which section the current mobile selection belongs to ("m3u" or "stalker") + var selectionSection by remember { mutableStateOf("m3u") } if (!isMobile) { LaunchedEffect(focusedIndex) { @@ -6988,7 +6990,16 @@ private fun IptvSettings( Text(stringResource(R.string.settings_n_selected, selectedIndices.size), style = ArflixTypography.sectionTitle, color = TextPrimary) Spacer(modifier = Modifier.weight(1f)) if (selectedIndices.isNotEmpty()) { - Box(modifier = Modifier.size(36.dp).clickable { selectedIndices.sortedDescending().forEach { onDeletePlaylist(it) }; selectionMode = false; selectedIndices = emptySet() }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.size(36.dp).clickable { + selectedIndices.sortedDescending().forEach { i -> + if (selectionSection == "stalker") { + stalkerPortals.getOrNull(i)?.let { onRemoveStalkerPortal(it.id) } + } else { + onDeletePlaylist(i) + } + } + selectionMode = false; selectedIndices = emptySet() + }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete), tint = Color.White, modifier = Modifier.size(20.dp)) } Spacer(modifier = Modifier.width(12.dp)) @@ -7009,7 +7020,7 @@ private fun IptvSettings( .background(if (isSelected) Pink.copy(alpha = 0.15f) else Color.Transparent) .then(Modifier.combinedClickable( onClick = { if (selectionMode) { selectedIndices = if (isSelected) selectedIndices - index else selectedIndices + index; if (selectedIndices.isEmpty()) selectionMode = false } else onEditPlaylist(index) }, - onLongClick = { if (!selectionMode) { selectionMode = true; selectedIndices = setOf(index) } } + onLongClick = { if (!selectionMode) { selectionSection = "m3u"; selectionMode = true; selectedIndices = setOf(index) } } )) .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically @@ -7065,52 +7076,48 @@ private fun IptvSettings( onClick = { if (!stalkerFull) onAddStalkerPortal() } ) stalkerPortals.forEachIndexed { index, portal -> + val isSelected = selectedIndices.contains(index) Column { Row( modifier = Modifier.fillMaxWidth() - .clickable { onEditStalkerPortal(portal) } + .background(if (isSelected) Pink.copy(alpha = 0.15f) else Color.Transparent) + .then(Modifier.combinedClickable( + onClick = { if (selectionMode) { selectedIndices = if (isSelected) selectedIndices - index else selectedIndices + index; if (selectedIndices.isEmpty()) selectionMode = false } else onEditStalkerPortal(portal) }, + onLongClick = { if (!selectionMode) { selectionSection = "stalker"; selectionMode = true; selectedIndices = setOf(index) } } + )) .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon(imageVector = Icons.Default.Tv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(16.dp)) + if (selectionMode) { + Icon(imageVector = if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, contentDescription = null, tint = if (isSelected) Pink else TextSecondary, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) + } else { + Icon(imageVector = Icons.Default.Tv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) + } Column(modifier = Modifier.weight(1f)) { Text(portal.name, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(stringResource(R.string.settings_stalker_subtitle), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) } - Icon( - imageVector = Icons.Default.List, - contentDescription = stringResource(R.string.settings_cd_manage_stalker_categories), - tint = TextSecondary, - modifier = Modifier - .size(36.dp) - .clickable { onManageStalkerCategories(portal.id) } - .padding(6.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - // Enable/disable toggle chip (same style as M3U) - Box(modifier = Modifier.width(44.dp).height(24.dp).background(color = if (portal.enabled) SuccessGreen else Color.White.copy(alpha = 0.2f), shape = RoundedCornerShape(13.dp)).clickable { onToggleStalkerPortal(portal.id) }.padding(3.dp), contentAlignment = if (portal.enabled) Alignment.CenterEnd else Alignment.CenterStart) { - Box(modifier = Modifier.size(18.dp).background(color = Color.White, shape = RoundedCornerShape(10.dp))) - } - Spacer(modifier = Modifier.width(12.dp)) - Box(modifier = Modifier.size(36.dp).clickable { onEditStalkerPortal(portal) }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { - Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.settings_cd_edit_stalker_config), tint = Color.White, modifier = Modifier.size(20.dp)) - } - Spacer(modifier = Modifier.width(8.dp)) - Box(modifier = Modifier.size(36.dp).clickable { onRenameStalkerPortal(portal) }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { - Icon(Icons.Default.TextFields, contentDescription = stringResource(R.string.settings_stalker_rename_title), tint = Color.White, modifier = Modifier.size(20.dp)) - } - Spacer(modifier = Modifier.width(8.dp)) - Box(modifier = Modifier.size(36.dp).clickable { onMoveStalkerPortalUp(portal.id) }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { - Icon(Icons.Default.ArrowUpward, contentDescription = stringResource(R.string.settings_cd_move_stalker_up), tint = Color.White, modifier = Modifier.size(20.dp)) - } - Spacer(modifier = Modifier.width(8.dp)) - Box(modifier = Modifier.size(36.dp).clickable { onMoveStalkerPortalDown(portal.id) }.background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { - Icon(Icons.Default.ArrowDownward, contentDescription = stringResource(R.string.settings_cd_move_stalker_down), tint = Color.White, modifier = Modifier.size(20.dp)) - } - Spacer(modifier = Modifier.width(12.dp)) - Box(modifier = Modifier.size(36.dp).clickable { onRemoveStalkerPortal(portal.id) }.background(Color(0xFFDC2626), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { - Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.settings_cd_remove_stalker_portal), tint = Color.White, modifier = Modifier.size(20.dp)) + if (selectionMode && selectedIndices.size == 1 && isSelected) { + Icon(imageVector = Icons.Default.DragHandle, contentDescription = stringResource(R.string.settings_cd_drag_reorder), tint = TextSecondary, modifier = Modifier.size(24.dp).pointerInput(index) { + var dragOffset = 0f; val itemHeight = 64.dp.toPx() + detectVerticalDragGestures(onDragEnd = { dragOffset = 0f }, onDragCancel = { dragOffset = 0f }) { change, dragAmount -> change.consume(); dragOffset += dragAmount; if (dragOffset > itemHeight) { onMoveStalkerPortalDown(portal.id); dragOffset -= itemHeight } else if (dragOffset < -itemHeight) { onMoveStalkerPortalUp(portal.id); dragOffset += itemHeight } } + }) + } else if (!selectionMode) { + Icon( + imageVector = Icons.Default.List, + contentDescription = stringResource(R.string.settings_cd_manage_stalker_categories), + tint = TextSecondary, + modifier = Modifier + .size(36.dp) + .clickable { onManageStalkerCategories(portal.id) } + .padding(6.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Box(modifier = Modifier.width(44.dp).height(24.dp).background(color = if (portal.enabled) SuccessGreen else Color.White.copy(alpha = 0.2f), shape = RoundedCornerShape(13.dp)).clickable { onToggleStalkerPortal(portal.id) }.padding(3.dp), contentAlignment = if (portal.enabled) Alignment.CenterEnd else Alignment.CenterStart) { + Box(modifier = Modifier.size(18.dp).background(color = Color.White, shape = RoundedCornerShape(10.dp))) + } } } if (index < stalkerPortals.size - 1) { From 3b3f72650f17ca6072f42701c59dda71a699e24c Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 17:32:51 +0000 Subject: [PATCH 14/16] fix(iptv): hide Stalker portal groups by portal id on touch UI Hidden groups for Stalker portals are stored under the portal id (e.g. `stalker1|group`), but the touch channel list derived the playlist id from `channel.id.substringBefore(':')`, which yields the literal `stalker` prefix for `stalker::` ids. The resulting `stalker|group` key never matched the stored `stalker1|group` key, so hidden Stalker groups stayed visible on phones. Resolve the playlist id for hidden-group keys via the Stalker portal segment (reusing StalkerPortalSupport.portalIdFromChannelId) and fall back to substringBefore(':') for M3U/Xtream ids. Apply this in buildCategoryIndex/buildCategoryTree/buildPagedStartupChannelState and the search filter, leaving category-id/sidebar keys unchanged. Also match the mobile Stalker row to M3U: LiveTv leading icon and the portal URL as subtitle. --- .../tv/ui/screens/settings/SettingsScreen.kt | 4 +-- .../tv/ui/screens/tv/live/LiveCategory.kt | 29 +++++++++++++++---- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 4 ++- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 1ff24a803..02aec397a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -7092,12 +7092,12 @@ private fun IptvSettings( Icon(imageVector = if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, contentDescription = null, tint = if (isSelected) Pink else TextSecondary, modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.width(16.dp)) } else { - Icon(imageVector = Icons.Default.Tv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) + Icon(imageVector = Icons.Default.LiveTv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.width(16.dp)) } Column(modifier = Modifier.weight(1f)) { Text(portal.name, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(stringResource(R.string.settings_stalker_subtitle), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(portal.portalUrl.take(56), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) } if (selectionMode && selectedIndices.size == 1 && isSelected) { Icon(imageVector = Icons.Default.DragHandle, contentDescription = stringResource(R.string.settings_cd_drag_reorder), tint = TextSecondary, modifier = Modifier.size(24.dp).pointerInput(index) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt index 6af31cc82..f0ed26474 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveCategory.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import com.arflix.tv.R import com.arflix.tv.data.model.IptvChannel +import com.arflix.tv.data.repository.StalkerPortalSupport /** Broad channel genre derived from M3U group name. */ enum class Genre { @@ -394,19 +395,32 @@ private fun playlistGroupLabel(group: String): String { return group.trim().ifBlank { "Ungrouped" } } +/** + * Resolves the playlist id used to build a hidden-group key (`playlistId|group`) + * for a given channel id. M3U/Xtream ids are shaped `:`, so + * the first segment is the playlist id. Stalker ids are shaped + * `stalker::`; hidden groups for Stalker portals are stored + * under the *portal* id (e.g. `stalker1|group`), not the literal `stalker` + * prefix, so the portal segment must be returned for the key to match. + */ +private fun hiddenGroupPlaylistId(channelId: String): String { + StalkerPortalSupport.portalIdFromChannelId(channelId)?.let { return it } + return channelId.substringBefore(':') +} + private fun playlistGroupKey(playlistId: String, group: String): String { return com.arflix.tv.data.model.PlaylistGroupKey.build(playlistId, playlistGroupLabel(group)) } fun isHiddenPlaylistGroup(channel: EnrichedChannel, hiddenGroups: Set): Boolean { if (hiddenGroups.isEmpty()) return false - val playlistId = channel.id.substringBefore(':') + val playlistId = hiddenGroupPlaylistId(channel.id) return playlistGroupKey(playlistId, channel.source.group) in hiddenGroups } private fun isHiddenPlaylistGroup(channel: IptvChannel, hiddenGroups: Set): Boolean { if (hiddenGroups.isEmpty()) return false - val playlistId = channel.id.substringBefore(':') + val playlistId = hiddenGroupPlaylistId(channel.id) return playlistGroupKey(playlistId, channel.group) in hiddenGroups } @@ -455,8 +469,9 @@ fun buildCategoryTree( channels.forEach { channel -> val playlistId = channel.id.substringBefore(':') + val hiddenPlaylistId = hiddenGroupPlaylistId(channel.id) val groupLabel = playlistGroupLabel(channel.source.group) - val groupKey = playlistGroupKey(playlistId, groupLabel) + val groupKey = playlistGroupKey(hiddenPlaylistId, groupLabel) val groupId = playlistGroupCategoryId(playlistId, channel.source.group) val targetCounts = if (groupKey in hiddenPlaylistGroups) hiddenPlaylistGroupCounts else playlistGroupCounts val groupCount = targetCounts[groupId]?.second ?: 0 @@ -631,9 +646,10 @@ fun buildCategoryTree( channels.forEach { channel -> val playlistId = channel.id.substringBefore(':') + val hiddenPlaylistId = hiddenGroupPlaylistId(channel.id) val traits = channel.traits() val groupLabel = playlistGroupLabel(channel.group) - val groupKey = playlistGroupKey(playlistId, groupLabel) + val groupKey = playlistGroupKey(hiddenPlaylistId, groupLabel) val groupId = playlistGroupCategoryId(playlistId, channel.group) val targetCounts = if (groupKey in hiddenPlaylistGroups) hiddenPlaylistGroupCounts else playlistGroupCounts val groupCount = targetCounts[groupId]?.second ?: 0 @@ -884,8 +900,9 @@ fun buildFastStartupChannelState( channels.forEachIndexed { index, rawChannel -> val channel = rawChannel.enrichForFastStartup(index + 1) val playlistId = rawChannel.id.substringBefore(':') + val hiddenPlaylistId = hiddenGroupPlaylistId(rawChannel.id) val groupLabel = playlistGroupLabel(rawChannel.group) - val groupKey = playlistGroupKey(playlistId, groupLabel) + val groupKey = playlistGroupKey(hiddenPlaylistId, groupLabel) val groupId = playlistGroupCategoryId(playlistId, rawChannel.group) val hidden = groupKey in hiddenPlaylistGroups @@ -1001,7 +1018,7 @@ fun buildPagedStartupChannelState( if (channel.id in favorites) add("fav", channel) if (channel.id in recents) add("recent", channel) val playlistId = channel.source.id.substringBefore(':') - val groupKey = playlistGroupKey(playlistId, channel.source.group) + val groupKey = playlistGroupKey(hiddenGroupPlaylistId(channel.source.id), channel.source.group) val groupId = playlistGroupCategoryId(playlistId, channel.source.group) add(groupId, channel) if (groupKey in hiddenPlaylistGroups) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt index ece1d9e61..fb9987597 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -2980,7 +2980,9 @@ fun LiveTvScreen( .asSequence() .filterNot { channel -> isAdultGroup(channel.group, channel.name) } .filterNot { channel -> - val playlistId = channel.id.substringBefore(':') + val playlistId = com.arflix.tv.data.repository.StalkerPortalSupport + .portalIdFromChannelId(channel.id) + ?: channel.id.substringBefore(':') val groupKey = com.arflix.tv.data.model.PlaylistGroupKey .build(playlistId, channel.group.ifBlank { "Ungrouped" }) groupKey in hiddenGroupSet From 4e8270ef3c0ac1e56c36a6ab7ee683e512552b23 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 18:20:41 +0000 Subject: [PATCH 15/16] feat(settings): add leading icon and portal URL to TV Stalker rows Align the TV Stalker portal row with the M3U row: a leading LiveTv icon and the portal URL as subtitle (instead of the literal "Stalker" label). Apply the same leading icon to the TV M3U row for visual parity. Icons are decorative, so the D-Pad index dispatch maps and focus slots are unchanged. Drop the now-unused settings_stalker_subtitle string resource. --- .../com/arflix/tv/ui/screens/settings/SettingsScreen.kt | 6 +++++- app/src/main/res/values/strings.xml | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 02aec397a..327b6f82f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -7170,6 +7170,8 @@ private fun IptvSettings( val epgSourceCount = playlist.settingsEpgInput().lineSequence().count { it.isNotBlank() } val focusRingColor = resolveAccentColor(fallback = Pink) Row(modifier = Modifier.settingsFocusSlot(rowIndex).fillMaxWidth().background(if (focusedIndex == rowIndex) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == rowIndex) 2.dp else 0.dp, color = if (focusedIndex == rowIndex) focusRingColor else Color.Transparent, shape = RoundedCornerShape(12.dp)).clickable { onEditPlaylist(index) }.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = Icons.Default.LiveTv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text(playlist.name, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = if (focusedIndex == rowIndex) TextPrimary else TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(4.dp)) @@ -7232,10 +7234,12 @@ private fun IptvSettings( val rowIndex = playlists.size + 2 + index val focusRingColor = resolveAccentColor(fallback = Pink) Row(modifier = Modifier.settingsFocusSlot(rowIndex).fillMaxWidth().background(if (focusedIndex == rowIndex) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == rowIndex) 2.dp else 0.dp, color = if (focusedIndex == rowIndex) focusRingColor else Color.Transparent, shape = RoundedCornerShape(12.dp)).clickable { onEditStalkerPortal(portal) }.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = Icons.Default.LiveTv, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text(portal.name, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = if (focusedIndex == rowIndex) TextPrimary else TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(4.dp)) - Text(stringResource(R.string.settings_stalker_subtitle), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(portal.portalUrl.take(56), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = TextSecondary.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis) } CatalogActionChip( icon = Icons.Default.List, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18b995582..a5ff4163d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -751,7 +751,6 @@ Add Stalker portal Add up to 3 Stalker / Ministra portals Add another Stalker portal - Stalker Portal name e.g. Portal 1 Enable or disable Stalker portal From 371c593394e88a369ff4cc43a4bb795b787230d8 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 21:41:56 +0000 Subject: [PATCH 16/16] chore(ci): remove test-apk workflow and signing key for upstream Drop the fork-only test-apk.yml workflow and the committed debug keystore under .github/test-signing/, plus the now-obsolete gitignore exception that whitelisted it. These were development-only helpers for building sideload debug APKs and must not ship in the upstream PR. --- .github/test-signing/debug.keystore | Bin 2786 -> 0 bytes .github/workflows/test-apk.yml | 46 ---------------------------- .gitignore | 3 -- 3 files changed, 49 deletions(-) delete mode 100644 .github/test-signing/debug.keystore delete mode 100644 .github/workflows/test-apk.yml diff --git a/.github/test-signing/debug.keystore b/.github/test-signing/debug.keystore deleted file mode 100644 index 5b74454de234bf793c8d0cdb49c72a7cbe274212..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2786 zcma);X*kpi`^RU-ER1bBGfWQ2p)4c$4KmYYsT})~C1r^bX6!s1Xv8f+QDu1tuzS;4&?t`pEMg<_j|~7PevF z#qh(1`*2f{f;ck;u?0o-Fi<1}z^1SaIv{&J6o6u8h z(eBnz9kS|PMel)4!r&gSG@y-^c^USA4>91UVb;vJCSKNY9#^SVrsyc5dsPnBn^)U5 z*IW9HUh@~^ldV{6PEKe|U98^*j87OQOGVaco(Vf$9w`;V?nU&wvUXgMR!Qtg*$)%J zVw>jNi^$r)(XR3b$U|m#B(Z*WqJi-?#`TJpnQq)xxgS1fDDHIEW_#p>GYL}DV5v>M zzCL~r;vR=Z3)b#_{2=^c$I!1T-QPvp28)|0D1k<3oN}<8oJ1Q zB=6fbK2f9SF>Xhj+MDE+ufFCMJ+SSlE5vVSKb@`O!@OX<@8is{&ZJ9`5f+@rS@8`7 z-Tw7c?F|2p#pB{B$8w%<_nU#lyT*@A<&*=J>-r~!(XNrEfNx$B-&(Oy(#(J{f-Q`NJRL6p1A zW$VImLMDPEdVjFq&RR*7zEfHW+;GY2Szd_l2!j$B$9q&jYW9YRZ}0Md91E?xCx(9^ z`c&){DoCn|p_v&R#f;XIoyR=b{hEu|!6NvP5pTMH@a1Vab@x17P2ljMn=%nHXLG<6 zaFO zkgi+m#rMbuDe5iuX)C(P*R#D0!drGe)gaxY-j}Z#;@%tEQBJAfzzQF}_r|znkouG1 zSzj=XII@f_E0N58wthTeTxsr()kcu^Fy;%YPiUhX8_P^eEf3bvQ&1_pWFqksT7IDu zka>*6r?hXLOTTkQ0u5~unuufUrFEY~2$LL4rVQ$R)I|uQn-T+=aEn_jf{j!HQRq}R z5>f|@m1V2VNx|+?EtnaCFRav(O(OF2sf4P6!m*o4hOC3=>L{_0ZTc~i3&94}vF_b4 z)skIb(H7HLZx=aAV75y*x^x|AuxBKh+G^n3PcSv?@yEZI2ZhI_(@;5rM+_fxy|JB3ALkQmsvj9aV|j;N z3b>^N)K4bf4mj~EF8MHcc}ZE&Wl#Vp7~}!E0#X891qJT+-XJd!WuGd6T=&%(6a;bu z{`kixg2zCAJMZB`k-`&*gcIsS6@n^3^&~)p7XMm8z>8?m%zcy!0fY7>;hzckzXdo) z!`aH5tH!?h`Xq_Az_#c&g17xI0Zvi$Ow!VHC2CBj8&_wFe~hL11uNB(qS6Vj`2C;a1prM^=fc>;hcm=O|vrx|6h+r@H^ULLY@W_K?)%xL37M zCD}PIZi%Xrq3}1FfemmB+D%m$A_vMe-3Di@9V!n+>fCX#D!jE-3craR+dDa`nIXJf zxlU{;4DHgmaR`jvC0 znNtx)xiJ?Q4Wj4L`CaB{&UbIfTbh&k;bQIPow97R95Tznh*dpt@$kuO8cg1rtZ7svNA?2uAHS$6GdVRq&cF~d3lB+ko zb0Ot29dEYEy>tS~Rf&QRj@L*Ve_f(JX%bN@HVESpP}_#r9?H`2VWbsXwJW8(0xs{l z`abqoo|Aj$kQ5&i{mEdZ_E+wMI;1ZFYFH@oY35ysSJBw6WD=Ie+9G zCH&gR$0gsltHmR!zVNqfRj+p6&GGKjs4$@7j$dVT$k^4JHp|mPhlD zjk&@0j1j@*X*6=)M(-({{6+7Y!Z}(&w~{k4S*^x=8M=B{A^uxjNNV8p6Jn0BL35aV zmY{;XBnENe&`71C9$w`?wF+_OHX<;ygV0iCouV^(Z7?U(Ct zt;C*=-oiTT#=8S|<{jmN1DlRkCKu*{1am)TfJEJ zTYu{l-f475&|II}Jj?d`*(ZA8=hF zOE(_%lPWF?$PbxLZ^bKo*QrJfoSr6OjzkER*j}@}V>J0B*wlM6Wck6o30)*bMEQ}T zfZ1PrKVm;@zB(wg^@w`Y?J=A(Wx48owKbtf{cAFRmyo@>bkZ#gZ4Trx5I%7k{d*Sa z{xDx+rd{$?+RG2R%EPKSZg_yaZmCR7s#Sw(j6kS${9|ETNKP`B0GI-JK=9Y!2LZ!C zP^mYR?K_HQs{E3oBX)y#y;M;2UdcROAW(K^XKpuT)(wiO7Qas#@b_{GH3>BI2z%N! L@?25=|03yMs}$@i diff --git a/.github/workflows/test-apk.yml b/.github/workflows/test-apk.yml deleted file mode 100644 index 3823af58e..000000000 --- a/.github/workflows/test-apk.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Build test APK (debug, parallel app id) - -on: - push: - branches: - - feat/stalker-live-config - workflow_dispatch: - -jobs: - debug-apk: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "17" - cache: gradle - - - name: Placeholder config - run: cp secrets.defaults.properties secrets.properties - - - name: Use committed debug keystore (stable signing key) - run: | - mkdir -p ~/.android - cp .github/test-signing/debug.keystore ~/.android/debug.keystore - echo "Using fixed debug keystore so test APKs can update in place." - - - name: Parallel-install app id (.stalkerlive) - run: sed -i 's/ debug {/ debug {\n applicationIdSuffix = ".stalkerlive"/' app/build.gradle.kts - - - name: Bump versionCode so Android allows in-place update - run: | - BUILD_NUMBER=${{ github.run_number }} - sed -i "s/versionCode = [0-9]*/versionCode = $((310 + BUILD_NUMBER))/" app/build.gradle.kts - echo "Bumped versionCode to $((310 + BUILD_NUMBER)) for run $BUILD_NUMBER" - - - name: Build debug APK - run: ./gradlew assembleSideloadDebug --no-daemon - - - name: Upload APK - uses: actions/upload-artifact@v4 - with: - name: arvio-stalkerlive-test - path: app/build/outputs/apk/sideload/debug/*.apk diff --git a/.gitignore b/.gitignore index 271e7f177..24dd7d59e 100644 --- a/.gitignore +++ b/.gitignore @@ -86,9 +86,6 @@ release.keystore upload.keystore *.jks *.keystore -# Exception: the committed debug keystore for test APKs (stable signing key -# so CI test builds can update in place; removed before the upstream PR). -!.github/test-signing/debug.keystore # Temporary files *.tmp