Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
69d797a
feat(iptv): make Stalker portal groups configurable like M3U playlists
openhands-agent Aug 23, 2026
cede927
ci: restore test-APK workflow (debug build, no secrets needed)
openhands-agent Aug 23, 2026
2b0b3df
ci: fix app-id suffix (no hyphens allowed in Android package names)
openhands-agent Aug 23, 2026
0858c22
feat(iptv): convert Stalker to a portal list (backend foundation)
openhands-agent Aug 24, 2026
b7197ad
fix(iptv): specify Triple/Pair type params for multi-portal merge
openhands-agent Aug 24, 2026
98cf2b0
ci: bump versionCode in test-apk builds so Android allows in-place up…
openhands-agent Aug 24, 2026
4e09c33
ci: add one-off workflow to generate a stable debug keystore
openhands-agent Aug 24, 2026
f680f90
ci: sign test APKs with a fixed debug keystore for in-place updates
openhands-agent Aug 24, 2026
1f8b7bd
fix(iptv): populate hidden groups and order in cache snapshots
openhands-agent Aug 24, 2026
dd17455
feat(iptv): multi-portal Stalker UI with CRUD, reorder and per-portal…
openhands-agent Aug 24, 2026
00e6510
fix(iptv): move STALKER_PLAYLIST_ID/MAX_STALKER_PORTALS to top-level …
openhands-agent Aug 24, 2026
c9649c4
fix(iptv): place Stalker dialogs in correct composable scope
openhands-agent Aug 24, 2026
2114198
fix(iptv): match mobile Stalker portal row to M3U style
openhands-agent Aug 24, 2026
3b3f726
fix(iptv): hide Stalker portal groups by portal id on touch UI
openhands-agent Aug 24, 2026
4e8270e
feat(settings): add leading icon and portal URL to TV Stalker rows
openhands-agent Aug 24, 2026
371c593
chore(ci): remove test-apk workflow and signing key for upstream
openhands-agent Aug 24, 2026
9857024
Merge current main into feat/stalker-live-config
Aug 28, 2026
b324a1b
fix(iptv): isolate Stalker portal state
Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper(
val byPlaylist = !playlistId.isNullOrBlank()
val sql = buildString {
append("SELECT * FROM channels WHERE source_key = ?")
if (byPlaylist) append(" AND id LIKE ?")
if (byPlaylist) append(" AND (id LIKE ? OR id LIKE ?)")
if (byGroup) {
if (normalizedGroup) append(" AND trim(group_title) = ?") else append(" AND group_title = ?")
}
Expand All @@ -217,7 +217,10 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper(
}
val args = buildList {
add(sourceKey)
if (byPlaylist) add("${playlistId}:%")
if (byPlaylist) {
add("${playlistId}:%")
add("stalker:${playlistId}:%")
}
if (byGroup) add(if (normalizedGroup) groupTitle!!.trim() else groupTitle!!)
}.toTypedArray()
return readableDatabase.rawQuery(sql, args).use { cursor ->
Expand All @@ -242,12 +245,15 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper(
if (!byGroup && !byPlaylist) return count(sourceKey)
val sql = buildString {
append("SELECT COUNT(*) FROM channels WHERE source_key = ?")
if (byPlaylist) append(" AND id LIKE ?")
if (byPlaylist) append(" AND (id LIKE ? OR id LIKE ?)")
if (byGroup) append(" AND group_title = ?")
}
val args = buildList {
add(sourceKey)
if (byPlaylist) add("${playlistId}:%")
if (byPlaylist) {
add("${playlistId}:%")
add("stalker:${playlistId}:%")
}
if (byGroup) add(groupTitle!!)
}.toTypedArray()
return readableDatabase.rawQuery(sql, args)
Expand Down Expand Up @@ -323,7 +329,16 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper(
return readableDatabase.rawQuery(
"""
SELECT
CASE WHEN instr(id, ':') > 0 THEN substr(id, 1, instr(id, ':') - 1) ELSE '' END AS playlist_id,
CASE
WHEN id LIKE 'stalker:%:%' THEN
substr(
substr(id, instr(id, ':') + 1),
1,
instr(substr(id, instr(id, ':') + 1), ':') - 1
)
WHEN instr(id, ':') > 0 THEN substr(id, 1, instr(id, ':') - 1)
ELSE ''
END AS playlist_id,
group_title,
COUNT(*),
MIN(ord) AS first_ord
Expand Down
515 changes: 401 additions & 114 deletions app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package com.arflix.tv.data.repository

import com.arflix.tv.data.model.PlaylistGroupKey
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.
*/
internal object StalkerPortalSupport {

private val gson = Gson()

/**
* Channel ids use the `stalker:<portalId>:<origId>` 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() }
}

/** Returns the owning source id for playlist/group and cache scoping. */
fun playlistIdFromChannelId(channelId: String): String {
return portalIdFromChannelId(channelId)
?: channelId.substringBefore(':').trim()
}

fun streamCacheKey(channelId: String, command: String): String {
return "${playlistIdFromChannelId(channelId)}|${command.trim()}"
}

/** Upgrades the old `stalker:<channelId>` shape to Portal 1. */
fun migrateLegacyChannelId(channelId: String): String {
val normalized = channelId.trim()
if (!normalized.startsWith("stalker:") || portalIdFromChannelId(normalized) != null) {
return normalized
}
val originalId = normalized.removePrefix("stalker:")
return if (originalId.isBlank()) normalized else "stalker:stalker1:$originalId"
}

/** Upgrades old single-portal group keys from `stalker|group` to Portal 1. */
fun migrateLegacyPlaylistGroupKey(rawKey: String): String {
val normalized = rawKey.trim()
if ('|' !in normalized) return normalized
val key = PlaylistGroupKey(normalized)
return if (key.playlistId == STALKER_PLAYLIST_ID) {
PlaylistGroupKey.build("stalker1", key.groupName)
} else {
normalized
}
}

fun normalizePlaylistGroupKeys(
keys: Collection<String>,
validSourceIds: Set<String> = emptySet(),
): List<String> {
return keys.asSequence()
.map(::migrateLegacyPlaylistGroupKey)
.filter { key ->
key.isNotBlank() &&
(validSourceIds.isEmpty() || ('|' in key && PlaylistGroupKey(key).playlistId in validSourceIds))
}
.distinct()
.toList()
}

fun nextAvailablePortalId(existingIds: Collection<String>, maxPortals: Int): String? {
val used = existingIds.mapTo(HashSet()) { it.trim() }
if (maxPortals <= 0 || used.size >= maxPortals) return null
var suffix = used.asSequence()
.mapNotNull { id -> id.removePrefix("stalker").toIntOrNull() }
.maxOrNull()
?.plus(1)
?: 1
while ("stalker$suffix" in used) suffix += 1
return "stalker$suffix"
}

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<StalkerPortalEntry> {
if (raw.isBlank()) return emptyList()
return runCatching {
val type = TypeToken.getParameterized(List::class.java, StalkerPortalEntry::class.java).type
normalizeStalkerPortals(
gson.fromJson<List<StalkerPortalEntry>>(raw, type).orEmpty(),
maxPortals,
)
}.getOrDefault(emptyList())
}

fun normalizeStalkerPortals(
portals: List<StalkerPortalEntry>,
maxPortals: Int,
): List<StalkerPortalEntry> {
if (maxPortals <= 0) return emptyList()
val usedIds = LinkedHashSet<String>()
return buildList {
portals.forEachIndexed { index, rawPortal ->
if (size >= maxPortals) return@forEachIndexed
val portal = normalizeStalkerPortalEntry(rawPortal, index) ?: return@forEachIndexed
val id = portal.id.takeIf { it !in usedIds }
?: nextAvailablePortalId(usedIds, maxPortals)
?: return@forEachIndexed
usedIds += id
add(portal.copy(id = id))
}
}
}

/**
* 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
)
}
}
Loading
Loading