From ccd3dd842f2bf7cf16c7b93e3b83a2afc8af4a69 Mon Sep 17 00:00:00 2001
From: t895 <clombardo169@gmail.com>
Date: Fri, 19 Jan 2024 00:56:43 -0500
Subject: [PATCH 1/4] frontend_common: Add content manager utility functions

Creates utility functions to remove/install DLC, updates, and base game content
---
 .../java/org/yuzu/yuzu_emu/NativeLibrary.kt   |  19 +-
 .../org/yuzu/yuzu_emu/model/InstallResult.kt  |  15 ++
 .../jni/android_common/android_common.cpp     |  16 ++
 .../main/jni/android_common/android_common.h  |   7 +
 src/android/app/src/main/jni/id_cache.cpp     |  46 +++++
 src/android/app/src/main/jni/id_cache.h       |   8 +
 src/android/app/src/main/jni/native.cpp       |  80 ++-------
 src/android/app/src/main/jni/native.h         |   2 +-
 src/frontend_common/CMakeLists.txt            |   1 +
 src/frontend_common/content_manager.h         | 168 ++++++++++++++++++
 src/yuzu/main.cpp                             | 166 ++++-------------
 src/yuzu/main.h                               |  11 +-
 12 files changed, 318 insertions(+), 221 deletions(-)
 create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt
 create mode 100644 src/frontend_common/content_manager.h

diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
index b7556e3530..8cb98d6d7b 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
@@ -21,6 +21,7 @@ import org.yuzu.yuzu_emu.utils.DocumentsTree
 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
 
 /**
  * Class which contains methods that interact
@@ -235,9 +236,12 @@ object NativeLibrary {
     /**
      * Installs a nsp or xci file to nand
      * @param filename String representation of file uri
-     * @param extension Lowercase string representation of file extension without "."
+     * @return int representation of [InstallResult]
      */
-    external fun installFileToNand(filename: String, extension: String): Int
+    external fun installFileToNand(
+        filename: String,
+        callback: (max: Long, progress: Long) -> Boolean
+    ): Int
 
     external fun doesUpdateMatchProgram(programId: String, updatePath: String): Boolean
 
@@ -609,15 +613,4 @@ object NativeLibrary {
         const val RELEASED = 0
         const val PRESSED = 1
     }
-
-    /**
-     * Result from installFileToNand
-     */
-    object InstallFileToNandResult {
-        const val Success = 0
-        const val SuccessFileOverwritten = 1
-        const val Error = 2
-        const val ErrorBaseGame = 3
-        const val ErrorFilenameExtension = 4
-    }
 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt
new file mode 100644
index 0000000000..0c3cd0521e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt
@@ -0,0 +1,15 @@
+// SPDX-FileCopyrightText: 2024 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+enum class InstallResult(val int: Int) {
+    Success(0),
+    Overwrite(1),
+    Failure(2),
+    BaseInstallAttempted(3);
+
+    companion object {
+        fun from(int: Int): InstallResult = entries.firstOrNull { it.int == int } ?: Success
+    }
+}
diff --git a/src/android/app/src/main/jni/android_common/android_common.cpp b/src/android/app/src/main/jni/android_common/android_common.cpp
index 1e884ffdd2..7018a52af3 100644
--- a/src/android/app/src/main/jni/android_common/android_common.cpp
+++ b/src/android/app/src/main/jni/android_common/android_common.cpp
@@ -42,3 +42,19 @@ double GetJDouble(JNIEnv* env, jobject jdouble) {
 jobject ToJDouble(JNIEnv* env, double value) {
     return env->NewObject(IDCache::GetDoubleClass(), IDCache::GetDoubleConstructor(), value);
 }
+
+s32 GetJInteger(JNIEnv* env, jobject jinteger) {
+    return env->GetIntField(jinteger, IDCache::GetIntegerValueField());
+}
+
+jobject ToJInteger(JNIEnv* env, s32 value) {
+    return env->NewObject(IDCache::GetIntegerClass(), IDCache::GetIntegerConstructor(), value);
+}
+
+bool GetJBoolean(JNIEnv* env, jobject jboolean) {
+    return env->GetBooleanField(jboolean, IDCache::GetBooleanValueField());
+}
+
+jobject ToJBoolean(JNIEnv* env, bool value) {
+    return env->NewObject(IDCache::GetBooleanClass(), IDCache::GetBooleanConstructor(), value);
+}
diff --git a/src/android/app/src/main/jni/android_common/android_common.h b/src/android/app/src/main/jni/android_common/android_common.h
index 8eb803e1b0..29a338c0ae 100644
--- a/src/android/app/src/main/jni/android_common/android_common.h
+++ b/src/android/app/src/main/jni/android_common/android_common.h
@@ -6,6 +6,7 @@
 #include <string>
 
 #include <jni.h>
+#include "common/common_types.h"
 
 std::string GetJString(JNIEnv* env, jstring jstr);
 jstring ToJString(JNIEnv* env, std::string_view str);
@@ -13,3 +14,9 @@ jstring ToJString(JNIEnv* env, std::u16string_view str);
 
 double GetJDouble(JNIEnv* env, jobject jdouble);
 jobject ToJDouble(JNIEnv* env, double value);
+
+s32 GetJInteger(JNIEnv* env, jobject jinteger);
+jobject ToJInteger(JNIEnv* env, s32 value);
+
+bool GetJBoolean(JNIEnv* env, jobject jboolean);
+jobject ToJBoolean(JNIEnv* env, bool value);
diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp
index c79ad7d76b..19ced175fa 100644
--- a/src/android/app/src/main/jni/id_cache.cpp
+++ b/src/android/app/src/main/jni/id_cache.cpp
@@ -47,6 +47,14 @@ static jclass s_double_class;
 static jmethodID s_double_constructor;
 static jfieldID s_double_value_field;
 
+static jclass s_integer_class;
+static jmethodID s_integer_constructor;
+static jfieldID s_integer_value_field;
+
+static jclass s_boolean_class;
+static jmethodID s_boolean_constructor;
+static jfieldID s_boolean_value_field;
+
 static constexpr jint JNI_VERSION = JNI_VERSION_1_6;
 
 namespace IDCache {
@@ -198,6 +206,30 @@ jfieldID GetDoubleValueField() {
     return s_double_value_field;
 }
 
+jclass GetIntegerClass() {
+    return s_integer_class;
+}
+
+jmethodID GetIntegerConstructor() {
+    return s_integer_constructor;
+}
+
+jfieldID GetIntegerValueField() {
+    return s_integer_value_field;
+}
+
+jclass GetBooleanClass() {
+    return s_boolean_class;
+}
+
+jmethodID GetBooleanConstructor() {
+    return s_boolean_constructor;
+}
+
+jfieldID GetBooleanValueField() {
+    return s_boolean_value_field;
+}
+
 } // namespace IDCache
 
 #ifdef __cplusplus
@@ -284,6 +316,18 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
     s_double_value_field = env->GetFieldID(double_class, "value", "D");
     env->DeleteLocalRef(double_class);
 
+    const jclass int_class = env->FindClass("java/lang/Integer");
+    s_integer_class = reinterpret_cast<jclass>(env->NewGlobalRef(int_class));
+    s_integer_constructor = env->GetMethodID(int_class, "<init>", "(I)V");
+    s_integer_value_field = env->GetFieldID(int_class, "value", "I");
+    env->DeleteLocalRef(int_class);
+
+    const jclass boolean_class = env->FindClass("java/lang/Boolean");
+    s_boolean_class = reinterpret_cast<jclass>(env->NewGlobalRef(boolean_class));
+    s_boolean_constructor = env->GetMethodID(boolean_class, "<init>", "(Z)V");
+    s_boolean_value_field = env->GetFieldID(boolean_class, "value", "Z");
+    env->DeleteLocalRef(boolean_class);
+
     // Initialize Android Storage
     Common::FS::Android::RegisterCallbacks(env, s_native_library_class);
 
@@ -310,6 +354,8 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
     env->DeleteGlobalRef(s_pair_class);
     env->DeleteGlobalRef(s_overlay_control_data_class);
     env->DeleteGlobalRef(s_double_class);
+    env->DeleteGlobalRef(s_integer_class);
+    env->DeleteGlobalRef(s_boolean_class);
 
     // UnInitialize applets
     SoftwareKeyboard::CleanupJNI(env);
diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h
index 784d1412f6..0e5267b738 100644
--- a/src/android/app/src/main/jni/id_cache.h
+++ b/src/android/app/src/main/jni/id_cache.h
@@ -47,4 +47,12 @@ jclass GetDoubleClass();
 jmethodID GetDoubleConstructor();
 jfieldID GetDoubleValueField();
 
+jclass GetIntegerClass();
+jmethodID GetIntegerConstructor();
+jfieldID GetIntegerValueField();
+
+jclass GetBooleanClass();
+jmethodID GetBooleanConstructor();
+jfieldID GetBooleanValueField();
+
 } // namespace IDCache
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index ed3b1353a0..b8fef5c6fd 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -17,6 +17,7 @@
 #include <core/file_sys/patch_manager.h>
 #include <core/file_sys/savedata_factory.h>
 #include <core/loader/nro.h>
