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 @@
+
+
+
+
+
+
+
+