From de5bb69cd5c9d0ee93dcfd7401d923b4832ab348 Mon Sep 17 00:00:00 2001 From: Kleidis <167202775+kleidis@users.noreply.github.com> Date: Sun, 2 Feb 2025 04:48:13 +0100 Subject: [PATCH] android: Add uninstall game and open folder options --- .../citra/citra_emu/adapters/GameAdapter.kt | 127 ++++++++++++++++++ .../citra/citra_emu/utils/DocumentsTree.kt | 34 +++++ .../app/src/main/res/drawable/ic_open.xml | 10 ++ .../src/main/res/drawable/ic_uninstall.xml | 10 ++ .../src/main/res/layout/dialog_about_game.xml | 19 +++ .../main/res/menu/game_context_menu_open.xml | 24 ++++ .../res/menu/game_context_menu_uninstall.xml | 12 ++ .../app/src/main/res/values/strings.xml | 12 ++ 8 files changed, 248 insertions(+) create mode 100644 src/android/app/src/main/res/drawable/ic_open.xml create mode 100644 src/android/app/src/main/res/drawable/ic_uninstall.xml create mode 100644 src/android/app/src/main/res/menu/game_context_menu_open.xml create mode 100644 src/android/app/src/main/res/menu/game_context_menu_uninstall.xml diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt index f3400e2f3..c6cb3cf1e 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt @@ -5,6 +5,7 @@ package org.citra.citra_emu.adapters import android.graphics.drawable.Icon +import android.content.Intent import android.net.Uri import android.os.SystemClock import android.text.TextUtils @@ -28,6 +29,7 @@ import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import android.widget.PopupMenu import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton @@ -44,7 +46,9 @@ import org.citra.citra_emu.databinding.CardGameBinding import org.citra.citra_emu.features.cheats.ui.CheatsFragmentDirections import org.citra.citra_emu.features.settings.ui.SettingsActivity import org.citra.citra_emu.features.settings.utils.SettingsFile +import org.citra.citra_emu.fragments.IndeterminateProgressDialogFragment import org.citra.citra_emu.model.Game +import org.citra.citra_emu.utils.FileUtil import org.citra.citra_emu.utils.GameIconUtils import org.citra.citra_emu.viewmodel.GamesViewModel @@ -203,6 +207,121 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater: } } + private data class GameDirectories( + val gameDir: String, + val saveDir: String, + val modsDir: String, + val texturesDir: String, + val appDir: String, + val dlcDir: String, + val updatesDir: String, + val extraDir: String + ) + private fun getGameDirectories(game: Game): GameDirectories { + return GameDirectories( + gameDir = game.path.substringBeforeLast("/"), + saveDir = "sdmc/Nintendo 3DS/00000000000000000000000000000000/00000000000000000000000000000000/title/${String.format("%016x", game.titleId).lowercase().substring(0, 8)}/${String.format("%016x", game.titleId).lowercase().substring(8)}/data/00000001", + modsDir = "load/mods/${String.format("%016X", game.titleId)}", + texturesDir = "load/textures/${String.format("%016X", game.titleId)}", + appDir = game.path.substringBeforeLast("/").split("/").filter { it.isNotEmpty() }.joinToString("/"), + dlcDir = "sdmc/Nintendo 3DS/00000000000000000000000000000000/00000000000000000000000000000000/title/0004008c/${String.format("%016x", game.titleId).lowercase().substring(8)}/content", + updatesDir = "sdmc/Nintendo 3DS/00000000000000000000000000000000/00000000000000000000000000000000/title/0004000e/${String.format("%016x", game.titleId).lowercase().substring(8)}/content", + extraDir = "sdmc/Nintendo 3DS/00000000000000000000000000000000/00000000000000000000000000000000/extdata/00000000/${String.format("%016X", game.titleId).substring(8, 14).padStart(8, '0')}" + ) + } + + private fun showOpenContextMenu(view: View, game: Game) { + val dirs = getGameDirectories(game) + + val popup = PopupMenu(view.context, view).apply { + menuInflater.inflate(R.menu.game_context_menu_open, menu) + listOf( + R.id.game_context_open_app to dirs.appDir, + R.id.game_context_open_save_dir to dirs.saveDir, + R.id.game_context_open_dlc to dirs.dlcDir, + R.id.game_context_open_updates to dirs.updatesDir + ).forEach { (id, dir) -> + menu.findItem(id)?.isEnabled = + CitraApplication.documentsTree.folderUriHelper(dir)?.let { + DocumentFile.fromTreeUri(view.context, it)?.exists() + } ?: false + } + menu.findItem(R.id.game_context_open_extra)?.let { item -> + if (CitraApplication.documentsTree.folderUriHelper(dirs.extraDir)?.let { + DocumentFile.fromTreeUri(view.context, it)?.exists() + } != true) { + menu.removeItem(item.itemId) + } + } + } + + popup.setOnMenuItemClickListener { menuItem -> + val intent = Intent(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setType("*/*") + + val uri = when (menuItem.itemId) { + R.id.game_context_open_app -> CitraApplication.documentsTree.folderUriHelper(dirs.appDir) + R.id.game_context_open_save_dir -> CitraApplication.documentsTree.folderUriHelper(dirs.saveDir) + R.id.game_context_open_dlc -> CitraApplication.documentsTree.folderUriHelper(dirs.dlcDir) + R.id.game_context_open_textures -> CitraApplication.documentsTree.folderUriHelper(dirs.texturesDir, true) + R.id.game_context_open_mods -> CitraApplication.documentsTree.folderUriHelper(dirs.modsDir, true) + R.id.game_context_open_extra -> CitraApplication.documentsTree.folderUriHelper(dirs.extraDir) + else -> null + } + + uri?.let { + intent.data = it + view.context.startActivity(intent) + true + } ?: false + } + + popup.show() + } + + private fun showUninstallContextMenu(view: View, game: Game, bottomSheetDialog: BottomSheetDialog) { + val dirs = getGameDirectories(game) + val popup = PopupMenu(view.context, view).apply { + menuInflater.inflate(R.menu.game_context_menu_uninstall, menu) + listOf( + R.id.game_context_uninstall to dirs.gameDir, + R.id.game_context_uninstall_dlc to dirs.dlcDir, + R.id.game_context_uninstall_updates to dirs.updatesDir + ).forEach { (id, dir) -> + menu.findItem(id)?.isEnabled = + CitraApplication.documentsTree.folderUriHelper(dir)?.let { + DocumentFile.fromTreeUri(view.context, it)?.exists() + } ?: false + } + } + + popup.setOnMenuItemClickListener { menuItem -> + val uninstallAction: () -> Unit = { + when (menuItem.itemId) { + R.id.game_context_uninstall -> CitraApplication.documentsTree.deleteDocument(dirs.gameDir) + R.id.game_context_uninstall_dlc -> FileUtil.deleteDocument(CitraApplication.documentsTree.folderUriHelper(dirs.dlcDir) + .toString()) + R.id.game_context_uninstall_updates -> FileUtil.deleteDocument(CitraApplication.documentsTree.folderUriHelper(dirs.updatesDir) + .toString()) + } + ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) + bottomSheetDialog.dismiss() + } + + if (menuItem.itemId in listOf(R.id.game_context_uninstall, R.id.game_context_uninstall_dlc, R.id.game_context_uninstall_updates)) { + IndeterminateProgressDialogFragment.newInstance(activity, R.string.uninstalling, false, uninstallAction) + .show(activity.supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + true + } else { + false + } + } + + popup.show() + } + private fun showAboutGameDialog(context: Context, game: Game, holder: GameViewHolder, view: View) { val bottomSheetView = inflater.inflate(R.layout.dialog_about_game, null) @@ -245,6 +364,14 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater: bottomSheetDialog.dismiss() } + bottomSheetView.findViewById(R.id.menu_button_open).setOnClickListener { + showOpenContextMenu(it, game) + } + + bottomSheetView.findViewById(R.id.menu_button_uninstall).setOnClickListener { + showUninstallContextMenu(it, game, bottomSheetDialog) + } + val bottomSheetBehavior = bottomSheetDialog.getBehavior() bottomSheetBehavior.skipCollapsed = true bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt index 4071bd1a9..29c6d55c7 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt @@ -106,6 +106,40 @@ class DocumentsTree { return node.uri ?: return Uri.EMPTY } + @Synchronized + fun folderUriHelper(path: String, createIfNotExists: Boolean = false): Uri? { + root ?: return null + val components = path.split(DELIMITER).filter { it.isNotEmpty() } + var current = root + + for (component in components) { + if (!current!!.loaded) { + structTree(current) + } + + var child = current.findChild(component) + + // Create directory if it doesn't exist and creation is enabled + if (child == null && createIfNotExists) { + try { + val createdDir = FileUtil.createDir(current.uri.toString(), component) ?: return null + child = DocumentsNode(createdDir, true).apply { + parent = current + } + current.addChild(child) + } catch (e: Exception) { + error("[DocumentsTree]: Cannot create directory, error: " + e.message) + return null + } + } else if (child == null) { + return null + } + + current = child + } + return current?.uri + } + @Synchronized fun isDirectory(filepath: String): Boolean { val node = resolvePath(filepath) ?: return false diff --git a/src/android/app/src/main/res/drawable/ic_open.xml b/src/android/app/src/main/res/drawable/ic_open.xml new file mode 100644 index 000000000..7f9fb35f0 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_open.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable/ic_uninstall.xml b/src/android/app/src/main/res/drawable/ic_uninstall.xml new file mode 100644 index 000000000..3252584c8 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_uninstall.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/dialog_about_game.xml b/src/android/app/src/main/res/layout/dialog_about_game.xml index 8b1ded71a..8554357fb 100644 --- a/src/android/app/src/main/res/layout/dialog_about_game.xml +++ b/src/android/app/src/main/res/layout/dialog_about_game.xml @@ -148,6 +148,25 @@ android:contentDescription="@string/cheats" android:text="@string/cheats" /> + + + + + diff --git a/src/android/app/src/main/res/menu/game_context_menu_open.xml b/src/android/app/src/main/res/menu/game_context_menu_open.xml new file mode 100644 index 000000000..83a481569 --- /dev/null +++ b/src/android/app/src/main/res/menu/game_context_menu_open.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/src/android/app/src/main/res/menu/game_context_menu_uninstall.xml b/src/android/app/src/main/res/menu/game_context_menu_uninstall.xml new file mode 100644 index 000000000..ef77e5583 --- /dev/null +++ b/src/android/app/src/main/res/menu/game_context_menu_uninstall.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 5495a2a23..140d322cc 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -477,6 +477,18 @@ Play Shortcut + Uninstall Game + Uninstalling... + Open Save Data Folder + Open Application Folder + Open Mods Folder + Open Textures Folder + Open DLC Folder + Open Updates Folder + Open Extra Folder + Uninstall DLC + Uninstall Updates + Cheats