diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 58f0fd1..a132daf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,4 +71,12 @@ dependencies { // Coil for image loading implementation("io.coil-kt:coil:2.5.0") + + // Testing + testImplementation("junit:junit:4.13.2") + testImplementation("io.mockk:mockk:1.13.9") + testImplementation("com.google.truth:truth:1.4.2") + testImplementation("org.jsoup:jsoup:1.17.2") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") } diff --git a/app/src/main/java/com/futbollibre/tv/FutbolLibreApp.kt b/app/src/main/java/com/futbollibre/tv/FutbolLibreApp.kt index ba01d86..63f1164 100644 --- a/app/src/main/java/com/futbollibre/tv/FutbolLibreApp.kt +++ b/app/src/main/java/com/futbollibre/tv/FutbolLibreApp.kt @@ -1,10 +1,11 @@ package com.futbollibre.tv import android.app.Application +import com.futbollibre.tv.util.SiteConfig class FutbolLibreApp : Application() { override fun onCreate() { super.onCreate() - // Initialize any global components here + SiteConfig.initialize(this) } } \ No newline at end of file diff --git a/app/src/main/java/com/futbollibre/tv/MainFragment.kt b/app/src/main/java/com/futbollibre/tv/MainFragment.kt index eeacde3..bb560b4 100644 --- a/app/src/main/java/com/futbollibre/tv/MainFragment.kt +++ b/app/src/main/java/com/futbollibre/tv/MainFragment.kt @@ -24,6 +24,8 @@ import com.futbollibre.tv.model.Channel import com.futbollibre.tv.repository.StreamRepository import com.futbollibre.tv.ui.detail.ChannelDetailsActivity import com.futbollibre.tv.util.LeagueArt +import com.futbollibre.tv.util.NetworkStack +import com.futbollibre.tv.util.SiteConfig import kotlinx.coroutines.launch /** @@ -115,18 +117,25 @@ class MainFragment : BrowseSupportFragment() { isLoadingAgenda = true showLoading() + SiteConfig.resolveActiveDomain(NetworkStack.httpClient) val result = repository.getChannels() result.fold( onSuccess = { events -> lastLoadAt = System.currentTimeMillis() - Log.d(TAG, "Loaded ${events.size} events") + Log.d(TAG, "Loaded ${events.size} events from ${SiteConfig.activeBaseUrl}") displayChannels(events) isLoadingAgenda = false }, onFailure = { error -> - Log.e(TAG, "Error loading agenda", error) - showError(error.message ?: "Error desconocido") + Log.e(TAG, "Error loading agenda from ${SiteConfig.activeBaseUrl}", error) + val message = when { + error is java.net.UnknownHostException -> getString(R.string.error_server_unreachable) + error is java.net.SocketTimeoutException -> getString(R.string.error_server_unreachable) + error.message?.contains("HTTP") == true -> "Error del servidor: ${error.message}" + else -> error.message ?: getString(R.string.error_loading) + } + showError(message) isLoadingAgenda = false } ) diff --git a/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt b/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt index 379890c..ae50c6b 100644 --- a/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt +++ b/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt @@ -8,6 +8,7 @@ import com.futbollibre.tv.model.StreamType import com.futbollibre.tv.model.StreamUrl import com.futbollibre.tv.util.AgendaTimeFormatter import com.futbollibre.tv.util.NetworkStack +import com.futbollibre.tv.util.SiteConfig import com.futbollibre.tv.util.StreamOptionMetadata import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -22,8 +23,6 @@ class StreamRepository { companion object { private const val TAG = "StreamRepository" - private const val BASE_URL = "https://futbollibretv.su" - private const val AGENDA_URL = "$BASE_URL/agenda/" private const val MAX_RESOLUTION_DEPTH = 6 const val USER_AGENT = "Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" @@ -38,7 +37,7 @@ class StreamRepository { */ suspend fun getChannels(): Result> = withContext(Dispatchers.IO) { try { - val html = fetchHtml(AGENDA_URL, referer = BASE_URL) + val html = fetchHtml(SiteConfig.agendaUrl, referer = SiteConfig.activeBaseUrl) val events = parseAgendaFromHtml(html) Result.success(events) } catch (e: Exception) { @@ -49,7 +48,7 @@ class StreamRepository { suspend fun extractStreamUrl(streamPageUrl: String): Result = withContext(Dispatchers.IO) { try { - resolveStream(streamPageUrl, referer = BASE_URL, depth = 0) + resolveStream(streamPageUrl, referer = SiteConfig.activeBaseUrl, depth = 0) } catch (e: Exception) { Log.e(TAG, "Error extracting stream", e) Result.failure(e) @@ -64,7 +63,7 @@ class StreamRepository { } private fun parseAgendaFromHtml(html: String): List { - val doc = Jsoup.parse(html, AGENDA_URL) + val doc = Jsoup.parse(html, SiteConfig.agendaUrl) val events = mutableListOf() doc.select("ul.menu > li").forEachIndexed { index, item -> @@ -88,7 +87,7 @@ class StreamRepository { val name = if (titleParts.size > 1) titleParts[1].trim() else fullTitle val options = item.select("ul > li > a").mapNotNull { link -> - val url = normalizeUrl(link.attr("href"), AGENDA_URL) ?: return@mapNotNull null + val url = normalizeUrl(link.attr("href"), SiteConfig.agendaUrl) ?: return@mapNotNull null val quality = link.selectFirst("span")?.text()?.trim().orEmpty() val label = link.ownText().trim() val language = StreamOptionMetadata.inferLanguage(label, url) @@ -134,7 +133,7 @@ class StreamRepository { return Result.failure(Exception("Demasiadas resoluciones internas")) } - val normalizedUrl = normalizeUrl(rawUrl, referer ?: BASE_URL) + val normalizedUrl = normalizeUrl(rawUrl, referer ?: SiteConfig.activeBaseUrl) ?: return Result.failure(Exception("URL de stream invalida")) if (isDirectHlsUrl(normalizedUrl)) { @@ -580,7 +579,7 @@ class StreamRepository { .header("Cache-Control", "no-cache") .header("Pragma", "no-cache") - val normalizedReferer = normalizeUrl(referer.orEmpty(), BASE_URL) + val normalizedReferer = normalizeUrl(referer.orEmpty(), SiteConfig.activeBaseUrl) if (!normalizedReferer.isNullOrBlank()) { builder.header("Referer", normalizedReferer) } @@ -589,16 +588,11 @@ class StreamRepository { } private fun decodeEmbeddedEventUrl(url: String): String? { - val httpUrl = url.toHttpUrlOrNull() ?: return null - if (!httpUrl.host.contains("futbollibretv.su")) { - return null - } - if (!httpUrl.encodedPath.contains("/eventos")) { - return null - } + if (!SiteConfig.isEventosUrl(url)) return null + val httpUrl = url.toHttpUrlOrNull() ?: return null val encoded = httpUrl.queryParameter("r") ?: httpUrl.queryParameter("embed") ?: return null - return decodeBase64Token(encoded)?.let { normalizeUrl(it, BASE_URL) } + return decodeBase64Token(encoded)?.let { normalizeUrl(it, SiteConfig.activeBaseUrl) } } private fun decodeBase64Token(value: String): String? { diff --git a/app/src/main/java/com/futbollibre/tv/util/SiteConfig.kt b/app/src/main/java/com/futbollibre/tv/util/SiteConfig.kt new file mode 100644 index 0000000..3a7cd34 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/util/SiteConfig.kt @@ -0,0 +1,97 @@ +package com.futbollibre.tv.util + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request + +object SiteConfig { + + private const val TAG = "SiteConfig" + private const val PREFS_NAME = "site_config" + private const val KEY_ACTIVE_DOMAIN = "active_domain" + + val KNOWN_DOMAINS = listOf( + "futbol-libre.su", + "futbollibretv.su" + ) + + private val CDN_DOMAINS = listOf( + "cdn.futbol-libre.su" + ) + + const val AGENDA_PATH = "/agenda/" + + val allSecurityDomains: List + get() = KNOWN_DOMAINS + CDN_DOMAINS + + private var prefs: SharedPreferences? = null + + var activeBaseUrl: String = "https://${KNOWN_DOMAINS.first()}" + internal set + + val agendaUrl: String + get() = "$activeBaseUrl$AGENDA_PATH" + + fun initialize(context: Context) { + prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val saved = prefs?.getString(KEY_ACTIVE_DOMAIN, null) + if (saved != null && saved in KNOWN_DOMAINS) { + activeBaseUrl = "https://$saved" + Log.d(TAG, "Restored saved domain: $saved") + } + } + + suspend fun resolveActiveDomain(client: OkHttpClient) { + val currentDomain = activeBaseUrl.removePrefix("https://") + + if (probeDomain(client, currentDomain)) { + Log.d(TAG, "Active domain confirmed: $currentDomain") + return + } + + Log.w(TAG, "Domain $currentDomain unreachable, probing alternatives...") + + for (domain in KNOWN_DOMAINS) { + if (domain == currentDomain) continue + if (probeDomain(client, domain)) { + activeBaseUrl = "https://$domain" + prefs?.edit()?.putString(KEY_ACTIVE_DOMAIN, domain)?.apply() + Log.d(TAG, "Switched to domain: $domain") + return + } + } + + Log.w(TAG, "All domains unreachable. Keeping: $currentDomain") + } + + private suspend fun probeDomain(client: OkHttpClient, domain: String): Boolean = + withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url("https://$domain/") + .head() + .build() + client.newCall(request).execute().use { response -> + val ok = response.isSuccessful + if (!ok) { + Log.d(TAG, "Domain $domain returned HTTP ${response.code}") + } + ok + } + } catch (e: Exception) { + Log.d(TAG, "Domain $domain unreachable: ${e.javaClass.simpleName}") + false + } + } + + fun isEventosUrl(url: String): Boolean { + val httpUrl = url.toHttpUrlOrNull() ?: return false + return httpUrl.encodedPath.contains("eventos") && + (httpUrl.queryParameter("r") != null || httpUrl.queryParameter("embed") != null) + } +} diff --git a/app/src/main/java/com/futbollibre/tv/util/StreamOptionMetadata.kt b/app/src/main/java/com/futbollibre/tv/util/StreamOptionMetadata.kt index 19014ce..39bf9e5 100644 --- a/app/src/main/java/com/futbollibre/tv/util/StreamOptionMetadata.kt +++ b/app/src/main/java/com/futbollibre/tv/util/StreamOptionMetadata.kt @@ -79,14 +79,9 @@ object StreamOptionMetadata { } private fun decodeEmbeddedUrl(url: String): String? { - val httpUrl = url.toHttpUrlOrNull() ?: return null - if (!httpUrl.host.contains("futbollibretv.su")) { - return null - } - if (!httpUrl.encodedPath.contains("/eventos")) { - return null - } + if (!SiteConfig.isEventosUrl(url)) return null + val httpUrl = url.toHttpUrlOrNull() ?: return null val encoded = httpUrl.queryParameter("r") ?: httpUrl.queryParameter("embed") ?: return null val paddedValue = encoded.trim().let { raw -> val missingPadding = (4 - raw.length % 4) % 4 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fb5e89..ad88f34 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,9 @@ Nombre del canal Opciones de stream No se encontraron eventos + No se pudo conectar al servidor. Verificá tu conexión o intentá más tarde. + No se pudo obtener el stream. Probá con otra opción. + Reintentar Retroceder Avanzar Reproducir/Pausar diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 1a75465..c4659c2 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -6,6 +6,8 @@ + futbol-libre.su + cdn.futbol-libre.su futbollibretv.su latamvidz1.com la14hd.com diff --git a/app/src/test/java/com/futbollibre/tv/SiteConfigTest.kt b/app/src/test/java/com/futbollibre/tv/SiteConfigTest.kt new file mode 100644 index 0000000..2f60626 --- /dev/null +++ b/app/src/test/java/com/futbollibre/tv/SiteConfigTest.kt @@ -0,0 +1,91 @@ +package com.futbollibre.tv + +import com.futbollibre.tv.util.SiteConfig +import com.google.common.truth.Truth.assertThat +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.junit.Before +import org.junit.Test + +class SiteConfigTest { + + @Before + fun setUp() { + SiteConfig.activeBaseUrl = "https://${SiteConfig.KNOWN_DOMAINS.first()}" + } + + @Test + fun `known domains contains both old and new`() { + assertThat(SiteConfig.KNOWN_DOMAINS).containsAtLeast( + "futbol-libre.su", + "futbollibretv.su" + ) + } + + @Test + fun `agenda url uses active base url`() { + SiteConfig.activeBaseUrl = "https://futbol-libre.su" + assertThat(SiteConfig.agendaUrl).isEqualTo("https://futbol-libre.su/agenda/") + } + + @Test + fun `isEventosUrl returns true for new domain eventos URL`() { + val url = "https://futbol-libre.su/eventos.html?r=aHR0cHM6Ly9leGFtcGxlLmNvbQ==" + assertThat(SiteConfig.isEventosUrl(url)).isTrue() + } + + @Test + fun `isEventosUrl returns true for old domain eventos URL`() { + val url = "https://futbollibretv.su/eventos?r=aHR0cHM6Ly9leGFtcGxlLmNvbQ==" + assertThat(SiteConfig.isEventosUrl(url)).isTrue() + } + + @Test + fun `isEventosUrl returns true for embed parameter`() { + val url = "https://futbol-libre.su/eventos.html?embed=aHR0cHM6Ly9leGFtcGxlLmNvbQ==" + assertThat(SiteConfig.isEventosUrl(url)).isTrue() + } + + @Test + fun `isEventosUrl returns false for agenda URL`() { + val url = "https://futbol-libre.su/agenda/" + assertThat(SiteConfig.isEventosUrl(url)).isFalse() + } + + @Test + fun `isEventosUrl returns false for URL without r or embed param`() { + val url = "https://futbol-libre.su/eventos.html" + assertThat(SiteConfig.isEventosUrl(url)).isFalse() + } + + @Test + fun `isEventosUrl returns false for unrelated URL`() { + val url = "https://latamvidz1.com/canal.php?stream=test" + assertThat(SiteConfig.isEventosUrl(url)).isFalse() + } + + @Test + fun `isEventosUrl returns false for null URL`() { + assertThat(SiteConfig.isEventosUrl("not-a-url")).isFalse() + } + + @Test + fun `isEventosUrl returns true for any domain with eventos path and r param`() { + val url = "https://some-new-domain.com/eventos?r=dGVzdA==" + assertThat(SiteConfig.isEventosUrl(url)).isTrue() + } + + @Test + fun `isEventosUrl returns true for eventos in subpath`() { + val url = "https://example.com/sub/eventos/page?r=dGVzdA==" + assertThat(SiteConfig.isEventosUrl(url)).isTrue() + } + + @Test + fun `all security domains includes CDN`() { + assertThat(SiteConfig.allSecurityDomains).containsAtLeast( + "futbol-libre.su", + "futbollibretv.su", + "cdn.futbol-libre.su" + ) + } +} diff --git a/app/src/test/java/com/futbollibre/tv/StreamRepositoryDecodeTest.kt b/app/src/test/java/com/futbollibre/tv/StreamRepositoryDecodeTest.kt new file mode 100644 index 0000000..09838e3 --- /dev/null +++ b/app/src/test/java/com/futbollibre/tv/StreamRepositoryDecodeTest.kt @@ -0,0 +1,155 @@ +package com.futbollibre.tv + +import com.futbollibre.tv.util.SiteConfig +import com.google.common.truth.Truth.assertThat +import org.jsoup.Jsoup +import org.junit.Before +import org.junit.Test +import java.util.Base64 + +class StreamRepositoryDecodeTest { + + @Before + fun setUp() { + SiteConfig.activeBaseUrl = "https://futbol-libre.su" + } + + private fun encodeBase64(text: String): String = + Base64.getEncoder().encodeToString(text.toByteArray()) + + @Test + fun `decodeEmbeddedEventUrl decodes new domain eventos URL`() { + val realPayload = "https://latamvidz1.com/canal.php?stream=fox_deportes_usa" + val url = "https://futbol-libre.su/eventos.html?r=${encodeBase64(realPayload)}" + assertThat(SiteConfig.isEventosUrl(url)).isTrue() + } + + @Test + fun `decodeEmbeddedEventUrl decodes old domain eventos URL`() { + val realPayload = "https://latamvidz1.com/canal.php?stream=espn" + val url = "https://futbollibretv.su/eventos?r=${encodeBase64(realPayload)}" + assertThat(SiteConfig.isEventosUrl(url)).isTrue() + } + + @Test + fun `agenda HTML parses correctly with new domain links`() { + val html = """ + + """.trimIndent() + + val doc = Jsoup.parse(html, SiteConfig.agendaUrl) + val items = doc.select("ul.menu > li") + + assertThat(items).hasSize(2) + + val firstHeader = items[0].children().firstOrNull { it.tagName() == "a" } + assertThat(firstHeader).isNotNull() + assertThat(firstHeader!!.selectFirst("span.t")?.text()?.trim()).isEqualTo("20:00") + + val fullTitle = firstHeader.ownText().trim() + assertThat(fullTitle).contains("PSG vs Bayern") + + val allLinks = items[0].select("ul > li > a") + assertThat(allLinks.size).isAtLeast(2) + + val streamLinks = allLinks.toList().filter { it.attr("href") != "#" } + assertThat(streamLinks).hasSize(2) + + val firstOptionUrl = streamLinks[0].attr("href") + assertThat(firstOptionUrl).startsWith("https://futbol-libre.su/eventos.html?r=") + assertThat(SiteConfig.isEventosUrl(firstOptionUrl)).isTrue() + + val secondHeader = items[1].children().firstOrNull { it.tagName() == "a" } + assertThat(secondHeader).isNotNull() + assertThat(secondHeader!!.ownText().trim()).contains("River vs Boca") + } + + @Test + fun `base64 encoded stream URLs decode correctly`() { + val encodedUrl = "aHR0cHM6Ly9sYXRhbXZpZHoxLmNvbS9jYW5hbC5waHA/c3RyZWFtPWZveF9kZXBvcnRlc191c2E=" + val decoded = String(Base64.getDecoder().decode(encodedUrl), Charsets.UTF_8) + assertThat(decoded).isEqualTo("https://latamvidz1.com/canal.php?stream=fox_deportes_usa") + } + + @Test + fun `base64 encoded URL from actual agenda decodes correctly`() { + val encoded = "aHR0cHM6Ly9lc3ZpZGVvZnkuY29tL3Byby5waHA/aWQ9Rk9YREVQT1JURVM=" + val decoded = String(Base64.getDecoder().decode(encoded), Charsets.UTF_8) + assertThat(decoded).isEqualTo("https://esvideofy.com/pro.php?id=FOXDEPORTES") + } + + @Test + fun `agenda with empty options parses without crash`() { + val html = """ + + """.trimIndent() + + val doc = Jsoup.parse(html, SiteConfig.agendaUrl) + val items = doc.select("ul.menu > li") + assertThat(items).hasSize(1) + + val allLinks = items[0].select("ul > li > a") + val streamLinks = allLinks.toList().filter { it.attr("href") != "#" } + assertThat(streamLinks).isEmpty() + } + + @Test + fun `iframe extraction from stream page`() { + val html = """ + + + + + """.trimIndent() + + val doc = Jsoup.parse(html, "https://futbol-libre.su/eventos.html") + val iframe = doc.selectFirst("iframe[src]") + assertThat(iframe).isNotNull() + assertThat(iframe!!.attr("src")).isEqualTo("https://latamvidz1.com/canal.php?stream=test") + } + + @Test + fun `iframe with empty src is detected as empty`() { + val html = """ + + + + + """.trimIndent() + + val doc = Jsoup.parse(html, "https://futbol-libre.su/eventos.html") + val iframe = doc.selectFirst("iframe[src]") + assertThat(iframe!!.attr("src")).isEmpty() + } + + @Test + fun `m3u8 direct URL detection`() { + val url = "https://example.com/stream.m3u8?token=abc123" + assertThat(url.contains(".m3u8", ignoreCase = true)).isTrue() + } + + @Test + fun `mpd direct URL detection`() { + val url = "https://example.com/stream.mpd?token=abc123" + assertThat(url.contains(".mpd", ignoreCase = true)).isTrue() + } +}