+#include <frontend_common/content_manager.h>
 #include <jni.h>
 
 #include "common/detached_tasks.h"
@@ -100,67 +101,6 @@ void EmulationSession::SetNativeWindow(ANativeWindow* native_window) {
     m_native_window = native_window;
 }
 
-int EmulationSession::InstallFileToNand(std::string filename, std::string file_extension) {
-    jconst copy_func = [](const FileSys::VirtualFile& src, const FileSys::VirtualFile& dest,
-                          std::size_t block_size) {
-        if (src == nullptr || dest == nullptr) {
-            return false;
-        }
-        if (!dest->Resize(src->GetSize())) {
-            return false;
-        }
-
-        using namespace Common::Literals;
-        [[maybe_unused]] std::vector<u8> buffer(1_MiB);
-
-        for (std::size_t i = 0; i < src->GetSize(); i += buffer.size()) {
-            jconst read = src->Read(buffer.data(), buffer.size(), i);
-            dest->Write(buffer.data(), read, i);
-        }
-        return true;
-    };
-
-    enum InstallResult {
-        Success = 0,
-        SuccessFileOverwritten = 1,
-        InstallError = 2,
-        ErrorBaseGame = 3,
-        ErrorFilenameExtension = 4,
-    };
-
-    [[maybe_unused]] std::shared_ptr<FileSys::NSP> nsp;
-    if (file_extension == "nsp") {
-        nsp = std::make_shared<FileSys::NSP>(m_vfs->OpenFile(filename, FileSys::Mode::Read));
-        if (nsp->IsExtractedType()) {
-            return InstallError;
-        }
-    } else {
-        return ErrorFilenameExtension;
-    }
-
-    if (!nsp) {
-        return InstallError;
-    }
-
-    if (nsp->GetStatus() != Loader::ResultStatus::Success) {
-        return InstallError;
-    }
-
-    jconst res = m_system.GetFileSystemController().GetUserNANDContents()->InstallEntry(*nsp, true,
-                                                                                        copy_func);
-
-    switch (res) {
-    case FileSys::InstallResult::Success:
-        return Success;
-    case FileSys::InstallResult::OverwriteExisting:
-        return SuccessFileOverwritten;
-    case FileSys::InstallResult::ErrorBaseInstall:
-        return ErrorBaseGame;
-    default:
-        return InstallError;
-    }
-}
-
 void EmulationSession::InitializeGpuDriver(const std::string& hook_lib_dir,
                                            const std::string& custom_driver_dir,
                                            const std::string& custom_driver_name,
@@ -512,10 +452,20 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env, jobject
 }
 
 int Java_org_yuzu_yuzu_1emu_NativeLibrary_installFileToNand(JNIEnv* env, jobject instance,
-                                                            jstring j_file,
-                                                            jstring j_file_extension) {
-    return EmulationSession::GetInstance().InstallFileToNand(GetJString(env, j_file),
-                                                             GetJString(env, j_file_extension));
+                                                            jstring j_file, jobject jcallback) {
+    auto jlambdaClass = env->GetObjectClass(jcallback);
+    auto jlambdaInvokeMethod = env->GetMethodID(
+        jlambdaClass, "invoke", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;");
+    const auto callback = [env, jcallback, jlambdaInvokeMethod](size_t max, size_t progress) {
+        auto jwasCancelled = env->CallObjectMethod(jcallback, jlambdaInvokeMethod,
+                                                   ToJDouble(env, max), ToJDouble(env, progress));
+        return GetJBoolean(env, jwasCancelled);
+    };
+
+    return static_cast<int>(
+        ContentManager::InstallNSP(&EmulationSession::GetInstance().System(),
+                                   EmulationSession::GetInstance().System().GetFilesystem().get(),
+                                   GetJString(env, j_file), callback));
 }
 
 jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_doesUpdateMatchProgram(JNIEnv* env, jobject jobj,
diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h
index 4a80495784..dadb138ad6 100644
--- a/src/android/app/src/main/jni/native.h
+++ b/src/android/app/src/main/jni/native.h
@@ -7,6 +7,7 @@
 #include "core/file_sys/registered_cache.h"
 #include "core/hle/service/acc/profile_manager.h"
 #include "core/perf_stats.h"
+#include "frontend_common/content_manager.h"
 #include "jni/applets/software_keyboard.h"
 #include "jni/emu_window/emu_window.h"
 #include "video_core/rasterizer_interface.h"
@@ -29,7 +30,6 @@ public:
     void SetNativeWindow(ANativeWindow* native_window);
     void SurfaceChanged();
 
-    int InstallFileToNand(std::string filename, std::string file_extension);
     void InitializeGpuDriver(const std::string& hook_lib_dir, const std::string& custom_driver_dir,
                              const std::string& custom_driver_name,
                              const std::string& file_redirect_dir);
diff --git a/src/frontend_common/CMakeLists.txt b/src/frontend_common/CMakeLists.txt
index 22e9337c4c..94d8cc4c3b 100644
--- a/src/frontend_common/CMakeLists.txt
+++ b/src/frontend_common/CMakeLists.txt
@@ -4,6 +4,7 @@
 add_library(frontend_common STATIC
     config.cpp
     config.h
+    content_manager.h
 )
 
 create_target_directory_groups(frontend_common)
diff --git a/src/frontend_common/content_manager.h b/src/frontend_common/content_manager.h
new file mode 100644
index 0000000000..8e55f4ca09
--- /dev/null
+++ b/src/frontend_common/content_manager.h
@@ -0,0 +1,168 @@
+// SPDX-FileCopyrightText: 2024 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <boost/algorithm/string.hpp>
+#include "common/common_types.h"
+#include "common/literals.h"
+#include "core/core.h"
+#include "core/file_sys/common_funcs.h"
+#include "core/file_sys/content_archive.h"
+#include "core/file_sys/mode.h"
+#include "core/file_sys/nca_metadata.h"
+#include "core/file_sys/registered_cache.h"
+#include "core/file_sys/submission_package.h"
+#include "core/hle/service/filesystem/filesystem.h"
+#include "core/loader/loader.h"
+
+namespace ContentManager {
+
+enum class InstallResult {
+    Success,
+    Overwrite,
+    Failure,
+    BaseInstallAttempted,
+};
+
+inline bool RemoveDLC(const Service::FileSystem::FileSystemController& fs_controller,
+                      const u64 title_id) {
+    return fs_controller.GetUserNANDContents()->RemoveExistingEntry(title_id) ||
+           fs_controller.GetSDMCContents()->RemoveExistingEntry(title_id);
+}
+
+inline size_t RemoveAllDLC(Core::System* system, const u64 program_id) {
+    size_t count{};
+    const auto& fs_controller = system->GetFileSystemController();
+    const auto dlc_entries = system->GetContentProvider().ListEntriesFilter(
+        FileSys::TitleType::AOC, FileSys::ContentRecordType::Data);
+    std::vector<u64> program_dlc_entries;
+
+    for (const auto& entry : dlc_entries) {
+        if (FileSys::GetBaseTitleID(entry.title_id) == program_id) {
+            program_dlc_entries.push_back(entry.title_id);
+        }
+    }
+
+    for (const auto& entry : program_dlc_entries) {
+        if (RemoveDLC(fs_controller, entry)) {
+            ++count;
+        }
+    }
+    return count;
+}
+
+inline bool RemoveUpdate(const Service::FileSystem::FileSystemController& fs_controller,
+                         const u64 program_id) {
+    const auto update_id = program_id | 0x800;
+    return fs_controller.GetUserNANDContents()->RemoveExistingEntry(update_id) ||
+           fs_controller.GetSDMCContents()->RemoveExistingEntry(update_id);
+}
+
+inline bool RemoveBaseContent(const Service::FileSystem::FileSystemController& fs_controller,
+                              const u64 program_id) {
+    return fs_controller.GetUserNANDContents()->RemoveExistingEntry(program_id) ||
+           fs_controller.GetSDMCContents()->RemoveExistingEntry(program_id);
+}
+
+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)>()) {
+    const auto copy = [callback](const FileSys::VirtualFile& src, const FileSys::VirtualFile& dest,
+                                 std::size_t block_size) {
+        if (src == nullptr || dest == nullptr) {
+            return false;
+        }
+        if (!dest->Resize(src->GetSize())) {
+            return false;
+        }
+
+        using namespace Common::Literals;
+        std::vector<u8> buffer(1_MiB);
+
+        for (std::size_t i = 0; i < src->GetSize(); i += buffer.size()) {
+            if (callback(src->GetSize(), i)) {
+                dest->Resize(0);
+                return false;
+            }
+            const auto read = src->Read(buffer.data(), buffer.size(), i);
+            dest->Write(buffer.data(), read, i);
+        }
+        return true;
+    };
+
+    std::shared_ptr<FileSys::NSP> nsp;
+    FileSys::VirtualFile file = vfs->OpenFile(filename, FileSys::Mode::Read);
+    if (boost::to_lower_copy(file->GetName()).ends_with(std::string("nsp"))) {
+        nsp = std::make_shared<FileSys::NSP>(file);
+        if (nsp->IsExtractedType()) {
+            return InstallResult::Failure;
+        }
+    } else {
+        return InstallResult::Failure;
+    }
+
+    if (nsp->GetStatus() != Loader::ResultStatus::Success) {
+        return InstallResult::Failure;
+    }
+    const auto res =
+        system->GetFileSystemController().GetUserNANDContents()->InstallEntry(*nsp, true, copy);
+    switch (res) {
+    case FileSys::InstallResult::Success:
+        return InstallResult::Success;
+    case FileSys::InstallResult::OverwriteExisting:
+        return InstallResult::Overwrite;
+    case FileSys::InstallResult::ErrorBaseInstall:
+        return InstallResult::BaseInstallAttempted;
+    default:
+        return InstallResult::Failure;
+    }
+}
+
+inline InstallResult InstallNCA(
+    FileSys::VfsFilesystem* vfs, const std::string& filename,
+    FileSys::RegisteredCache* registered_cache, const FileSys::TitleType title_type,
+    const std::function<bool(size_t, size_t)>& callback = std::function<bool(size_t, size_t)>()) {
+    const auto copy = [callback](const FileSys::VirtualFile& src, const FileSys::VirtualFile& dest,
+                                 std::size_t block_size) {
+        if (src == nullptr || dest == nullptr) {
+            return false;
+        }
+        if (!dest->Resize(src->GetSize())) {
+            return false;
+        }
+
+        using namespace Common::Literals;
+        std::vector<u8> buffer(1_MiB);
+
+        for (std::size_t i = 0; i < src->GetSize(); i += buffer.size()) {
+            if (callback(src->GetSize(), i)) {
+                dest->Resize(0);
+                return false;
+            }
+            const auto read = src->Read(buffer.data(), buffer.size(), i);
+            dest->Write(buffer.data(), read, i);
+        }
+        return true;
+    };
+
+    const auto nca = std::make_shared<FileSys::NCA>(vfs->OpenFile(filename, FileSys::Mode::Read));
+    const auto id = nca->GetStatus();
+
+    // Game updates necessary are missing base RomFS
+    if (id != Loader::ResultStatus::Success &&
+        id != Loader::ResultStatus::ErrorMissingBKTRBaseRomFS) {
+        return InstallResult::Failure;
+    }
+
+    const auto res = registered_cache->InstallEntry(*nca, title_type, true, copy);
+    if (res == FileSys::InstallResult::Success) {
+        return InstallResult::Success;
+    } else if (res == FileSys::InstallResult::OverwriteExisting) {
+        return InstallResult::Overwrite;
+    } else {
+        return InstallResult::Failure;
+    }
+}
+
+} // namespace ContentManager
diff --git a/src/yuzu/main.cpp b/src/yuzu/main.cpp
index 3c562e3b28..05bd4174c7 100644
--- a/src/yuzu/main.cpp
+++ b/src/yuzu/main.cpp
@@ -47,6 +47,7 @@
 #include "core/hle/service/am/applet_oe.h"
 #include "core/hle/service/am/applets/applets.h"
 #include "core/hle/service/set/system_settings_server.h"
