feat(iptv): support multiple configurable Stalker portals with per-portal groups - #607
feat(iptv): support multiple configurable Stalker portals with per-portal groups#607ReichiMD wants to merge 16 commits into
Conversation
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 <openhands@all-hands.dev>
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 <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
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<StalkerPortalEntry>
- 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<portalId, StalkerApi>
- resolveStalkerStreamUrl(channelId, command) routes by stalker:<portalId>:
prefix; falls back to the first portal for legacy callers
- Channel ids prefixed stalker:<portalId>:<origId> 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 <openhands@all-hands.dev>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<String, StalkerApi?, List<IptvChannel>> and emptyMap/emptyList fallbacks explicit so compileSideloadDebugKotlin succeeds. Co-authored-by: openhands <openhands@all-hands.dev>
…date 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 <openhands@all-hands.dev>
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 <openhands@all-hands.dev>
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 <openhands@all-hands.dev>
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.
… categories 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:<portalId>: 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 <openhands@all-hands.dev>
…constants 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 <openhands@all-hands.dev>
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 <openhands@all-hands.dev>
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.
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:<portalId>:<origId>` 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.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.
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.
ProdigyV21
commented
Aug 25, 2026
This is a valuable feature and the overall direction looks good. I verified the exact merge against current main: Play and Sideload compile, the Sideload unit tests pass, and GitHub checks are green. However, I found a few functional cases that should be fixed before merging:
Please add regression tests for two portals returning the same command, delete-middle-then-add, duplicate group names across portals, legacy upgrade, and cloud round-trip. Once these are covered, this should be a strong addition. |
Summary
Adds first-class support for configuring multiple Stalker/Ministra portals (up to 3) alongside existing M3U playlists, so each portal's category groups can be managed independently in the Settings UI — hide/show, reorder, bulk toggle — exactly like M3U playlist groups. Previously Stalker was limited to a single portal exposed only through a config dialog, with no group management.
What changed
stalker:<portalId>:<origId>id shape. Each portal's hidden groups and group order are stored under the portal's own id (e.g.stalker1|group) via the existingPlaylistGroupKeyinfrastructure, so managing one portal never affects another.Bug fixes (pre-existing / discovered during development)
hiddenGroups/groupOrderare stored asplaylistId|groupNamekeys, butTvViewModel.buildPreparedGroupsandcurrentVisiblePlaylistGroupscompared them against plain group names — so hiding/ordering had no effect on the TV view. Both now extract the group name from each key before comparing.stalkerprefix instead of the portal id. The touch channel list derived the playlist id for hidden-group keys viachannel.id.substringBefore(':'), which yields the literalstalkerprefix forstalker:<portalId>:<origId>ids and therefore never matched the storedstalker1|groupkeys — hidden Stalker groups stayed visible on phones. A new helper resolves the portal id for Stalker ids (reusingStalkerPortalSupport.portalIdFromChannelId) and falls back tosubstringBefore(':')for M3U/Xtream, applied inbuildCategoryIndex/buildCategoryTree/buildPagedStartupChannelStateand the search filter. Category-id / sidebar keys are left unchanged.D-pad navigation
All hardcoded index dispatch maps in
SettingsScreenwere updated for the new Stalker rows and the categories bulk-toggle row. Helpers:iptvRowMaxAction() = 5firstIptvGroupIndex(playlistId, groups, stalkerPortalIds)sectionMaxIndex("iptv") = 4 + playlists.size + stalkerPortals.sizeOn-device TV testing confirmed correct focus, OK dispatch and left/right guards across all rows.
Files changed
StalkerPortalSupport.kt(new)STALKER_PLAYLIST_ID,MAX_STALKER_PORTALS,normalizeStalkerPortalEntry,portalIdFromChannelId, encode/decodeIptvRepository.ktsaveStalkerPortals(normalize, cap at 3, clear prefs of removed portals); legacysaveStalkerConfig/clearStalkerConfigretainedSettingsViewModel.ktpersistStalkerPortals()privateSettingsScreen.ktTvViewModel.ktPlaylistGroupKeymismatch in hidden-group/order filteringLiveCategory.kt/LiveTvScreen.ktstrings.xmlStalkerPortalSupportTest(normalize),StalkerDpadIndexTest(dpad indices)Compatibility / scope notes
getMemoryCachedSnapshot/getCachedSnapshotOrNull) is intentionally untouched.test-apk.ymland.github/test-signing/) used during development have been removed from this branch; the upstreambuild-check.ymlremains the gate.This PR was created by an AI agent (OpenHands) on behalf of @ReichiMD.