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:
518
app/src/main/java/com/streamplayer/UpdateManager.java
Normal file
518
app/src/main/java/com/streamplayer/UpdateManager.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user