Преглед изворни кода

android: Add addon delete button

Required some refactoring of retrieving patches in order for the frontend to pass the right information to ContentManager for deletion.
t895 пре 2 година
родитељ
комит
03fa91ba3c

+ 23 - 2
src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt

@@ -22,6 +22,7 @@ import org.yuzu.yuzu_emu.utils.FileUtil
 import org.yuzu.yuzu_emu.utils.Log
 import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable
 import org.yuzu.yuzu_emu.model.InstallResult
+import org.yuzu.yuzu_emu.model.Patch
 
 /**
  * Class which contains methods that interact
@@ -539,9 +540,29 @@ object NativeLibrary {
      *
      * @param path Path to game file. Can be a [Uri].
      * @param programId String representation of a game's program ID
-     * @return Array of pairs where the first value is the name of an addon and the second is the version
+     * @return Array of available patches
      */
-    external fun getAddonsForFile(path: String, programId: String): Array<Pair<String, String>>?
+    external fun getPatchesForFile(path: String, programId: String): Array<Patch>?
+
+    /**
+     * Removes an update for a given [programId]
+     * @param programId String representation of a game's program ID
+     */
+    external fun removeUpdate(programId: String)
+
+    /**
+     * Removes all DLC for a  [programId]
+     * @param programId String representation of a game's program ID
+     */
+    external fun removeDLC(programId: String)
+
+    /**
+     * Removes a mod installed for a given [programId]
+     * @param programId String representation of a game's program ID
+     * @param name The name of a mod as given by [getPatchesForFile]. This corresponds with the name
+     * of the mod's directory in a game's load folder.
+     */
+    external fun removeMod(programId: String, name: String)
 
     /**
      * Gets the save location for a specific game

+ 13 - 8
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt

@@ -6,27 +6,32 @@ package org.yuzu.yuzu_emu.adapters
 import android.view.LayoutInflater
 import android.view.ViewGroup
 import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding
-import org.yuzu.yuzu_emu.model.Addon
+import org.yuzu.yuzu_emu.model.Patch
+import org.yuzu.yuzu_emu.model.AddonViewModel
 import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
-class AddonAdapter : AbstractDiffAdapter<Addon, AddonAdapter.AddonViewHolder>() {
+class AddonAdapter(val addonViewModel: AddonViewModel) :
+    AbstractDiffAdapter<Patch, AddonAdapter.AddonViewHolder>() {
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder {
         ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
             .also { return AddonViewHolder(it) }
     }
 
     inner class AddonViewHolder(val binding: ListItemAddonBinding) :
-        AbstractViewHolder<Addon>(binding) {
-        override fun bind(model: Addon) {
+        AbstractViewHolder<Patch>(binding) {
+        override fun bind(model: Patch) {
             binding.root.setOnClickListener {
-                binding.addonSwitch.isChecked = !binding.addonSwitch.isChecked
+                binding.addonCheckbox.isChecked = !binding.addonCheckbox.isChecked
             }
-            binding.title.text = model.title
+            binding.title.text = model.name
             binding.version.text = model.version
-            binding.addonSwitch.setOnCheckedChangeListener { _, checked ->
+            binding.addonCheckbox.setOnCheckedChangeListener { _, checked ->
                 model.enabled = checked
             }
-            binding.addonSwitch.isChecked = model.enabled
+            binding.addonCheckbox.isChecked = model.enabled
+            binding.buttonDelete.setOnClickListener {
+                addonViewModel.setAddonToDelete(model)
+            }
         }
     }
 }

+ 16 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt

@@ -74,7 +74,7 @@ class AddonsFragment : Fragment() {
 
         binding.listAddons.apply {
             layoutManager = LinearLayoutManager(requireContext())
-            adapter = AddonAdapter()
+            adapter = AddonAdapter(addonViewModel)
         }
 
         viewLifecycleOwner.lifecycleScope.apply {
@@ -110,6 +110,21 @@ class AddonsFragment : Fragment() {
                     }
                 }
             }
+            launch {
+                repeatOnLifecycle(Lifecycle.State.STARTED) {
+                    addonViewModel.addonToDelete.collect {
+                        if (it != null) {
+                            MessageDialogFragment.newInstance(
+                                requireActivity(),
+                                titleId = R.string.confirm_uninstall,
+                                descriptionId = R.string.confirm_uninstall_description,
+                                positiveAction = { addonViewModel.onDeleteAddon(it) }
+                            ).show(parentFragmentManager, MessageDialogFragment.TAG)
+                            addonViewModel.setAddonToDelete(null)
+                        }
+                    }
+                }
+            }
         }
 
         binding.buttonInstall.setOnClickListener {

+ 0 - 10
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt

@@ -1,10 +0,0 @@
-// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-package org.yuzu.yuzu_emu.model
-
-data class Addon(
-    var enabled: Boolean,
-    val title: String,
-    val version: String
-)

+ 28 - 14
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt

@@ -15,8 +15,8 @@ import org.yuzu.yuzu_emu.utils.NativeConfig
 import java.util.concurrent.atomic.AtomicBoolean
 
 class AddonViewModel : ViewModel() {
-    private val _addonList = MutableStateFlow(mutableListOf<Addon>())
-    val addonList get() = _addonList.asStateFlow()
+    private val _patchList = MutableStateFlow(mutableListOf<Patch>())
+    val addonList get() = _patchList.asStateFlow()
 
     private val _showModInstallPicker = MutableStateFlow(false)
     val showModInstallPicker get() = _showModInstallPicker.asStateFlow()
@@ -24,6 +24,9 @@ class AddonViewModel : ViewModel() {
     private val _showModNoticeDialog = MutableStateFlow(false)
     val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow()
 
+    private val _addonToDelete = MutableStateFlow<Patch?>(null)
+    val addonToDelete = _addonToDelete.asStateFlow()
+
     var game: Game? = null
 
     private val isRefreshing = AtomicBoolean(false)
@@ -40,36 +43,47 @@ class AddonViewModel : ViewModel() {
         isRefreshing.set(true)
         viewModelScope.launch {
             withContext(Dispatchers.IO) {
-                val addonList = mutableListOf<Addon>()
-                val disabledAddons = NativeConfig.getDisabledAddons(game!!.programId)
-                NativeLibrary.getAddonsForFile(game!!.path, game!!.programId)?.forEach {
-                    val name = it.first.replace("[D] ", "")
-                    addonList.add(Addon(!disabledAddons.contains(name), name, it.second))
-                }
-                addonList.sortBy { it.title }
-                _addonList.value = addonList
+                val patchList = (
+                    NativeLibrary.getPatchesForFile(game!!.path, game!!.programId)
+                        ?: emptyArray()
+                    ).toMutableList()
+                patchList.sortBy { it.name }
+                _patchList.value = patchList
                 isRefreshing.set(false)
             }
         }
     }
 
+    fun setAddonToDelete(patch: Patch?) {
+        _addonToDelete.value = patch
+    }
+
+    fun onDeleteAddon(patch: Patch) {
+        when (PatchType.from(patch.type)) {
+            PatchType.Update -> NativeLibrary.removeUpdate(patch.programId)
+            PatchType.DLC -> NativeLibrary.removeDLC(patch.programId)
+            PatchType.Mod -> NativeLibrary.removeMod(patch.programId, patch.name)
+        }
+        refreshAddons()
+    }
+
     fun onCloseAddons() {
-        if (_addonList.value.isEmpty()) {
+        if (_patchList.value.isEmpty()) {
             return
         }
 
         NativeConfig.setDisabledAddons(
             game!!.programId,
-            _addonList.value.mapNotNull {
+            _patchList.value.mapNotNull {
                 if (it.enabled) {
                     null
                 } else {
-                    it.title
+                    it.name
                 }
             }.toTypedArray()
         )
         NativeConfig.saveGlobalConfig()
-        _addonList.value.clear()
+        _patchList.value.clear()
         game = null
     }
 

+ 16 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt

@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.annotation.Keep
+
+@Keep
+data class Patch(
+    var enabled: Boolean,
+    val name: String,
+    val version: String,
+    val type: Int,
+    val programId: String,
+    val titleId: String
+)

+ 14 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt

@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2024 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+enum class PatchType(val int: Int) {
+    Update(0),
+    DLC(1),
+    Mod(2);
+
+    companion object {
+        fun from(int: Int): PatchType = entries.firstOrNull { it.int == int } ?: Update
+    }
+}

+ 55 - 0
src/android/app/src/main/jni/id_cache.cpp

@@ -43,6 +43,15 @@ static jfieldID s_overlay_control_data_landscape_position_field;
 static jfieldID s_overlay_control_data_portrait_position_field;
 static jfieldID s_overlay_control_data_foldable_position_field;
 
+static jclass s_patch_class;
+static jmethodID s_patch_constructor;
+static jfieldID s_patch_enabled_field;
+static jfieldID s_patch_name_field;
+static jfieldID s_patch_version_field;
+static jfieldID s_patch_type_field;
+static jfieldID s_patch_program_id_field;
+static jfieldID s_patch_title_id_field;
+
 static jclass s_double_class;
 static jmethodID s_double_constructor;
 static jfieldID s_double_value_field;
@@ -194,6 +203,38 @@ jfieldID GetOverlayControlDataFoldablePositionField() {
     return s_overlay_control_data_foldable_position_field;
 }
 
+jclass GetPatchClass() {
+    return s_patch_class;
+}
+
+jmethodID GetPatchConstructor() {
+    return s_patch_constructor;
+}
+
+jfieldID GetPatchEnabledField() {
+    return s_patch_enabled_field;
+}
+
+jfieldID GetPatchNameField() {
+    return s_patch_name_field;
+}
+
+jfieldID GetPatchVersionField() {
+    return s_patch_version_field;
+}
+
+jfieldID GetPatchTypeField() {
+    return s_patch_type_field;
+}
+
+jfieldID GetPatchProgramIdField() {
+    return s_patch_program_id_field;
+}
+
+jfieldID GetPatchTitleIdField() {
+    return s_patch_title_id_field;
+}
+
 jclass GetDoubleClass() {
     return s_double_class;
 }
@@ -310,6 +351,19 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
         env->GetFieldID(overlay_control_data_class, "foldablePosition", "Lkotlin/Pair;");
     env->DeleteLocalRef(overlay_control_data_class);
 
+    const jclass patch_class = env->FindClass("org/yuzu/yuzu_emu/model/Patch");
+    s_patch_class = reinterpret_cast<jclass>(env->NewGlobalRef(patch_class));
+    s_patch_constructor = env->GetMethodID(
+        patch_class, "<init>",
+        "(ZLjava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)V");
+    s_patch_enabled_field = env->GetFieldID(patch_class, "enabled", "Z");
+    s_patch_name_field = env->GetFieldID(patch_class, "name", "Ljava/lang/String;");
+    s_patch_version_field = env->GetFieldID(patch_class, "version", "Ljava/lang/String;");
+    s_patch_type_field = env->GetFieldID(patch_class, "type", "I");
+    s_patch_program_id_field = env->GetFieldID(patch_class, "programId", "Ljava/lang/String;");
+    s_patch_title_id_field = env->GetFieldID(patch_class, "titleId", "Ljava/lang/String;");
+    env->DeleteLocalRef(patch_class);
+
     const jclass double_class = env->FindClass("java/lang/Double");
     s_double_class = reinterpret_cast<jclass>(env->NewGlobalRef(double_class));
     s_double_constructor = env->GetMethodID(double_class, "<init>", "(D)V");
@@ -353,6 +407,7 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
     env->DeleteGlobalRef(s_string_class);
     env->DeleteGlobalRef(s_pair_class);
     env->DeleteGlobalRef(s_overlay_control_data_class);
+    env->DeleteGlobalRef(s_patch_class);
     env->DeleteGlobalRef(s_double_class);
     env->DeleteGlobalRef(s_integer_class);
     env->DeleteGlobalRef(s_boolean_class);

+ 9 - 0
src/android/app/src/main/jni/id_cache.h

@@ -43,6 +43,15 @@ jfieldID GetOverlayControlDataLandscapePositionField();
 jfieldID GetOverlayControlDataPortraitPositionField();
 jfieldID GetOverlayControlDataFoldablePositionField();
 
+jclass GetPatchClass();
+jmethodID GetPatchConstructor();
+jfieldID GetPatchEnabledField();
+jfieldID GetPatchNameField();
+jfieldID GetPatchVersionField();
+jfieldID GetPatchTypeField();
+jfieldID GetPatchProgramIdField();
+jfieldID GetPatchTitleIdField();
+
 jclass GetDoubleClass();
 jmethodID GetDoubleConstructor();
 jfieldID GetDoubleValueField();

+ 34 - 14
src/android/app/src/main/jni/native.cpp

@@ -774,9 +774,9 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isFirmwareAvailable(JNIEnv* env,
     return true;
 }
 
-jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAddonsForFile(JNIEnv* env, jobject jobj,
-                                                                    jstring jpath,
-                                                                    jstring jprogramId) {
+jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPatchesForFile(JNIEnv* env, jobject jobj,
+                                                                     jstring jpath,
+                                                                     jstring jprogramId) {
     const auto path = GetJString(env, jpath);
     const auto vFile =
         Core::GetGameFileFromPath(EmulationSession::GetInstance().System().GetFilesystem(), path);
@@ -793,20 +793,40 @@ jobjectArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAddonsForFile(JNIEnv* env,
     FileSys::VirtualFile update_raw;
     loader->ReadUpdateRaw(update_raw);
 
-    auto addons = pm.GetPatchVersionNames(update_raw);
-    auto jemptyString = ToJString(env, "");
-    auto jemptyStringPair = env->NewObject(IDCache::GetPairClass(), IDCache::GetPairConstructor(),
-                                           jemptyString, jemptyString);
-    jobjectArray jaddonsArray =
-        env->NewObjectArray(addons.size(), IDCache::GetPairClass(), jemptyStringPair);
+    auto patches = pm.GetPatches(update_raw);
+    jobjectArray jpatchArray =
+        env->NewObjectArray(patches.size(), IDCache::GetPatchClass(), nullptr);
     int i = 0;
-    for (const auto& addon : addons) {
-        jobject jaddon = env->NewObject(IDCache::GetPairClass(), IDCache::GetPairConstructor(),
-                                        ToJString(env, addon.first), ToJString(env, addon.second));
-        env->SetObjectArrayElement(jaddonsArray, i, jaddon);
+    for (const auto& patch : patches) {
+        jobject jpatch = env->NewObject(
+            IDCache::GetPatchClass(), IDCache::GetPatchConstructor(), patch.enabled,
+            ToJString(env, patch.name), ToJString(env, patch.version),
+            static_cast<jint>(patch.type), ToJString(env, std::to_string(patch.program_id)),
+            ToJString(env, std::to_string(patch.title_id)));
+        env->SetObjectArrayElement(jpatchArray, i, jpatch);
         ++i;
     }
-    return jaddonsArray;
+    return jpatchArray;
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_removeUpdate(JNIEnv* env, jobject jobj,
+                                                        jstring jprogramId) {
+    auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+    ContentManager::RemoveUpdate(EmulationSession::GetInstance().System().GetFileSystemController(),
+                                 program_id);
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_removeDLC(JNIEnv* env, jobject jobj,
+                                                     jstring jprogramId) {
+    auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+    ContentManager::RemoveAllDLC(&EmulationSession::GetInstance().System(), program_id);
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_removeMod(JNIEnv* env, jobject jobj, jstring jprogramId,
+                                                     jstring jname) {
+    auto program_id = EmulationSession::GetProgramId(env, jprogramId);
+    ContentManager::RemoveMod(EmulationSession::GetInstance().System().GetFileSystemController(),
+                              program_id, GetJString(env, jname));
 }
 
 jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getSavePath(JNIEnv* env, jobject jobj,

+ 22 - 10
src/android/app/src/main/res/layout/list_item_addon.xml

@@ -14,12 +14,11 @@
         android:id="@+id/text_container"
         android:layout_width="0dp"
         android:layout_height="wrap_content"
-        android:layout_marginEnd="16dp"
         android:orientation="vertical"
-        app:layout_constraintBottom_toBottomOf="@+id/addon_switch"
-        app:layout_constraintEnd_toStartOf="@+id/addon_switch"
+        android:layout_marginEnd="16dp"
+        app:layout_constraintEnd_toStartOf="@+id/addon_checkbox"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="@+id/addon_switch">
+        app:layout_constraintTop_toTopOf="parent">
 
         <com.google.android.material.textview.MaterialTextView
             android:id="@+id/title"
@@ -42,16 +41,29 @@
 
     </LinearLayout>
 
-    <com.google.android.material.materialswitch.MaterialSwitch
-        android:id="@+id/addon_switch"
+    <com.google.android.material.checkbox.MaterialCheckBox
+        android:id="@+id/addon_checkbox"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:focusable="true"
         android:gravity="center"
-        android:nextFocusLeft="@id/addon_container"
-        app:layout_constraintBottom_toBottomOf="parent"
+        android:layout_marginEnd="8dp"
+        app:layout_constraintTop_toTopOf="@+id/text_container"
+        app:layout_constraintBottom_toBottomOf="@+id/text_container"
+        app:layout_constraintEnd_toStartOf="@+id/button_delete" />
+
+    <Button
+        android:id="@+id/button_delete"
+        style="@style/Widget.Material3.Button.IconButton"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center_vertical"
+        android:contentDescription="@string/delete"
+        android:tooltipText="@string/delete"
+        app:icon="@drawable/ic_delete"
+        app:iconTint="?attr/colorControlNormal"
         app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toEndOf="@id/text_container"
-        app:layout_constraintTop_toTopOf="parent" />
+        app:layout_constraintTop_toTopOf="@+id/addon_checkbox"
+        app:layout_constraintBottom_toBottomOf="@+id/addon_checkbox" />
 
 </androidx.constraintlayout.widget.ConstraintLayout>

+ 3 - 0
src/android/app/src/main/res/values/strings.xml

@@ -286,6 +286,7 @@
     <string name="custom">Custom</string>
     <string name="notice">Notice</string>
     <string name="import_complete">Import complete</string>
+    <string name="more_options">More options</string>
 
     <!-- GPU driver installation -->
     <string name="select_gpu_driver">Select GPU driver</string>
@@ -348,6 +349,8 @@
     <string name="verifying_content">Verifying content…</string>
     <string name="content_install_notice">Content install notice</string>
     <string name="content_install_notice_description">The content that you selected does not match this game.\nInstall anyway?</string>
+    <string name="confirm_uninstall">Confirm uninstall</string>
+    <string name="confirm_uninstall_description">Are you sure you want to uninstall this addon?</string>
 
     <!-- ROM loading errors -->
     <string name="loader_error_encrypted">Your ROM is encrypted</string>

+ 33 - 10
src/core/file_sys/patch_manager.cpp

@@ -466,12 +466,12 @@ VirtualFile PatchManager::PatchRomFS(const NCA* base_nca, VirtualFile base_romfs
     return romfs;
 }
 
-PatchManager::PatchVersionNames PatchManager::GetPatchVersionNames(VirtualFile update_raw) const {
+std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
     if (title_id == 0) {
         return {};
     }
 
-    std::map<std::string, std::string, std::less<>> out;
+    std::vector<Patch> out;
     const auto& disabled = Settings::values.disabled_addons[title_id];
 
     // Game Updates
@@ -482,20 +482,28 @@ PatchManager::PatchVersionNames PatchManager::GetPatchVersionNames(VirtualFile u
 
     const auto update_disabled =
         std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
-    const auto update_label = update_disabled ? "[D] Update" : "Update";
+    Patch update_patch = {.enabled = !update_disabled,
+                          .name = "Update",
+                          .version = "",
+                          .type = PatchType::Update,
+                          .program_id = title_id,
+                          .title_id = title_id};
 
     if (nacp != nullptr) {
-        out.insert_or_assign(update_label, nacp->GetVersionString());
+        update_patch.version = nacp->GetVersionString();
+        out.push_back(update_patch);
     } else {
         if (content_provider.HasEntry(update_tid, ContentRecordType::Program)) {
             const auto meta_ver = content_provider.GetEntryVersion(update_tid);
             if (meta_ver.value_or(0) == 0) {
-                out.insert_or_assign(update_label, "");
+                out.push_back(update_patch);
             } else {
-                out.insert_or_assign(update_label, FormatTitleVersion(*meta_ver));
+                update_patch.version = FormatTitleVersion(*meta_ver);
+                out.push_back(update_patch);
             }
         } else if (update_raw != nullptr) {
-            out.insert_or_assign(update_label, "PACKED");
+            update_patch.version = "PACKED";
+            out.push_back(update_patch);
         }
     }
 
@@ -539,7 +547,12 @@ PatchManager::PatchVersionNames PatchManager::GetPatchVersionNames(VirtualFile u
 
             const auto mod_disabled =
                 std::find(disabled.begin(), disabled.end(), mod->GetName()) != disabled.end();
-            out.insert_or_assign(mod_disabled ? "[D] " + mod->GetName() : mod->GetName(), types);
+            out.push_back({.enabled = !mod_disabled,
+                           .name = mod->GetName(),
+                           .version = types,
+                           .type = PatchType::Mod,
+                           .program_id = title_id,
+                           .title_id = title_id});
         }
     }
 
@@ -557,7 +570,12 @@ PatchManager::PatchVersionNames PatchManager::GetPatchVersionNames(VirtualFile u
         if (!types.empty()) {
             const auto mod_disabled =
                 std::find(disabled.begin(), disabled.end(), "SDMC") != disabled.end();
-            out.insert_or_assign(mod_disabled ? "[D] SDMC" : "SDMC", types);
+            out.push_back({.enabled = !mod_disabled,
+                           .name = "SDMC",
+                           .version = types,
+                           .type = PatchType::Mod,
+                           .program_id = title_id,
+                           .title_id = title_id});
         }
     }
 
@@ -584,7 +602,12 @@ PatchManager::PatchVersionNames PatchManager::GetPatchVersionNames(VirtualFile u
 
         const auto dlc_disabled =
             std::find(disabled.begin(), disabled.end(), "DLC") != disabled.end();
-        out.insert_or_assign(dlc_disabled ? "[D] DLC" : "DLC", std::move(list));
+        out.push_back({.enabled = !dlc_disabled,
+                       .name = "DLC",
+                       .version = std::move(list),
+                       .type = PatchType::DLC,
+                       .program_id = title_id,
+                       .title_id = dlc_match.back().title_id});
     }
 
     return out;

+ 13 - 4
src/core/file_sys/patch_manager.h

@@ -26,12 +26,22 @@ class ContentProvider;
 class NCA;
 class NACP;
 
+enum class PatchType { Update, DLC, Mod };
+
+struct Patch {
+    bool enabled;
+    std::string name;
+    std::string version;
+    PatchType type;
+    u64 program_id;
+    u64 title_id;
+};
+
 // A centralized class to manage patches to games.
 class PatchManager {
 public:
     using BuildID = std::array<u8, 0x20>;
     using Metadata = std::pair<std::unique_ptr<NACP>, VirtualFile>;
-    using PatchVersionNames = std::map<std::string, std::string, std::less<>>;
 
     explicit PatchManager(u64 title_id_,
                           const Service::FileSystem::FileSystemController& fs_controller_,
@@ -66,9 +76,8 @@ public:
                                          VirtualFile packed_update_raw = nullptr,
                                          bool apply_layeredfs = true) const;
 
-    // Returns a vector of pairs between patch names and patch versions.
-    // i.e. Update 3.2.2 will return {"Update", "3.2.2"}
-    [[nodiscard]] PatchVersionNames GetPatchVersionNames(VirtualFile update_raw = nullptr) const;
+    // Returns a vector of patches
+    [[nodiscard]] std::vector<Patch> GetPatches(VirtualFile update_raw = nullptr) const;
 
     // If the game update exists, returns the u32 version field in its Meta-type NCA. If that fails,
     // it will fallback to the Meta-type NCA of the base game. If that fails, the result will be

+ 17 - 0
src/frontend_common/content_manager.h

@@ -65,6 +65,23 @@ inline bool RemoveBaseContent(const Service::FileSystem::FileSystemController& f
            fs_controller.GetSDMCContents()->RemoveExistingEntry(program_id);
 }
 
+inline bool RemoveMod(const Service::FileSystem::FileSystemController& fs_controller,
+                      const u64 program_id, const std::string& mod_name) {
+    // Check general Mods (LayeredFS and IPS)
+    const auto mod_dir = fs_controller.GetModificationLoadRoot(program_id);
+    if (mod_dir != nullptr) {
+        return mod_dir->DeleteSubdirectoryRecursive(mod_name);
+    }
+
+    // Check SDMC mod directory (RomFS LayeredFS)
+    const auto sdmc_mod_dir = fs_controller.GetSDMCModificationLoadRoot(program_id);
+    if (sdmc_mod_dir != nullptr) {
+        return sdmc_mod_dir->DeleteSubdirectoryRecursive(mod_name);
+    }
+
+    return false;
+}
+
 inline InstallResult InstallNSP(
     Core::System* system, FileSys::VfsFilesystem* vfs, const std::string& filename,
     const std::function<bool(size_t, size_t)>& callback = std::function<bool(size_t, size_t)>()) {

+ 3 - 4
src/yuzu/configuration/configure_per_game_addons.cpp

@@ -122,9 +122,8 @@ void ConfigurePerGameAddons::LoadConfiguration() {
 
     const auto& disabled = Settings::values.disabled_addons[title_id];
 
-    for (const auto& patch : pm.GetPatchVersionNames(update_raw)) {
-        const auto name =
-            QString::fromStdString(patch.first).replace(QStringLiteral("[D] "), QString{});
+    for (const auto& patch : pm.GetPatches(update_raw)) {
+        const auto name = QString::fromStdString(patch.name);
 
         auto* const first_item = new QStandardItem;
         first_item->setText(name);
@@ -136,7 +135,7 @@ void ConfigurePerGameAddons::LoadConfiguration() {
         first_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked);
 
         list_items.push_back(QList<QStandardItem*>{
-            first_item, new QStandardItem{QString::fromStdString(patch.second)}});
+            first_item, new QStandardItem{QString::fromStdString(patch.version)}});
         item_model->appendRow(list_items.back());
     }
 

+ 6 - 5
src/yuzu/game_list_worker.cpp

@@ -164,18 +164,19 @@ QString FormatPatchNameVersions(const FileSys::PatchManager& patch_manager,
     QString out;
     FileSys::VirtualFile update_raw;
     loader.ReadUpdateRaw(update_raw);
-    for (const auto& kv : patch_manager.GetPatchVersionNames(update_raw)) {
-        const bool is_update = kv.first == "Update" || kv.first == "[D] Update";
+    for (const auto& patch : patch_manager.GetPatches(update_raw)) {
+        const bool is_update = patch.name == "Update";
         if (!updatable && is_update) {
             continue;
         }
 
-        const QString type = QString::fromStdString(kv.first);
+        const QString type =
+            QString::fromStdString(patch.enabled ? patch.name : "[D] " + patch.name);
 
-        if (kv.second.empty()) {
+        if (patch.version.empty()) {
             out.append(QStringLiteral("%1\n").arg(type));
         } else {
-            auto ver = kv.second;
+            auto ver = patch.version;
 
             // Display container name for packed updates
             if (is_update && ver == "PACKED") {