Browse Source

[android] Automatic update fetcher and APK installer (#2987)

This might need a test run before merging. Just to make sure.

Co-authored-by: crueter <crueter@eden-emu.dev>
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/2987
Reviewed-by: crueter <crueter@eden-emu.dev>
Reviewed-by: Lizzie <lizzie@eden-emu.dev>
Co-authored-by: kleidis <kleidis1@protonmail.com>
Co-committed-by: kleidis <kleidis1@protonmail.com>
xbzk-saf-recursive-write-with-permission-request
kleidis 4 weeks ago
committed by crueter
parent
commit
79b162a37c
No known key found for this signature in database GPG Key ID: 425ACD2D4830EBC6
  1. 13
      src/android/app/src/main/AndroidManifest.xml
  2. 5
      src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
  3. 1
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
  4. 93
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
  5. 61
      src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/APKDownloader.kt
  6. 41
      src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/APKInstaller.kt
  7. 30
      src/android/app/src/main/java/org/yuzu/yuzu_emu/updater/AppInstallReceiver.kt
  8. 31
      src/android/app/src/main/jni/native.cpp
  9. 31
      src/android/app/src/main/res/layout/dialog_download_progress.xml
  10. 7
      src/android/app/src/main/res/values/strings.xml
  11. 8
      src/android/app/src/main/res/xml/file_paths.xml

13
src/android/app/src/main/AndroidManifest.xml

@ -29,8 +29,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name="org.yuzu.yuzu_emu.YuzuApplication" android:name="org.yuzu.yuzu_emu.YuzuApplication"
@ -110,5 +109,15 @@ SPDX-License-Identifier: GPL-3.0-or-later
</intent-filter> </intent-filter>
</provider> </provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

5
src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt

@ -222,6 +222,11 @@ object NativeLibrary {
*/ */
external fun getUpdateUrl(version: String): String 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. * Returns whether the update checker is enabled through CMAKE options.
*/ */

1
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt

@ -762,6 +762,7 @@ abstract class SettingsItem(
SwitchSetting( SwitchSetting(
BooleanSetting.ENABLE_UPDATE_CHECKS, BooleanSetting.ENABLE_UPDATE_CHECKS,
titleId = R.string.enable_update_checks, titleId = R.string.enable_update_checks,
descriptionId = R.string.enable_update_checks_description,
) )
) )
put( put(

93
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 org.yuzu.yuzu_emu.activities.EmulationActivity
import kotlin.text.compareTo import kotlin.text.compareTo
import androidx.core.net.toUri 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.features.settings.model.BooleanSetting
import org.yuzu.yuzu_emu.YuzuApplication 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 { class MainActivity : AppCompatActivity(), ThemeProvider {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
@ -186,9 +194,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
.setTitle(R.string.update_available) .setTitle(R.string.update_available)
.setMessage(getString(R.string.update_available_description, version)) .setMessage(getString(R.string.update_available_description, version))
.setPositiveButton(android.R.string.ok) { _, _ -> .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, _ -> .setNeutralButton(R.string.cancel) { dialog, _ ->
dialog.dismiss() dialog.dismiss()
@ -201,6 +207,87 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
.show() .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() { fun displayMultiplayerDialog() {
val dialog = NetPlayDialog(this) val dialog = NetPlayDialog(this)

61
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)
}
}
})
}
}

41
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)
}
}
}

30
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)
}
}
}
}

31
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); env->ReleaseStringUTFChars(version, version_str);
return env->NewStringUTF(url.c_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 #endif
JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getBuildVersion( JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_getBuildVersion(

31
src/android/app/src/main/res/layout/dialog_download_progress.xml

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/download_progress_message"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="0%"
android:textAlignment="center"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/download_progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="100"
android:progress="0"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
</LinearLayout>

7
src/android/app/src/main/res/values/strings.xml

@ -271,9 +271,14 @@
<string name="folder">Folder</string> <string name="folder">Folder</string>
<string name="dont_show_again">Don\'t Show Again</string> <string name="dont_show_again">Don\'t Show Again</string>
<string name="add_directory_success">New game directory added successfully </string> <string name="add_directory_success">New game directory added successfully </string>
<string name="enable_update_checks">Check for updates on app startup.</string>
<string name="enable_update_checks">Check for Updates</string>
<string name="enable_update_checks_description">Check for updates on launch, and optionally download and install the new update.</string>
<string name="update_available">Update Available</string> <string name="update_available">Update Available</string>
<string name="update_available_description">A new version is available: %1$s\n\nWould you like to download it?</string> <string name="update_available_description">A new version is available: %1$s\n\nWould you like to download it?</string>
<string name="downloading_update">Downloading Update</string>
<string name="update_download_failed">Failed to download update</string>
<string name="update_installed_successfully">Update installed successfully</string>
<string name="update_install_failed">Failed to install update: %1$s</string>
<string name="home_search">Search</string> <string name="home_search">Search</string>
<string name="home_settings">Settings</string> <string name="home_settings">Settings</string>
<string name="empty_gamelist">No files were found or no game directory has been selected yet.</string> <string name="empty_gamelist">No files were found or no game directory has been selected yet.</string>

8
src/android/app/src/main/res/xml/file_paths.xml

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- this is required to share files in the app's internal storage -->
<cache-path name="apk_cache" path="." />
<external-cache-path name="external_apk_cache" path="." />
<files-path name="files" path="." />
<external-files-path name="external_files" path="." />
</paths>
Loading…
Cancel
Save