diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
index a7e3581ee..78198632a 100644
--- a/src/android/app/src/main/AndroidManifest.xml
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -78,6 +78,16 @@
+
+
+
+
+
+
+
+ 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()
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.kt
index 98845319d..2a858a899 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.kt
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.kt
@@ -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
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 4faf71283..a4a7dbfa7 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -496,6 +496,14 @@
Remove
Custom Settings
Use default
+ Custom settings for this game already exist. Overwrite them and proceed?
+ Game not found
+ This title isn\'t in your Azahar library (ID: %1$s). Add it to the library and try again.
+ Vulkan backend requested
+ 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?
+ Use Vulkan
+ Use OpenGL
+ Missing required driver: %1$s. Please install it and try again.
Select Amiibo File
Error Loading Amiibo
While loading the specified Amiibo file, an error occurred. Please check that the file is correct.