Add v9.0: Auto-update system with Gitea integration

- Implement UpdateManager to check Gitea releases for new versions
- Add update dialogs with mandatory/optional update support
- Integrate DownloadManager for APK downloads
- Add FileProvider configuration for app installation
- Support update-manifest.json for version control enforcement
- Add comprehensive update strings and error handling
- Include gradlew executable permissions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
renato97
2025-11-23 21:14:14 +01:00
parent d2d66a7906
commit cf11aa04bc
9 changed files with 746 additions and 5 deletions

View File

@@ -82,6 +82,44 @@ chmod +x build_apk.sh
./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 ## 📱 Estructura del Proyecto
``` ```
@@ -146,8 +184,8 @@ String[] GOOGLE_DNS = {"8.8.8.8", "8.8.4.4"};
| `applicationId` | `com.streamplayer` | | `applicationId` | `com.streamplayer` |
| `minSdk` | 21 | | `minSdk` | 21 |
| `targetSdk` | 33 | | `targetSdk` | 33 |
| `versionCode` | 1 | | `versionCode` | 90000 |
| `versionName` | "1.0" | | `versionName` | "9.0.0" |
| `compileSdk` | 33 | | `compileSdk` | 33 |
## 🔐 Permisos y Seguridad ## 🔐 Permisos y Seguridad

View File

@@ -8,8 +8,8 @@ android {
applicationId "com.streamplayer" applicationId "com.streamplayer"
minSdk 21 minSdk 21
targetSdk 33 targetSdk 33
versionCode 1 versionCode 90000
versionName "1.0" versionName "9.0.0"
} }
buildTypes { buildTypes {
@@ -30,6 +30,10 @@ android {
abortOnError = false abortOnError = false
} }
buildFeatures {
buildConfig = true
}
packaging { packaging {
resources { resources {
excludes += 'META-INF/com.android.tools/proguard/coroutines.pro' excludes += 'META-INF/com.android.tools/proguard/coroutines.pro'

View File

@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature <uses-feature
android:name="android.software.leanback" android:name="android.software.leanback"
android:required="false" /> android:required="false" />
@@ -22,6 +23,16 @@
android:theme="@style/Theme.StreamPlayer" android:theme="@style/Theme.StreamPlayer"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity <activity
android:name=".PlayerActivity" android:name=".PlayerActivity"
android:exported="false" android:exported="false"

View File

@@ -1,10 +1,13 @@
package com.streamplayer; package com.streamplayer;
import android.app.AlertDialog;
import android.content.Intent; import android.content.Intent;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.view.View; import android.view.View;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.GridLayoutManager;
@@ -35,6 +38,8 @@ public class MainActivity extends AppCompatActivity {
private final List<EventItem> cachedEvents = new ArrayList<>(); private final List<EventItem> cachedEvents = new ArrayList<>();
private List<SectionEntry> sections; private List<SectionEntry> sections;
private SectionEntry currentSection; private SectionEntry currentSection;
private UpdateManager updateManager;
private AlertDialog updateDialog;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@@ -60,6 +65,45 @@ public class MainActivity extends AppCompatActivity {
sectionList.setAdapter(sectionAdapter); sectionList.setAdapter(sectionAdapter);
selectSection(0); 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) { private void selectSection(int index) {
@@ -151,6 +195,87 @@ public class MainActivity extends AppCompatActivity {
startActivity(intent); 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() { private int getSpanCount() {
return getResources().getInteger(R.integer.channel_grid_span); return getResources().getInteger(R.integer.channel_grid_span);
} }

View File

@@ -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<Activity> 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);
}
}
}

View File

@@ -7,4 +7,33 @@
<string name="message_no_channels">No hay canales disponibles</string> <string name="message_no_channels">No hay canales disponibles</string>
<string name="message_no_events">No hay eventos disponibles</string> <string name="message_no_events">No hay eventos disponibles</string>
<string name="message_events_error">No se pudieron cargar los eventos: %1$s</string> <string name="message_events_error">No se pudieron cargar los eventos: %1$s</string>
<string name="update_required_title">Actualización obligatoria</string>
<string name="update_available_title">Actualización disponible</string>
<string name="update_action_download">Actualizar ahora</string>
<string name="update_action_view_release">Ver detalles</string>
<string name="update_action_close_app">Cerrar aplicación</string>
<string name="update_action_later">Más tarde</string>
<string name="update_current_version">Versión instalada: %1$s (%2$d)</string>
<string name="update_latest_version">Última versión publicada: %1$s (%2$d)</string>
<string name="update_min_supported">Versiones anteriores a %1$d ya no están permitidas.</string>
<string name="update_download_size">Tamaño aproximado: %1$s</string>
<string name="update_downloads">Descargas registradas: %1$d</string>
<string name="update_release_notes_title">Novedades</string>
<string name="update_optional_hint">Puedes continuar usando la app, pero recomendamos instalar la actualización para obtener el mejor rendimiento.</string>
<string name="update_error_checking">No se pudo verificar actualizaciones (%1$s)</string>
<string name="update_error_open_release">No se pudo abrir el detalle de la versión</string>
<string name="update_error_empty_response">Respuesta vacía del servidor de releases</string>
<string name="update_error_http">Error de red (%1$d)</string>
<string name="update_error_missing_url">No se encontró URL de descarga</string>
<string name="update_error_download_manager">DownloadManager no está disponible en este dispositivo</string>
<string name="update_error_storage">No se pudo preparar el almacenamiento para la actualización</string>
<string name="update_error_download">Error al iniciar la descarga: %1$s</string>
<string name="update_download_started">Descarga iniciada, revisa la notificación para ver el progreso</string>
<string name="update_download_complete">Descarga finalizada, preparando instalación…</string>
<string name="update_error_download_failed">La descarga falló (código %1$d)</string>
<string name="update_error_install_permissions">No se pudo abrir la configuración de instalación desconocida</string>
<string name="update_permission_request">Habilita "Instalar apps desconocidas" para StreamPlayer y regresa para continuar.</string>
<string name="update_error_install_intent">No se pudo abrir el instalador de paquetes</string>
<string name="update_notification_title">StreamPlayer %1$s</string>
<string name="update_notification_description">Descargando nueva versión</string>
</resources> </resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path
name="updates"
path="." />
</paths>

0
gradlew vendored Normal file → Executable file
View File

View File

@@ -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"
}