diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 45c5dfef8c..7b11cb54fd 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -29,8 +29,7 @@ SPDX-License-Identifier: GPL-3.0-or-later - - + + + + + diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index 4b93e36254..394a1f8e55 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -222,6 +222,11 @@ object NativeLibrary { */ external fun getUpdateUrl(version: String): String + /** + * Return the URL to download the APK for the given version + */ + external fun getUpdateApkUrl(version: String, packageId: String): String + /** * Returns whether the update checker is enabled through CMAKE options. */ diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt index f6f5ede224..d8100e07e2 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -762,6 +762,7 @@ abstract class SettingsItem( SwitchSetting( BooleanSetting.ENABLE_UPDATE_CHECKS, titleId = R.string.enable_update_checks, + descriptionId = R.string.enable_update_checks_description, ) ) put( diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index da790a4fc4..538d8f6e49 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -53,8 +53,16 @@ import androidx.core.content.edit import org.yuzu.yuzu_emu.activities.EmulationActivity import kotlin.text.compareTo import androidx.core.net.toUri +import com.google.android.material.progressindicator.LinearProgressIndicator +import com.google.android.material.textview.MaterialTextView import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.updater.APKDownloader +import org.yuzu.yuzu_emu.updater.APKInstaller +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class MainActivity : AppCompatActivity(), ThemeProvider { private lateinit var binding: ActivityMainBinding @@ -186,9 +194,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { .setTitle(R.string.update_available) .setMessage(getString(R.string.update_available_description, version)) .setPositiveButton(android.R.string.ok) { _, _ -> - val url = NativeLibrary.getUpdateUrl(version) - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - startActivity(intent) + downloadAndInstallUpdate(version) } .setNeutralButton(R.string.cancel) { dialog, _ -> dialog.dismiss() @@ -201,6 +207,87 @@ class MainActivity : AppCompatActivity(), ThemeProvider { .show() } + private fun downloadAndInstallUpdate(version: String) { + CoroutineScope(Dispatchers.IO).launch { + val packageId = applicationContext.packageName + val apkUrl = NativeLibrary.getUpdateApkUrl(version, packageId) + val apkFile = File(cacheDir, "update-$version.apk") + + withContext(Dispatchers.Main) { + showDownloadProgressDialog() + } + + val downloader = APKDownloader(apkUrl, apkFile) + downloader.download( + onProgress = { progress -> + runOnUiThread { + updateDownloadProgress(progress) + } + }, + onComplete = { success -> + runOnUiThread { + dismissDownloadProgressDialog() + if (success) { + val installer = APKInstaller(this@MainActivity) + installer.install( + apkFile, + onComplete = { + Toast.makeText( + this@MainActivity, + R.string.update_installed_successfully, + Toast.LENGTH_LONG + ).show() + }, + onFailure = { exception -> + Toast.makeText( + this@MainActivity, + getString(R.string.update_install_failed, exception.message), + Toast.LENGTH_LONG + ).show() + } + ) + } else { + Toast.makeText( + this@MainActivity, + getString(R.string.update_download_failed) + "\n\nURL: $apkUrl", + Toast.LENGTH_LONG + ).show() + } + } + } + ) + } + } + + private var progressDialog: androidx.appcompat.app.AlertDialog? = null + private var progressBar: LinearProgressIndicator? = null + private var progressMessage: MaterialTextView? = null + + private fun showDownloadProgressDialog() { + val progressView = layoutInflater.inflate(R.layout.dialog_download_progress, null) + progressBar = progressView.findViewById(R.id.download_progress_bar) + progressMessage = progressView.findViewById(R.id.download_progress_message) + + progressDialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.downloading_update) + .setView(progressView) + .setCancelable(false) + .create() + progressDialog?.show() + } + + private fun updateDownloadProgress(progress: Int) { + progressBar?.progress = progress + progressMessage?.text = "$progress%" + } + + private fun dismissDownloadProgressDialog() { + progressDialog?.dismiss() + progressDialog = null + progressBar = null + progressMessage = null + } + fun displayMultiplayerDialog() { val dialog = NetPlayDialog(this) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/APKDownloader.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/APKDownloader.kt new file mode 100644 index 0000000000..4484bfd5de --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/APKDownloader.kt @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.updater + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +class APKDownloader(private val url: String, private val outputFile: File) { + + fun download(onProgress: (Int) -> Unit, onComplete: (Boolean) -> Unit) { + val client = OkHttpClient() + val request = Request.Builder().url(url).build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + e.printStackTrace() + onComplete(false) + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + response.body?.let { body -> + val contentLength = body.contentLength() + try { + val inputStream = body.byteStream() + val outputStream = FileOutputStream(outputFile) + val buffer = ByteArray(4096) + var bytesRead: Int + var totalBytesRead: Long = 0 + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + val progress = (totalBytesRead * 100 / contentLength).toInt() + onProgress(progress) + } + outputStream.flush() + outputStream.close() + inputStream.close() + onComplete(true) + } catch (e: IOException) { + e.printStackTrace() + onComplete(false) + } + } ?: run { + onComplete(false) + } + } else { + onComplete(false) + } + } + }) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/APKInstaller.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/APKInstaller.kt new file mode 100644 index 0000000000..9014597ff9 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/APKInstaller.kt @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.updater + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import androidx.core.content.FileProvider +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.io.File + +class APKInstaller(private val context: Context) { + + fun install(apkFile: File, onComplete: () -> Unit, onFailure: (Exception) -> Unit) { + try { + val apkUri: Uri = FileProvider.getUriForFile( + context, + context.applicationContext.packageName + ".provider", + apkFile + ) + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(apkUri, "application/vnd.android.package-archive") + intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + + GlobalScope.launch { + val receiver = AppInstallReceiver(onComplete, onFailure) + context.registerReceiver(receiver, IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addDataScheme("package") + }) + } + } catch (e: Exception) { + onFailure(e) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/AppInstallReceiver.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/AppInstallReceiver.kt new file mode 100644 index 0000000000..d58c7c2de2 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/AppInstallReceiver.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.updater + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class AppInstallReceiver( + private val onComplete: () -> Unit, + private val onFailure: (Exception) -> Unit +) : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val packageName = intent.data?.schemeSpecificPart + when (intent.action) { + Intent.ACTION_PACKAGE_ADDED, Intent.ACTION_PACKAGE_REPLACED -> { + Log.i("AppInstallReceiver", "Package installed or updated: $packageName") + onComplete() + context.unregisterReceiver(this) + } + else -> { + onFailure(Exception("Installation failed for package: $packageName")) + context.unregisterReceiver(this) + } + } + } +} diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index b0a414d1c3..f28f4a9b7b 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -1613,6 +1613,37 @@ JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getUpdateUrl( env->ReleaseStringUTFChars(version, version_str); return env->NewStringUTF(url.c_str()); } + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getUpdateApkUrl( + JNIEnv* env, + jobject obj, + jstring version, + jstring packageId) { + const char* version_str = env->GetStringUTFChars(version, nullptr); + const char* package_id_str = env->GetStringUTFChars(packageId, nullptr); + + std::string variant; + std::string package_id(package_id_str); + + if (package_id.find("dev.legacy.eden_emulator") != std::string::npos) { + variant = "legacy"; + } else if (package_id.find("com.miHoYo.Yuanshen") != std::string::npos) { + variant = "optimized"; + } else { + variant = "standard"; + } + + const std::string apk_filename = fmt::format("Eden-Android-{}-{}.apk", version_str, variant); + const std::string url = fmt::format("{}/{}/releases/download/{}/{}", + std::string{Common::g_build_auto_update_website}, + std::string{Common::g_build_auto_update_repo}, + version_str, + apk_filename); + + env->ReleaseStringUTFChars(version, version_str); + env->ReleaseStringUTFChars(packageId, package_id_str); + return env->NewStringUTF(url.c_str()); +} #endif JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getBuildVersion( diff --git a/src/android/app/src/main/res/layout/dialog_download_progress.xml b/src/android/app/src/main/res/layout/dialog_download_progress.xml new file mode 100644 index 0000000000..896864085e --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_download_progress.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 265b74d66e..9daf3642e8 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -271,9 +271,14 @@ Folder Don\'t Show Again New game directory added successfully - Check for updates on app startup. + Check for Updates + Check for updates on launch, and optionally download and install the new update. Update Available A new version is available: %1$s\n\nWould you like to download it? + Downloading Update + Failed to download update + Update installed successfully + Failed to install update: %1$s Search Settings No files were found or no game directory has been selected yet. diff --git a/src/android/app/src/main/res/xml/file_paths.xml b/src/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000000..6f38ecb7e4 --- /dev/null +++ b/src/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,8 @@ + + + + + + + +