diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2215c48ddc..2dc236f7a6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -61,5 +61,10 @@ android:screenOrientation="fullSensor" android:exported="false" /> + + diff --git a/android/app/src/main/cpp/xemu_android.cpp b/android/app/src/main/cpp/xemu_android.cpp index 2bfb2149da..1b83c40865 100644 --- a/android/app/src/main/cpp/xemu_android.cpp +++ b/android/app/src/main/cpp/xemu_android.cpp @@ -39,6 +39,7 @@ extern "C" AddfdInfo* monitor_fdset_add_fd(int fd, bool has_fdset_id, namespace { constexpr const char* kLogTag = "xemu-android"; constexpr const char* kPrefsName = "x1box_prefs"; +constexpr const char* kRuntimeOverridePrefPrefix = "runtime_override_"; constexpr const char* kDebugLogPrefKey = "setting_debug_logs_enabled"; constexpr const char* kHrtfPrefKey = "setting_hrtf"; constexpr const char* kHrtfDefaultOffMigrationPrefKey = @@ -56,6 +57,12 @@ static jobject GetActivity(JNIEnv* env); static bool HasException(JNIEnv* env, const char* context); static std::string GetFilesDirPath(JNIEnv* env, jobject activity); static std::string GetPrefString(JNIEnv* env, jobject activity, const char* key); +static std::string GetEffectivePrefString(JNIEnv* env, jobject activity, + const char* key); +static bool GetEffectivePrefBool(JNIEnv* env, jobject activity, const char* key, + bool defValue); +static int GetEffectivePrefInt(JNIEnv* env, jobject activity, const char* key, + int defValue); static void ConfigureNativeDebugLogging(JNIEnv* env, jobject activity); static void ApplyHrtfDefaultOffMigration(JNIEnv* env, jobject activity); static bool NativeDebugLoggingEnabled(); @@ -348,7 +355,8 @@ static std::string ResolveAndroidOrientationHint(JNIEnv* env, jobject activity) return kDefaultOrientationHint; } - std::string value = ToLowerAscii(GetPrefString(env, activity, "setting_game_orientation")); + std::string value = + ToLowerAscii(GetEffectivePrefString(env, activity, "setting_game_orientation")); if (value == "landscape") { return "LandscapeLeft"; } @@ -729,6 +737,74 @@ static int GetPrefInt(JNIEnv* env, jobject activity, const char* key, int defVal return out; } +static std::string BuildRuntimeOverrideKey(const char* key) { + if (!key || key[0] == '\0') { + return {}; + } + return std::string(kRuntimeOverridePrefPrefix) + key; +} + +static bool ParseOverrideBool(const std::string& value, bool* out) { + if (!out) { + return false; + } + const std::string normalized = ToLowerAscii(value); + if (normalized == "true" || normalized == "1") { + *out = true; + return true; + } + if (normalized == "false" || normalized == "0") { + *out = false; + return true; + } + return false; +} + +static std::string GetEffectivePrefString(JNIEnv* env, jobject activity, + const char* key) { + const std::string runtimeKey = BuildRuntimeOverrideKey(key); + if (!runtimeKey.empty()) { + const std::string overrideValue = + GetPrefString(env, activity, runtimeKey.c_str()); + if (!overrideValue.empty()) { + return overrideValue; + } + } + return GetPrefString(env, activity, key); +} + +static bool GetEffectivePrefBool(JNIEnv* env, jobject activity, const char* key, + bool defValue) { + const std::string runtimeKey = BuildRuntimeOverrideKey(key); + if (!runtimeKey.empty()) { + const std::string overrideValue = + GetPrefString(env, activity, runtimeKey.c_str()); + bool parsed = false; + if (ParseOverrideBool(overrideValue, &parsed)) { + return parsed; + } + } + return GetPrefBool(env, activity, key, defValue); +} + +static int GetEffectivePrefInt(JNIEnv* env, jobject activity, const char* key, + int defValue) { + const std::string runtimeKey = BuildRuntimeOverrideKey(key); + if (!runtimeKey.empty()) { + const std::string overrideValue = + GetPrefString(env, activity, runtimeKey.c_str()); + if (!overrideValue.empty()) { + char* end = nullptr; + const long parsed = std::strtol(overrideValue.c_str(), &end, 10); + if (end != overrideValue.c_str() && end && *end == '\0' && + parsed >= INT_MIN && parsed <= INT_MAX) { + return static_cast(parsed); + } + } + } + return GetPrefInt(env, activity, key, defValue); +} + static void ConfigureNativeDebugLogging(JNIEnv* env, jobject activity) { g_native_debug_logging_enabled.store( GetPrefBool(env, activity, kDebugLogPrefKey, false)); @@ -1273,29 +1349,36 @@ static SetupFiles SyncSetupFiles() { } EmulatorSettings emuSettings; - emuSettings.surface_scale = GetPrefInt(env, activity, "setting_surface_scale", 1); + emuSettings.surface_scale = + GetEffectivePrefInt(env, activity, "setting_surface_scale", 1); emuSettings.system_memory_mib = - GetPrefInt(env, activity, "setting_system_memory_mib", 64); + GetEffectivePrefInt(env, activity, "setting_system_memory_mib", 64); if (emuSettings.system_memory_mib != 64 && emuSettings.system_memory_mib != 128) { emuSettings.system_memory_mib = 64; } - emuSettings.use_dsp = GetPrefBool(env, activity, "setting_use_dsp", false); - emuSettings.hrtf = GetPrefBool(env, activity, kHrtfPrefKey, false); - emuSettings.cache_shaders = GetPrefBool(env, activity, "setting_cache_shaders", true); - emuSettings.hard_fpu = GetPrefBool(env, activity, "setting_hard_fpu", true); + emuSettings.use_dsp = + GetEffectivePrefBool(env, activity, "setting_use_dsp", false); + emuSettings.hrtf = + GetEffectivePrefBool(env, activity, kHrtfPrefKey, false); + emuSettings.cache_shaders = + GetEffectivePrefBool(env, activity, "setting_cache_shaders", true); + emuSettings.hard_fpu = + GetEffectivePrefBool(env, activity, "setting_hard_fpu", true); emuSettings.skip_boot_anim = - GetPrefBool(env, activity, "setting_skip_boot_anim", false); + GetEffectivePrefBool(env, activity, "setting_skip_boot_anim", false); emuSettings.network_enabled = - GetPrefBool(env, activity, "setting_network_enable", false); + GetEffectivePrefBool(env, activity, "setting_network_enable", false); { - std::string tcgThread = GetPrefString(env, activity, "setting_tcg_thread"); + std::string tcgThread = + GetEffectivePrefString(env, activity, "setting_tcg_thread"); if (tcgThread == "single") { emuSettings.tcg_thread = "single"; } } { - std::string rendererPref = GetPrefString(env, activity, "setting_renderer"); + std::string rendererPref = + GetEffectivePrefString(env, activity, "setting_renderer"); if (rendererPref == "vulkan") { emuSettings.renderer = "vulkan"; } else if (rendererPref == "opengl") { @@ -1303,13 +1386,16 @@ static SetupFiles SyncSetupFiles() { } } { - std::string filteringPref = GetPrefString(env, activity, "setting_filtering"); + std::string filteringPref = + GetEffectivePrefString(env, activity, "setting_filtering"); if (filteringPref == "nearest") { emuSettings.filtering = "nearest"; } } - emuSettings.vsync = GetPrefBool(env, activity, "setting_vsync", false); - out.audio_driver = GetPrefString(env, activity, "setting_audio_driver"); + emuSettings.vsync = + GetEffectivePrefBool(env, activity, "setting_vsync", false); + out.audio_driver = + GetEffectivePrefString(env, activity, "setting_audio_driver"); { std::string normalized = ToLowerAscii(out.audio_driver); if (normalized == "android" || normalized == "audiotrack") { @@ -1317,7 +1403,7 @@ static SetupFiles SyncSetupFiles() { } } - int displayMode = GetPrefInt(env, activity, "setting_display_mode", 0); + int displayMode = GetEffectivePrefInt(env, activity, "setting_display_mode", 0); xemu_android_set_display_mode_setting(displayMode); unsetenv("XEMU_VULKAN_DRIVER"); diff --git a/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt b/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt index 75c6a36fc9..54d9f1b1e4 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt @@ -13,9 +13,15 @@ object FrontendLaunchHelper { data class LaunchTarget( val dvdUri: Uri? = null, val dvdPath: String? = null, + val relativePath: String? = null, val source: String ) + private data class TreeMatch( + val uri: Uri, + val relativePath: String, + ) + private val stringExtraKeys = listOf( "rom", "ROM", @@ -151,7 +157,11 @@ object FrontendLaunchHelper { val treeMatch = resolvePathAgainstGamesFolder(context, gamesFolderUri, path) if (treeMatch != null) { - return LaunchTarget(dvdUri = treeMatch, source = label) + return LaunchTarget( + dvdUri = treeMatch.uri, + relativePath = treeMatch.relativePath, + source = label, + ) } return null @@ -161,7 +171,7 @@ object FrontendLaunchHelper { context: Context, gamesFolderUri: Uri?, rawPath: String - ): Uri? { + ): TreeMatch? { val treeUri = gamesFolderUri ?: return null val treeRootPath = treeUriToFilesystemPath(treeUri) ?: return null val normalizedTree = normalizeFilesystemPath(treeRootPath) @@ -175,7 +185,9 @@ object FrontendLaunchHelper { var node = DocumentFile.fromTreeUri(context, treeUri) ?: return null if (relativePath.isEmpty()) { - return node.takeIf { it.isFile }?.uri + return node.takeIf { it.isFile }?.let { file -> + TreeMatch(uri = file.uri, relativePath = "") + } } for (segment in relativePath.split('/')) { @@ -184,7 +196,9 @@ object FrontendLaunchHelper { } node = node.findFile(segment) ?: return null } - return node.takeIf { it.isFile }?.uri + return node.takeIf { it.isFile }?.let { file -> + TreeMatch(uri = file.uri, relativePath = relativePath) + } } private fun treeUriToFilesystemPath(treeUri: Uri): String? { diff --git a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt index 7f87ef1de7..3eb73d6bd3 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt @@ -65,6 +65,7 @@ class GameLibraryActivity : AppCompatActivity() { } private enum class GameContextAction { + PER_GAME_SETTINGS, SET_CUSTOM_COVER, REMOVE_CUSTOM_COVER, DELETE_GAME, @@ -283,7 +284,13 @@ class GameLibraryActivity : AppCompatActivity() { // MainActivity runs in :xemu, so the disc selection must be flushed before // the other process reads SharedPreferences during startup. - prefs.edit() + val launchEditor = prefs.edit() + PerGameSettingsManager.applyRuntimeOverridesToEditor( + context = this, + editor = launchEditor, + relativePath = null, + ) + launchEditor .remove("dvdUri") .remove("dvdPath") .putBoolean("skip_game_picker", false) @@ -1249,7 +1256,13 @@ class GameLibraryActivity : AppCompatActivity() { persistUriPermission(game.uri) // MainActivity runs in :xemu, so the disc selection must be flushed before // the other process reads SharedPreferences during startup. - prefs.edit() + val launchEditor = prefs.edit() + PerGameSettingsManager.applyRuntimeOverridesToEditor( + context = this, + editor = launchEditor, + relativePath = game.relativePath, + ) + launchEditor .putString("dvdUri", game.uri.toString()) .remove("dvdPath") .putBoolean("skip_game_picker", false) @@ -1689,6 +1702,7 @@ class GameLibraryActivity : AppCompatActivity() { if (customCover.exists()) { customCover.delete() } + PerGameSettingsManager.clearOverrides(this, game.relativePath) boxArtCache.remove(normalizeCoverKey(game.title)) boxArtMisses.remove(normalizeCoverKey(game.title)) @@ -1699,6 +1713,7 @@ class GameLibraryActivity : AppCompatActivity() { private fun showGameContextMenu(game: GameEntry) { val hasCustomCover = getCustomCoverFile(game).exists() val actions = buildList { + add(GameContextAction.PER_GAME_SETTINGS) add(GameContextAction.SET_CUSTOM_COVER) if (hasCustomCover) { add(GameContextAction.REMOVE_CUSTOM_COVER) @@ -1707,6 +1722,7 @@ class GameLibraryActivity : AppCompatActivity() { } val options = actions.map { action -> when (action) { + GameContextAction.PER_GAME_SETTINGS -> getString(R.string.library_per_game_settings_option) GameContextAction.SET_CUSTOM_COVER -> getString(R.string.library_custom_cover_set_option) GameContextAction.REMOVE_CUSTOM_COVER -> getString(R.string.library_custom_cover_remove_option) GameContextAction.DELETE_GAME -> getString(R.string.library_delete_option) @@ -1730,6 +1746,13 @@ class GameLibraryActivity : AppCompatActivity() { setOnClickListener { contextDialog.dismiss() when (actions[i]) { + GameContextAction.PER_GAME_SETTINGS -> { + startActivity( + Intent(this@GameLibraryActivity, PerGameSettingsActivity::class.java) + .putExtra(PerGameSettingsActivity.EXTRA_GAME_TITLE, game.title) + .putExtra(PerGameSettingsActivity.EXTRA_GAME_RELATIVE_PATH, game.relativePath) + ) + } GameContextAction.SET_CUSTOM_COVER -> { pendingCustomCoverGame = game pickCustomCover.launch("image/*") diff --git a/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt index 7ac92141d9..3c9be6cdfc 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt @@ -96,21 +96,24 @@ class LauncherActivity : Activity() { } // MainActivity runs in :xemu, so launch data must be flushed before // handing off to the emulator process. - prefs.edit() - .putBoolean("skip_game_picker", false) - .apply { - when { - frontendLaunch.dvdUri != null -> { - putString("dvdUri", frontendLaunch.dvdUri.toString()) - remove("dvdPath") - } - frontendLaunch.dvdPath != null -> { - putString("dvdPath", frontendLaunch.dvdPath) - remove("dvdUri") - } - } + val launchEditor = prefs.edit() + launchEditor.putBoolean("skip_game_picker", false) + PerGameSettingsManager.applyRuntimeOverridesToEditor( + context = this, + editor = launchEditor, + relativePath = frontendLaunch.relativePath, + ) + when { + frontendLaunch.dvdUri != null -> { + launchEditor.putString("dvdUri", frontendLaunch.dvdUri.toString()) + launchEditor.remove("dvdPath") } - .commit() + frontendLaunch.dvdPath != null -> { + launchEditor.putString("dvdPath", frontendLaunch.dvdPath) + launchEditor.remove("dvdUri") + } + } + launchEditor.commit() if (hasMcpx && hasFlash && hasHdd) { DebugLog.i(TAG) { "Frontend launch resolved via ${frontendLaunch.source}" } diff --git a/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt b/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt index fe0e8f56c8..e5fd5b6b13 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/OrientationPreferences.kt @@ -58,7 +58,11 @@ object OrientationPreferences { } fun getGameOrientation(context: Context): GameOrientation { - return GameOrientation.fromPrefValue(sharedPreferences(context).getString(PREF_GAME_ORIENTATION, null)) + val prefs = sharedPreferences(context) + val runtimeOverride = PerGameSettingsManager.getRuntimeOverride(context, PREF_GAME_ORIENTATION) + return GameOrientation.fromPrefValue( + runtimeOverride ?: prefs.getString(PREF_GAME_ORIENTATION, null) + ) } fun getUiRequestedOrientation(context: Context): Int { diff --git a/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsActivity.kt new file mode 100644 index 0000000000..6ec698a3ff --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsActivity.kt @@ -0,0 +1,281 @@ +package com.izzy2lost.x1box + +import android.os.Bundle +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputLayout + +class PerGameSettingsActivity : AppCompatActivity() { + companion object { + const val EXTRA_GAME_TITLE = "com.izzy2lost.x1box.extra.GAME_TITLE" + const val EXTRA_GAME_RELATIVE_PATH = "com.izzy2lost.x1box.extra.GAME_RELATIVE_PATH" + } + + private data class SettingOption( + val value: String?, + val labelRes: Int, + ) + + private data class SettingField( + val key: String, + val inputLayoutId: Int, + val dropdownId: Int, + val options: List, + ) + + private val fieldSelections = linkedMapOf() + + private val fields by lazy { + listOf( + SettingField( + key = "setting_renderer", + inputLayoutId = R.id.input_per_game_renderer, + dropdownId = R.id.dropdown_per_game_renderer, + options = listOf( + SettingOption(null, R.string.per_game_settings_use_global), + SettingOption("vulkan", R.string.settings_graphics_api_vulkan), + SettingOption("opengl", R.string.settings_graphics_api_opengl), + ), + ), + SettingField( + key = "setting_filtering", + inputLayoutId = R.id.input_per_game_filtering, + dropdownId = R.id.dropdown_per_game_filtering, + options = listOf( + SettingOption(null, R.string.per_game_settings_use_global), + SettingOption("linear", R.string.settings_filtering_linear), + SettingOption("nearest", R.string.settings_filtering_nearest), + ), + ), + SettingField( + key = "setting_vsync", + inputLayoutId = R.id.input_per_game_vsync, + dropdownId = R.id.dropdown_per_game_vsync, + options = booleanOptions(), + ), + SettingField( + key = "setting_surface_scale", + inputLayoutId = R.id.input_per_game_surface_scale, + dropdownId = R.id.dropdown_per_game_surface_scale, + options = listOf( + SettingOption(null, R.string.per_game_settings_use_global), + SettingOption("1", R.string.settings_resolution_scale_1x), + SettingOption("2", R.string.settings_resolution_scale_2x), + SettingOption("3", R.string.settings_resolution_scale_3x), + ), + ), + SettingField( + key = "setting_display_mode", + inputLayoutId = R.id.input_per_game_display_mode, + dropdownId = R.id.dropdown_per_game_display_mode, + options = listOf( + SettingOption(null, R.string.per_game_settings_use_global), + SettingOption("0", R.string.settings_display_mode_stretch), + SettingOption("1", R.string.settings_display_mode_4_3), + SettingOption("2", R.string.settings_display_mode_16_9), + ), + ), + SettingField( + key = OrientationPreferences.PREF_GAME_ORIENTATION, + inputLayoutId = R.id.input_per_game_orientation, + dropdownId = R.id.dropdown_per_game_orientation, + options = listOf( + SettingOption(null, R.string.per_game_settings_use_global), + SettingOption( + OrientationPreferences.GameOrientation.FOLLOW_DEVICE.prefValue, + R.string.settings_orientation_follow_device, + ), + SettingOption( + OrientationPreferences.GameOrientation.LANDSCAPE.prefValue, + R.string.settings_orientation_landscape, + ), + SettingOption( + OrientationPreferences.GameOrientation.REVERSE_LANDSCAPE.prefValue, + R.string.settings_orientation_reverse_landscape, + ), + ), + ), + SettingField( + key = "setting_system_memory_mib", + inputLayoutId = R.id.input_per_game_system_memory, + dropdownId = R.id.dropdown_per_game_system_memory, + options = listOf( + SettingOption(null, R.string.per_game_settings_use_global), + SettingOption("64", R.string.settings_system_memory_64), + SettingOption("128", R.string.settings_system_memory_128), + ), + ), + SettingField( + key = "setting_tcg_thread", + inputLayoutId = R.id.input_per_game_tcg_thread, + dropdownId = R.id.dropdown_per_game_tcg_thread, + options = listOf( + SettingOption(null, R.string.per_game_settings_use_global), + SettingOption("multi", R.string.settings_tcg_thread_multi), + SettingOption("single", R.string.settings_tcg_thread_single), + ), + ), + SettingField( + key = "setting_hard_fpu", + inputLayoutId = R.id.input_per_game_hard_fpu, + dropdownId = R.id.dropdown_per_game_hard_fpu, + options = booleanOptions(), + ), + SettingField( + key = "setting_cache_shaders", + inputLayoutId = R.id.input_per_game_cache_shaders, + dropdownId = R.id.dropdown_per_game_cache_shaders, + options = booleanOptions(), + ), + SettingField( + key = "setting_skip_boot_anim", + inputLayoutId = R.id.input_per_game_skip_boot_anim, + dropdownId = R.id.dropdown_per_game_skip_boot_anim, + options = booleanOptions(), + ), + SettingField( + key = "setting_use_dsp", + inputLayoutId = R.id.input_per_game_use_dsp, + dropdownId = R.id.dropdown_per_game_use_dsp, + options = booleanOptions(), + ), + SettingField( + key = "setting_hrtf", + inputLayoutId = R.id.input_per_game_hrtf, + dropdownId = R.id.dropdown_per_game_hrtf, + options = booleanOptions(), + ), + SettingField( + key = "setting_audio_driver", + inputLayoutId = R.id.input_per_game_audio_driver, + dropdownId = R.id.dropdown_per_game_audio_driver, + options = listOf( + SettingOption(null, R.string.per_game_settings_use_global), + SettingOption("openslES", R.string.settings_audio_driver_opensles), + SettingOption("aaudio", R.string.settings_audio_driver_aaudio), + SettingOption("dummy", R.string.settings_audio_driver_disabled), + ), + ), + SettingField( + key = "setting_network_enable", + inputLayoutId = R.id.input_per_game_network_enable, + dropdownId = R.id.dropdown_per_game_network_enable, + options = booleanOptions(), + ), + ) + } + + private lateinit var gameTitle: String + private lateinit var relativePath: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + OrientationLocker(this).enable() + setContentView(R.layout.activity_per_game_settings) + EdgeToEdgeHelper.enable(this) + EdgeToEdgeHelper.applySystemBarPadding(findViewById(R.id.per_game_settings_scroll)) + + gameTitle = intent.getStringExtra(EXTRA_GAME_TITLE)?.trim().orEmpty() + relativePath = intent.getStringExtra(EXTRA_GAME_RELATIVE_PATH)?.trim().orEmpty() + + if (relativePath.isEmpty()) { + Toast.makeText(this, R.string.per_game_settings_missing_game, Toast.LENGTH_SHORT).show() + finish() + return + } + + findViewById(R.id.tv_per_game_settings_game_title).text = + gameTitle.ifEmpty { relativePath.substringAfterLast('/') } + findViewById(R.id.tv_per_game_settings_game_path).text = relativePath + + val savedOverrides = PerGameSettingsManager.loadOverrides(this, relativePath) + bindFields(savedOverrides) + + findViewById(R.id.btn_per_game_settings_clear).setOnClickListener { + PerGameSettingsManager.clearOverrides(this, relativePath) + Toast.makeText(this, R.string.per_game_settings_cleared, Toast.LENGTH_SHORT).show() + finish() + } + + findViewById(R.id.btn_per_game_settings_save).setOnClickListener { + PerGameSettingsManager.saveOverrides(this, relativePath, fieldSelections) + Toast.makeText(this, R.string.per_game_settings_saved, Toast.LENGTH_SHORT).show() + finish() + } + } + + private fun bindFields(savedOverrides: Map) { + for (field in fields) { + val inputLayout = findViewById(field.inputLayoutId) + val dropdown = findViewById(field.dropdownId) + val labels = field.options.map { option -> getString(option.labelRes) } + + dropdown.setAdapter(ArrayAdapter(this, android.R.layout.simple_list_item_1, labels)) + dropdown.setOnItemClickListener { _, _, position, _ -> + fieldSelections[field.key] = field.options[position].value + } + + val selectedValue = savedOverrides[field.key] + fieldSelections[field.key] = selectedValue + setFieldSelection(dropdown, field, selectedValue) + inputLayout.helperText = getString( + R.string.per_game_settings_global_value, + describeGlobalValue(field), + ) + } + } + + private fun setFieldSelection( + dropdown: AutoCompleteTextView, + field: SettingField, + value: String?, + ) { + val option = field.options.firstOrNull { it.value == value } ?: field.options.first() + dropdown.setText(getString(option.labelRes), false) + } + + private fun describeGlobalValue(field: SettingField): String { + val globalValue = readGlobalValue(field.key) + val matchingOption = field.options.firstOrNull { option -> option.value == globalValue } + ?: field.options.first() + return getString(matchingOption.labelRes) + } + + private fun readGlobalValue(key: String): String { + val prefs = getSharedPreferences("x1box_prefs", MODE_PRIVATE) + return when (key) { + "setting_renderer" -> prefs.getString(key, "opengl") ?: "opengl" + "setting_filtering" -> prefs.getString(key, "linear") ?: "linear" + "setting_vsync" -> prefs.getBoolean(key, false).toString() + "setting_surface_scale" -> prefs.getInt(key, 1).toString() + "setting_display_mode" -> prefs.getInt(key, 0).toString() + OrientationPreferences.PREF_GAME_ORIENTATION -> + prefs.getString( + key, + OrientationPreferences.GameOrientation.FOLLOW_DEVICE.prefValue, + ) ?: OrientationPreferences.GameOrientation.FOLLOW_DEVICE.prefValue + "setting_system_memory_mib" -> prefs.getInt(key, 64).toString() + "setting_tcg_thread" -> prefs.getString(key, "multi") ?: "multi" + "setting_use_dsp" -> prefs.getBoolean(key, false).toString() + "setting_hrtf" -> prefs.getBoolean(key, false).toString() + "setting_cache_shaders" -> prefs.getBoolean(key, true).toString() + "setting_hard_fpu" -> prefs.getBoolean(key, true).toString() + "setting_skip_boot_anim" -> prefs.getBoolean(key, false).toString() + "setting_audio_driver" -> prefs.getString(key, "openslES") ?: "openslES" + "setting_network_enable" -> prefs.getBoolean(key, false).toString() + else -> "" + } + } + + private fun booleanOptions(): List { + return listOf( + SettingOption(null, R.string.per_game_settings_use_global), + SettingOption("true", R.string.per_game_settings_enabled), + SettingOption("false", R.string.per_game_settings_disabled), + ) + } +} diff --git a/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsManager.kt b/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsManager.kt new file mode 100644 index 0000000000..93777d647a --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/x1box/PerGameSettingsManager.kt @@ -0,0 +1,126 @@ +package com.izzy2lost.x1box + +import android.content.Context +import android.content.SharedPreferences +import java.security.MessageDigest +import java.util.Locale + +object PerGameSettingsManager { + private const val APP_PREFS_NAME = "x1box_prefs" + private const val STORE_PREFS_NAME = "x1box_per_game_settings" + private const val STORE_KEY_PREFIX = "game_override:" + private const val RUNTIME_KEY_PREFIX = "runtime_override_" + + val overridablePreferenceKeys = listOf( + "setting_renderer", + "setting_filtering", + "setting_vsync", + "setting_surface_scale", + "setting_display_mode", + OrientationPreferences.PREF_GAME_ORIENTATION, + "setting_system_memory_mib", + "setting_tcg_thread", + "setting_use_dsp", + "setting_hrtf", + "setting_cache_shaders", + "setting_hard_fpu", + "setting_skip_boot_anim", + "setting_audio_driver", + "setting_network_enable", + ) + + fun hasOverrides(context: Context, relativePath: String): Boolean { + val prefs = storePreferences(context) + val gameId = gameId(relativePath) + return overridablePreferenceKeys.any { key -> + prefs.contains(storageKey(gameId, key)) + } + } + + fun loadOverrides(context: Context, relativePath: String): Map { + val prefs = storePreferences(context) + val gameId = gameId(relativePath) + return buildMap { + for (key in overridablePreferenceKeys) { + prefs.getString(storageKey(gameId, key), null) + ?.takeIf { value -> value.isNotEmpty() } + ?.let { value -> put(key, value) } + } + } + } + + fun saveOverrides( + context: Context, + relativePath: String, + overrides: Map, + ) { + val prefs = storePreferences(context) + val gameId = gameId(relativePath) + val editor = prefs.edit() + for (key in overridablePreferenceKeys) { + val value = overrides[key] + if (value.isNullOrEmpty()) { + editor.remove(storageKey(gameId, key)) + } else { + editor.putString(storageKey(gameId, key), value) + } + } + editor.apply() + } + + fun clearOverrides(context: Context, relativePath: String) { + val prefs = storePreferences(context) + val gameId = gameId(relativePath) + val editor = prefs.edit() + for (key in overridablePreferenceKeys) { + editor.remove(storageKey(gameId, key)) + } + editor.apply() + } + + fun applyRuntimeOverridesToEditor( + context: Context, + editor: SharedPreferences.Editor, + relativePath: String?, + ) { + val overrides = relativePath + ?.takeIf { path -> path.isNotBlank() } + ?.let { path -> loadOverrides(context, path) } + .orEmpty() + + for (key in overridablePreferenceKeys) { + val runtimeKey = runtimeKey(key) + val value = overrides[key] + if (value.isNullOrEmpty()) { + editor.remove(runtimeKey) + } else { + editor.putString(runtimeKey, value) + } + } + } + + fun getRuntimeOverride(context: Context, key: String): String? { + return appPreferences(context).getString(runtimeKey(key), null) + ?.takeIf { value -> value.isNotEmpty() } + } + + fun runtimeKey(key: String): String = RUNTIME_KEY_PREFIX + key + + private fun gameId(relativePath: String): String { + val normalized = relativePath.trim().lowercase(Locale.ROOT) + val digest = MessageDigest.getInstance("SHA-256") + val bytes = digest.digest(normalized.toByteArray(Charsets.UTF_8)) + return bytes.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xFF) } + } + + private fun storageKey(gameId: String, key: String): String = + STORE_KEY_PREFIX + gameId + ":" + key + + private fun appPreferences(context: Context): SharedPreferences { + return context.applicationContext.getSharedPreferences(APP_PREFS_NAME, Context.MODE_PRIVATE) + } + + private fun storePreferences(context: Context): SharedPreferences { + return context.applicationContext.getSharedPreferences(STORE_PREFS_NAME, Context.MODE_PRIVATE) + } +} diff --git a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt index 6fd48f9b05..d3b86998e5 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt @@ -1534,7 +1534,13 @@ class SettingsActivity : AppCompatActivity() { private fun launchInsigniaSetupAssistant(uri: Uri) { persistUriPermission(uri) switchNetworkEnable.isChecked = true - prefs.edit() + val launchEditor = prefs.edit() + PerGameSettingsManager.applyRuntimeOverridesToEditor( + context = this, + editor = launchEditor, + relativePath = null, + ) + launchEditor .putBoolean("setting_network_enable", true) .putString("dvdUri", uri.toString()) .remove("dvdPath") diff --git a/android/app/src/main/res/layout/activity_per_game_settings.xml b/android/app/src/main/res/layout/activity_per_game_settings.xml new file mode 100644 index 0000000000..ad705545de --- /dev/null +++ b/android/app/src/main/res/layout/activity_per_game_settings.xml @@ -0,0 +1,408 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5d8762de25..8758577a65 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -345,4 +345,17 @@ Custom cover saved Custom cover removed Failed to save custom cover + Per-Game Settings + + Per-Game Settings + Choose only the settings this game should override. Leave a field on Use Global to keep the normal app setting. + Use Global + Global default: %1$s + Enabled + Disabled + Save Overrides + Clear Overrides + Per-game settings saved + Per-game settings cleared + This game could not be resolved.