+#include "frontend_common/content_manager.h"
 #include "hid_core/frontend/emulated_controller.h"
 #include "hid_core/hid_core.h"
 #include "yuzu/multiplayer/state.h"
@@ -2476,10 +2477,8 @@ void GMainWindow::OnGameListRemoveInstalledEntry(u64 program_id, InstalledEntryT
 }
 
 void GMainWindow::RemoveBaseContent(u64 program_id, InstalledEntryType type) {
-    const auto& fs_controller = system->GetFileSystemController();
-    const auto res = fs_controller.GetUserNANDContents()->RemoveExistingEntry(program_id) ||
-                     fs_controller.GetSDMCContents()->RemoveExistingEntry(program_id);
-
+    const auto res =
+        ContentManager::RemoveBaseContent(system->GetFileSystemController(), program_id);
     if (res) {
         QMessageBox::information(this, tr("Successfully Removed"),
                                  tr("Successfully removed the installed base game."));
@@ -2491,11 +2490,7 @@ void GMainWindow::RemoveBaseContent(u64 program_id, InstalledEntryType type) {
 }
 
 void GMainWindow::RemoveUpdateContent(u64 program_id, InstalledEntryType type) {
-    const auto update_id = program_id | 0x800;
-    const auto& fs_controller = system->GetFileSystemController();
-    const auto res = fs_controller.GetUserNANDContents()->RemoveExistingEntry(update_id) ||
-                     fs_controller.GetSDMCContents()->RemoveExistingEntry(update_id);
-
+    const auto res = ContentManager::RemoveUpdate(system->GetFileSystemController(), program_id);
     if (res) {
         QMessageBox::information(this, tr("Successfully Removed"),
                                  tr("Successfully removed the installed update."));
@@ -2506,22 +2501,7 @@ void GMainWindow::RemoveUpdateContent(u64 program_id, InstalledEntryType type) {
 }
 
 void GMainWindow::RemoveAddOnContent(u64 program_id, InstalledEntryType type) {
-    u32 count{};
-    const auto& fs_controller = system->GetFileSystemController();
-    const auto dlc_entries = system->GetContentProvider().ListEntriesFilter(
-        FileSys::TitleType::AOC, FileSys::ContentRecordType::Data);
-
-    for (const auto& entry : dlc_entries) {
-        if (FileSys::GetBaseTitleID(entry.title_id) == program_id) {
-            const auto res =
-                fs_controller.GetUserNANDContents()->RemoveExistingEntry(entry.title_id) ||
-                fs_controller.GetSDMCContents()->RemoveExistingEntry(entry.title_id);
-            if (res) {
-                ++count;
-            }
-        }
-    }
-
+    const size_t count = ContentManager::RemoveAllDLC(system.get(), program_id);
     if (count == 0) {
         QMessageBox::warning(this, GetGameListErrorRemoving(type),
                              tr("There are no DLC installed for this title."));
@@ -3290,12 +3270,21 @@ void GMainWindow::OnMenuInstallToNAND() {
         install_progress->setLabelText(
             tr("Installing file \"%1\"...").arg(QFileInfo(file).fileName()));
 
-        QFuture<InstallResult> future;
-        InstallResult result;
+        QFuture<ContentManager::InstallResult> future;
+        ContentManager::InstallResult result;
 
         if (file.endsWith(QStringLiteral("nsp"), Qt::CaseInsensitive)) {
-
-            future = QtConcurrent::run([this, &file] { return InstallNSP(file); });
+            const auto progress_callback = [this](size_t size, size_t progress) {
+                emit UpdateInstallProgress();
+                if (install_progress->wasCanceled()) {
+                    return true;
+                }
+                return false;
+            };
+            future = QtConcurrent::run([this, &file, progress_callback] {
+                return ContentManager::InstallNSP(system.get(), vfs.get(), file.toStdString(),
+                                                  progress_callback);
+            });
 
             while (!future.isFinished()) {
                 QCoreApplication::processEvents();
@@ -3311,16 +3300,16 @@ void GMainWindow::OnMenuInstallToNAND() {
         std::this_thread::sleep_for(std::chrono::milliseconds(10));
 
         switch (result) {
-        case InstallResult::Success:
+        case ContentManager::InstallResult::Success:
             new_files.append(QFileInfo(file).fileName());
             break;
-        case InstallResult::Overwrite:
+        case ContentManager::InstallResult::Overwrite:
             overwritten_files.append(QFileInfo(file).fileName());
             break;
-        case InstallResult::Failure:
+        case ContentManager::InstallResult::Failure:
             failed_files.append(QFileInfo(file).fileName());
             break;
-        case InstallResult::BaseInstallAttempted:
+        case ContentManager::InstallResult::BaseInstallAttempted:
             failed_files.append(QFileInfo(file).fileName());
             detected_base_install = true;
             break;
@@ -3354,96 +3343,7 @@ void GMainWindow::OnMenuInstallToNAND() {
     ui->action_Install_File_NAND->setEnabled(true);
 }
 
-InstallResult GMainWindow::InstallNSP(const QString& filename) {
-    const auto qt_raw_copy = [this](const FileSys::VirtualFile& src,
-                                    const FileSys::VirtualFile& dest, std::size_t block_size) {
-        if (src == nullptr || dest == nullptr) {
-            return false;
-        }
-        if (!dest->Resize(src->GetSize())) {
-            return false;
-        }
-
-        std::vector<u8> buffer(CopyBufferSize);
-
-        for (std::size_t i = 0; i < src->GetSize(); i += buffer.size()) {
-            if (install_progress->wasCanceled()) {
-                dest->Resize(0);
-                return false;
-            }
-
-            emit UpdateInstallProgress();
-
-            const auto read = src->Read(buffer.data(), buffer.size(), i);
-            dest->Write(buffer.data(), read, i);
-        }
-        return true;
-    };
-
-    std::shared_ptr<FileSys::NSP> nsp;
-    if (filename.endsWith(QStringLiteral("nsp"), Qt::CaseInsensitive)) {
-        nsp = std::make_shared<FileSys::NSP>(
-            vfs->OpenFile(filename.toStdString(), FileSys::Mode::Read));
-        if (nsp->IsExtractedType()) {
-            return InstallResult::Failure;
-        }
-    } else {
-        return InstallResult::Failure;
-    }
-
-    if (nsp->GetStatus() != Loader::ResultStatus::Success) {
-        return InstallResult::Failure;
-    }
-    const auto res = system->GetFileSystemController().GetUserNANDContents()->InstallEntry(
-        *nsp, true, qt_raw_copy);
-    switch (res) {
-    case FileSys::InstallResult::Success:
-        return InstallResult::Success;
-    case FileSys::InstallResult::OverwriteExisting:
-        return InstallResult::Overwrite;
-    case FileSys::InstallResult::ErrorBaseInstall:
-        return InstallResult::BaseInstallAttempted;
-    default:
-        return InstallResult::Failure;
-    }
-}
-
-InstallResult GMainWindow::InstallNCA(const QString& filename) {
-    const auto qt_raw_copy = [this](const FileSys::VirtualFile& src,
-                                    const FileSys::VirtualFile& dest, std::size_t block_size) {
-        if (src == nullptr || dest == nullptr) {
-            return false;
-        }
-        if (!dest->Resize(src->GetSize())) {
-            return false;
-        }
-
-        std::vector<u8> buffer(CopyBufferSize);
-
-        for (std::size_t i = 0; i < src->GetSize(); i += buffer.size()) {
-            if (install_progress->wasCanceled()) {
-                dest->Resize(0);
-                return false;
-            }
-
-            emit UpdateInstallProgress();
-
-            const auto read = src->Read(buffer.data(), buffer.size(), i);
-            dest->Write(buffer.data(), read, i);
-        }
-        return true;
-    };
-
-    const auto nca =
-        std::make_shared<FileSys::NCA>(vfs->OpenFile(filename.toStdString(), FileSys::Mode::Read));
-    const auto id = nca->GetStatus();
-
-    // Game updates necessary are missing base RomFS
-    if (id != Loader::ResultStatus::Success &&
-        id != Loader::ResultStatus::ErrorMissingBKTRBaseRomFS) {
-        return InstallResult::Failure;
-    }
-
+ContentManager::InstallResult GMainWindow::InstallNCA(const QString& filename) {
     const QStringList tt_options{tr("System Application"),
                                  tr("System Archive"),
                                  tr("System Application Update"),
@@ -3464,7 +3364,7 @@ InstallResult GMainWindow::InstallNCA(const QString& filename) {
     if (!ok || index == -1) {
         QMessageBox::warning(this, tr("Failed to Install"),
                              tr("The title type you selected for the NCA is invalid."));
-        return InstallResult::Failure;
+        return ContentManager::InstallResult::Failure;
     }
 
     // If index is equal to or past Game, add the jump in TitleType.
@@ -3478,15 +3378,15 @@ InstallResult GMainWindow::InstallNCA(const QString& filename) {
     auto* registered_cache = is_application ? fs_controller.GetUserNANDContents()
                                             : fs_controller.GetSystemNANDContents();
 
-    const auto res = registered_cache->InstallEntry(*nca, static_cast<FileSys::TitleType>(index),
-                                                    true, qt_raw_copy);
-    if (res == FileSys::InstallResult::Success) {
-        return InstallResult::Success;
-    } else if (res == FileSys::InstallResult::OverwriteExisting) {
-        return InstallResult::Overwrite;
-    } else {
-        return InstallResult::Failure;
-    }
+    const auto progress_callback = [this](size_t size, size_t progress) {
+        emit UpdateInstallProgress();
+        if (install_progress->wasCanceled()) {
+            return true;
+        }
+        return false;
+    };
+    return ContentManager::InstallNCA(vfs.get(), filename.toStdString(), registered_cache,
+                                      static_cast<FileSys::TitleType>(index), progress_callback);
 }
 
 void GMainWindow::OnMenuRecentFile() {
diff --git a/src/yuzu/main.h b/src/yuzu/main.h
index f3276da642..280fae5c35 100644
--- a/src/yuzu/main.h
+++ b/src/yuzu/main.h
@@ -16,6 +16,7 @@
 #include "common/announce_multiplayer_room.h"
 #include "common/common_types.h"
 #include "configuration/qt_config.h"
+#include "frontend_common/content_manager.h"
 #include "input_common/drivers/tas_input.h"
 #include "yuzu/compatibility_list.h"
 #include "yuzu/hotkeys.h"
@@ -124,13 +125,6 @@ enum class EmulatedDirectoryTarget {
     SDMC,
 };
 
-enum class InstallResult {
-    Success,
-    Overwrite,
-    Failure,
-    BaseInstallAttempted,
-};
-
 enum class ReinitializeKeyBehavior {
     NoWarning,
     Warning,
@@ -427,8 +421,7 @@ private:
     void RemoveCacheStorage(u64 program_id);
     bool SelectRomFSDumpTarget(const FileSys::ContentProvider&, u64 program_id,
                                u64* selected_title_id, u8* selected_content_record_type);
-    InstallResult InstallNSP(const QString& filename);
-    InstallResult InstallNCA(const QString& filename);
+    ContentManager::InstallResult InstallNCA(const QString& filename);
     void MigrateConfigFiles();
     void UpdateWindowTitle(std::string_view title_name = {}, std::string_view title_version = {},
                            std::string_view gpu_vendor = {});

From d79d4d5986e952000624edb244839fd1996be4ae Mon Sep 17 00:00:00 2001
From: t895 <clombardo169@gmail.com>
Date: Fri, 19 Jan 2024 01:06:10 -0500
Subject: [PATCH 2/4] android: Use callback to update progress bar dialogs

---
 .../yuzu/yuzu_emu/fragments/AddonsFragment.kt |   8 +-
 .../fragments/DriverManagerFragment.kt        |   6 +-
 .../fragments/GamePropertiesFragment.kt       |  25 ++---
 .../yuzu_emu/fragments/InstallableFragment.kt |  29 ++---
 ...gFragment.kt => ProgressDialogFragment.kt} |  44 +++++++-
 .../org/yuzu/yuzu_emu/model/TaskViewModel.kt  |  29 ++++-
 .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 101 ++++++++----------
 .../java/org/yuzu/yuzu_emu/utils/FileUtil.kt  |  80 ++++++++++++--
 .../yuzu/yuzu_emu/utils/GpuDriverHelper.kt    |   5 +-
 .../main/res/layout/dialog_progress_bar.xml   |  30 +++++-
 10 files changed, 236 insertions(+), 121 deletions(-)
 rename src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/{IndeterminateProgressDialogFragment.kt => ProgressDialogFragment.kt} (73%)

diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
index 816336820f..b63ece9a4f 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
@@ -156,22 +156,22 @@ class AddonsFragment : Fragment() {
                 descriptionId = R.string.invalid_directory_description
             )
             if (isValid) {
-                IndeterminateProgressDialogFragment.newInstance(
+                ProgressDialogFragment.newInstance(
                     requireActivity(),
                     R.string.installing_game_content,
                     false
-                ) {
+                ) { progressCallback, _ ->
                     val parentDirectoryName = externalAddonDirectory.name
                     val internalAddonDirectory =
                         File(args.game.addonDir + parentDirectoryName)
                     try {
-                        externalAddonDirectory.copyFilesTo(internalAddonDirectory)
+                        externalAddonDirectory.copyFilesTo(internalAddonDirectory, progressCallback)
                     } catch (_: Exception) {
                         return@newInstance errorMessage
                     }
                     addonViewModel.refreshAddons()
                     return@newInstance getString(R.string.addon_installed_successfully)
-                }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+                }.show(parentFragmentManager, ProgressDialogFragment.TAG)
             } else {
                 errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG)
             }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt
index 9dabb9c416..6c758d80bb 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt
@@ -173,11 +173,11 @@ class DriverManagerFragment : Fragment() {
                 return@registerForActivityResult
             }
 
-            IndeterminateProgressDialogFragment.newInstance(
+            ProgressDialogFragment.newInstance(
                 requireActivity(),
                 R.string.installing_driver,
                 false
-            ) {
+            ) { _, _ ->
                 val driverPath =
                     "${GpuDriverHelper.driverStoragePath}${FileUtil.getFilename(result)}"
                 val driverFile = File(driverPath)
@@ -213,6 +213,6 @@ class DriverManagerFragment : Fragment() {
                     }
                 }
                 return@newInstance Any()
-            }.show(childFragmentManager, IndeterminateProgressDialogFragment.TAG)
+            }.show(childFragmentManager, ProgressDialogFragment.TAG)
         }
 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
index b04d1208fa..83a8454349 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
@@ -44,7 +44,6 @@ import org.yuzu.yuzu_emu.utils.FileUtil
 import org.yuzu.yuzu_emu.utils.GameIconUtils
 import org.yuzu.yuzu_emu.utils.GpuDriverHelper
 import org.yuzu.yuzu_emu.utils.MemoryUtil
-import java.io.BufferedInputStream
 import java.io.BufferedOutputStream
 import java.io.File
 
@@ -357,27 +356,17 @@ class GamePropertiesFragment : Fragment() {
                 return@registerForActivityResult
             }
 
-            val inputZip = requireContext().contentResolver.openInputStream(result)
             val savesFolder = File(args.game.saveDir)
             val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/")
             cacheSaveDir.mkdir()
 
-            if (inputZip == null) {
-                Toast.makeText(
-                    YuzuApplication.appContext,
-                    getString(R.string.fatal_error),
-                    Toast.LENGTH_LONG
-                ).show()
-                return@registerForActivityResult
-            }
-
-            IndeterminateProgressDialogFragment.newInstance(
+            ProgressDialogFragment.newInstance(
                 requireActivity(),
                 R.string.save_files_importing,
                 false
-            ) {
+            ) { _, _ ->
                 try {
-                    FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
+                    FileUtil.unzipToInternalStorage(result.toString(), cacheSaveDir)
                     val files = cacheSaveDir.listFiles()
                     var savesFolderFile: File? = null
                     if (files != null) {
@@ -422,7 +411,7 @@ class GamePropertiesFragment : Fragment() {
                         Toast.LENGTH_LONG
                     ).show()
                 }
-            }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+            }.show(parentFragmentManager, ProgressDialogFragment.TAG)
         }
 
     /**
@@ -436,11 +425,11 @@ class GamePropertiesFragment : Fragment() {
             return@registerForActivityResult
         }
 
-        IndeterminateProgressDialogFragment.newInstance(
+        ProgressDialogFragment.newInstance(
             requireActivity(),
             R.string.save_files_exporting,
             false
-        ) {
+        ) { _, _ ->
             val saveLocation = args.game.saveDir
             val zipResult = FileUtil.zipFromInternalStorage(
                 File(saveLocation),
@@ -452,6 +441,6 @@ class GamePropertiesFragment : Fragment() {
                 TaskState.Completed -> getString(R.string.export_success)
                 TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed)
             }
-        }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+        }.show(parentFragmentManager, ProgressDialogFragment.TAG)
     }
 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
index 5b4bf2c9ff..7df8e6bf46 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt
@@ -34,7 +34,6 @@ import org.yuzu.yuzu_emu.model.TaskState
 import org.yuzu.yuzu_emu.ui.main.MainActivity
 import org.yuzu.yuzu_emu.utils.DirectoryInitialization
 import org.yuzu.yuzu_emu.utils.FileUtil
-import java.io.BufferedInputStream
 import java.io.BufferedOutputStream
 import java.io.File
 import java.math.BigInteger
@@ -195,26 +194,20 @@ class InstallableFragment : Fragment() {
                 return@registerForActivityResult
             }
 
-            val inputZip = requireContext().contentResolver.openInputStream(result)
             val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/")
             cacheSaveDir.mkdir()
 
-            if (inputZip == null) {
-                Toast.makeText(
-                    YuzuApplication.appContext,
-                    getString(R.string.fatal_error),
-                    Toast.LENGTH_LONG
-                ).show()
-                return@registerForActivityResult
-            }
-
-            IndeterminateProgressDialogFragment.newInstance(
+            ProgressDialogFragment.newInstance(
                 requireActivity(),
                 R.string.save_files_importing,
                 false
-            ) {
+            ) { progressCallback, _ ->
                 try {
-                    FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheSaveDir)
+                    FileUtil.unzipToInternalStorage(
+                        result.toString(),
+                        cacheSaveDir,
+                        progressCallback
+                    )
                     val files = cacheSaveDir.listFiles()
                     var successfulImports = 0
                     var failedImports = 0
@@ -287,7 +280,7 @@ class InstallableFragment : Fragment() {
                         Toast.LENGTH_LONG
                     ).show()
                 }
-            }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+            }.show(parentFragmentManager, ProgressDialogFragment.TAG)
         }
 
     private val exportSaves = registerForActivityResult(
@@ -297,11 +290,11 @@ class InstallableFragment : Fragment() {
             return@registerForActivityResult
         }
 
-        IndeterminateProgressDialogFragment.newInstance(
+        ProgressDialogFragment.newInstance(
             requireActivity(),
             R.string.save_files_exporting,
             false
-        ) {
+        ) { _, _ ->
             val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/")
             cacheSaveDir.mkdir()
 
@@ -338,6 +331,6 @@ class InstallableFragment : Fragment() {
                 TaskState.Completed -> getString(R.string.export_success)
                 TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed)
             }
-        }.show(parentFragmentManager, IndeterminateProgressDialogFragment.TAG)
+        }.show(parentFragmentManager, ProgressDialogFragment.TAG)
     }
 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProgressDialogFragment.kt
similarity index 73%
rename from src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
rename to src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProgressDialogFragment.kt
index 8847e5531a..d201cb80c6 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProgressDialogFragment.kt
@@ -23,11 +23,13 @@ import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
 import org.yuzu.yuzu_emu.model.TaskViewModel
 
-class IndeterminateProgressDialogFragment : DialogFragment() {
+class ProgressDialogFragment : DialogFragment() {
     private val taskViewModel: TaskViewModel by activityViewModels()
 
     private lateinit var binding: DialogProgressBarBinding
 
+    private val PROGRESS_BAR_RESOLUTION = 1000
+
     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
         val titleId = requireArguments().getInt(TITLE)
         val cancellable = requireArguments().getBoolean(CANCELLABLE)
@@ -61,6 +63,7 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
+        binding.message.isSelected = true
         viewLifecycleOwner.lifecycleScope.apply {
             launch {
                 repeatOnLifecycle(Lifecycle.State.CREATED) {
@@ -97,6 +100,35 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
                     }
                 }
             }
+            launch {
+                repeatOnLifecycle(Lifecycle.State.CREATED) {
+                    taskViewModel.progress.collect {
+                        if (it != 0.0) {
+                            binding.progressBar.apply {
+                                isIndeterminate = false
+                                progress = (
+                                    (it / taskViewModel.maxProgress.value) *
+                                        PROGRESS_BAR_RESOLUTION
+                                    ).toInt()
+                                min = 0
+                                max = PROGRESS_BAR_RESOLUTION
+                            }
+                        }
+                    }
+                }
+            }
+            launch {
+                repeatOnLifecycle(Lifecycle.State.CREATED) {
+                    taskViewModel.message.collect {
+                        if (it.isEmpty()) {
+                            binding.message.visibility = View.GONE
+                        } else {
+                            binding.message.visibility = View.VISIBLE
+                            binding.message.text = it
+                        }
+                    }
+                }
+            }
         }
     }
 
@@ -108,6 +140,7 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
         val negativeButton = alertDialog.getButton(Dialog.BUTTON_NEGATIVE)
         negativeButton.setOnClickListener {
             alertDialog.setTitle(getString(R.string.cancelling))
+            binding.progressBar.isIndeterminate = true
             taskViewModel.setCancelled(true)
         }
     }
@@ -122,9 +155,12 @@ class IndeterminateProgressDialogFragment : DialogFragment() {
             activity: FragmentActivity,
             titleId: Int,
             cancellable: Boolean = false,
-            task: suspend () -> Any
-        ): IndeterminateProgressDialogFragment {
-            val dialog = IndeterminateProgressDialogFragment()
+            task: suspend (
+                progressCallback: (max: Long, progress: Long) -> Boolean,
+                messageCallback: (message: String) -> Unit
+            ) -> Any
+        ): ProgressDialogFragment {
+            val dialog = ProgressDialogFragment()
             val args = Bundle()
             ViewModelProvider(activity)[TaskViewModel::class.java].task = task
             args.putInt(TITLE, titleId)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
index e59c957335..4361eb9726 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.launch
 
 class TaskViewModel : ViewModel() {
@@ -23,13 +24,28 @@ class TaskViewModel : ViewModel() {
     val cancelled: StateFlow<Boolean> get() = _cancelled
     private val _cancelled = MutableStateFlow(false)
 
-    lateinit var task: suspend () -> Any
+    private val _progress = MutableStateFlow(0.0)
+    val progress = _progress.asStateFlow()
+
+    private val _maxProgress = MutableStateFlow(0.0)
+    val maxProgress = _maxProgress.asStateFlow()
+
+    private val _message = MutableStateFlow("")
+    val message = _message.asStateFlow()
+
+    lateinit var task: suspend (
+        progressCallback: (max: Long, progress: Long) -> Boolean,
+        messageCallback: (message: String) -> Unit
+    ) -> Any
 
     fun clear() {
         _result.value = Any()
         _isComplete.value = false
         _isRunning.value = false
         _cancelled.value = false
+        _progress.value = 0.0
+        _maxProgress.value = 0.0
+        _message.value = ""
     }
 
     fun setCancelled(value: Boolean) {
@@ -43,7 +59,16 @@ class TaskViewModel : ViewModel() {
         _isRunning.value = true
 
         viewModelScope.launch(Dispatchers.IO) {
-            val res = task()
+            val res = task(
+                { max, progress ->
+                    _maxProgress.value = max.toDouble()
+                    _progress.value = progress.toDouble()
+                    return@task cancelled.value
+                },
+                { message ->
+                    _message.value = message
+                }
+            )
             _result.value = res
             _isComplete.value = true
             _isRunning.value = false
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
index 644289e250..c2cc29961e 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -38,12 +38,13 @@ import org.yuzu.yuzu_emu.activities.EmulationActivity
 import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
 import org.yuzu.yuzu_emu.features.settings.model.Settings
 import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment
-import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
+import org.yuzu.yuzu_emu.fragments.ProgressDialogFragment
 import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
 import org.yuzu.yuzu_emu.model.AddonViewModel
 import org.yuzu.yuzu_emu.model.DriverViewModel
 import org.yuzu.yuzu_emu.model.GamesViewModel
 import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.model.InstallResult
 import org.yuzu.yuzu_emu.model.TaskState
 import org.yuzu.yuzu_emu.model.TaskViewModel
 import org.yuzu.yuzu_emu.utils.*
@@ -369,26 +370,23 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                 return@registerForActivityResult
             }
 
-            val inputZip = contentResolver.openInputStream(result)
-            if (inputZip == null) {
-                Toast.makeText(
-                    applicationContext,
-                    getString(R.string.fatal_error),
-                    Toast.LENGTH_LONG
-                ).show()
-                return@registerForActivityResult
-            }
-
             val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") }
 
             val firmwarePath =
                 File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/")
             val cacheFirmwareDir = File("${cacheDir.path}/registered/")
 
-            val task: () -> Any = {
+            ProgressDialogFragment.newInstance(
+                this,
+                R.string.firmware_installing
+            ) { progressCallback, _ ->
                 var messageToShow: Any
                 try {
-                    FileUtil.unzipToInternalStorage(BufferedInputStream(inputZip), cacheFirmwareDir)
+                    FileUtil.unzipToInternalStorage(
+                        result.toString(),
+                        cacheFirmwareDir,
+                        progressCallback
+                    )
                     val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1
                     val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2
                     messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) {
@@ -404,18 +402,13 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                         getString(R.string.save_file_imported_success)
                     }
                 } catch (e: Exception) {
+                    Log.error("[MainActivity] Firmware install failed - ${e.message}")
                     messageToShow = getString(R.string.fatal_error)
                 } finally {
                     cacheFirmwareDir.deleteRecursively()
                 }
                 messageToShow
-            }
-
-            IndeterminateProgressDialogFragment.newInstance(
-                this,
-                R.string.firmware_installing,
-                task = task
-            ).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
+            }.show(supportFragmentManager, ProgressDialogFragment.TAG)
         }
 
     val getAmiiboKey =
@@ -474,11 +467,11 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
             return@registerForActivityResult
         }
 
-        IndeterminateProgressDialogFragment.newInstance(
+        ProgressDialogFragment.newInstance(
             this@MainActivity,
             R.string.verifying_content,
             false
-        ) {
+        ) { _, _ ->
             var updatesMatchProgram = true
             for (document in documents) {
                 val valid = NativeLibrary.doesUpdateMatchProgram(
@@ -501,44 +494,42 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                     positiveAction = { homeViewModel.setContentToInstall(documents) }
                 )
             }
-        }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
+        }.show(supportFragmentManager, ProgressDialogFragment.TAG)
     }
 
     private fun installContent(documents: List<Uri>) {
-        IndeterminateProgressDialogFragment.newInstance(
+        ProgressDialogFragment.newInstance(
             this@MainActivity,
             R.string.installing_game_content
-        ) {
+        ) { progressCallback, messageCallback ->
             var installSuccess = 0
             var installOverwrite = 0
             var errorBaseGame = 0
-            var errorExtension = 0
-            var errorOther = 0
+            var error = 0
             documents.forEach {
+                messageCallback.invoke(FileUtil.getFilename(it))
                 when (
-                    NativeLibrary.installFileToNand(
-                        it.toString(),
-                        FileUtil.getExtension(it)
+                    InstallResult.from(
+                        NativeLibrary.installFileToNand(
+                            it.toString(),
+                            progressCallback
+                        )
                     )
                 ) {
-                    NativeLibrary.InstallFileToNandResult.Success -> {
+                    InstallResult.Success -> {
                         installSuccess += 1
                     }
 
-                    NativeLibrary.InstallFileToNandResult.SuccessFileOverwritten -> {
+                    InstallResult.Overwrite -> {
                         installOverwrite += 1
                     }
 
-                    NativeLibrary.InstallFileToNandResult.ErrorBaseGame -> {
+                    InstallResult.BaseInstallAttempted -> {
                         errorBaseGame += 1
                     }
 
-                    NativeLibrary.InstallFileToNandResult.ErrorFilenameExtension -> {
-                        errorExtension += 1
-                    }
-
-                    else -> {
-                        errorOther += 1
+                    InstallResult.Failure -> {
+                        error += 1
                     }
                 }
             }
@@ -565,7 +556,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                 )
                 installResult.append(separator)
             }
-            val errorTotal: Int = errorBaseGame + errorExtension + errorOther
+            val errorTotal: Int = errorBaseGame + error
             if (errorTotal > 0) {
                 installResult.append(separator)
                 installResult.append(
@@ -582,14 +573,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                     )
                     installResult.append(separator)
                 }
-                if (errorExtension > 0) {
-                    installResult.append(separator)
-                    installResult.append(
-                        getString(R.string.install_game_content_failure_file_extension)
-                    )
-                    installResult.append(separator)
-                }
-                if (errorOther > 0) {
+                if (error > 0) {
                     installResult.append(
                         getString(R.string.install_game_content_failure_description)
                     )
@@ -608,7 +592,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                     descriptionString = installResult.toString().trim()
                 )
             }
-        }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
+        }.show(supportFragmentManager, ProgressDialogFragment.TAG)
     }
 
     val exportUserData = registerForActivityResult(
@@ -618,16 +602,16 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
             return@registerForActivityResult
         }
 
-        IndeterminateProgressDialogFragment.newInstance(
+        ProgressDialogFragment.newInstance(
             this,
             R.string.exporting_user_data,
             true
-        ) {
+        ) { progressCallback, _ ->
             val zipResult = FileUtil.zipFromInternalStorage(
                 File(DirectoryInitialization.userDirectory!!),
                 DirectoryInitialization.userDirectory!!,
                 BufferedOutputStream(contentResolver.openOutputStream(result)),
-                taskViewModel.cancelled,
+                progressCallback,
                 compression = false
             )
             return@newInstance when (zipResult) {
@@ -635,7 +619,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                 TaskState.Failed -> R.string.export_failed
                 TaskState.Cancelled -> R.string.user_data_export_cancelled
             }
-        }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
+        }.show(supportFragmentManager, ProgressDialogFragment.TAG)
     }
 
     val importUserData =
@@ -644,10 +628,10 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                 return@registerForActivityResult
             }
 
-            IndeterminateProgressDialogFragment.newInstance(
+            ProgressDialogFragment.newInstance(
                 this,
                 R.string.importing_user_data
-            ) {
+            ) { progressCallback, _ ->
                 val checkStream =
                     ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result)))
                 var isYuzuBackup = false
@@ -676,8 +660,9 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                 // Copy archive to internal storage
                 try {
                     FileUtil.unzipToInternalStorage(
-                        BufferedInputStream(contentResolver.openInputStream(result)),
-                        File(DirectoryInitialization.userDirectory!!)
+                        result.toString(),
+                        File(DirectoryInitialization.userDirectory!!),
+                        progressCallback
                     )
                 } catch (e: Exception) {
                     return@newInstance MessageDialogFragment.newInstance(
@@ -694,6 +679,6 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                 driverViewModel.reloadDriverData()
 
                 return@newInstance getString(R.string.user_data_import_success)
-            }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
+            }.show(supportFragmentManager, ProgressDialogFragment.TAG)
         }
 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
index b54a19c652..fc2339f5a2 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
@@ -7,7 +7,6 @@ import android.database.Cursor
 import android.net.Uri
 import android.provider.DocumentsContract
 import androidx.documentfile.provider.DocumentFile
-import kotlinx.coroutines.flow.StateFlow
 import java.io.BufferedInputStream
 import java.io.File
 import java.io.IOException
@@ -19,6 +18,7 @@ import org.yuzu.yuzu_emu.YuzuApplication
 import org.yuzu.yuzu_emu.model.MinimalDocumentFile
 import org.yuzu.yuzu_emu.model.TaskState
 import java.io.BufferedOutputStream
+import java.io.OutputStream
 import java.lang.NullPointerException
 import java.nio.charset.StandardCharsets
 import java.util.zip.Deflater
@@ -283,12 +283,34 @@ object FileUtil {
 
     /**
      * Extracts the given zip file into the given directory.
+     * @param path String representation of a [Uri] or a typical path delimited by '/'
+     * @param destDir Location to unzip the contents of [path] into
+     * @param progressCallback Lambda that is called with the total number of files and the current
+     * progress through the process. Stops execution as soon as possible if this returns true.
      */
     @Throws(SecurityException::class)
-    fun unzipToInternalStorage(zipStream: BufferedInputStream, destDir: File) {
-        ZipInputStream(zipStream).use { zis ->
+    fun unzipToInternalStorage(
+        path: String,
+        destDir: File,
+        progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false }
+    ) {
+        var totalEntries = 0L
+        ZipInputStream(getInputStream(path)).use { zis ->
+            var tempEntry = zis.nextEntry
+            while (tempEntry != null) {
+                tempEntry = zis.nextEntry
+                totalEntries++
+            }
+        }
+
+        var progress = 0L
+        ZipInputStream(getInputStream(path)).use { zis ->
             var entry: ZipEntry? = zis.nextEntry
             while (entry != null) {
+                if (progressCallback.invoke(totalEntries, progress)) {
+                    return@use
+                }
+
                 val newFile = File(destDir, entry.name)
                 val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile
 
@@ -304,6 +326,7 @@ object FileUtil {
                     newFile.outputStream().use { fos -> zis.copyTo(fos) }
                 }
                 entry = zis.nextEntry
+                progress++
             }
         }
     }
@@ -313,14 +336,15 @@ object FileUtil {
      * @param inputFile File representation of the item that will be zipped
      * @param rootDir Directory containing the inputFile
      * @param outputStream Stream where the zip file will be output
-     * @param cancelled [StateFlow] that reports whether this process has been cancelled
+     * @param progressCallback Lambda that is called with the total number of files and the current
+     * progress through the process. Stops execution as soon as possible if this returns true.
      * @param compression Disables compression if true
      */
     fun zipFromInternalStorage(
         inputFile: File,
         rootDir: String,
         outputStream: BufferedOutputStream,
-        cancelled: StateFlow<Boolean>? = null,
+        progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false },
         compression: Boolean = true
     ): TaskState {
         try {
@@ -330,8 +354,10 @@ object FileUtil {
                     zos.setLevel(Deflater.NO_COMPRESSION)
                 }
 
+                var count = 0L
+                val totalFiles = inputFile.walkTopDown().count().toLong()
                 inputFile.walkTopDown().forEach { file ->
-                    if (cancelled?.value == true) {
+                    if (progressCallback.invoke(totalFiles, count)) {
                         return TaskState.Cancelled
                     }
 
@@ -343,6 +369,7 @@ object FileUtil {
                         if (file.isFile) {
                             file.inputStream().use { fis -> fis.copyTo(zos) }
                         }
+                        count++
                     }
                 }
             }
@@ -356,9 +383,14 @@ object FileUtil {
     /**
      * Helper function that copies the contents of a DocumentFile folder into a [File]
      * @param file [File] representation of the folder to copy into
+     * @param progressCallback Lambda that is called with the total number of files and the current
+     * progress through the process. Stops execution as soon as possible if this returns true.
      * @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa
      */
-    fun DocumentFile.copyFilesTo(file: File) {
+    fun DocumentFile.copyFilesTo(
+        file: File,
+        progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false }
+    ) {
         file.mkdirs()
         if (!this.isDirectory || !file.isDirectory) {
             throw IllegalStateException(
@@ -366,7 +398,13 @@ object FileUtil {
             )
         }
 
+        var count = 0L
+        val totalFiles = this.listFiles().size.toLong()
         this.listFiles().forEach {
+            if (progressCallback.invoke(totalFiles, count)) {
+                return
+            }
+
             val newFile = File(file, it.name!!)
             if (it.isDirectory) {
                 newFile.mkdirs()
@@ -381,6 +419,7 @@ object FileUtil {
                     newFile.outputStream().use { os -> bos.copyTo(os) }
                 }
             }
+            count++
         }
     }
 
@@ -427,6 +466,18 @@ object FileUtil {
         }
     }
 
+    fun getInputStream(path: String) = if (path.contains("content://")) {
+        Uri.parse(path).inputStream()
+    } else {
+        File(path).inputStream()
+    }
+
+    fun getOutputStream(path: String) = if (path.contains("content://")) {
+        Uri.parse(path).outputStream()
+    } else {
+        File(path).outputStream()
+    }
+
     @Throws(IOException::class)
     fun getStringFromFile(file: File): String =
         String(file.readBytes(), StandardCharsets.UTF_8)
@@ -434,4 +485,19 @@ object FileUtil {
     @Throws(IOException::class)
     fun getStringFromInputStream(stream: InputStream): String =
         String(stream.readBytes(), StandardCharsets.UTF_8)
+
+    fun DocumentFile.inputStream(): InputStream =
+        YuzuApplication.appContext.contentResolver.openInputStream(uri)!!
+
+    fun DocumentFile.outputStream(): OutputStream =
+        YuzuApplication.appContext.contentResolver.openOutputStream(uri)!!
+
+    fun Uri.inputStream(): InputStream =
+        YuzuApplication.appContext.contentResolver.openInputStream(this)!!
+
+    fun Uri.outputStream(): OutputStream =
+        YuzuApplication.appContext.contentResolver.openOutputStream(this)!!
+
+    fun Uri.asDocumentFile(): DocumentFile? =
+        DocumentFile.fromSingleUri(YuzuApplication.appContext, this)
 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt
index a8f9dcc349..81212cbee3 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt
@@ -5,7 +5,6 @@ package org.yuzu.yuzu_emu.utils
 
 import android.net.Uri
 import android.os.Build
-import java.io.BufferedInputStream
 import java.io.File
 import java.io.IOException
 import org.yuzu.yuzu_emu.NativeLibrary
@@ -123,7 +122,7 @@ object GpuDriverHelper {
         // Unzip the driver.
         try {
             FileUtil.unzipToInternalStorage(
-                BufferedInputStream(copiedFile.inputStream()),
+                copiedFile.path,
                 File(driverInstallationPath!!)
             )
         } catch (e: SecurityException) {
@@ -156,7 +155,7 @@ object GpuDriverHelper {
         // Unzip the driver to the private installation directory
         try {
             FileUtil.unzipToInternalStorage(
-                BufferedInputStream(driver.inputStream()),
+                driver.path,
                 File(driverInstallationPath!!)
             )
         } catch (e: SecurityException) {
diff --git a/src/android/app/src/main/res/layout/dialog_progress_bar.xml b/src/android/app/src/main/res/layout/dialog_progress_bar.xml
index 0209ea0826..e61aa52948 100644
--- a/src/android/app/src/main/res/layout/dialog_progress_bar.xml
+++ b/src/android/app/src/main/res/layout/dialog_progress_bar.xml
@@ -1,8 +1,30 @@
 <?xml version="1.0" encoding="utf-8"?>
-<com.google.android.material.progressindicator.LinearProgressIndicator xmlns:android="http://schemas.android.com/apk/res/android"
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:id="@+id/progress_bar"
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
-    android:padding="24dp"
-    app:trackCornerRadius="4dp" />
+    android:orientation="vertical">
+
+    <com.google.android.material.textview.MaterialTextView
+        android:id="@+id/message"
+        style="@style/TextAppearance.Material3.BodyMedium"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="24dp"
+        android:layout_marginTop="12dp"
+        android:layout_marginBottom="6dp"
+        android:ellipsize="marquee"
+        android:marqueeRepeatLimit="marquee_forever"
+        android:requiresFadingEdge="horizontal"
+        android:singleLine="true"
+        android:textAlignment="viewStart"
+        android:visibility="gone" />
+
+    <com.google.android.material.progressindicator.LinearProgressIndicator
+        android:id="@+id/progress_bar"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:padding="24dp"
+        app:trackCornerRadius="4dp" />
+
+</LinearLayout>

From 03fa91ba3c52c0371f0d57ea8a5618feaf3012e7 Mon Sep 17 00:00:00 2001
From: t895 <clombardo169@gmail.com>
Date: Fri, 19 Jan 2024 16:37:34 -0500
Subject: [PATCH 3/4] 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.
---
 .../java/org/yuzu/yuzu_emu/NativeLibrary.kt   | 25 ++++++++-
 .../yuzu/yuzu_emu/adapters/AddonAdapter.kt    | 21 ++++---
 .../yuzu/yuzu_emu/fragments/AddonsFragment.kt | 17 +++++-
 .../java/org/yuzu/yuzu_emu/model/Addon.kt     | 10 ----
 .../org/yuzu/yuzu_emu/model/AddonViewModel.kt | 42 +++++++++-----
 .../java/org/yuzu/yuzu_emu/model/Patch.kt     | 16 ++++++
 .../java/org/yuzu/yuzu_emu/model/PatchType.kt | 14 +++++
 src/android/app/src/main/jni/id_cache.cpp     | 55 +++++++++++++++++++
 src/android/app/src/main/jni/id_cache.h       |  9 +++
 src/android/app/src/main/jni/native.cpp       | 48 +++++++++++-----
 .../src/main/res/layout/list_item_addon.xml   | 32 +++++++----
 .../app/src/main/res/values/strings.xml       |  3 +
 src/core/file_sys/patch_manager.cpp           | 43 +++++++++++----
 src/core/file_sys/patch_manager.h             | 17 ++++--
 src/frontend_common/content_manager.h         | 17 ++++++
 .../configure_per_game_addons.cpp             |  7 +--
 src/yuzu/game_list_worker.cpp                 | 11 ++--
 17 files changed, 305 insertions(+), 82 deletions(-)
 delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt
 create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt
 create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt

diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
index 8cb98d6d7b..1c9fb06751 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
+++ b/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
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt
index 94c151325e..ff254d9b77 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt
+++ b/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)
+            }
         }
     }
 }
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
index b63ece9a4f..adb65812c7 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt
+++ b/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 {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt
deleted file mode 100644
index ed79a8b028..0000000000
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Addon.kt
+++ /dev/null
@@ -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
-)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt
index 075252f5bc..b9c8e49ca4 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt
+++ b/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
     }
 
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt
new file mode 100644
index 0000000000..25cb9e3654
--- /dev/null
+++ b/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
+)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt
new file mode 100644
index 0000000000..e9a54162b0
--- /dev/null
+++ b/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
+    }
+}
diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp
index 19ced175fa..96f2ad3d4f 100644
--- a/src/android/app/src/main/jni/id_cache.cpp
+++ b/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);
diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h
index 0e5267b738..a002e705dc 100644
--- a/src/android/app/src/main/jni/id_cache.h
+++ b/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();
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index b8fef5c6fd..be0a723b1c 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/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,
diff --git a/src/android/app/src/main/res/layout/list_item_addon.xml b/src/android/app/src/main/res/layout/list_item_addon.xml
index 74ca04ef1b..3a1382fe26 100644
--- a/src/android/app/src/main/res/layout/list_item_addon.xml
+++ b/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>
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 547752bda1..db5b27d38d 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/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>
diff --git a/src/core/file_sys/patch_manager.cpp b/src/core/file_sys/patch_manager.cpp
index 4a3dbc6a33..612122224d 100644
--- a/src/core/file_sys/patch_manager.cpp
+++ b/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;
diff --git a/src/core/file_sys/patch_manager.h b/src/core/file_sys/patch_manager.h
index 03e9c7301d..2601b82171 100644
--- a/src/core/file_sys/patch_manager.h
+++ b/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
diff --git a/src/frontend_common/content_manager.h b/src/frontend_common/content_manager.h
index 8e55f4ca09..248ce573e1 100644
--- a/src/frontend_common/content_manager.h
+++ b/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)>()) {
diff --git a/src/yuzu/configuration/configure_per_game_addons.cpp b/src/yuzu/configuration/configure_per_game_addons.cpp
index 140a7fe5db..568775027b 100644
--- a/src/yuzu/configuration/configure_per_game_addons.cpp
+++ b/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());
     }
 
diff --git a/src/yuzu/game_list_worker.cpp b/src/yuzu/game_list_worker.cpp
index dc006832e8..9747e3fb31 100644
--- a/src/yuzu/game_list_worker.cpp
+++ b/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") {

From a363fa78ef65ded9f85d8c16940f2fbdc66894e5 Mon Sep 17 00:00:00 2001
From: t895 <clombardo169@gmail.com>
Date: Fri, 19 Jan 2024 16:57:13 -0500
Subject: [PATCH 4/4] frontend_common: Add documentation for content_mananger

---
 src/frontend_common/content_manager.h | 53 +++++++++++++++++++++++++++
 1 file changed, 53 insertions(+)

diff --git a/src/frontend_common/content_manager.h b/src/frontend_common/content_manager.h
index 248ce573e1..23f2979db1 100644
--- a/src/frontend_common/content_manager.h
+++ b/src/frontend_common/content_manager.h
@@ -25,12 +25,24 @@ enum class InstallResult {
     BaseInstallAttempted,
 };
 
+/**
+ * \brief Removes a single installed DLC
+ * \param fs_controller [FileSystemController] reference from the Core::System instance
+ * \param title_id Unique title ID representing the DLC which will be removed
+ * \return 'true' if successful
+ */
 inline bool RemoveDLC(const Service::FileSystem::FileSystemController& fs_controller,
                       const u64 title_id) {
     return fs_controller.GetUserNANDContents()->RemoveExistingEntry(title_id) ||
            fs_controller.GetSDMCContents()->RemoveExistingEntry(title_id);
 }
 
+/**
+ * \brief Removes all DLC for a game
+ * \param system Raw pointer to the system instance
+ * \param program_id Program ID for the game that will have all of its DLC removed
+ * \return Number of DLC removed
+ */
 inline size_t RemoveAllDLC(Core::System* system, const u64 program_id) {
     size_t count{};
     const auto& fs_controller = system->GetFileSystemController();
@@ -52,6 +64,12 @@ inline size_t RemoveAllDLC(Core::System* system, const u64 program_id) {
     return count;
 }
 
+/**
+ * \brief Removes the installed update for a game
+ * \param fs_controller [FileSystemController] reference from the Core::System instance
+ * \param program_id Program ID for the game that will have its installed update removed
+ * \return 'true' if successful
+ */
 inline bool RemoveUpdate(const Service::FileSystem::FileSystemController& fs_controller,
                          const u64 program_id) {
     const auto update_id = program_id | 0x800;
@@ -59,12 +77,26 @@ inline bool RemoveUpdate(const Service::FileSystem::FileSystemController& fs_con
            fs_controller.GetSDMCContents()->RemoveExistingEntry(update_id);
 }
 
+/**
+ * \brief Removes the base content for a game
+ * \param fs_controller [FileSystemController] reference from the Core::System instance
+ * \param program_id Program ID for the game that will have its base content removed
+ * \return 'true' if successful
+ */
 inline bool RemoveBaseContent(const Service::FileSystem::FileSystemController& fs_controller,
                               const u64 program_id) {
     return fs_controller.GetUserNANDContents()->RemoveExistingEntry(program_id) ||
            fs_controller.GetSDMCContents()->RemoveExistingEntry(program_id);
 }
 
+/**
+ * \brief Removes a mod for a game
+ * \param fs_controller [FileSystemController] reference from the Core::System instance
+ * \param program_id Program ID for the game where [mod_name] will be removed
+ * \param mod_name The name of a mod as given by FileSys::PatchManager::GetPatches. This corresponds
+ * with the name of the mod's directory in a game's load folder.
+ * \return 'true' if successful
+ */
 inline bool RemoveMod(const Service::FileSystem::FileSystemController& fs_controller,
                       const u64 program_id, const std::string& mod_name) {
     // Check general Mods (LayeredFS and IPS)
@@ -82,6 +114,16 @@ inline bool RemoveMod(const Service::FileSystem::FileSystemController& fs_contro
     return false;
 }
 
+/**
+ * \brief Installs an NSP
+ * \param system Raw pointer to the system instance
+ * \param vfs Raw pointer to the VfsFilesystem instance in Core::System
+ * \param filename Path to the NSP file
+ * \param callback Optional callback to report the progress of the installation. The first size_t
+ * parameter is the total size of the virtual file and the second is the current progress. If you
+ * return false to the callback, it will cancel the installation as soon as possible.
+ * \return [InstallResult] representing how the installation finished
+ */
 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)>()) {
@@ -136,6 +178,17 @@ inline InstallResult InstallNSP(
     }
 }
 
+/**
+ * \brief Installs an NCA
+ * \param vfs Raw pointer to the VfsFilesystem instance in Core::System
+ * \param filename Path to the NCA file
+ * \param registered_cache Raw pointer to the registered cache that the NCA will be installed to
+ * \param title_type Type of NCA package to install
+ * \param callback Optional callback to report the progress of the installation. The first size_t
+ * parameter is the total size of the virtual file and the second is the current progress. If you
+ * return false to the callback, it will cancel the installation as soon as possible.
+ * \return [InstallResult] representing how the installation finished
+ */
 inline InstallResult InstallNCA(
     FileSys::VfsFilesystem* vfs, const std::string& filename,
     FileSys::RegisteredCache* registered_cache, const FileSys::TitleType title_type,