mirror of
https://git.eden-emu.dev/eden-emu/eden
synced 2026-04-25 03:08:59 +02:00
[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>
This commit is contained in:
parent
f3fbb3812f
commit
79b162a37c
11 changed files with 315 additions and 6 deletions
|
|
@ -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.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
downloadAndInstallUpdate(version)
|
||||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
}
|
||||||
.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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
Normal file
8
src/android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -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…
Add table
Add a link
Reference in a new issue