commit 97845f6210d0f4e7de17458b1a2b442fbfcb2515 Author: Renato97 Date: Tue Mar 31 01:23:28 2026 -0300 Initial commit - cleaned for CV diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69d1f68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ +# Gradle files +.gradle/ +gradle-app.setting +!gradle-wrapper.jar +!.gradle +gradlew +gradlew.bat + +# Local configuration file (sdk path, etc) +local.properties + +# Built application files +*.apk +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle generated files +.gradle/ +build/ + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/caches +.idea/modules.xml +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/scopes/scope_settings.xml +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml +.idea/gradle.xml +.idea/libraries +.idea/*.xml +.idea/copyright/profiles_settings.xml + +# OS-specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ + +# Android Profiling +*.hprof + +# Environment variables +.env +.env.local +.env.*.local + +# APK build outputs +app/release/ +app/debug/ +*.apk diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..091dc35 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +FROM eclipse-temurin:17-jdk + +# Evitar interactividad durante la instalación +ENV DEBIAN_FRONTEND=noninteractive + +# Instalar dependencias necesarias para Android SDK +RUN apt-get update && apt-get install -y \ + wget \ + unzip \ + git \ + python3 \ + python3-pip \ + ncurses-bin \ + build-essential \ + lib32z1 \ + lib32ncurses6 \ + lib32stdc++6 \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +# Instalar Android SDK +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV SDKMANAGER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" + +RUN mkdir -p $ANDROID_SDK_ROOT/cmdline-tools && \ + wget -q https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -O tools.zip && \ + unzip -q tools.zip && \ + mv cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools/latest && \ + rm tools.zip + +# Aceptar licencias +RUN yes | $SDKMANAGER --licenses + +# Instalar componentes necesarios +RUN $SDKMANAGER "platform-tools" "platforms;android-33" "build-tools;33.0.2" "platforms;android-31" + +# Instalar Gradle +ENV GRADLE_HOME=/opt/gradle +RUN wget -q https://services.gradle.org/distributions/gradle-8.2-bin.zip -O gradle.zip && \ + unzip -q gradle.zip && \ + mv gradle-8.2 $GRADLE_HOME && \ + rm gradle.zip + +ENV PATH=$PATH:$GRADLE_HOME/bin:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools + +# Copiar proyecto +COPY . /app +WORKDIR /app + +# Dar permisos de ejecución a gradlew +RUN chmod +x ./gradlew + +# Construir APK +RUN ./gradlew assembleRelease + +# Comando para copiar APK a un volumen montado +CMD ["cp", "/app/app/build/outputs/apk/release/app-release.apk", "/output/StreamPlayer-v10.0.apk"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea43a33 --- /dev/null +++ b/README.md @@ -0,0 +1,258 @@ +# 📺 StreamPlayer + +[![Android](https://img.shields.io/badge/Platform-Android-green.svg)](https://android.com) +[![Java](https://img.shields.io/badge/Language-Java-orange.svg)](https://www.oracle.com/java/) +[![API](https://img.shields.io/badge/Min%20SDK-21%2B-brightgreen.svg)](https://android-developers.blogspot.com/) +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +Aplicación Android reproductora de streaming con optimización DNS para mejor rendimiento y acceso a contenido multimedia. + +## 🌟 Características + +- **▶️ Reproducción Streaming**: Reproductor de video streaming optimizado con ExoPlayer +- **🌐 Optimización DNS**: Configuración automática de DNS de Google (8.8.8.8, 8.8.4.4) para mejor conectividad +- **🔍 Resolución de URL**: Sistema avanzado que resuelve URLs ofuscadas de streaming +- **📱 Orientación Landscape**: Diseño optimizado para experiencia multimedia inmersiva +- **⚡ Alto Rendimiento**: Implementación asíncrona para respuesta rápida +- **🛡️ Manejo de Errores**: Sistema robusto de gestión de errores y estados + +## 📋 Requisitos + +- **Android SDK**: API 21 (Android 5.0) o superior +- **Target SDK**: API 33 (Android 13) +- **Permisos**: + - `INTERNET` - Acceso a streaming + - `ACCESS_NETWORK_STATE` - Verificación de conectividad + - `CHANGE_NETWORK_STATE` - Configuración de red + +## 🏗️ Arquitectura + +### Componentes Principales + +- **MainActivity.java** (`/app/src/main/java/com/streamplayer/MainActivity.java`) + - Gestión del ciclo de vida del reproductor + - Configuración de ExoPlayer + - Manejo de estados (loading, error, reproducción) + +- **StreamUrlResolver.java** (`/app/src/main/java/com/streamplayer/StreamUrlResolver.java`) + - Resolución de URLs ofuscadas + - Decodificación Base64 + - Extracción de claves de JavaScript + +- **DNSSetter.java** (`/app/src/main/java/com/streamplayer/DNSSetter.java`) + - Configuración de DNS de Google + - Optimización de red para streaming + - Pre-resolución de dominios + +## 🚀 Instalación y Build + +### Prerequisites +```bash +# Android SDK +# Java 8+ +# Gradle 8.2+ +``` + +### Build con Gradle +```bash +# Clone el repositorio +git clone https://gitea.cbcren.online/renato97/app.git +cd app + +# Build APK debug +./gradlew assembleDebug + +# Build APK release +./gradlew assembleRelease +``` + +### Build con Docker +```bash +# Construir imagen +docker build -t streamplayer . + +# Ejecutar build +docker run --rm -v $(pwd)/output:/output streamplayer +``` + +### Build Script Alternativo +```bash +# Usar script de build +chmod +x build_apk.sh +./build_apk.sh +``` + +## 🔄 Control de Instalaciones y Actualizaciones + +StreamPlayer ahora consulta automáticamente las releases públicas del repositorio Gitea y puede forzar o sugerir actualizaciones directamente desde la app. Para aprovecharlo: + +1. Ejecuta un build nuevo incrementando `versionCode`/`versionName` en `app/build.gradle`. +2. Crea una release en Gitea asignando un tag como `v9.1` y sube el APK. +3. Adjunta un archivo `update-manifest.json` en la misma release (puedes partir de `release/update-manifest.example.json`). + +### Formato de `update-manifest.json` + +```json +{ + "versionCode": 91000, + "versionName": "9.1.0", + "minSupportedVersionCode": 90000, + "forceUpdate": false, + "downloadUrl": "https://gitea.cbcren.online/renato97/app/releases/download/v9.1/StreamPlayer-v9.1.apk", + "fileName": "StreamPlayer-v9.1.apk", + "sizeBytes": 12345678, + "notes": "Novedades destacadas que aparecerán en el diálogo dentro de la app" +} +``` + +- `versionCode` / `versionName`: deben coincidir con el APK publicado. +- `minSupportedVersionCode`: define desde qué versión mínima se permite seguir usando la app (ideal para bloquear instalaciones antiguas). +- `forceUpdate`: si es `true` la app mostrará un diálogo sin opción de omitir. +- `downloadUrl` / `fileName`: apuntan al asset `.apk` publicado en Gitea (puede ser el enlace de descarga directo). +- `notes`: texto libre mostrado en el diálogo dentro de la app; si lo omitís se usará el cuerpo de la release. + +Si por algún motivo olvidas subir el manifiesto, la app igualmente tomará el primer asset `.apk` de la release, pero no podrá forzar versiones mínimas. + +### Flujo dentro de la app + +- Cada vez que se abre `MainActivity` se consulta `https://gitea.cbcren.online/api/v1/repos/renato97/app/releases/latest`. +- Si `versionCode` del servidor es mayor al instalado se muestra un diálogo para actualizar; el usuario puede abrir la release o descargarla directamente. +- Si `minSupportedVersionCode` es mayor al instalado la app bloqueará el uso hasta actualizar, cumpliendo con el requerimiento de controlar instalaciones. +- La descarga se gestiona con `DownloadManager` y, una vez completada, se lanza el instalador usando FileProvider. + +## 📱 Estructura del Proyecto + +``` +app/ +├── src/main/ +│ ├── java/com/streamplayer/ +│ │ ├── MainActivity.java # Actividad principal +│ │ ├── StreamUrlResolver.java # Resolvedor de URLs +│ │ └── DNSSetter.java # Configuración DNS +│ ├── res/ +│ │ ├── layout/ +│ │ │ └── activity_main.xml # UI principal +│ │ ├── mipmap-*/ # Íconos de la app +│ │ ├── values/ +│ │ │ ├── strings.xml # Cadenas de texto +│ │ │ ├── colors.xml # Colores +│ │ │ └── themes.xml # Temas +│ │ └── xml/ # Configuraciones +│ └── AndroidManifest.xml # Manifiesto Android +├── build.gradle # Configuración Gradle +└── proguard-rules.pro # Reglas ProGuard +``` + +## ⚙️ Configuración + +### URL de Streaming +La aplicación está configurada por defecto para: +``` +https://streamtpmedia.com/global2.php?stream=espn +``` + +### Configuración DNS +```java +// DNS configurados automáticamente +String[] GOOGLE_DNS = {"8.8.8.8", "8.8.4.4"}; +``` + +## 🔧 Dependencias Principales + +- **ExoPlayer 2.18.7**: Motor de reproducción multimedia +- **AndroidX AppCompat 1.6.1**: Compatibilidad hacia atrás +- **ConstraintLayout 2.1.4**: Layout moderno y flexible + +## 🛠️ Desarrollo + +### Flujo de Reproducción +1. **MainActivity** inicializa y configura DNS de Google +2. **StreamUrlResolver** obtiene y decodifica la URL real del stream +3. **ExoPlayer** inicia la reproducción con la URL resuelta +4. UI actualiza estados (loading, playing, error) + +### Características Técnicas +- **Threading**: Operaciones de red en background thread +- **Memory Management**: Proper lifecycle management de ExoPlayer +- **Error Handling**: Captura y display de errores al usuario +- **Network Optimization**: Configuración DNS específica para streaming + +## 📊 Build Configuration + +| Atributo | Valor | +|----------|-------| +| `applicationId` | `com.streamplayer` | +| `minSdk` | 21 | +| `targetSdk` | 33 | +| `versionCode` | 90000 | +| `versionName` | "9.0.0" | +| `compileSdk` | 33 | + +## 🔐 Permisos y Seguridad + +La aplicación requiere los siguientes permisos: +- ✅ `INTERNET` - Para streaming de contenido +- ✅ `ACCESS_NETWORK_STATE` - Para verificar conectividad +- ✅ `CHANGE_NETWORK_STATE` - Para optimización de red + +## 🐛 Troubleshooting + +### Problemas Comunes + +**Error de Conexión** +- Verificar conexión a internet +- Confirmar configuración DNS +- Revisar disponibilidad del servicio de streaming + +**Error de Reproducción** +- Validar formato de URL +- Verificar permisos de red +- Revisar logs de ExoPlayer + +**Build Fail** +```bash +# Limpiar proyecto +./gradlew clean + +# Rebuild +./gradlew build +``` + +## 📝 Logs y Debug + +La aplicación incluye console logging para: +- Configuración DNS +- Resolución de URLs +- Estados del reproductor +- Errores de red + +## 🤝 Contribución + +1. Fork del repositorio +2. Feature branch (`git checkout -b feature/NuevaCaracteristica`) +3. Commit cambios (`git commit -m 'Add feature'`) +4. Push al branch (`git push origin feature/NuevaCaracteristica`) +5. Pull Request + +## 📄 Licencia + +Este proyecto está licenciado bajo la Licencia MIT - ver archivo [LICENSE](LICENSE) para detalles. + +## 👨‍💻 Autor + +**renato97** - [Gitea Profile](https://gitea.cbcren.online/renato97) + +--- + +⚠️ **Disclaimer**: Esta aplicación es para fines educativos y de demostración. El usuario es responsable de cumplir con los términos de servicio de las plataformas de streaming utilizadas. + +## 📞 Soporte + +Para soporte y preguntas: +- 📧 Crear un issue en el repositorio +- 💬 Comentarios en el código +- 📱 Testing en dispositivos reales recomendado + +--- + +**🔗 Repositorio**: https://gitea.cbcren.online/renato97/app diff --git a/app/.idea/.gitignore b/app/.idea/.gitignore new file mode 100644 index 0000000..eaf91e2 --- /dev/null +++ b/app/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/app/.idea/AndroidProjectSystem.xml b/app/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..d58d49b --- /dev/null +++ b/app/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/.idea/caches/deviceStreaming.xml b/app/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000..f731c33 --- /dev/null +++ b/app/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1017 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/deviceManager.xml b/app/.idea/deviceManager.xml new file mode 100644 index 0000000..81c3e56 --- /dev/null +++ b/app/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/gradle.xml b/app/.idea/gradle.xml new file mode 100644 index 0000000..334eea7 --- /dev/null +++ b/app/.idea/gradle.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/migrations.xml b/app/.idea/migrations.xml new file mode 100644 index 0000000..48052b2 --- /dev/null +++ b/app/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/.idea/misc.xml b/app/.idea/misc.xml new file mode 100644 index 0000000..143c6a4 --- /dev/null +++ b/app/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/.idea/runConfigurations.xml b/app/.idea/runConfigurations.xml new file mode 100644 index 0000000..5bd6771 --- /dev/null +++ b/app/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..3f7a5d8 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,62 @@ +apply plugin: 'com.android.application' + +android { + namespace "com.streamplayer" + compileSdk 35 + + defaultConfig { + applicationId "com.streamplayer" + minSdk 21 + targetSdk 35 + versionCode 100201 + versionName "11.0.1" + } + + buildTypes { + release { + minifyEnabled false + signingConfig signingConfigs.debug + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lint { + checkReleaseBuilds = false + abortOnError = false + } + + buildFeatures { + buildConfig = true + } + + packaging { + resources { + excludes += 'META-INF/com.android.tools/proguard/coroutines.pro' + } + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'androidx.media3:media3-exoplayer:1.4.1' + implementation 'androidx.media3:media3-exoplayer-hls:1.4.1' + implementation 'androidx.media3:media3-exoplayer-dash:1.4.1' + implementation 'androidx.media3:media3-ui:1.4.1' + implementation 'androidx.media3:media3-datasource-okhttp:1.4.1' + + // OkHttp con DNS over HTTPS (para StreamUrlResolver) + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' +} diff --git a/app/build_simple.gradle b/app/build_simple.gradle new file mode 100644 index 0000000..7f9f732 --- /dev/null +++ b/app/build_simple.gradle @@ -0,0 +1,31 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 28 + buildToolsVersion "28.0.3" + + defaultConfig { + applicationId "com.streamplayer" + minSdkVersion 21 + targetSdkVersion 28 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation 'com.google.android.exoplayer:exoplayer:2.8.4' + implementation 'androidx.appcompat:appcompat:1.0.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.2' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..44f8fa8 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,24 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class com.google.android.exoplayer2.** { *; } +-keep class com.streamplayer.** { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f8500fa --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/streamplayer/ChannelAdapter.java b/app/src/main/java/com/streamplayer/ChannelAdapter.java new file mode 100644 index 0000000..740f1ff --- /dev/null +++ b/app/src/main/java/com/streamplayer/ChannelAdapter.java @@ -0,0 +1,92 @@ +package com.streamplayer; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; + +public class ChannelAdapter extends ListAdapter { + + public interface OnChannelClickListener { + void onChannelClick(StreamChannel channel); + } + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = + new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull StreamChannel oldItem, @NonNull StreamChannel newItem) { + return oldItem.getPageUrl().equals(newItem.getPageUrl()); + } + + @Override + public boolean areContentsTheSame(@NonNull StreamChannel oldItem, @NonNull StreamChannel newItem) { + return oldItem.getName().equals(newItem.getName()) + && oldItem.getPageUrl().equals(newItem.getPageUrl()); + } + }; + + private final OnChannelClickListener listener; + + public ChannelAdapter(OnChannelClickListener listener) { + super(DIFF_CALLBACK); + this.listener = listener; + } + + @NonNull + @Override + public ChannelViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_channel, parent, false); + return new ChannelViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ChannelViewHolder holder, int position) { + StreamChannel channel = getItem(position); + holder.name.setText(channel.getName()); + holder.icon.setImageResource(R.drawable.ic_channel_default); + holder.itemView.setOnClickListener(v -> { + if (listener != null) { + listener.onChannelClick(channel); + } + }); + holder.itemView.setOnFocusChangeListener((v, hasFocus) -> { + float scale = hasFocus ? 1.08f : 1f; + v.animate().scaleX(scale).scaleY(scale).setDuration(120).start(); + v.setSelected(hasFocus); + }); + } + + @Override + public int getItemCount() { + return super.getItemCount(); + } + + static class ChannelViewHolder extends RecyclerView.ViewHolder { + final ImageView icon; + final TextView name; + + ChannelViewHolder(@NonNull View itemView) { + super(itemView); + icon = itemView.findViewById(R.id.channel_icon); + name = itemView.findViewById(R.id.channel_name); + } + } + + public void submitList(List newChannels) { + if (newChannels == null) { + super.submitList(null); + return; + } + super.submitList(new ArrayList<>(newChannels)); + } +} diff --git a/app/src/main/java/com/streamplayer/ChannelRepository.java b/app/src/main/java/com/streamplayer/ChannelRepository.java new file mode 100644 index 0000000..6e71b91 --- /dev/null +++ b/app/src/main/java/com/streamplayer/ChannelRepository.java @@ -0,0 +1,99 @@ +package com.streamplayer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public final class ChannelRepository { + + private static final Comparator CHANNEL_NAME_COMPARATOR = + new Comparator() { + @Override + public int compare(StreamChannel left, StreamChannel right) { + return String.CASE_INSENSITIVE_ORDER.compare(left.getName(), right.getName()); + } + }; + private static final List CHANNELS = createChannels(); + + private static List createChannels() { + List channels = new ArrayList<>(Arrays.asList( + new StreamChannel("ESPN", "http://streamtp10.com/global2.php?stream=espn"), + new StreamChannel("ESPN 2", "http://streamtp10.com/global2.php?stream=espn2"), + new StreamChannel("ESPN 3", "http://streamtp10.com/global2.php?stream=espn3"), + new StreamChannel("ESPN 4", "http://streamtp10.com/global2.php?stream=espn4"), + new StreamChannel("ESPN 3 MX", "http://streamtp10.com/global2.php?stream=espn3mx"), + new StreamChannel("ESPN 5", "http://streamtp10.com/global2.php?stream=espn5"), + new StreamChannel("Fox Sports 3 MX", "http://streamtp10.com/global2.php?stream=foxsports3mx"), + new StreamChannel("ESPN 6", "http://streamtp10.com/global2.php?stream=espn6"), + new StreamChannel("Fox Sports MX", "http://streamtp10.com/global2.php?stream=foxsportsmx"), + new StreamChannel("ESPN 7", "http://streamtp10.com/global2.php?stream=espn7"), + new StreamChannel("Azteca Deportes", "http://streamtp10.com/global2.php?stream=azteca_deportes"), + new StreamChannel("Win Plus", "http://streamtp10.com/global2.php?stream=winplus"), + new StreamChannel("DAZN 1", "http://streamtp10.com/global2.php?stream=dazn1"), + new StreamChannel("Win Plus 2", "http://streamtp10.com/global2.php?stream=winplus2"), + new StreamChannel("DAZN 2", "http://streamtp10.com/global2.php?stream=dazn2"), + new StreamChannel("Win Sports", "http://streamtp10.com/global2.php?stream=winsports"), + new StreamChannel("DAZN LaLiga", "http://streamtp10.com/global2.php?stream=dazn_laliga"), + new StreamChannel("Win Plus Online 1", "http://streamtp10.com/global2.php?stream=winplusonline1"), + new StreamChannel("Caracol TV", "http://streamtp10.com/global2.php?stream=caracoltv"), + new StreamChannel("Fox 1 AR", "http://streamtp10.com/global2.php?stream=fox1ar"), + new StreamChannel("Fox 2 USA", "http://streamtp10.com/global2.php?stream=fox_2_usa"), + new StreamChannel("Fox 2 AR", "http://streamtp10.com/global2.php?stream=fox2ar"), + new StreamChannel("TNT 1 GB", "http://streamtp10.com/global2.php?stream=tnt_1_gb"), + new StreamChannel("TNT 2 GB", "http://streamtp10.com/global2.php?stream=tnt_2_gb"), + new StreamChannel("Fox 3 AR", "http://streamtp10.com/global2.php?stream=fox3ar"), + new StreamChannel("Universo USA", "http://streamtp10.com/global2.php?stream=universo_usa"), + new StreamChannel("DSports", "http://streamtp10.com/global2.php?stream=dsports"), + new StreamChannel("Univision USA", "http://streamtp10.com/global2.php?stream=univision_usa"), + new StreamChannel("DSports 2", "http://streamtp10.com/global2.php?stream=dsports2"), + new StreamChannel("Fox Deportes USA", "http://streamtp10.com/global2.php?stream=fox_deportes_usa"), + new StreamChannel("DSports Plus", "http://streamtp10.com/global2.php?stream=dsportsplus"), + new StreamChannel("Fox Sports 2 MX", "http://streamtp10.com/global2.php?stream=foxsports2mx"), + new StreamChannel("TNT Sports Chile", "http://streamtp10.com/global2.php?stream=tntsportschile"), + new StreamChannel("Fox Sports Premium", "http://streamtp10.com/global2.php?stream=foxsportspremium"), + new StreamChannel("TNT Sports", "http://streamtp10.com/global2.php?stream=tntsports"), + new StreamChannel("ESPN MX", "http://streamtp10.com/global2.php?stream=espnmx"), + new StreamChannel("ESPN Premium", "http://streamtp10.com/global2.php?stream=espnpremium"), + new StreamChannel("ESPN 2 MX", "http://streamtp10.com/global2.php?stream=espn2mx"), + new StreamChannel("TyC Sports", "http://streamtp10.com/global2.php?stream=tycsports"), + new StreamChannel("TUDN USA", "http://streamtp10.com/global2.php?stream=tudn_usa"), + new StreamChannel("Telefe", "http://streamtp10.com/global2.php?stream=telefe"), + new StreamChannel("TNT 3 GB", "http://streamtp10.com/global2.php?stream=tnt_3_gb"), + new StreamChannel("TV Pública", "http://streamtp10.com/global2.php?stream=tv_publica"), + new StreamChannel("Fox 1 USA", "http://streamtp10.com/global2.php?stream=fox_1_usa"), + new StreamChannel("Liga 1 Max", "http://streamtp10.com/global2.php?stream=liga1max"), + new StreamChannel("Gol TV", "http://streamtp10.com/global2.php?stream=goltv"), + new StreamChannel("VTV Plus", "http://streamtp10.com/global2.php?stream=vtvplus"), + new StreamChannel("ESPN Deportes", "http://streamtp10.com/global2.php?stream=espndeportes"), + new StreamChannel("Gol Perú", "http://streamtp10.com/global2.php?stream=golperu"), + new StreamChannel("TNT 4 GB", "http://streamtp10.com/global2.php?stream=tnt_4_gb"), + new StreamChannel("SportTV BR 1", "http://streamtp10.com/global2.php?stream=sporttvbr1"), + new StreamChannel("SportTV BR 2", "http://streamtp10.com/global2.php?stream=sporttvbr2"), + new StreamChannel("SportTV BR 3", "http://streamtp10.com/global2.php?stream=sporttvbr3"), + new StreamChannel("Premiere 1", "http://streamtp10.com/global2.php?stream=premiere1"), + new StreamChannel("Premiere 2", "http://streamtp10.com/global2.php?stream=premiere2"), + new StreamChannel("Premiere 3", "http://streamtp10.com/global2.php?stream=premiere3"), + new StreamChannel("ESPN NL 1", "http://streamtp10.com/global2.php?stream=espn_nl1"), + new StreamChannel("ESPN NL 2", "http://streamtp10.com/global2.php?stream=espn_nl2"), + new StreamChannel("ESPN NL 3", "http://streamtp10.com/global2.php?stream=espn_nl3"), + new StreamChannel("Caliente TV MX", "http://streamtp10.com/global2.php?stream=calientetvmx"), + new StreamChannel("USA Network", "http://streamtp10.com/global2.php?stream=usa_network"), + new StreamChannel("TyC Internacional", "http://streamtp10.com/global2.php?stream=tycinternacional"), + new StreamChannel("Canal 5 MX", "http://streamtp10.com/global2.php?stream=canal5mx"), + new StreamChannel("TUDN MX", "http://streamtp10.com/global2.php?stream=TUDNMX"), + new StreamChannel("FUTV", "http://streamtp10.com/global2.php?stream=futv"), + new StreamChannel("LaLiga Hypermotion", "http://streamtp10.com/global2.php?stream=laligahypermotion") + )); + Collections.sort(channels, CHANNEL_NAME_COMPARATOR); + return Collections.unmodifiableList(channels); + } + + private ChannelRepository() { + } + + public static List getChannels() { + return CHANNELS; + } +} diff --git a/app/src/main/java/com/streamplayer/EventAdapter.java b/app/src/main/java/com/streamplayer/EventAdapter.java new file mode 100644 index 0000000..d0085d8 --- /dev/null +++ b/app/src/main/java/com/streamplayer/EventAdapter.java @@ -0,0 +1,121 @@ +package com.streamplayer; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class EventAdapter extends ListAdapter { + + public interface OnEventClickListener { + void onEventClick(EventItem event); + } + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = + new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull EventItem oldItem, @NonNull EventItem newItem) { + return oldItem.getPageUrl().equals(newItem.getPageUrl()) + && oldItem.getStartMillis() == newItem.getStartMillis(); + } + + @Override + public boolean areContentsTheSame(@NonNull EventItem oldItem, @NonNull EventItem newItem) { + return oldItem.getTitle().equals(newItem.getTitle()) + && oldItem.getTime().equals(newItem.getTime()) + && oldItem.getCategory().equals(newItem.getCategory()) + && oldItem.getStatus().equals(newItem.getStatus()) + && oldItem.getPageUrl().equals(newItem.getPageUrl()) + && oldItem.getChannelName().equals(newItem.getChannelName()) + && oldItem.getStartMillis() == newItem.getStartMillis(); + } + }; + + private final OnEventClickListener listener; + + public EventAdapter(OnEventClickListener listener) { + super(DIFF_CALLBACK); + this.listener = listener; + } + + public void submitList(List newEvents) { + if (newEvents == null) { + super.submitList(null); + return; + } + super.submitList(new ArrayList<>(newEvents)); + } + + @NonNull + @Override + public EventViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_event, parent, false); + return new EventViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull EventViewHolder holder, int position) { + EventItem event = getItem(position); + holder.title.setText(event.getTitle()); + holder.time.setText(event.getTime()); + holder.channel.setText(event.getChannelName()); + holder.status.setText(buildStatusText(event)); + holder.itemView.setOnClickListener(v -> { + if (listener != null) { + listener.onEventClick(event); + } + }); + } + + @Override + public int getItemCount() { + return super.getItemCount(); + } + + static class EventViewHolder extends RecyclerView.ViewHolder { + final TextView title; + final TextView time; + final TextView channel; + final TextView status; + + EventViewHolder(@NonNull View itemView) { + super(itemView); + title = itemView.findViewById(R.id.event_title); + time = itemView.findViewById(R.id.event_time); + channel = itemView.findViewById(R.id.event_channel); + status = itemView.findViewById(R.id.event_status); + } + } + + private String buildStatusText(EventItem event) { + long start = event.getStartMillis(); + if (start <= 0) { + return event.getStatus(); + } + long now = System.currentTimeMillis(); + long diff = start - now; + if (diff > 0) { + long hours = diff / 3600000; + long minutes = (diff % 3600000) / 60000; + if (hours > 0) { + return String.format(Locale.getDefault(), "En %dh %02dm", hours, minutes); + } else { + return String.format(Locale.getDefault(), "En %d min", Math.max(1, minutes)); + } + } else if (Math.abs(diff) <= 2 * 3600000L) { + return "En vivo"; + } else { + return "Finalizado"; + } + } +} diff --git a/app/src/main/java/com/streamplayer/EventItem.java b/app/src/main/java/com/streamplayer/EventItem.java new file mode 100644 index 0000000..f2127ee --- /dev/null +++ b/app/src/main/java/com/streamplayer/EventItem.java @@ -0,0 +1,49 @@ +package com.streamplayer; + +public class EventItem { + private final String title; + private final String time; + private final String category; + private final String status; + private final String pageUrl; + private final String channelName; + private final long startMillis; + + public EventItem(String title, String time, String category, String status, String pageUrl, String channelName, long startMillis) { + this.title = title; + this.time = time; + this.category = category; + this.status = status; + this.pageUrl = pageUrl; + this.channelName = channelName; + this.startMillis = startMillis; + } + + public String getTitle() { + return title; + } + + public String getTime() { + return time; + } + + public String getCategory() { + return category; + } + + public String getStatus() { + return status; + } + + public String getPageUrl() { + return pageUrl; + } + + public String getChannelName() { + return channelName; + } + + public long getStartMillis() { + return startMillis; + } +} diff --git a/app/src/main/java/com/streamplayer/EventRepository.java b/app/src/main/java/com/streamplayer/EventRepository.java new file mode 100644 index 0000000..ca65e87 --- /dev/null +++ b/app/src/main/java/com/streamplayer/EventRepository.java @@ -0,0 +1,218 @@ +package com.streamplayer; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.text.ParseException; +import java.text.SimpleDateFormat; + +import okhttp3.Request; +import okhttp3.Response; + +public class EventRepository { + + private static final String PREFS_NAME = "events_cache"; + private static final String KEY_JSON = "json"; + private static final String KEY_TIMESTAMP = "timestamp"; + private static final long CACHE_DURATION = 24L * 60 * 60 * 1000; // 24 horas + private static final String ARGENTINA_TIMEZONE_ID = "America/Argentina/Buenos_Aires"; + private static final TimeZone ARGENTINA_TIMEZONE = TimeZone.getTimeZone(ARGENTINA_TIMEZONE_ID); + private static final int ARGENTINA_OFFSET_HOURS = 2; + private static final long EVENT_ROLLOVER_WINDOW_MS = 12L * 60 * 60 * 1000; + + // URL única para eventos (actualizado para evitar bloqueos de ISP) + private static final String EVENTS_URL = "http://streamtp10.com/eventos.json"; + + public interface Callback { + void onSuccess(List events); + + void onError(String message); + } + + public void loadEvents(Context context, boolean forceRefresh, Callback callback) { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + long last = prefs.getLong(KEY_TIMESTAMP, 0); + long now = System.currentTimeMillis(); + if (!forceRefresh && now - last < CACHE_DURATION) { + String cachedJson = prefs.getString(KEY_JSON, null); + if (cachedJson != null) { + try { + callback.onSuccess(parseEvents(cachedJson)); + return; + } catch (JSONException ignored) { + } + } + } + + new Thread(() -> { + try { + String json = downloadJson(context); + List events = parseEvents(json); + prefs.edit().putString(KEY_JSON, json).putLong(KEY_TIMESTAMP, System.currentTimeMillis()).apply(); + callback.onSuccess(events); + } catch (IOException | JSONException e) { + String cachedJson = prefs.getString(KEY_JSON, null); + if (cachedJson != null) { + try { + callback.onSuccess(parseEvents(cachedJson)); + return; + } catch (JSONException ignored) { + } + } + callback.onError(e.getMessage() != null ? e.getMessage() : "Error desconocido"); + } + }).start(); + } + + private String downloadJson(Context context) throws IOException { + Request request = new Request.Builder() + .url(EVENTS_URL) + .header("User-Agent", NetworkUtils.getUserAgent()) + .header("Accept", "application/json") + .build(); + + try (Response response = NetworkUtils.getClient().newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Error HTTP " + response.code() + ": " + response.message()); + } + + if (response.body() == null) { + throw new IOException("Respuesta vacía del servidor"); + } + + String responseBody = response.body().string(); + + // Validar que no sea HTML + if (responseBody.trim().startsWith(" parseEvents(String json) throws JSONException { + if (json == null || json.trim().isEmpty()) { + throw new JSONException("La respuesta está vacía"); + } + + // Validar que no sea HTML antes de parsear + String trimmed = json.trim(); + if (trimmed.startsWith(" events = new ArrayList<>(); + + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + String title = obj.optString("title"); + String time = obj.optString("time"); + String category = obj.optString("category"); + String status = obj.optString("status"); + String link = obj.optString("link"); + String normalized = normalizeLink(link); + + EventSchedule schedule = computeEventSchedule(time); + String displayTime = schedule.displayTime; + long startMillis = schedule.startMillis; + events.add(new EventItem(title, displayTime, category, status, normalized, extractChannelName(link), startMillis)); + } + return Collections.unmodifiableList(events); + } + + private String normalizeLink(String link) { + if (link == null) { + return ""; + } + // Mantener el endpoint original (global1/global2) que entregue el proveedor. + return link.replace("streamtpmedia.com", "streamtp10.com") + .replace("streamtpcloud.com", "streamtp10.com"); + } + + private String extractChannelName(String link) { + if (link == null) { + return ""; + } + int idx = link.indexOf("stream="); + if (idx == -1) { + return ""; + } + return link.substring(idx + 7).replace("_", " ").toUpperCase(Locale.ROOT); + } + + private EventSchedule computeEventSchedule(String time) { + if (time == null || time.trim().isEmpty()) { + return new EventSchedule(time == null ? "" : time, -1L); + } + + try { + Calendar adjustedTime = parseAdjustedTime(time); + String displayTime = formatTime(adjustedTime); + + Calendar now = Calendar.getInstance(ARGENTINA_TIMEZONE, Locale.US); + Calendar start = Calendar.getInstance(ARGENTINA_TIMEZONE, Locale.US); + start.set(Calendar.YEAR, now.get(Calendar.YEAR)); + start.set(Calendar.MONTH, now.get(Calendar.MONTH)); + start.set(Calendar.DAY_OF_MONTH, now.get(Calendar.DAY_OF_MONTH)); + start.set(Calendar.HOUR_OF_DAY, adjustedTime.get(Calendar.HOUR_OF_DAY)); + start.set(Calendar.MINUTE, adjustedTime.get(Calendar.MINUTE)); + start.set(Calendar.SECOND, 0); + start.set(Calendar.MILLISECOND, 0); + + long nowMillis = now.getTimeInMillis(); + long startMillis = start.getTimeInMillis(); + if (startMillis < nowMillis - EVENT_ROLLOVER_WINDOW_MS) { + start.add(Calendar.DAY_OF_MONTH, 1); + startMillis = start.getTimeInMillis(); + } + + return new EventSchedule(displayTime, startMillis); + } catch (ParseException ignored) { + return new EventSchedule(time, -1L); + } + } + + private Calendar parseAdjustedTime(String time) throws ParseException { + SimpleDateFormat parser = new SimpleDateFormat("HH:mm", Locale.US); + parser.setLenient(false); + parser.setTimeZone(ARGENTINA_TIMEZONE); + java.util.Date parsedDate = parser.parse(time.trim()); + if (parsedDate == null) { + throw new ParseException("Hora inválida: " + time, 0); + } + + Calendar adjusted = Calendar.getInstance(ARGENTINA_TIMEZONE, Locale.US); + adjusted.setTime(parsedDate); + adjusted.add(Calendar.HOUR_OF_DAY, ARGENTINA_OFFSET_HOURS); + return adjusted; + } + + private String formatTime(Calendar calendar) { + SimpleDateFormat formatter = new SimpleDateFormat("HH:mm", Locale.US); + formatter.setTimeZone(ARGENTINA_TIMEZONE); + return formatter.format(calendar.getTime()); + } + + private static final class EventSchedule { + final String displayTime; + final long startMillis; + + EventSchedule(String displayTime, long startMillis) { + this.displayTime = displayTime; + this.startMillis = startMillis; + } + } +} diff --git a/app/src/main/java/com/streamplayer/MainActivity.java b/app/src/main/java/com/streamplayer/MainActivity.java new file mode 100644 index 0000000..d7ea231 --- /dev/null +++ b/app/src/main/java/com/streamplayer/MainActivity.java @@ -0,0 +1,418 @@ +package com.streamplayer; + +import android.app.AlertDialog; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class MainActivity extends AppCompatActivity { + + private RecyclerView sectionList; + private RecyclerView contentList; + private ProgressBar loadingIndicator; + private TextView messageView; + private TextView contentTitle; + private Button refreshButton; + + private ChannelAdapter channelAdapter; + private EventAdapter eventAdapter; + private EventRepository eventRepository; + private SectionAdapter sectionAdapter; + private GridLayoutManager channelLayoutManager; + private LinearLayoutManager eventLayoutManager; + private final List cachedEvents = new ArrayList<>(); + private List sections; + private SectionEntry currentSection; + private UpdateManager updateManager; + private AlertDialog updateDialog; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + sectionList = findViewById(R.id.section_list); + contentList = findViewById(R.id.content_list); + loadingIndicator = findViewById(R.id.loading_indicator); + messageView = findViewById(R.id.message_view); + contentTitle = findViewById(R.id.content_title); + refreshButton = findViewById(R.id.refresh_button); + + refreshButton.setOnClickListener(v -> { + loadEvents(true); + Toast.makeText(this, "Actualizando eventos...", Toast.LENGTH_SHORT).show(); + }); + + channelAdapter = new ChannelAdapter( + channel -> openPlayer(channel.getName(), channel.getPageUrl())); + eventAdapter = new EventAdapter(event -> openPlayer(event.getTitle(), event.getPageUrl())); + eventRepository = new EventRepository(); + channelLayoutManager = new GridLayoutManager(this, getSpanCount()); + eventLayoutManager = new LinearLayoutManager(this) { + @Override + public View onInterceptFocusSearch(View focused, int direction) { + if (direction == View.FOCUS_DOWN) { + int pos = getPosition(focused); + if (pos == getItemCount() - 1) { + return focused; + } + } + return super.onInterceptFocusSearch(focused, direction); + } + }; + + sections = buildSections(); + sectionList.setLayoutManager(new LinearLayoutManager(this)); + sectionAdapter = new SectionAdapter(getSectionTitles(), this::selectSection); + sectionList.setAdapter(sectionAdapter); + + selectSection(0); + + updateManager = new UpdateManager(this); + updateManager.checkForUpdates(new UpdateManager.UpdateCallback() { + @Override + public void onUpdateAvailable(UpdateManager.UpdateInfo info) { + handleUpdateInfo(info); + } + + @Override + public void onUpToDate() { + // Nothing to do. + } + + @Override + public void onError(String message) { + Toast.makeText(MainActivity.this, + getString(R.string.update_error_checking, message), + Toast.LENGTH_SHORT).show(); + } + }); + } + + @Override + protected void onResume() { + super.onResume(); + if (updateManager != null) { + updateManager.resumePendingInstall(this); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (updateDialog != null && updateDialog.isShowing()) { + updateDialog.dismiss(); + } + if (updateManager != null) { + updateManager.release(); + } + } + + private void selectSection(int index) { + if (sections == null || sections.isEmpty()) { + return; + } + if (index < 0 || index >= sections.size()) { + index = 0; + } + sectionAdapter.setSelectedIndex(index); + currentSection = sections.get(index); + if (currentSection.type == SectionEntry.Type.EVENTS) { + showEvents(); + } else { + showChannels(currentSection); + } + } + + private void showChannels(SectionEntry section) { + contentTitle.setText(section.title); + refreshButton.setVisibility(View.GONE); + contentList.setLayoutManager(channelLayoutManager); + contentList.setAdapter(channelAdapter); + // Clear any scroll listeners from Events section + contentList.clearOnScrollListeners(); + loadingIndicator.setVisibility(View.GONE); + channelAdapter.submitList(section.channels); + if (section.channels.isEmpty()) { + messageView.setVisibility(View.VISIBLE); + messageView.setText(R.string.message_no_channels); + } else { + messageView.setVisibility(View.GONE); + contentList.post(() -> contentList.scrollToPosition(0)); + } + } + + private void showEvents() { + contentTitle.setText(currentSection != null ? currentSection.title : getString(R.string.section_events)); + refreshButton.setVisibility(View.VISIBLE); + contentList.setLayoutManager(eventLayoutManager); + contentList.setAdapter(eventAdapter); + // Clear existing listeners + contentList.clearOnScrollListeners(); + + if (cachedEvents.isEmpty()) { + loadEvents(false); + } else { + displayEvents(); + } + } + + private void loadEvents(boolean forceRefresh) { + loadingIndicator.setVisibility(View.VISIBLE); + messageView.setVisibility(View.GONE); + eventRepository.loadEvents(this, forceRefresh, new EventRepository.Callback() { + @Override + public void onSuccess(List events) { + runOnUiThread(() -> { + cachedEvents.clear(); + cachedEvents.addAll(events); + if (currentSection != null && currentSection.type == SectionEntry.Type.EVENTS) { + displayEvents(); + } else { + loadingIndicator.setVisibility(View.GONE); + } + }); + } + + @Override + public void onError(String message) { + runOnUiThread(() -> { + loadingIndicator.setVisibility(View.GONE); + messageView.setVisibility(View.VISIBLE); + messageView.setText(getString(R.string.message_events_error, message)); + }); + } + }); + } + + private void displayEvents() { + loadingIndicator.setVisibility(View.GONE); + if (cachedEvents.isEmpty()) { + messageView.setVisibility(View.VISIBLE); + messageView.setText(R.string.message_no_events); + eventAdapter.submitList(new ArrayList<>()); + } else { + messageView.setVisibility(View.GONE); + eventAdapter.submitList(new ArrayList<>(cachedEvents)); + } + } + + private void openPlayer(String name, String pageUrl) { + Intent intent = new Intent(MainActivity.this, PlayerActivity.class); + intent.putExtra(PlayerActivity.EXTRA_CHANNEL_NAME, name); + intent.putExtra(PlayerActivity.EXTRA_CHANNEL_URL, pageUrl); + startActivity(intent); + } + + private void handleUpdateInfo(UpdateManager.UpdateInfo info) { + if (info == null) { + return; + } + boolean forceUpdate = info.isMandatory(BuildConfig.VERSION_CODE); + showUpdateDialog(info, forceUpdate); + } + + private void showUpdateDialog(UpdateManager.UpdateInfo info, boolean mandatory) { + if (isFinishing()) { + return; + } + if (updateDialog != null && updateDialog.isShowing()) { + updateDialog.dismiss(); + } + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setTitle(mandatory ? R.string.update_required_title : R.string.update_available_title) + .setMessage(buildUpdateMessage(info)) + .setPositiveButton(R.string.update_action_download, + (dialog, which) -> updateManager.downloadUpdate(MainActivity.this, info)) + .setNeutralButton(R.string.update_action_view_release, + (dialog, which) -> openReleasePage(info)); + if (mandatory) { + builder.setCancelable(false); + builder.setNegativeButton(R.string.update_action_close_app, + (dialog, which) -> finish()); + } else { + builder.setNegativeButton(R.string.update_action_later, null); + } + updateDialog = builder.create(); + updateDialog.setOnShowListener(dialog -> { + int actionColor = ContextCompat.getColor(this, R.color.refresh_button_focused); + Button positive = updateDialog.getButton(AlertDialog.BUTTON_POSITIVE); + Button neutral = updateDialog.getButton(AlertDialog.BUTTON_NEUTRAL); + Button negative = updateDialog.getButton(AlertDialog.BUTTON_NEGATIVE); + if (positive != null) { + positive.setTextColor(actionColor); + } + if (neutral != null) { + neutral.setTextColor(actionColor); + } + if (negative != null) { + negative.setTextColor(actionColor); + } + }); + updateDialog.show(); + } + + private CharSequence buildUpdateMessage(UpdateManager.UpdateInfo info) { + StringBuilder builder = new StringBuilder(); + builder.append(getString(R.string.update_current_version, + BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)); + builder.append('\n'); + builder.append(getString(R.string.update_latest_version, + info.versionName, info.versionCode)); + if (info.minSupportedVersionCode > 0) { + builder.append('\n').append(getString(R.string.update_min_supported, + info.minSupportedVersionCode)); + } + String size = info.formatSize(this); + if (!size.isEmpty()) { + builder.append('\n').append(getString(R.string.update_download_size, size)); + } + if (info.downloadCount > 0) { + builder.append('\n').append(getString(R.string.update_downloads, + info.downloadCount)); + } + if (!info.releaseNotes.isEmpty()) { + builder.append("\n\n"); + builder.append(getString(R.string.update_release_notes_title)); + builder.append('\n'); + builder.append(info.getReleaseNotesPreview()); + } + if (!info.isMandatory(BuildConfig.VERSION_CODE)) { + builder.append("\n\n"); + builder.append(getString(R.string.update_optional_hint)); + } + return builder.toString(); + } + + private void openReleasePage(UpdateManager.UpdateInfo info) { + String url = info.releasePageUrl; + if (url == null || url.isEmpty()) { + url = info.downloadUrl; + } + if (url == null || url.isEmpty()) { + Toast.makeText(this, R.string.update_error_missing_url, Toast.LENGTH_SHORT).show(); + return; + } + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + try { + startActivity(intent); + } catch (ActivityNotFoundException e) { + Toast.makeText(this, R.string.update_error_open_release, Toast.LENGTH_SHORT).show(); + } + } + + private int getSpanCount() { + return getResources().getInteger(R.integer.channel_grid_span); + } + + private List buildSections() { + List list = new ArrayList<>(); + list.add(SectionEntry.events(getString(R.string.section_events))); + + Map> grouped = new HashMap<>(); + List allChannels = ChannelRepository.getChannels(); + for (StreamChannel channel : allChannels) { + String key = deriveGroupName(channel.getName()); + List group = grouped.get(key); + if (group == null) { + group = new ArrayList<>(); + grouped.put(key, group); + } + group.add(channel); + } + + List espnChannels = grouped.remove("ESPN"); + if (espnChannels != null && !espnChannels.isEmpty()) { + list.add(SectionEntry.channels("ESPN", espnChannels)); + } + + List remaining = new ArrayList<>(grouped.keySet()); + Collections.sort(remaining, String.CASE_INSENSITIVE_ORDER); + for (String key : remaining) { + List channels = grouped.get(key); + if (channels == null || channels.isEmpty()) { + continue; + } + list.add(SectionEntry.channels(key, channels)); + } + + list.add(SectionEntry.channels(getString(R.string.section_all_channels), allChannels)); + return list; + } + + private List getSectionTitles() { + List titles = new ArrayList<>(); + for (SectionEntry entry : sections) { + titles.add(entry.title); + } + return titles; + } + + private String deriveGroupName(String name) { + if (name == null) { + return getString(R.string.section_all_channels); + } + String upper = name.toUpperCase(Locale.US); + if (upper.startsWith("ESPN")) { + return "ESPN"; + } else if (upper.contains("FOX SPORTS")) { + return "Fox Sports"; + } else if (upper.contains("FOX")) { + return "Fox"; + } else if (upper.contains("TNT")) { + return "TNT"; + } else if (upper.contains("DAZN")) { + return "DAZN"; + } else if (upper.contains("TUDN")) { + return "TUDN"; + } else if (upper.contains("TYC")) { + return "TyC"; + } else if (upper.contains("GOL")) { + return "Gol"; + } + int spaceIndex = upper.indexOf(' '); + return spaceIndex > 0 ? upper.substring(0, spaceIndex) : upper; + } + + private static class SectionEntry { + enum Type { EVENTS, CHANNELS } + + final String title; + final Type type; + final List channels; + + private SectionEntry(String title, Type type, List channels) { + this.title = title; + this.type = type; + this.channels = channels == null ? new ArrayList<>() : new ArrayList<>(channels); + } + + static SectionEntry events(String title) { + return new SectionEntry(title, Type.EVENTS, null); + } + + static SectionEntry channels(String title, List channels) { + return new SectionEntry(title, Type.CHANNELS, channels); + } + } +} diff --git a/app/src/main/java/com/streamplayer/NetworkUtils.java b/app/src/main/java/com/streamplayer/NetworkUtils.java new file mode 100644 index 0000000..a0e9b53 --- /dev/null +++ b/app/src/main/java/com/streamplayer/NetworkUtils.java @@ -0,0 +1,126 @@ +package com.streamplayer; + +import android.util.Log; + +import java.net.InetAddress; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import okhttp3.Dns; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.dnsoverhttps.DnsOverHttps; + +/** + * Utilidad centralizada para configuración de red. + * Fuerza DNS over HTTPS con fallback Google -> Cloudflare -> DNS del sistema. + */ +public final class NetworkUtils { + + private static final String TAG = "NetworkUtils"; + private static final OkHttpClient CLIENT; + private static final String USER_AGENT = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"; + private static final String GOOGLE_DOH_URL = "https://dns.google/dns-query"; + private static final String CLOUDFLARE_DOH_URL = "https://cloudflare-dns.com/dns-query"; + + static { + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(20, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .retryOnConnectionFailure(true); + + try { + // Configurar para aceptar todos los certificados SSL (útil para diagnosticar problemas de ISP) + // NOTA: Esto es temporal para diagnosticar si hay problemas de certificados MITM + final TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[]{}; + } + } + }; + + final SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); + + builder.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0]); + builder.hostnameVerifier((hostname, session) -> true); + + OkHttpClient bootstrap = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0]) + .hostnameVerifier((hostname, session) -> true) + .retryOnConnectionFailure(true) + .build(); + + final DnsOverHttps googleDns = new DnsOverHttps.Builder() + .client(bootstrap) + .url(HttpUrl.get(GOOGLE_DOH_URL)) + .bootstrapDnsHosts( + InetAddress.getByName("8.8.8.8"), + InetAddress.getByName("8.8.4.4")) + .includeIPv6(false) + .build(); + + final DnsOverHttps cloudflareDns = new DnsOverHttps.Builder() + .client(bootstrap) + .url(HttpUrl.get(CLOUDFLARE_DOH_URL)) + .bootstrapDnsHosts( + InetAddress.getByName("1.1.1.1"), + InetAddress.getByName("1.0.0.1")) + .includeIPv6(false) + .build(); + + builder.dns(hostname -> { + try { + List result = googleDns.lookup(hostname); + if (result != null && !result.isEmpty()) { + return result; + } + } catch (Exception ignored) { + } + + try { + List result = cloudflareDns.lookup(hostname); + if (result != null && !result.isEmpty()) { + return result; + } + } catch (Exception ignored) { + } + + return Dns.SYSTEM.lookup(hostname); + }); + + } catch (Exception e) { + builder.dns(Dns.SYSTEM); + Log.w(TAG, "Error configurando DNS over HTTPS", e); + } + + CLIENT = builder.build(); + } + + private NetworkUtils() { + } + + public static OkHttpClient getClient() { + return CLIENT; + } + + public static String getUserAgent() { + return USER_AGENT; + } +} diff --git a/app/src/main/java/com/streamplayer/PlayerActivity.java b/app/src/main/java/com/streamplayer/PlayerActivity.java new file mode 100644 index 0000000..4ea9174 --- /dev/null +++ b/app/src/main/java/com/streamplayer/PlayerActivity.java @@ -0,0 +1,511 @@ +package com.streamplayer; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Base64; +import android.util.Log; +import android.view.View; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.OptIn; +import androidx.appcompat.app.AppCompatActivity; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.okhttp.OkHttpDataSource; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.drm.DefaultDrmSessionManager; +import androidx.media3.exoplayer.drm.FrameworkMediaDrm; +import androidx.media3.exoplayer.drm.LocalMediaDrmCallback; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.ui.PlayerView; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +@OptIn(markerClass = UnstableApi.class) +public class PlayerActivity extends AppCompatActivity { + + public static final String EXTRA_CHANNEL_NAME = "extra_channel_name"; + public static final String EXTRA_CHANNEL_URL = "extra_channel_url"; + private static final String TAG = "PlayerActivity"; + private static final long STARTUP_TIMEOUT_MS = 12000L; + + private PlayerView playerView; + private ProgressBar loadingIndicator; + private TextView errorMessage; + private TextView channelLabel; + private Button closeButton; + private View playerToolbar; + + private ExoPlayer player; + private String channelName; + private String channelUrl; + private boolean overlayVisible = true; + private int retryCount = 0; + private StreamUrlResolver.ResolvedStream lastResolvedStream; + private String currentChannelPageUrl; + private boolean playbackStarted = false; + private boolean alternateSourceAttempted = false; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private Runnable startupTimeoutRunnable; + private final Object resolveLock = new Object(); + private int resolveGeneration = 0; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_player); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + Intent intent = getIntent(); + if (intent == null) { + finish(); + return; + } + + channelName = intent.getStringExtra(EXTRA_CHANNEL_NAME); + channelUrl = intent.getStringExtra(EXTRA_CHANNEL_URL); + + if (channelName == null || channelUrl == null) { + finish(); + return; + } + currentChannelPageUrl = channelUrl; + + initViews(); + channelLabel.setText(channelName); + + loadChannel(); + } + + private void initViews() { + playerView = findViewById(R.id.player_view); + loadingIndicator = findViewById(R.id.loading_indicator); + errorMessage = findViewById(R.id.error_message); + channelLabel = findViewById(R.id.player_channel_label); + closeButton = findViewById(R.id.close_button); + playerToolbar = findViewById(R.id.player_toolbar); + + closeButton.setOnClickListener(v -> finish()); + playerView.setOnClickListener(v -> toggleOverlay()); + playerView.setUseController(false); + } + + private void loadChannel() { + showLoading(true); + retryCount = 0; + alternateSourceAttempted = false; + currentChannelPageUrl = channelUrl; + loadChannelFromPageUrl(channelUrl); + } + + private void loadChannelFromPageUrl(String pageUrl) { + currentChannelPageUrl = pageUrl; + final int requestGeneration; + synchronized (resolveLock) { + resolveGeneration++; + requestGeneration = resolveGeneration; + } + + Log.d(TAG, "Resolviendo stream desde: " + pageUrl + " (req=" + requestGeneration + ")"); + + new Thread(() -> { + try { + StreamUrlResolver.ResolvedStream resolvedStream = StreamUrlResolver.resolve(pageUrl); + Log.d(TAG, "Stream resuelto: " + resolvedStream.getStreamUrl() + + " | mime=" + resolvedStream.getMimeType() + + " | drm=" + resolvedStream.hasClearKey() + + " (req=" + requestGeneration + ")"); + runOnUiThread(() -> { + if (!isLatestResolveRequest(requestGeneration)) { + Log.d(TAG, "Ignorando resultado viejo (req=" + requestGeneration + ")"); + return; + } + startPlayback(resolvedStream); + }); + } catch (IOException e) { + runOnUiThread(() -> { + if (!isLatestResolveRequest(requestGeneration)) { + return; + } + if (!tryAlternateSource("No se pudo conectar con el canal. " + e.getMessage())) { + showError("No se pudo conectar con el canal: " + e.getMessage()); + } + }); + } catch (Exception e) { + runOnUiThread(() -> { + if (!isLatestResolveRequest(requestGeneration)) { + return; + } + if (!tryAlternateSource("Error inesperado al resolver stream. " + e.getMessage())) { + showError("Error inesperado: " + e.getMessage()); + } + }); + } + }).start(); + } + + private boolean isLatestResolveRequest(int requestGeneration) { + synchronized (resolveLock) { + return requestGeneration == resolveGeneration; + } + } + + private void startPlayback(StreamUrlResolver.ResolvedStream resolvedStream) { + try { + releasePlayer(); + lastResolvedStream = resolvedStream; + retryCount = 0; + playbackStarted = false; + scheduleStartupTimeout(); + Log.d(TAG, "Iniciando reproducción: " + resolvedStream.getStreamUrl() + + " | mime=" + resolvedStream.getMimeType() + + " | drm=" + resolvedStream.hasClearKey()); + + MediaSource mediaSource = buildMediaSource(resolvedStream); + + player = new ExoPlayer.Builder(this).build(); + playerView.setPlayer(player); + setupPlayerListener(); + + player.setMediaSource(mediaSource); + player.prepare(); + player.play(); + setOverlayVisible(false); + } catch (Exception e) { + cancelStartupTimeout(); + Log.e(TAG, "Error al iniciar reproducción", e); + if (!tryAlternateSource("Error al inicializar reproductor. Probando fuente alterna...")) { + showError("Error al inicializar reproductor: " + e.getMessage()); + } + } + } + + private MediaSource buildMediaSource(StreamUrlResolver.ResolvedStream resolvedStream) { + HttpDataSource.Factory httpFactory = createHttpDataSourceFactory(currentChannelPageUrl); + MediaItem.Builder mediaItemBuilder = new MediaItem.Builder() + .setUri(Uri.parse(resolvedStream.getStreamUrl())) + .setMimeType(resolvedStream.getMimeType()); + + DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(httpFactory); + if (resolvedStream.hasClearKey()) { + mediaItemBuilder.setDrmConfiguration( + new MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build()); + DefaultDrmSessionManager drmSessionManager = new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(C.CLEARKEY_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER) + .build(new LocalMediaDrmCallback(buildClearKeyLicenseResponse( + resolvedStream.getClearKeyIdHex(), + resolvedStream.getClearKeyHex()))); + mediaSourceFactory.setDrmSessionManagerProvider(mediaItem -> drmSessionManager); + } + + return mediaSourceFactory.createMediaSource(mediaItemBuilder.build()); + } + + private HttpDataSource.Factory createHttpDataSourceFactory(String pageUrl) { + Map headers = new HashMap<>(); + headers.put("User-Agent", VlcPlayerConfig.USER_AGENT); + headers.put("Accept", "*/*"); + + String origin = buildOrigin(pageUrl); + if (origin != null) { + headers.put("Origin", origin); + headers.put("Referer", origin + "/"); + } + + return new OkHttpDataSource.Factory(NetworkUtils.getClient()) + .setUserAgent(VlcPlayerConfig.USER_AGENT) + .setDefaultRequestProperties(headers); + } + + private String buildOrigin(String pageUrl) { + if (pageUrl == null || pageUrl.isEmpty()) { + return null; + } + + Uri uri = Uri.parse(pageUrl); + if (uri.getScheme() == null || uri.getHost() == null) { + return null; + } + + StringBuilder origin = new StringBuilder() + .append(uri.getScheme()) + .append("://") + .append(uri.getHost()); + if (uri.getPort() != -1) { + origin.append(":").append(uri.getPort()); + } + return origin.toString(); + } + + private byte[] buildClearKeyLicenseResponse(String keyIdHex, String keyHex) { + String keyIdBase64Url = encodeBase64Url(hexToBytes(keyIdHex)); + String keyBase64Url = encodeBase64Url(hexToBytes(keyHex)); + String response = "{\"keys\":[{\"k\":\"" + keyBase64Url + + "\",\"kid\":\"" + keyIdBase64Url + + "\",\"kty\":\"oct\"}],\"type\":\"temporary\"}"; + return response.getBytes(StandardCharsets.UTF_8); + } + + private byte[] hexToBytes(String value) { + int length = value.length(); + byte[] bytes = new byte[length / 2]; + for (int i = 0; i < length; i += 2) { + bytes[i / 2] = (byte) Integer.parseInt(value.substring(i, i + 2), 16); + } + return bytes; + } + + private String encodeBase64Url(byte[] value) { + return Base64.encodeToString(value, Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE); + } + + private void setupPlayerListener() { + if (player == null) { + return; + } + + player.addListener(new Player.Listener() { + @Override + public void onPlaybackStateChanged(int playbackState) { + switch (playbackState) { + case Player.STATE_BUFFERING: + Log.d(TAG, "Exo Event: BUFFERING"); + runOnUiThread(() -> showLoading(true)); + break; + case Player.STATE_READY: + Log.d(TAG, "Exo Event: READY"); + runOnUiThread(() -> { + playbackStarted = true; + cancelStartupTimeout(); + showLoading(false); + retryCount = 0; + }); + break; + case Player.STATE_ENDED: + Log.d(TAG, "Exo Event: ENDED"); + runOnUiThread(() -> { + cancelStartupTimeout(); + finish(); + }); + break; + default: + break; + } + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + Log.d(TAG, "Exo Event: isPlaying=" + isPlaying); + if (isPlaying) { + runOnUiThread(() -> { + playbackStarted = true; + cancelStartupTimeout(); + showLoading(false); + }); + } + } + + @Override + public void onPlayerError(PlaybackException error) { + String message = error.getMessage() != null + ? error.getMessage() + : "code=" + error.errorCode; + Log.e(TAG, "Exo Error: " + message, error); + runOnUiThread(() -> { + cancelStartupTimeout(); + handlePlaybackError("Error de reproducción Exo: " + message); + }); + } + }); + } + + private void handlePlaybackError(String errorMsg) { + if (tryAlternateSource("Falló la reproducción. " + errorMsg)) { + return; + } + + String lower = errorMsg.toLowerCase(Locale.ROOT); + boolean isRetryableError = + lower.contains("404") || + lower.contains("403") || + lower.contains("timeout") || + lower.contains("network") || + lower.contains("connection") || + lower.contains("source"); + + if (isRetryableError && retryCount < VlcPlayerConfig.MAX_RETRIES) { + retryCount++; + runOnUiThread(() -> { + showLoading(true); + errorMessage.setVisibility(View.VISIBLE); + errorMessage.setText(getString(R.string.player_retrying, retryCount, VlcPlayerConfig.MAX_RETRIES)); + }); + + mainHandler.postDelayed(() -> { + if (lastResolvedStream != null) { + startPlayback(lastResolvedStream); + } else { + loadChannel(); + } + }, 1500); + } else { + String finalMessage = "Error al reproducir: " + errorMsg; + if (retryCount >= VlcPlayerConfig.MAX_RETRIES) { + finalMessage += "\n\nSe agotaron los reintentos (" + VlcPlayerConfig.MAX_RETRIES + ")."; + } + showError(finalMessage); + } + } + + private void scheduleStartupTimeout() { + cancelStartupTimeout(); + startupTimeoutRunnable = () -> { + if (!playbackStarted) { + Log.w(TAG, "Timeout de inicio de reproducción"); + if (!tryAlternateSource("El canal no inició a tiempo. Probando fuente alterna...")) { + handlePlaybackError("Timeout al iniciar stream"); + } + } + }; + mainHandler.postDelayed(startupTimeoutRunnable, STARTUP_TIMEOUT_MS); + } + + private void cancelStartupTimeout() { + if (startupTimeoutRunnable != null) { + mainHandler.removeCallbacks(startupTimeoutRunnable); + startupTimeoutRunnable = null; + } + } + + private boolean tryAlternateSource(String reason) { + if (alternateSourceAttempted) { + return false; + } + + String alternateUrl = buildAlternateGlobalUrl(currentChannelPageUrl); + if (alternateUrl == null || alternateUrl.equals(currentChannelPageUrl)) { + return false; + } + + alternateSourceAttempted = true; + Log.w(TAG, "Probando fuente alterna: " + alternateUrl + " | motivo: " + reason); + + showLoading(true); + errorMessage.setVisibility(View.VISIBLE); + errorMessage.setText("Problema con la fuente actual.\nProbando fuente alterna..."); + loadChannelFromPageUrl(alternateUrl); + return true; + } + + private String buildAlternateGlobalUrl(String url) { + if (url == null || url.isEmpty()) { + return null; + } + if (url.contains("global2.php")) { + return url.replace("global2.php", "global1.php"); + } + if (url.contains("global1.php")) { + return url.replace("global1.php", "global2.php"); + } + return null; + } + + private void showLoading(boolean show) { + loadingIndicator.setVisibility(show ? View.VISIBLE : View.GONE); + errorMessage.setVisibility(View.GONE); + playerView.setVisibility(View.VISIBLE); + if (show) { + setOverlayVisible(true); + } + } + + private void showError(String message) { + loadingIndicator.setVisibility(View.GONE); + playerView.setVisibility(View.GONE); + errorMessage.setVisibility(View.VISIBLE); + errorMessage.setText(message); + setOverlayVisible(true); + } + + private void releasePlayer() { + cancelStartupTimeout(); + playbackStarted = false; + if (player != null) { + player.release(); + player = null; + } + playerView.setPlayer(null); + } + + @Override + protected void onStart() { + super.onStart(); + if (player != null && !player.isPlaying()) { + player.play(); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (player != null) { + player.play(); + } + } + + @Override + protected void onPause() { + super.onPause(); + if (player != null) { + player.pause(); + } + } + + @Override + protected void onStop() { + super.onStop(); + // Keep player for quick resume. + } + + @Override + protected void onDestroy() { + mainHandler.removeCallbacksAndMessages(null); + super.onDestroy(); + releasePlayer(); + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + private void toggleOverlay() { + setOverlayVisible(!overlayVisible); + } + + private void setOverlayVisible(boolean visible) { + overlayVisible = visible; + playerToolbar.setVisibility(visible ? View.VISIBLE : View.GONE); + } + + @Override + public void onBackPressed() { + if (!overlayVisible) { + setOverlayVisible(true); + } else { + super.onBackPressed(); + } + } +} diff --git a/app/src/main/java/com/streamplayer/SectionAdapter.java b/app/src/main/java/com/streamplayer/SectionAdapter.java new file mode 100644 index 0000000..3832c28 --- /dev/null +++ b/app/src/main/java/com/streamplayer/SectionAdapter.java @@ -0,0 +1,88 @@ +package com.streamplayer; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +public class SectionAdapter extends RecyclerView.Adapter { + + public interface OnSectionSelectedListener { + void onSectionSelected(int position); + } + + private final List sections; + private final OnSectionSelectedListener listener; + private int selectedIndex = 0; + + public SectionAdapter(List sections, OnSectionSelectedListener listener) { + this.sections = sections; + this.listener = listener; + } + + @NonNull + @Override + public SectionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_section, parent, false); + return new SectionViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull SectionViewHolder holder, int position) { + holder.title.setText(sections.get(position)); + holder.itemView.setSelected(position == selectedIndex); + holder.itemView.setOnClickListener(v -> notifySelection(holder)); + holder.itemView.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + notifySelection(holder); + } + }); + } + + @Override + public int getItemCount() { + return sections.size(); + } + + public void setSelectedIndex(int index) { + if (index < 0 || index >= sections.size()) { + return; + } + if (selectedIndex == index) { + return; + } + int previous = selectedIndex; + selectedIndex = index; + notifyItemChanged(previous); + notifyItemChanged(selectedIndex); + } + + public int getSelectedIndex() { + return selectedIndex; + } + + private void notifySelection(SectionViewHolder holder) { + int position = holder.getBindingAdapterPosition(); + if (position == RecyclerView.NO_POSITION) { + return; + } + if (listener != null) { + listener.onSectionSelected(position); + } + } + + static class SectionViewHolder extends RecyclerView.ViewHolder { + final TextView title; + + SectionViewHolder(@NonNull View itemView) { + super(itemView); + title = itemView.findViewById(R.id.section_title); + } + } +} diff --git a/app/src/main/java/com/streamplayer/StreamChannel.java b/app/src/main/java/com/streamplayer/StreamChannel.java new file mode 100644 index 0000000..beb6025 --- /dev/null +++ b/app/src/main/java/com/streamplayer/StreamChannel.java @@ -0,0 +1,19 @@ +package com.streamplayer; + +public class StreamChannel { + private final String name; + private final String pageUrl; + + public StreamChannel(String name, String pageUrl) { + this.name = name; + this.pageUrl = pageUrl; + } + + public String getName() { + return name; + } + + public String getPageUrl() { + return pageUrl; + } +} diff --git a/app/src/main/java/com/streamplayer/StreamUrlResolver.java b/app/src/main/java/com/streamplayer/StreamUrlResolver.java new file mode 100644 index 0000000..69a5436 --- /dev/null +++ b/app/src/main/java/com/streamplayer/StreamUrlResolver.java @@ -0,0 +1,489 @@ +package com.streamplayer; + +import android.util.Base64; + +import androidx.media3.common.MimeTypes; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import okhttp3.Request; +import okhttp3.Response; + +/** + * Resuelve la URL real del stream extrayendo playbackURL de la página. + * Utiliza DNS over HTTPS (Google + Cloudflare) para evitar bloqueos. + * Soporta múltiples formatos de páginas y streams directos. + * Incluye fallback para páginas con JWPlayer y formatos ofuscados. + */ +public final class StreamUrlResolver { + + // Patrón original para streamtp10.com + private static final Pattern PLAYBACK_URL_PATTERN = + Pattern.compile("var\\s+playbackURL\\s*=\\s*[\"']([^\"']+)[\"']"); + + // Patrón para source src en tags video + private static final Pattern VIDEO_SOURCE_PATTERN = + Pattern.compile("]+src=[\"']([^\"']+)[\"']"); + + // Patrón para URLs HLS/DASH en cualquier parte del HTML + private static final Pattern STREAM_MANIFEST_URL_PATTERN = + Pattern.compile("(https?://[^\\s'\"<>]+\\.(?:m3u8|mpd)[^\\s'\"<>]*)"); + + // Patrón para URLs de stream en comillas dobles o simples + private static final Pattern STREAM_URL_PATTERN = + Pattern.compile("['\"](https?://[^'\"<>\\s]+?\\.(?:m3u8|mpd|mp4|ts)[^'\"<>\\s]*)['\"]"); + + // Patrón para file: o url: en JavaScript + private static final Pattern JS_URL_PATTERN = + Pattern.compile("(?:file|url|stream|source)\\s*[:=]\\s*[\"'](https?://[^\"']+)[\"']", + Pattern.CASE_INSENSITIVE); + + // Patrón para JWPlayer sources con "file": "url" + private static final Pattern JWPLAYER_FILE_PATTERN = + Pattern.compile("\"file\"\\s*:\\s*\"([^\"]+\\.(?:m3u8|mpd)[^\"]*)\"", + Pattern.CASE_INSENSITIVE); + + // Patrón para pares [indice, "base64"] del nuevo playbackURL ofuscado + private static final Pattern OBFUSCATED_PAIR_PATTERN = + Pattern.compile("\\[(\\d+)\\s*,\\s*[\"']([^\"']+)[\"']\\]"); + + // Patrón para k = fn1() + fn2() + private static final Pattern OBFUSCATED_K_PATTERN = + Pattern.compile("var\\s+k\\s*=\\s*([A-Za-z_$][\\w$]*)\\(\\)\\s*\\+\\s*([A-Za-z_$][\\w$]*)\\(\\)"); + + // Patrón para function fn() { return 12345; } + private static final Pattern JS_RETURN_NUMBER_FUNCTION_PATTERN = + Pattern.compile("function\\s+([A-Za-z_$][\\w$]*)\\s*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*;?\\s*\\}", + Pattern.DOTALL); + + // Patrón para IIFEs que calculan k con dos returns inline. + private static final Pattern INLINE_OBFUSCATED_K_PATTERN = + Pattern.compile("k\\s*=\\s*\\(function\\s+[A-Za-z_$][\\w$]*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*\\}\\)\\(\\)\\s*\\+\\s*\\(function\\s+[A-Za-z_$][\\w$]*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*\\}\\)\\(\\)", + Pattern.DOTALL); + + private static final Pattern CLEAR_KEY_HEX_PATTERN = + Pattern.compile("^[0-9a-fA-F]{32}$"); + + private StreamUrlResolver() { + } + + public static ResolvedStream resolve(String pageUrl) throws IOException { + // Primero verificar si la URL ya parece ser un stream directo + if (isDirectStreamUrl(pageUrl)) { + return ResolvedStream.fromUrl(pageUrl); + } + + String html = downloadPage(pageUrl); + String trimmedHtml = html.trim(); + + // Si el contenido ya es un manifiesto directo, reproducirlo como tal. + if (trimmedHtml.startsWith("#EXTM3U") || trimmedHtml.startsWith("#EXT")) { + return ResolvedStream.hls(pageUrl); + } + if (isDashManifest(trimmedHtml)) { + return ResolvedStream.dash(pageUrl); + } + + // Intentar múltiples patrones de extracción + String streamUrl = null; + + // 1. Patrón original: var playbackURL = "..." + streamUrl = extractWithPattern(html, PLAYBACK_URL_PATTERN); + if (isValidStreamUrl(streamUrl)) { + return ResolvedStream.fromUrl(streamUrl); + } + + // 2. Patrón: + streamUrl = extractWithPattern(html, VIDEO_SOURCE_PATTERN); + if (isValidStreamUrl(streamUrl)) { + return ResolvedStream.fromUrl(streamUrl); + } + + // 3. Patrón: URLs HLS/DASH directas + streamUrl = extractWithPattern(html, STREAM_MANIFEST_URL_PATTERN); + if (isValidStreamUrl(streamUrl)) { + return ResolvedStream.fromUrl(streamUrl); + } + + // 4. Patrón: URLs de stream en comillas + streamUrl = extractWithPattern(html, STREAM_URL_PATTERN); + if (isValidStreamUrl(streamUrl)) { + return ResolvedStream.fromUrl(streamUrl); + } + + // 5. Patrón: JavaScript file: / url: / stream: + streamUrl = extractWithPattern(html, JS_URL_PATTERN); + if (isValidStreamUrl(streamUrl)) { + return ResolvedStream.fromUrl(streamUrl); + } + + // 6. Patrón: JWPlayer "file": "url" (para reproductores web y otros) + streamUrl = extractWithPattern(html, JWPLAYER_FILE_PATTERN); + if (isValidStreamUrl(streamUrl)) { + return ResolvedStream.fromUrl(streamUrl); + } + + // 7. Nuevo formato ofuscado: playbackURL generado con atob + fromCharCode + streamUrl = decodeObfuscatedPlaybackUrl(html); + if (isValidStreamUrl(streamUrl)) { + return ResolvedStream.fromUrl(streamUrl); + } + + // 8. Eventos "transmision*.php": DASH + ClearKey en variables ofuscadas. + ResolvedStream dashClearKeyStream = decodeDashClearKeyStream(html); + if (dashClearKeyStream != null) { + return dashClearKeyStream; + } + + // Último recurso: si la URL viene de sudamericaplay.com o similares, + // intentar usarla directamente + if (pageUrl.contains("sudamericaplay.com") || + pageUrl.contains("paramount")) { + return ResolvedStream.fromUrl(pageUrl); + } + + // Si no encontramos la URL, mostrar un fragmento del HTML para debug + String preview = html.length() > 500 ? html.substring(0, 500) : html; + throw new IOException("No se encontró la URL del stream en la página. URL: " + pageUrl + ". Vista previa: " + preview); + } + + /** + * Decodifica páginas donde playbackURL se arma carácter por carácter con: + * playbackURL += String.fromCharCode(parseInt(atob(v).replace(/\D/g,'')) - k) + */ + private static String decodeObfuscatedPlaybackUrl(String html) { + if (html == null || + !html.contains("var playbackURL") || + !html.contains("playbackURL+=") || + !html.contains("String.fromCharCode") || + !html.contains("atob(")) { + return null; + } + + int scriptStart = html.indexOf("var playbackURL"); + if (scriptStart < 0) { + return null; + } + + int scriptEnd = html.indexOf("var p2pConfig", scriptStart); + if (scriptEnd < 0) { + scriptEnd = html.indexOf("", scriptStart); + } + if (scriptEnd < 0 || scriptEnd <= scriptStart) { + scriptEnd = Math.min(html.length(), scriptStart + 20000); + } + + String script = html.substring(scriptStart, scriptEnd); + + Matcher kMatcher = OBFUSCATED_K_PATTERN.matcher(script); + if (!kMatcher.find()) { + return null; + } + + String functionA = kMatcher.group(1); + String functionB = kMatcher.group(2); + + Map functionValues = new HashMap<>(); + Matcher functionMatcher = JS_RETURN_NUMBER_FUNCTION_PATTERN.matcher(script); + while (functionMatcher.find()) { + try { + functionValues.put(functionMatcher.group(1), Long.parseLong(functionMatcher.group(2))); + } catch (NumberFormatException ignored) { + } + } + + Long valueA = functionValues.get(functionA); + Long valueB = functionValues.get(functionB); + if (valueA == null || valueB == null) { + return null; + } + + long k = valueA + valueB; + + return decodePairs(extractEncodedPairs(script), k); + } + + private static ResolvedStream decodeDashClearKeyStream(String html) { + if (html == null || + !html.contains("\"type\": \"dash\"") || + !html.contains("clearkey")) { + return null; + } + + String dashUrl = decodeObfuscatedVariable(html, "_u"); + String keyId = decodeObfuscatedVariable(html, "_ki"); + String key = decodeObfuscatedVariable(html, "_k"); + + if (!isValidStreamUrl(dashUrl) || + !isValidClearKeyHex(keyId) || + !isValidClearKeyHex(key)) { + return null; + } + + return ResolvedStream.dashClearKey(dashUrl, keyId, key); + } + + private static String decodeObfuscatedVariable(String html, String variableName) { + String marker = "var " + variableName + "='';"; + int start = html.indexOf(marker); + if (start < 0) { + return null; + } + + int end = html.indexOf("var _", start + marker.length()); + if (end < 0) { + end = html.indexOf("var data = jwplayer", start + marker.length()); + } + if (end < 0) { + end = html.indexOf("", start + marker.length()); + } + if (end <= start) { + return null; + } + + String block = html.substring(start, end); + Matcher kMatcher = INLINE_OBFUSCATED_K_PATTERN.matcher(block); + if (!kMatcher.find()) { + return null; + } + + long k; + try { + k = Long.parseLong(kMatcher.group(1)) + Long.parseLong(kMatcher.group(2)); + } catch (NumberFormatException e) { + return null; + } + + return decodePairs(extractEncodedPairs(block), k); + } + + private static List extractEncodedPairs(String script) { + List pairs = new ArrayList<>(); + Matcher pairMatcher = OBFUSCATED_PAIR_PATTERN.matcher(script); + while (pairMatcher.find()) { + try { + int index = Integer.parseInt(pairMatcher.group(1)); + pairs.add(new EncodedPair(index, pairMatcher.group(2))); + } catch (NumberFormatException ignored) { + } + } + + Collections.sort(pairs, new Comparator() { + @Override + public int compare(EncodedPair left, EncodedPair right) { + return Integer.compare(left.index, right.index); + } + }); + return pairs; + } + + private static String decodePairs(List pairs, long k) { + if (pairs.isEmpty()) { + return null; + } + + StringBuilder decoded = new StringBuilder(pairs.size()); + for (EncodedPair pair : pairs) { + byte[] decodedBytes; + try { + decodedBytes = Base64.decode(pair.encodedValue, Base64.DEFAULT); + } catch (IllegalArgumentException e) { + continue; + } + + String decodedText = new String(decodedBytes, StandardCharsets.UTF_8); + String digits = decodedText.replaceAll("\\D", ""); + if (digits.isEmpty()) { + continue; + } + + long numericValue; + try { + numericValue = Long.parseLong(digits); + } catch (NumberFormatException e) { + continue; + } + + long charCode = numericValue - k; + if (charCode < 0 || charCode > Character.MAX_VALUE) { + return null; + } + + decoded.append((char) charCode); + } + + if (decoded.length() == 0) { + return null; + } + + return decoded.toString().trim() + .replace("\\/", "/") + .replace("\\u0026", "&") + .replace("\\u002F", "/"); + } + + private static boolean isDashManifest(String body) { + if (body == null || body.isEmpty()) { + return false; + } + String lower = body.toLowerCase(Locale.ROOT); + return lower.startsWith("\\s].*$", ""); + } + return url; + } + return null; + } + + private static String downloadPage(String pageUrl) throws IOException { + Request request = new Request.Builder() + .url(pageUrl) + .header("User-Agent", NetworkUtils.getUserAgent()) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "es-ES,es;q=0.9,en;q=0.8") + .header("Referer", "http://streamtp10.com/") + .build(); + + try (Response response = NetworkUtils.getClient().newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Error HTTP " + response.code() + " al cargar la página del stream"); + } + if (response.body() == null) { + throw new IOException("Respuesta vacía del servidor"); + } + + return response.body().string(); + } + } + + private static final class EncodedPair { + final int index; + final String encodedValue; + + EncodedPair(int index, String encodedValue) { + this.index = index; + this.encodedValue = encodedValue; + } + } + + public static final class ResolvedStream { + private final String streamUrl; + private final String mimeType; + private final String clearKeyIdHex; + private final String clearKeyHex; + + private ResolvedStream(String streamUrl, + String mimeType, + String clearKeyIdHex, + String clearKeyHex) { + this.streamUrl = streamUrl; + this.mimeType = mimeType; + this.clearKeyIdHex = clearKeyIdHex; + this.clearKeyHex = clearKeyHex; + } + + public static ResolvedStream fromUrl(String streamUrl) { + String lower = streamUrl.toLowerCase(Locale.ROOT); + if (lower.contains(".mpd")) { + return dash(streamUrl); + } + if (lower.contains(".mp4")) { + return progressive(streamUrl, MimeTypes.VIDEO_MP4); + } + if (lower.contains(".ts")) { + return progressive(streamUrl, MimeTypes.VIDEO_MP2T); + } + return hls(streamUrl); + } + + public static ResolvedStream hls(String streamUrl) { + return new ResolvedStream(streamUrl, MimeTypes.APPLICATION_M3U8, null, null); + } + + public static ResolvedStream dash(String streamUrl) { + return new ResolvedStream(streamUrl, MimeTypes.APPLICATION_MPD, null, null); + } + + public static ResolvedStream dashClearKey(String streamUrl, + String clearKeyIdHex, + String clearKeyHex) { + return new ResolvedStream(streamUrl, + MimeTypes.APPLICATION_MPD, + clearKeyIdHex, + clearKeyHex); + } + + public static ResolvedStream progressive(String streamUrl, String mimeType) { + return new ResolvedStream(streamUrl, mimeType, null, null); + } + + public String getStreamUrl() { + return streamUrl; + } + + public String getMimeType() { + return mimeType; + } + + public String getClearKeyIdHex() { + return clearKeyIdHex; + } + + public String getClearKeyHex() { + return clearKeyHex; + } + + public boolean hasClearKey() { + return clearKeyIdHex != null && clearKeyHex != null; + } + } +} diff --git a/app/src/main/java/com/streamplayer/VlcPlayerConfig.java b/app/src/main/java/com/streamplayer/VlcPlayerConfig.java new file mode 100644 index 0000000..a703c4e --- /dev/null +++ b/app/src/main/java/com/streamplayer/VlcPlayerConfig.java @@ -0,0 +1,14 @@ +package com.streamplayer; + +public final class VlcPlayerConfig { + + // User Agent + public static final String USER_AGENT = + "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"; + + // Maximum retries for playback + public static final int MAX_RETRIES = 3; + + private VlcPlayerConfig() { + } +} diff --git a/app/src/main/res/color/section_text_selector.xml b/app/src/main/res/color/section_text_selector.xml new file mode 100644 index 0000000..c20609b --- /dev/null +++ b/app/src/main/res/color/section_text_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/banner_streamplayer.xml b/app/src/main/res/drawable/banner_streamplayer.xml new file mode 100644 index 0000000..ae786fd --- /dev/null +++ b/app/src/main/res/drawable/banner_streamplayer.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_channel_item_selector.xml b/app/src/main/res/drawable/bg_channel_item_selector.xml new file mode 100644 index 0000000..a31cbae --- /dev/null +++ b/app/src/main/res/drawable/bg_channel_item_selector.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_section_indicator.xml b/app/src/main/res/drawable/bg_section_indicator.xml new file mode 100644 index 0000000..fdbfdeb --- /dev/null +++ b/app/src/main/res/drawable/bg_section_indicator.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/btn_refresh_selector.xml b/app/src/main/res/drawable/btn_refresh_selector.xml new file mode 100644 index 0000000..0a0ab15 --- /dev/null +++ b/app/src/main/res/drawable/btn_refresh_selector.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_channel_default.xml b/app/src/main/res/drawable/ic_channel_default.xml new file mode 100644 index 0000000..4d3663b --- /dev/null +++ b/app/src/main/res/drawable/ic_channel_default.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/scrollbar_vertical.xml b/app/src/main/res/drawable/scrollbar_vertical.xml new file mode 100644 index 0000000..0e24e05 --- /dev/null +++ b/app/src/main/res/drawable/scrollbar_vertical.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..37eef73 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + +