feat: Add ExternalLaunchActivity for custom game settings

This commit is contained in:
Producdevity 2025-09-23 16:00:44 +02:00 committed by OpenSauce
parent e1007f1f2e
commit f52f83e3fc
4 changed files with 187 additions and 0 deletions

View File

@ -78,6 +78,16 @@
</activity>
<activity
android:name="org.citra.citra_emu.features.external.ExternalLaunchActivity"
android:exported="true"
android:theme="@style/Theme.Citra.Main">
<intent-filter>
<action android:name="org.citra.citra_emu.LAUNCH_WITH_CUSTOM_CONFIG" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="org.citra.citra_emu.features.cheats.ui.CheatsActivity"
android:exported="false"

View File

@ -0,0 +1,156 @@
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.external
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.R
import org.citra.citra_emu.activities.EmulationActivity
import org.citra.citra_emu.features.settings.utils.SettingsFile
import org.citra.citra_emu.model.Game
import org.citra.citra_emu.utils.DirectoryInitialization
import org.citra.citra_emu.utils.GameHelper
import org.citra.citra_emu.utils.Log
class ExternalLaunchActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Ensure user directory is initialized
DirectoryInitialization.start()
val titleIdStr = intent.getStringExtra(EXTRA_TITLE_ID)
val iniText = intent.getStringExtra(EXTRA_CONFIG_INI) ?: ""
if (titleIdStr.isNullOrEmpty()) {
finishWithResult(success = false)
return
}
val titleId = parseTitleId(titleIdStr)
if (titleId == null) {
finishWithResult(success = false)
return
}
// If existing per-game file exists, confirm overwrite
val hasExisting = SettingsFile.customExists(String.format("%016X", titleId))
if (hasExisting) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.application_settings)
.setMessage(R.string.overwrite_custom_settings_prompt)
.setPositiveButton(android.R.string.ok) { _, _ ->
proceedWithDriverCheckAndLaunch(titleId, iniText)
}
.setNegativeButton(android.R.string.cancel) { _, _ -> finishWithResult(success = false) }
.setOnCancelListener { finishWithResult(success = false) }
.show()
} else {
proceedWithDriverCheckAndLaunch(titleId, iniText)
}
}
private fun proceedWithDriverCheckAndLaunch(titleId: Long, iniText: String) {
val idHex = String.format("%016X", titleId)
val requestedBackend = extractGraphicsApi(iniText)
if (requestedBackend == GRAPHICS_BACKEND_VULKAN) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.custom_launch_backend_warning_title)
.setMessage(R.string.custom_launch_backend_warning_message)
.setPositiveButton(R.string.custom_launch_backend_option_use_vulkan) { _, _ ->
writeConfigAndLaunch(titleId, iniText, idHex)
}
.setNegativeButton(R.string.custom_launch_backend_option_use_opengl) { _, _ ->
val adjusted = replaceGraphicsApi(iniText, GRAPHICS_BACKEND_OPENGL)
Log.info("[ExternalLaunch] Falling back to OpenGL for external launch")
writeConfigAndLaunch(titleId, adjusted, idHex)
}
.setOnCancelListener { finishWithResult(success = false) }
.show()
return
}
writeConfigAndLaunch(titleId, iniText, idHex)
}
private fun writeConfigAndLaunch(titleId: Long, iniText: String, idHex: String) {
SettingsFile.saveCustomFileRaw(idHex, iniText)
val game = findGameByTitleId(titleId)
if (game == null) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.custom_launch_missing_game_title)
.setMessage(getString(R.string.custom_launch_missing_game_message, idHex))
.setPositiveButton(android.R.string.ok) { _, _ -> finishWithResult(success = false) }
.setOnCancelListener { finishWithResult(success = false) }
.show()
return
}
val launch = Intent(this, EmulationActivity::class.java)
launch.putExtra("game", game)
startActivity(launch)
finishWithResult(success = true)
}
private fun findGameByTitleId(titleId: Long): Game? {
// Try cached games first
val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
val serialized = prefs.getStringSet(GameHelper.KEY_GAMES, emptySet()) ?: emptySet()
if (serialized.isNotEmpty()) {
val games = serialized.mapNotNull {
try { kotlinx.serialization.json.Json.decodeFromString(org.citra.citra_emu.model.Game.serializer(), it) } catch (_: Exception) { null }
}
games.firstOrNull { it.titleId == titleId }?.let { return it }
}
// Fallback: rescan library
return GameHelper.getGames().firstOrNull { it.titleId == titleId }
}
companion object {
const val EXTRA_TITLE_ID = "title_id"
const val EXTRA_CONFIG_INI = "config_ini"
private const val GRAPHICS_BACKEND_OPENGL = 1
private const val GRAPHICS_BACKEND_VULKAN = 2
}
private fun parseTitleId(raw: String?): Long? {
if (raw.isNullOrBlank()) return null
val trimmed = raw.trim()
val withoutPrefix = if (trimmed.startsWith("0x", true)) trimmed.substring(2) else trimmed
// Prefer hexadecimal interpretation Title IDs are traditionally provided in hex.
val hexValue = withoutPrefix.toLongOrNull(16)
if (hexValue != null) return hexValue
return withoutPrefix.toLongOrNull()
}
private fun extractGraphicsApi(config: String): Int? {
val regex = Regex(
"^\\s*graphics_api\\s*=\\s*(\\d+)\\s*$",
setOf(RegexOption.IGNORE_CASE, RegexOption.MULTILINE)
)
val match = regex.find(config) ?: return null
return match.groupValues.getOrNull(1)?.trim()?.toIntOrNull()
}
private fun replaceGraphicsApi(config: String, backend: Int): String {
val regex = Regex(
"^\\s*graphics_api\\s*=\\s*(\\d+)\\s*$",
setOf(RegexOption.IGNORE_CASE, RegexOption.MULTILINE)
)
return regex.replace(config) { "graphics_api = $backend" }
}
private fun finishWithResult(success: Boolean) {
setResult(if (success) RESULT_OK else RESULT_CANCELED)
finish()
}
}

