Merge de5bb69cd5c9d0ee93dcfd7401d923b4832ab348 into 26ce7e4f2844a445bf77b4b14977d62e6434df08

This commit is contained in:
Kleidis 2025-03-11 19:52:59 -05:00 committed by GitHub
commit c9f9cc939b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 248 additions and 0 deletions

View File

@ -5,6 +5,7 @@
package org.citra.citra_emu.adapters package org.citra.citra_emu.adapters
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.SystemClock import android.os.SystemClock
import android.text.TextUtils import android.text.TextUtils
@ -28,6 +29,7 @@ import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import android.widget.PopupMenu
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.button.MaterialButton 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.cheats.ui.CheatsFragmentDirections
import org.citra.citra_emu.features.settings.ui.SettingsActivity import org.citra.citra_emu.features.settings.ui.SettingsActivity
import org.citra.citra_emu.features.settings.utils.SettingsFile 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.model.Game
import org.citra.citra_emu.utils.FileUtil
import org.citra.citra_emu.utils.GameIconUtils import org.citra.citra_emu.utils.GameIconUtils
import org.citra.citra_emu.viewmodel.GamesViewModel 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) { private fun showAboutGameDialog(context: Context, game: Game, holder: GameViewHolder, view: View) {
val bottomSheetView = inflater.inflate(R.layout.dialog_about_game, null) 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() bottomSheetDialog.dismiss()
} }
bottomSheetView.findViewById<MaterialButton>(R.id.menu_button_open).setOnClickListener {
showOpenContextMenu(it, game)
}
bottomSheetView.findViewById<MaterialButton>(R.id.menu_button_uninstall).setOnClickListener {
showUninstallContextMenu(it, game, bottomSheetDialog)
}
val bottomSheetBehavior = bottomSheetDialog.getBehavior() val bottomSheetBehavior = bottomSheetDialog.getBehavior()
bottomSheetBehavior.skipCollapsed = true bottomSheetBehavior.skipCollapsed = true
bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED

View File

@ -106,6 +106,40 @@ class DocumentsTree {
return node.uri ?: return Uri.EMPTY 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 @Synchronized
fun isDirectory(filepath: String): Boolean { fun isDirectory(filepath: String): Boolean {
val node = resolvePath(filepath) ?: return false val node = resolvePath(filepath) ?: return false

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M20,6h-8l-2,-2H4C2.89,4 2,4.89 2,6v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V8C22,6.89 21.1,6 20,6zM19,18H5V8h14v10zM12,9l-4,4h3v3h2v-3h3L12,9z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View File

@ -148,6 +148,25 @@
android:contentDescription="@string/cheats" android:contentDescription="@string/cheats"
android:text="@string/cheats" /> android:text="@string/cheats" />
<com.google.android.material.button.MaterialButton
android:id="@+id/menu_button_open"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="62dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:icon="@drawable/ic_open"
app:iconGravity="textStart" />
<com.google.android.material.button.MaterialButton
android:id="@+id/menu_button_uninstall"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="62dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:icon="@drawable/ic_uninstall"
app:iconGravity="textStart" />
</LinearLayout> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/game_context_open_app"
android:title="@string/game_context_open_app" />
<item
android:id="@+id/game_context_open_save_dir"
android:title="@string/game_context_open_save_dir" />
<item
android:id="@+id/game_context_open_updates"
android:title="@string/game_context_open_updates" />
<item
android:id="@+id/game_context_open_dlc"
android:title="@string/game_context_open_dlc" />
<item
android:id="@+id/game_context_open_extra"
android:title="@string/game_context_open_extra" />
<item
android:id="@+id/game_context_open_textures"
android:title="@string/game_context_open_textures" />
<item
android:id="@+id/game_context_open_mods"
android:title="@string/game_context_open_mods" />
</menu>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/game_context_uninstall"
android:title="@string/uninstall_cia" />
<item
android:id="@+id/game_context_uninstall_dlc"
android:title="@string/game_context_uninstall_dlc" />
<item
android:id="@+id/game_context_uninstall_updates"
android:title="@string/game_context_uninstall_updates" />
</menu>

View File

@ -477,6 +477,18 @@
<!-- About Game Dialog --> <!-- About Game Dialog -->
<string name="play">Play</string> <string name="play">Play</string>
<string name="shortcut">Shortcut</string> <string name="shortcut">Shortcut</string>
<string name="uninstall_cia">Uninstall Game</string>
<string name="uninstalling">Uninstalling...</string>
<string name="game_context_open_save_dir">Open Save Data Folder</string>
<string name="game_context_open_app">Open Application Folder</string>
<string name="game_context_open_mods">Open Mods Folder</string>
<string name="game_context_open_textures">Open Textures Folder</string>
<string name="game_context_open_dlc">Open DLC Folder</string>
<string name="game_context_open_updates">Open Updates Folder</string>
<string name="game_context_open_extra">Open Extra Folder</string>
<string name="game_context_uninstall_dlc">Uninstall DLC</string>
<string name="game_context_uninstall_updates">Uninstall Updates</string>
<!-- Cheats --> <!-- Cheats -->
<string name="cheats">Cheats</string> <string name="cheats">Cheats</string>