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 okhttp3.OkHttpClient; import okhttp3.Request; 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 static final String GITEA_TOKEN = "4b94b3610136529861af0821040a801906821a0f"; 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(); // Usar NetworkUtils para obtener cliente con DNS over HTTPS configurado this.httpClient = NetworkUtils.getClient(); } public void checkForUpdates(UpdateCallback callback) { networkExecutor.execute(() -> { try { Request request = new Request.Builder() .url(LATEST_RELEASE_URL) .header("Authorization", "token " + GITEA_TOKEN) .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 { if (responseBody == null || responseBody.trim().isEmpty()) { throw new JSONException("La respuesta está vacía"); } // Validar que no sea HTML antes de parsear String trimmed = responseBody.trim(); if (trimmed.startsWith("= 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); } } }