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