View File

@ -170,6 +170,19 @@ object SettingsFile {
}
}
fun saveCustomFileRaw(gameId: String, contents: String) {
val ini = getOrCreateCustomGameSettingsFile(gameId)
val context: Context = CitraApplication.appContext
context.contentResolver.openOutputStream(ini.uri, "wt").use { out ->
out?.write(contents.toByteArray())
out?.flush()
}
}
fun customExists(gameId: String): Boolean {
return findCustomGameSettingsFile(gameId) != null
}
fun saveFile(
fileName: String,
setting: AbstractSetting

View File

@ -496,6 +496,14 @@
<string name="menu_emulation_amiibo_remove">Remove</string>
<string name="application_settings">Custom Settings</string>
<string name="use_default">Use default</string>
<string name="overwrite_custom_settings_prompt">Custom settings for this game already exist. Overwrite them and proceed?</string>
<string name="custom_launch_missing_game_title">Game not found</string>
<string name="custom_launch_missing_game_message">This title isn\'t in your Azahar library (ID: %1$s). Add it to the library and try again.</string>
<string name="custom_launch_backend_warning_title">Vulkan backend requested</string>
<string name="custom_launch_backend_warning_message">This configuration asks Azahar to use the Vulkan renderer. Some devices crash when launching games externally with Vulkan. Keep Vulkan, or switch this launch to OpenGL for safety?</string>
<string name="custom_launch_backend_option_use_vulkan">Use Vulkan</string>
<string name="custom_launch_backend_option_use_opengl">Use OpenGL</string>
<string name="required_driver_missing">Missing required driver: %1$s. Please install it and try again.</string>
<string name="select_amiibo">Select Amiibo File</string>
<string name="amiibo_load_error">Error Loading Amiibo</string>
<string name="amiibo_load_error_message">While loading the specified Amiibo file, an error occurred. Please check that the file is correct.</string>