diff --git a/README.md b/README.md
index 419c98a..ea43a33 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,44 @@ 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
```
@@ -146,8 +184,8 @@ String[] GOOGLE_DNS = {"8.8.8.8", "8.8.4.4"};
| `applicationId` | `com.streamplayer` |
| `minSdk` | 21 |
| `targetSdk` | 33 |
-| `versionCode` | 1 |
-| `versionName` | "1.0" |
+| `versionCode` | 90000 |
+| `versionName` | "9.0.0" |
| `compileSdk` | 33 |
## 🔐 Permisos y Seguridad
@@ -217,4 +255,4 @@ Para soporte y preguntas:
---
-**🔗 Repositorio**: https://gitea.cbcren.online/renato97/app
\ No newline at end of file
+**🔗 Repositorio**: https://gitea.cbcren.online/renato97/app
diff --git a/app/build.gradle b/app/build.gradle
index 8121d6c..19d1907 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -8,8 +8,8 @@ android {
applicationId "com.streamplayer"
minSdk 21
targetSdk 33
- versionCode 1
- versionName "1.0"
+ versionCode 90000
+ versionName "9.0.0"
}
buildTypes {
@@ -30,6 +30,10 @@ android {
abortOnError = false
}
+ buildFeatures {
+ buildConfig = true
+ }
+
packaging {
resources {
excludes += 'META-INF/com.android.tools/proguard/coroutines.pro'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c566f25..606252c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
@@ -22,6 +23,16 @@
android:theme="@style/Theme.StreamPlayer"
android:usesCleartextTraffic="true">
+
+
+
+
cachedEvents = new ArrayList<>();
private List sections;
private SectionEntry currentSection;
+ private UpdateManager updateManager;
+ private AlertDialog updateDialog;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -60,6 +65,45 @@ public class MainActivity extends AppCompatActivity {
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) {
@@ -151,6 +195,87 @@ public class MainActivity extends AppCompatActivity {
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.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 (Exception 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);
}
diff --git a/app/src/main/java/com/streamplayer/UpdateManager.java b/app/src/main/java/com/streamplayer/UpdateManager.java
new file mode 100644
index 0000000..71043e6
--- /dev/null
+++ b/app/src/main/java/com/streamplayer/UpdateManager.java
@@ -0,0 +1,518 @@
+package com.streamplayer;
+
+import android.app.Activity;
+import android.app.DownloadManager;
+import android.content.ActivityNotFoundException;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.text.format.Formatter;
+import android.util.Log;
+import android.widget.Toast;
+
+import androidx.core.content.FileProvider;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+/**
+ * Encapsula toda la lógica para consultar releases de Gitea, descargar el APK y lanzarlo.
+ */
+public class UpdateManager {
+
+ private static final String TAG = "UpdateManager";
+ private static final String LATEST_RELEASE_URL =
+ "https://gitea.cbcren.online/api/v1/repos/renato97/app/releases/latest";
+
+ private final Context appContext;
+ private final Handler mainHandler;
+ private final ExecutorService networkExecutor;
+ private final OkHttpClient httpClient;
+
+ private WeakReference activityRef;
+ private DownloadReceiver downloadReceiver;
+ private long currentDownloadId = -1L;
+ private File downloadingFile;
+ private File pendingInstallFile;
+ private UpdateInfo cachedUpdate;
+
+ public UpdateManager(Context context) {
+ this.appContext = context.getApplicationContext();
+ this.mainHandler = new Handler(Looper.getMainLooper());
+ this.networkExecutor = Executors.newSingleThreadExecutor();
+ this.httpClient = new OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(20, TimeUnit.SECONDS)
+ .callTimeout(25, TimeUnit.SECONDS)
+ .build();
+ }
+
+ public void checkForUpdates(UpdateCallback callback) {
+ networkExecutor.execute(() -> {
+ try {
+ Request request = new Request.Builder()
+ .url(LATEST_RELEASE_URL)
+ .get()
+ .build();
+ try (Response response = httpClient.newCall(request).execute()) {
+ if (response.body() == null) {
+ postError(callback, appContext.getString(R.string.update_error_empty_response));
+ return;
+ }
+ if (!response.isSuccessful()) {
+ postError(callback, appContext.getString(R.string.update_error_http,
+ response.code()));
+ return;
+ }
+ String body = response.body().string();
+ UpdateInfo info = parseRelease(body);
+ cachedUpdate = info;
+ if (info != null && info.isUpdateAvailable(BuildConfig.VERSION_CODE)) {
+ postAvailable(callback, info);
+ } else {
+ postUpToDate(callback);
+ }
+ }
+ } catch (IOException | JSONException e) {
+ Log.w(TAG, "Error checking updates", e);
+ postError(callback, e.getMessage());
+ }
+ });
+ }
+
+ public UpdateInfo getCachedUpdate() {
+ return cachedUpdate;
+ }
+
+ public void downloadUpdate(Activity activity, UpdateInfo info) {
+ if (info == null || TextUtils.isEmpty(info.downloadUrl)) {
+ showToast(appContext.getString(R.string.update_error_missing_url));
+ return;
+ }
+ DownloadManager downloadManager =
+ (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
+ if (downloadManager == null) {
+ showToast(appContext.getString(R.string.update_error_download_manager));
+ return;
+ }
+ File targetDir = appContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
+ if (targetDir == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ targetDir = appContext.getExternalFilesDir(null);
+ }
+ if (targetDir == null) {
+ showToast(appContext.getString(R.string.update_error_storage));
+ return;
+ }
+ if (!targetDir.exists() && !targetDir.mkdirs()) {
+ showToast(appContext.getString(R.string.update_error_storage));
+ return;
+ }
+ String fileName = info.getResolvedFileName();
+ File apkFile = new File(targetDir, fileName);
+ if (apkFile.exists() && !apkFile.delete()) {
+ showToast(appContext.getString(R.string.update_error_storage));
+ return;
+ }
+
+ Uri destination = Uri.fromFile(apkFile);
+ DownloadManager.Request request = new DownloadManager.Request(Uri.parse(info.downloadUrl));
+ request.setTitle(appContext.getString(R.string.update_notification_title, info.versionName));
+ request.setDescription(appContext.getString(R.string.update_notification_description));
+ request.setAllowedOverMetered(true);
+ request.setAllowedOverRoaming(false);
+ request.setNotificationVisibility(
+ DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
+ request.setDestinationUri(destination);
+
+ try {
+ currentDownloadId = downloadManager.enqueue(request);
+ } catch (IllegalArgumentException e) {
+ showToast(appContext.getString(R.string.update_error_download, e.getMessage()));
+ return;
+ }
+ downloadingFile = apkFile;
+ activityRef = new WeakReference<>(activity);
+ registerDownloadReceiver();
+ showToast(appContext.getString(R.string.update_download_started));
+ }
+
+ public void resumePendingInstall(Activity activity) {
+ if (pendingInstallFile == null || !pendingInstallFile.exists()) {
+ return;
+ }
+ installDownloadedApk(activity, pendingInstallFile);
+ }
+
+ public void release() {
+ unregisterDownloadReceiver();
+ networkExecutor.shutdownNow();
+ activityRef = null;
+ }
+
+ private UpdateInfo parseRelease(String responseBody) throws JSONException, IOException {
+ JSONObject releaseJson = new JSONObject(responseBody);
+ String tagName = releaseJson.optString("tag_name", "");
+ String versionName = deriveVersionName(tagName, releaseJson.optString("name"));
+ int versionCode = parseVersionCode(versionName);
+ String releaseNotes = releaseJson.optString("body", "");
+ String releasePageUrl = releaseJson.optString("html_url", "");
+ JSONArray assets = releaseJson.optJSONArray("assets");
+ JSONObject apkAsset = findApkAsset(assets);
+ String downloadUrl = apkAsset != null ? apkAsset.optString("browser_download_url", "") : "";
+ String downloadFileName = apkAsset != null ? apkAsset.optString("name", "") : "";
+ long sizeBytes = apkAsset != null ? apkAsset.optLong("size", 0L) : 0L;
+ int downloadCount = apkAsset != null ? apkAsset.optInt("download_count", 0) : 0;
+
+ int minSupported = 0;
+ boolean forceUpdate = false;
+ JSONObject manifestJson = fetchManifest(assets);
+ if (manifestJson != null) {
+ versionCode = manifestJson.optInt("versionCode", versionCode);
+ versionName = manifestJson.optString("versionName", versionName);
+ minSupported = manifestJson.optInt("minSupportedVersionCode", 0);
+ forceUpdate = manifestJson.optBoolean("forceUpdate", false);
+ String manifestUrl = manifestJson.optString("downloadUrl", null);
+ if (!TextUtils.isEmpty(manifestUrl)) {
+ downloadUrl = manifestUrl;
+ }
+ if (manifestJson.has("fileName")) {
+ downloadFileName = manifestJson.optString("fileName", downloadFileName);
+ }
+ if (manifestJson.has("sizeBytes")) {
+ sizeBytes = manifestJson.optLong("sizeBytes", sizeBytes);
+ }
+ if (manifestJson.has("notes") && TextUtils.isEmpty(releaseNotes)) {
+ releaseNotes = manifestJson.optString("notes", releaseNotes);
+ }
+ }
+
+ if (TextUtils.isEmpty(downloadUrl)) {
+ return null;
+ }
+
+ return new UpdateInfo(versionCode, versionName, releaseNotes, downloadUrl,
+ downloadFileName, sizeBytes, downloadCount, releasePageUrl,
+ minSupported, forceUpdate);
+ }
+
+ private JSONObject fetchManifest(JSONArray assets) throws IOException, JSONException {
+ if (assets == null) {
+ return null;
+ }
+ for (int i = 0; i < assets.length(); i++) {
+ JSONObject asset = assets.optJSONObject(i);
+ if (asset == null) {
+ continue;
+ }
+ String name = asset.optString("name", "").toLowerCase(Locale.US);
+ if (TextUtils.isEmpty(name) || !name.endsWith(".json")) {
+ continue;
+ }
+ if (!(name.contains("update") || name.contains("manifest"))) {
+ continue;
+ }
+ String url = asset.optString("browser_download_url", "");
+ if (TextUtils.isEmpty(url)) {
+ continue;
+ }
+ Request request = new Request.Builder().url(url).get().build();
+ try (Response response = httpClient.newCall(request).execute()) {
+ if (!response.isSuccessful() || response.body() == null) {
+ continue;
+ }
+ String json = response.body().string();
+ if (!TextUtils.isEmpty(json)) {
+ return new JSONObject(json);
+ }
+ }
+ }
+ return null;
+ }
+
+ private JSONObject findApkAsset(JSONArray assets) {
+ if (assets == null) {
+ return null;
+ }
+ JSONObject fallback = null;
+ for (int i = 0; i < assets.length(); i++) {
+ JSONObject asset = assets.optJSONObject(i);
+ if (asset == null) {
+ continue;
+ }
+ if (fallback == null) {
+ fallback = asset;
+ }
+ String name = asset.optString("name", "").toLowerCase(Locale.US);
+ if (name.endsWith(".apk")) {
+ return asset;
+ }
+ }
+ return fallback;
+ }
+
+ private String deriveVersionName(String tagName, String fallback) {
+ String base = !TextUtils.isEmpty(tagName) ? tagName : fallback;
+ if (TextUtils.isEmpty(base)) {
+ return "";
+ }
+ return base.replaceFirst("^[Vv]", "").trim();
+ }
+
+ private int parseVersionCode(String versionName) {
+ if (TextUtils.isEmpty(versionName)) {
+ return -1;
+ }
+ String normalized = versionName.replaceAll("[^0-9\\.]", "");
+ if (TextUtils.isEmpty(normalized)) {
+ return -1;
+ }
+ String[] parts = normalized.split("\\.");
+ int major = parsePart(parts, 0);
+ int minor = parsePart(parts, 1);
+ int patch = parsePart(parts, 2);
+ return major * 10000 + minor * 100 + patch;
+ }
+
+ private int parsePart(String[] parts, int index) {
+ if (parts.length <= index) {
+ return 0;
+ }
+ try {
+ return Integer.parseInt(parts[index]);
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+
+ private void installDownloadedApk(Activity activity, File apkFile) {
+ if (activity == null || apkFile == null || !apkFile.exists()) {
+ return;
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ boolean canInstall = appContext.getPackageManager().canRequestPackageInstalls();
+ if (!canInstall) {
+ pendingInstallFile = apkFile;
+ Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
+ Uri.parse("package:" + appContext.getPackageName()));
+ try {
+ activity.startActivity(intent);
+ } catch (ActivityNotFoundException ignored) {
+ showToast(appContext.getString(R.string.update_error_install_permissions));
+ }
+ showToast(appContext.getString(R.string.update_permission_request));
+ return;
+ }
+ }
+ Uri uri = FileProvider.getUriForFile(appContext,
+ appContext.getPackageName() + ".fileprovider", apkFile);
+ Intent installIntent = new Intent(Intent.ACTION_VIEW);
+ installIntent.setDataAndType(uri, "application/vnd.android.package-archive");
+ installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ try {
+ activity.startActivity(installIntent);
+ pendingInstallFile = null;
+ } catch (ActivityNotFoundException e) {
+ showToast(appContext.getString(R.string.update_error_install_intent));
+ }
+ }
+
+ private void registerDownloadReceiver() {
+ if (downloadReceiver != null) {
+ return;
+ }
+ downloadReceiver = new DownloadReceiver();
+ IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
+ appContext.registerReceiver(downloadReceiver, filter);
+ }
+
+ private void unregisterDownloadReceiver() {
+ if (downloadReceiver != null) {
+ try {
+ appContext.unregisterReceiver(downloadReceiver);
+ } catch (IllegalArgumentException ignored) {
+ }
+ downloadReceiver = null;
+ }
+ }
+
+ private void handleDownloadComplete(long downloadId) {
+ if (downloadId != currentDownloadId) {
+ return;
+ }
+ DownloadManager downloadManager =
+ (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
+ if (downloadManager == null) {
+ showToast(appContext.getString(R.string.update_error_download_manager));
+ cleanupDownloadState();
+ return;
+ }
+ DownloadManager.Query query = new DownloadManager.Query();
+ query.setFilterById(downloadId);
+ Cursor cursor = downloadManager.query(query);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ int status = cursor.getInt(cursor.getColumnIndexOrThrow(
+ DownloadManager.COLUMN_STATUS));
+ if (status == DownloadManager.STATUS_SUCCESSFUL && downloadingFile != null) {
+ pendingInstallFile = downloadingFile;
+ Activity activity = activityRef != null ? activityRef.get() : null;
+ mainHandler.post(() -> {
+ showToast(appContext.getString(R.string.update_download_complete));
+ installDownloadedApk(activity, pendingInstallFile);
+ });
+ } else {
+ int reason = cursor.getInt(cursor.getColumnIndexOrThrow(
+ DownloadManager.COLUMN_REASON));
+ mainHandler.post(() -> showToast(appContext.getString(
+ R.string.update_error_download_failed, reason)));
+ }
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ cleanupDownloadState();
+ }
+
+ private void cleanupDownloadState() {
+ unregisterDownloadReceiver();
+ currentDownloadId = -1L;
+ downloadingFile = null;
+ }
+
+ private void postAvailable(UpdateCallback callback, UpdateInfo info) {
+ if (callback == null) {
+ return;
+ }
+ mainHandler.post(() -> callback.onUpdateAvailable(info));
+ }
+
+ private void postUpToDate(UpdateCallback callback) {
+ if (callback == null) {
+ return;
+ }
+ mainHandler.post(callback::onUpToDate);
+ }
+
+ private void postError(UpdateCallback callback, String message) {
+ if (callback == null) {
+ return;
+ }
+ mainHandler.post(() -> callback.onError(message));
+ }
+
+ private void showToast(String message) {
+ mainHandler.post(() -> Toast.makeText(appContext, message, Toast.LENGTH_LONG).show());
+ }
+
+ private class DownloadReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L);
+ handleDownloadComplete(id);
+ }
+ }
+
+ public interface UpdateCallback {
+ void onUpdateAvailable(UpdateInfo info);
+
+ void onUpToDate();
+
+ void onError(String message);
+ }
+
+ public static class UpdateInfo {
+ public final int versionCode;
+ public final String versionName;
+ public final String releaseNotes;
+ public final String downloadUrl;
+ public final String downloadFileName;
+ public final long downloadSizeBytes;
+ public final int downloadCount;
+ public final String releasePageUrl;
+ public final int minSupportedVersionCode;
+ public final boolean forceUpdate;
+
+ UpdateInfo(int versionCode,
+ String versionName,
+ String releaseNotes,
+ String downloadUrl,
+ String downloadFileName,
+ long downloadSizeBytes,
+ int downloadCount,
+ String releasePageUrl,
+ int minSupportedVersionCode,
+ boolean forceUpdate) {
+ this.versionCode = versionCode;
+ this.versionName = versionName;
+ this.releaseNotes = releaseNotes == null ? "" : releaseNotes.trim();
+ this.downloadUrl = downloadUrl;
+ this.downloadFileName = downloadFileName;
+ this.downloadSizeBytes = downloadSizeBytes;
+ this.downloadCount = downloadCount;
+ this.releasePageUrl = releasePageUrl;
+ this.minSupportedVersionCode = minSupportedVersionCode;
+ this.forceUpdate = forceUpdate;
+ }
+
+ public boolean isUpdateAvailable(int currentVersionCode) {
+ return versionCode > currentVersionCode;
+ }
+
+ public boolean isMandatory(int currentVersionCode) {
+ return forceUpdate || currentVersionCode < minSupportedVersionCode;
+ }
+
+ public String getReleaseNotesPreview() {
+ if (TextUtils.isEmpty(releaseNotes)) {
+ return "";
+ }
+ final int limit = 900;
+ if (releaseNotes.length() <= limit) {
+ return releaseNotes;
+ }
+ return releaseNotes.substring(0, limit) + "\n…";
+ }
+
+ public String getResolvedFileName() {
+ if (!TextUtils.isEmpty(downloadFileName)) {
+ return downloadFileName;
+ }
+ String safeVersion = TextUtils.isEmpty(versionName) ? String.valueOf(versionCode)
+ : versionName.replaceAll("[^0-9a-zA-Z._-]", "");
+ return "StreamPlayer-" + safeVersion + ".apk";
+ }
+
+ public String formatSize(Context context) {
+ if (downloadSizeBytes <= 0) {
+ return "";
+ }
+ return Formatter.formatShortFileSize(context, downloadSizeBytes);
+ }
+ }
+}
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c25d68d..d8c6a6a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -7,4 +7,33 @@
No hay canales disponibles
No hay eventos disponibles
No se pudieron cargar los eventos: %1$s
+ Actualización obligatoria
+ Actualización disponible
+ Actualizar ahora
+ Ver detalles
+ Cerrar aplicación
+ Más tarde
+ Versión instalada: %1$s (%2$d)
+ Última versión publicada: %1$s (%2$d)
+ Versiones anteriores a %1$d ya no están permitidas.
+ Tamaño aproximado: %1$s
+ Descargas registradas: %1$d
+ Novedades
+ Puedes continuar usando la app, pero recomendamos instalar la actualización para obtener el mejor rendimiento.
+ No se pudo verificar actualizaciones (%1$s)
+ No se pudo abrir el detalle de la versión
+ Respuesta vacía del servidor de releases
+ Error de red (%1$d)
+ No se encontró URL de descarga
+ DownloadManager no está disponible en este dispositivo
+ No se pudo preparar el almacenamiento para la actualización
+ Error al iniciar la descarga: %1$s
+ Descarga iniciada, revisa la notificación para ver el progreso
+ Descarga finalizada, preparando instalación…
+ La descarga falló (código %1$d)
+ No se pudo abrir la configuración de instalación desconocida
+ Habilita "Instalar apps desconocidas" para StreamPlayer y regresa para continuar.
+ No se pudo abrir el instalador de paquetes
+ StreamPlayer %1$s
+ Descargando nueva versión
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..bb3185d
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/gradlew b/gradlew
old mode 100644
new mode 100755
diff --git a/release/update-manifest.example.json b/release/update-manifest.example.json
new file mode 100644
index 0000000..77dc329
--- /dev/null
+++ b/release/update-manifest.example.json
@@ -0,0 +1,10 @@
+{
+ "versionCode": 90000,
+ "versionName": "9.0.0",
+ "minSupportedVersionCode": 80000,
+ "forceUpdate": false,
+ "downloadUrl": "https://gitea.cbcren.online/renato97/app/releases/download/v9.0/StreamPlayer-v9.0-DefinitiveEdition.apk",
+ "fileName": "StreamPlayer-v9.0-DefinitiveEdition.apk",
+ "sizeBytes": 12000000,
+ "notes": "Texto opcional si necesitas personalizar las notas que verá el usuario"
+}