mirror of
https://git.ryujinx.app/kenji-nx/ryujinx.git
synced 2025-12-15 19:37:05 +00:00
Merge branch 'libryujinx_bionic_cheatManager' into 'libryujinx_bionic'
Added Cheat Manager See merge request kenji-nx/ryujinx!9
This commit is contained in:
commit
cb2290885c
3 changed files with 493 additions and 10 deletions
|
|
@ -0,0 +1,260 @@
|
||||||
|
package org.kenjinx.android.cheats
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
|
data class CheatItem(val buildId: String, val name: String) {
|
||||||
|
val key get() = "$buildId-$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Paths -------- */
|
||||||
|
|
||||||
|
private fun cheatsDirExternal(activity: Activity, titleId: String): File {
|
||||||
|
val base = activity.getExternalFilesDir(null) // /storage/emulated/0/Android/data/<pkg>/files
|
||||||
|
return File(base, "mods/contents/$titleId/cheats")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cheatsDirInternal(activity: Activity, titleId: String): File {
|
||||||
|
val base = activity.filesDir // /data/user/0/<pkg>/files
|
||||||
|
return File(base, "mods/contents/$titleId/cheats")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allCheatDirs(activity: Activity, titleId: String): List<File> {
|
||||||
|
// Order: internal first (LibKenjinx usually writes here), then external
|
||||||
|
return listOf(cheatsDirInternal(activity, titleId), cheatsDirExternal(activity, titleId))
|
||||||
|
.distinct()
|
||||||
|
.filter { it.exists() && it.isDirectory }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Parser -------- */
|
||||||
|
|
||||||
|
private fun parseCheatNames(text: String): List<String> {
|
||||||
|
// Trim BOM, CRLF tolerant
|
||||||
|
val clean = text.replace("\uFEFF", "")
|
||||||
|
val rx = Regex("""(?m)^\s*\[(.+?)\]\s*$""")
|
||||||
|
return rx.findAll(clean)
|
||||||
|
.map { it.groupValues[1].trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Public: Load cheats -------- */
|
||||||
|
|
||||||
|
fun loadCheatsFromDisk(activity: Activity, titleId: String): List<CheatItem> {
|
||||||
|
val dirs = allCheatDirs(activity, titleId)
|
||||||
|
if (dirs.isEmpty()) {
|
||||||
|
Log.d("CheatFs", "No cheat dirs for $titleId (checked internal+external).")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val out = mutableListOf<CheatItem>()
|
||||||
|
for (dir in dirs) {
|
||||||
|
dir.listFiles { f -> f.isFile && f.name.endsWith(".txt", ignoreCase = true) }?.forEach { file ->
|
||||||
|
val buildId = file.nameWithoutExtension
|
||||||
|
val text = runCatching { file.readText(Charset.forName("UTF-8")) }.getOrElse { "" }
|
||||||
|
parseCheatNames(text).forEach { nm ->
|
||||||
|
out += CheatItem(buildId, nm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
.distinctBy { it.key.lowercase() }
|
||||||
|
.sortedWith(compareBy({ it.buildId.lowercase() }, { it.name.lowercase() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Public: Apply selection to disk immediately -------- */
|
||||||
|
|
||||||
|
fun applyCheatSelectionOnDisk(activity: Activity, titleId: String, enabledKeys: Set<String>) {
|
||||||
|
// We pick exactly ONE BUILDID file (the "best") and toggle sections inside it.
|
||||||
|
val dirs = allCheatDirs(activity, titleId)
|
||||||
|
val allTxt = dirs.flatMap { d ->
|
||||||
|
d.listFiles { f -> f.isFile && f.name.endsWith(".txt", ignoreCase = true) }?.toList() ?: emptyList()
|
||||||
|
}
|
||||||
|
if (allTxt.isEmpty()) {
|
||||||
|
Log.d("CheatFs", "applyCheatSelectionOnDisk: no *.txt found for $titleId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val buildFile = pickBestBuildFile(allTxt)
|
||||||
|
val text = runCatching { buildFile.readText(Charset.forName("UTF-8")) }.getOrElse { "" }
|
||||||
|
if (text.isEmpty()) return
|
||||||
|
|
||||||
|
// Normalize enabled set: keys are "<BUILDID>-<SectionName>"
|
||||||
|
val enabledSections = enabledKeys.asSequence()
|
||||||
|
.mapNotNull { key ->
|
||||||
|
val dash = key.indexOf('-')
|
||||||
|
if (dash <= 0) null else key.substring(dash + 1).trim()
|
||||||
|
}
|
||||||
|
.map { it.lowercase() }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
val rewritten = rewriteCheatFile(text, enabledSections)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
buildFile.writeText(rewritten, Charset.forName("UTF-8"))
|
||||||
|
}.onFailure {
|
||||||
|
Log.w("CheatFs", "Failed to write ${buildFile.absolutePath}: ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Implementation: apply selection (using only ';' as comment) -------- */
|
||||||
|
|
||||||
|
private fun pickBestBuildFile(files: List<File>): File {
|
||||||
|
fun looksHexName(p: File): Boolean {
|
||||||
|
val n = p.nameWithoutExtension
|
||||||
|
return n.length >= 16 && n.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
|
||||||
|
}
|
||||||
|
return files.firstOrNull(::looksHexName)
|
||||||
|
?: files.maxByOrNull { runCatching { it.lastModified() }.getOrDefault(0L) }
|
||||||
|
?: files.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSectionHeader(line: String): Boolean {
|
||||||
|
val t = line.trim()
|
||||||
|
return t.length > 2 && t.first() == '[' && t.contains(']')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sectionNameFromHeader(line: String): String {
|
||||||
|
val t = line.trim()
|
||||||
|
val close = t.indexOf(']')
|
||||||
|
return if (t.startsWith("[") && close > 1) t.substring(1, close).trim() else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes ONE leading comment marker (';') + optional space.
|
||||||
|
* Only at absolute column 0 (no leading spaces allowed).
|
||||||
|
*/
|
||||||
|
private fun uncommentOnce(raw: String): String {
|
||||||
|
if (raw.isEmpty()) return raw
|
||||||
|
return if (raw.startsWith(";")) {
|
||||||
|
raw.drop(1).let { if (it.startsWith(" ")) it.drop(1) else it }
|
||||||
|
} else raw
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comments out the line if it does not already start with ';'.
|
||||||
|
* Atmosphère uses ';' — we use that exclusively.
|
||||||
|
*/
|
||||||
|
private fun commentOut(raw: String): String {
|
||||||
|
val t = raw.trimStart()
|
||||||
|
if (t.isEmpty()) return raw
|
||||||
|
if (t.startsWith(";")) return raw
|
||||||
|
return "; $raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites the file:
|
||||||
|
* - Do not insert markers
|
||||||
|
* - For each section, comment/uncomment the body according to enabled/disabled (enabledSections)
|
||||||
|
* - Keep pure comment/empty lines (only ';') intact
|
||||||
|
*/
|
||||||
|
// Helpers: trim trailing blank lines / normalize header
|
||||||
|
private fun trimTrailingBlankLines(lines: MutableList<String>) {
|
||||||
|
while (lines.isNotEmpty() && lines.last().trim().isEmpty()) {
|
||||||
|
lines.removeAt(lines.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun joinHeaderBufferOnce(header: List<String>): String {
|
||||||
|
// Keep header lines unchanged, but remove trailing blanks and add exactly one blank line after
|
||||||
|
val buf = header.toMutableList()
|
||||||
|
trimTrailingBlankLines(buf)
|
||||||
|
return if (buf.isEmpty()) "" else buf.joinToString("\n") + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites the file:
|
||||||
|
* - Do not insert markers
|
||||||
|
* - For each section, comment/uncomment the body according to enabled/disabled (enabledSections)
|
||||||
|
* - Keep pure comment/empty lines intact
|
||||||
|
* - Exactly ONE blank line between sections, exactly ONE newline at the end.
|
||||||
|
*/
|
||||||
|
private fun rewriteCheatFile(original: String, enabledSections: Set<String>): String {
|
||||||
|
val lines = original.replace("\uFEFF", "").lines()
|
||||||
|
|
||||||
|
val out = StringBuilder(original.length + 1024)
|
||||||
|
|
||||||
|
var currentSection: String? = null
|
||||||
|
val currentBlock = ArrayList<String>()
|
||||||
|
val headerBuffer = ArrayList<String>()
|
||||||
|
var sawAnySection = false
|
||||||
|
var wroteAnySection = false
|
||||||
|
|
||||||
|
fun flushCurrent() {
|
||||||
|
val sec = currentSection ?: return
|
||||||
|
|
||||||
|
// Remove trailing blank lines from the block so spacing doesn't grow
|
||||||
|
trimTrailingBlankLines(currentBlock)
|
||||||
|
|
||||||
|
val enabled = enabledSections.contains(sec.lowercase())
|
||||||
|
|
||||||
|
// Insert exactly one blank line between sections (but not before the first)
|
||||||
|
if (wroteAnySection) out.append('\n')
|
||||||
|
|
||||||
|
out.append('[').append(sec).append(']').append('\n')
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// Uncomment (only a single leading ';' at column 0)
|
||||||
|
for (l in currentBlock) {
|
||||||
|
val trimmed = l.trim()
|
||||||
|
if (trimmed.isEmpty() || (trimmed.startsWith(";") && trimmed.length <= 1)) {
|
||||||
|
out.append(l).append('\n')
|
||||||
|
} else {
|
||||||
|
if (l.startsWith(";")) {
|
||||||
|
out.append(
|
||||||
|
l.drop(1).let { if (it.startsWith(" ")) it.drop(1) else it }
|
||||||
|
).append('\n')
|
||||||
|
} else {
|
||||||
|
out.append(l).append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Disable: comment out anything that doesn't already start with ';' and isn't empty
|
||||||
|
for (l in currentBlock) {
|
||||||
|
val t = l.trim()
|
||||||
|
if (t.isEmpty() || t.startsWith(";")) {
|
||||||
|
out.append(l).append('\n')
|
||||||
|
} else {
|
||||||
|
out.append("; ").append(l).append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wroteAnySection = true
|
||||||
|
currentSection = null
|
||||||
|
currentBlock.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (raw in lines) {
|
||||||
|
if (isSectionHeader(raw)) {
|
||||||
|
flushCurrent()
|
||||||
|
currentSection = sectionNameFromHeader(raw)
|
||||||
|
sawAnySection = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sawAnySection) {
|
||||||
|
headerBuffer.add(raw)
|
||||||
|
} else {
|
||||||
|
currentBlock.add(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushCurrent()
|
||||||
|
|
||||||
|
// Prepend header (with exactly one blank line after it, if present)
|
||||||
|
val headerText = joinHeaderBufferOnce(headerBuffer)
|
||||||
|
if (headerText.isNotEmpty()) {
|
||||||
|
out.insert(0, headerText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global normalization: 3+ newlines -> 2, and exactly ONE '\n' at the end
|
||||||
|
val result = out.toString()
|
||||||
|
.replace(Regex("\n{3,}"), "\n\n") // never more than 1 blank line between sections
|
||||||
|
.trimEnd() + "\n" // exactly one newline at the end
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package org.kenjinx.android.cheats
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
class CheatPrefs(private val context: Context) {
|
||||||
|
private fun key(titleId: String) = "cheats_$titleId"
|
||||||
|
private val prefs get() = context.getSharedPreferences("cheats", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun getEnabled(titleId: String): MutableSet<String> {
|
||||||
|
return prefs.getStringSet(key(titleId), emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(titleId: String, keys: Set<String>) {
|
||||||
|
prefs.edit().putStringSet(key(titleId), keys.toSet()).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,12 @@ import org.kenjinx.android.viewmodels.HomeViewModel
|
||||||
import org.kenjinx.android.viewmodels.QuickSettings
|
import org.kenjinx.android.viewmodels.QuickSettings
|
||||||
import org.kenjinx.android.widgets.SimpleAlertDialog
|
import org.kenjinx.android.widgets.SimpleAlertDialog
|
||||||
|
|
||||||
|
// NEW: Cheats
|
||||||
|
import org.kenjinx.android.cheats.CheatPrefs
|
||||||
|
import org.kenjinx.android.cheats.CheatItem
|
||||||
|
import org.kenjinx.android.cheats.loadCheatsFromDisk
|
||||||
|
import org.kenjinx.android.cheats.applyCheatSelectionOnDisk
|
||||||
|
|
||||||
class HomeViews {
|
class HomeViews {
|
||||||
companion object {
|
companion object {
|
||||||
const val ListImageSize = 150
|
const val ListImageSize = 150
|
||||||
|
|
@ -124,6 +130,15 @@ class HomeViews {
|
||||||
var isFabVisible by remember { mutableStateOf(true) }
|
var isFabVisible by remember { mutableStateOf(true) }
|
||||||
val isNavigating = remember { mutableStateOf(false) }
|
val isNavigating = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// NEW: Cheats UI state
|
||||||
|
val openCheatsDialog = remember { mutableStateOf(false) }
|
||||||
|
val cheatsForSelected = remember { mutableStateOf(listOf<CheatItem>()) }
|
||||||
|
val enabledCheatKeys = remember { mutableStateOf(mutableSetOf<String>()) }
|
||||||
|
|
||||||
|
// Shortcut dialog state
|
||||||
|
val showShortcutDialog = remember { mutableStateOf(false) }
|
||||||
|
val shortcutName = remember { mutableStateOf("") }
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val nestedScrollConnection = remember {
|
val nestedScrollConnection = remember {
|
||||||
|
|
@ -241,7 +256,7 @@ class HomeViews {
|
||||||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = query.value,
|
value = query.value,
|
||||||
|
|
@ -256,13 +271,13 @@ class HomeViews {
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedContainerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
unfocusedContainerColor = Color.Transparent,
|
unfocusedContainerColor = Color.Transparent,
|
||||||
disabledContainerColor = Color.Transparent,
|
disabledContainerColor = Color.Transparent,
|
||||||
errorContainerColor = Color.Transparent,
|
errorContainerColor = Color.Transparent,
|
||||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -386,8 +401,14 @@ class HomeViews {
|
||||||
|
|
||||||
thread {
|
thread {
|
||||||
showLoading.value = true
|
showLoading.value = true
|
||||||
|
|
||||||
|
// NEW: Push cheats before launch (auto-start path)
|
||||||
|
val gm = viewModel.mainViewModel.loadGameModel.value!!
|
||||||
|
val tId = gm.titleId ?: ""
|
||||||
|
val act = viewModel.activity
|
||||||
|
|
||||||
val success = viewModel.mainViewModel.loadGame(
|
val success = viewModel.mainViewModel.loadGame(
|
||||||
viewModel.mainViewModel.loadGameModel.value!!,
|
gm,
|
||||||
true,
|
true,
|
||||||
viewModel.mainViewModel.forceNceAndPptc.value
|
viewModel.mainViewModel.forceNceAndPptc.value
|
||||||
)
|
)
|
||||||
|
|
@ -415,10 +436,16 @@ class HomeViews {
|
||||||
if (showAppActions.value) {
|
if (showAppActions.value) {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
if (viewModel.mainViewModel?.selected != null) {
|
if (viewModel.mainViewModel?.selected != null) {
|
||||||
|
|
||||||
|
// NEW: Push cheats before launch (Run button)
|
||||||
|
val gmSel = viewModel.mainViewModel!!.selected!!
|
||||||
|
val tId = gmSel.titleId ?: ""
|
||||||
|
val act = viewModel.activity
|
||||||
|
|
||||||
thread {
|
thread {
|
||||||
showLoading.value = true
|
showLoading.value = true
|
||||||
val success = viewModel.mainViewModel.loadGame(
|
val success = viewModel.mainViewModel.loadGame(
|
||||||
viewModel.mainViewModel.selected!!
|
gmSel
|
||||||
)
|
)
|
||||||
if (success == 1) {
|
if (success == 1) {
|
||||||
launchOnUiThread {
|
launchOnUiThread {
|
||||||
|
|
@ -489,6 +516,23 @@ class HomeViews {
|
||||||
openDlcDialog.value = true
|
openDlcDialog.value = true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
// NEW: Manage Cheats
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(text = "Manage Cheats") },
|
||||||
|
onClick = {
|
||||||
|
showAppMenu.value = false
|
||||||
|
val gm = viewModel.mainViewModel?.selected
|
||||||
|
val act = viewModel.activity
|
||||||
|
if (gm != null && !gm.titleId.isNullOrEmpty() && act != null) {
|
||||||
|
val titleId = gm.titleId!!
|
||||||
|
cheatsForSelected.value = loadCheatsFromDisk(act, titleId)
|
||||||
|
enabledCheatKeys.value = CheatPrefs(act).getEnabled(titleId)
|
||||||
|
openCheatsDialog.value = true
|
||||||
|
} else {
|
||||||
|
showError.value = "No title selected."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -500,6 +544,159 @@ class HomeViews {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- Cheats Bottom Sheet ---
|
||||||
|
if (openCheatsDialog.value) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { openCheatsDialog.value = false }
|
||||||
|
) {
|
||||||
|
val gm = viewModel.mainViewModel?.selected
|
||||||
|
val act = viewModel.activity
|
||||||
|
val titleId = gm?.titleId ?: ""
|
||||||
|
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { openCheatsDialog.value = false }) { Text("Cancel") }
|
||||||
|
TextButton(onClick = {
|
||||||
|
val act2 = act
|
||||||
|
if (act2 != null && titleId.isNotEmpty()) {
|
||||||
|
// 1) Persist selection (UI state)
|
||||||
|
CheatPrefs(act2).setEnabled(titleId, enabledCheatKeys.value)
|
||||||
|
|
||||||
|
// 2) Immediately rewrite the .txt on disk
|
||||||
|
applyCheatSelectionOnDisk(act2, titleId, enabledCheatKeys.value)
|
||||||
|
|
||||||
|
// 3) Reload list (so disabled entries remain visible)
|
||||||
|
cheatsForSelected.value = loadCheatsFromDisk(act2, titleId)
|
||||||
|
}
|
||||||
|
openCheatsDialog.value = false
|
||||||
|
}) { Text("Save") }
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Manage Cheats", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
text = gm?.titleName ?: "",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cheatsForSelected.value.isEmpty()) {
|
||||||
|
Text("No cheats found for this title.")
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 12.dp)
|
||||||
|
) {
|
||||||
|
items(cheatsForSelected.value) { cheat ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 12.dp)
|
||||||
|
) {
|
||||||
|
Text(cheat.name, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(
|
||||||
|
cheat.buildId,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val checked = enabledCheatKeys.value.contains(cheat.key)
|
||||||
|
androidx.compose.material3.Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { isOn ->
|
||||||
|
enabledCheatKeys.value =
|
||||||
|
enabledCheatKeys.value.toMutableSet().apply {
|
||||||
|
if (isOn) add(cheat.key) else remove(cheat.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shortcut dialog
|
||||||
|
if (showShortcutDialog.value) {
|
||||||
|
val gm = viewModel.mainViewModel?.selected
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showShortcutDialog.value = false },
|
||||||
|
title = { Text("Create shortcut") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = shortcutName.value,
|
||||||
|
onValueChange = { shortcutName.value = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Choose icon:",
|
||||||
|
modifier = Modifier.padding(top = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
// App icon (grid image)
|
||||||
|
if (gm != null && activity != null) {
|
||||||
|
val gameUri = resolveGameUri(gm)
|
||||||
|
if (gameUri != null) {
|
||||||
|
// persist rights for the game file
|
||||||
|
ShortcutUtils.persistReadWrite(activity, gameUri)
|
||||||
|
|
||||||
|
val bmp = decodeGameIcon(gm)
|
||||||
|
val label = shortcutName.value.ifBlank { gm.titleName ?: "Start Game" }
|
||||||
|
|
||||||
|
ShortcutUtils.pinShortcutForGame(
|
||||||
|
activity = activity,
|
||||||
|
gameUri = gameUri,
|
||||||
|
label = label,
|
||||||
|
iconBitmap = bmp
|
||||||
|
) { }
|
||||||
|
showShortcutDialog.value = false
|
||||||
|
} else {
|
||||||
|
showShortcutDialog.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showShortcutDialog.value = false
|
||||||
|
}
|
||||||
|
}) { Text("App icon") }
|
||||||
|
|
||||||
|
TextButton(onClick = {
|
||||||
|
// Custom icon: open picker
|
||||||
|
pickImageLauncher.launch(arrayOf("image/*"))
|
||||||
|
showShortcutDialog.value = false
|
||||||
|
}) { Text("Custom icon") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showShortcutDialog.value = false }) {
|
||||||
|
Text("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Version badge bottom left above the entire content
|
// --- Version badge bottom left above the entire content
|
||||||
VersionBadge(
|
VersionBadge(
|
||||||
modifier = Modifier.align(Alignment.BottomStart)
|
modifier = Modifier.align(Alignment.BottomStart)
|
||||||
|
|
@ -540,6 +737,11 @@ class HomeViews {
|
||||||
) {
|
) {
|
||||||
thread {
|
thread {
|
||||||
showLoading.value = true
|
showLoading.value = true
|
||||||
|
|
||||||
|
// NEW: Push cheats before launch
|
||||||
|
val tId = gameModel.titleId ?: ""
|
||||||
|
val act = viewModel.activity
|
||||||
|
|
||||||
val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false
|
val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false
|
||||||
if (success == 1) {
|
if (success == 1) {
|
||||||
launchOnUiThread { viewModel.mainViewModel?.navigateToGame() }
|
launchOnUiThread { viewModel.mainViewModel?.navigateToGame() }
|
||||||
|
|
@ -631,6 +833,11 @@ class HomeViews {
|
||||||
) {
|
) {
|
||||||
thread {
|
thread {
|
||||||
showLoading.value = true
|
showLoading.value = true
|
||||||
|
|
||||||
|
// NEW: Push cheats before launch
|
||||||
|
val tId = gameModel.titleId ?: ""
|
||||||
|
val act = viewModel.activity
|
||||||
|
|
||||||
val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false
|
val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false
|
||||||
if (success == 1) {
|
if (success == 1) {
|
||||||
launchOnUiThread { viewModel.mainViewModel?.navigateToGame() }
|
launchOnUiThread { viewModel.mainViewModel?.navigateToGame() }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue