From 997c43ef50eb8d497e28d670e972d9deaa04400b Mon Sep 17 00:00:00 2001 From: BeZide93 Date: Tue, 28 Oct 2025 08:45:21 +0100 Subject: [PATCH 1/2] Added Cheat Manager --- .../org/kenjinx/android/cheats/CheatFs.kt | 260 ++++++++++++++++++ .../org/kenjinx/android/cheats/CheatPrefs.kt | 16 ++ .../org/kenjinx/android/views/HomeViews.kt | 231 +++++++++++++++- 3 files changed, 497 insertions(+), 10 deletions(-) create mode 100644 src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt create mode 100644 src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt new file mode 100644 index 000000000..365822885 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt @@ -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" +} + +/* -------- Pfade -------- */ + +private fun cheatsDirExternal(activity: Activity, titleId: String): File { + val base = activity.getExternalFilesDir(null) // /storage/emulated/0/Android/data//files + return File(base, "mods/contents/$titleId/cheats") +} + +private fun cheatsDirInternal(activity: Activity, titleId: String): File { + val base = activity.filesDir // /data/user/0//files + return File(base, "mods/contents/$titleId/cheats") +} + +private fun allCheatDirs(activity: Activity, titleId: String): List { + // Reihenfolge: internal zuerst (hier schreibt LibKenjinx i.d.R.), dann external + return listOf(cheatsDirInternal(activity, titleId), cheatsDirExternal(activity, titleId)) + .distinct() + .filter { it.exists() && it.isDirectory } +} + +/* -------- Parser -------- */ + +private fun parseCheatNames(text: String): List { + // 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: Cheats laden -------- */ + +fun loadCheatsFromDisk(activity: Activity, titleId: String): List { + val dirs = allCheatDirs(activity, titleId) + if (dirs.isEmpty()) { + Log.d("CheatFs", "No cheat dirs for $titleId (checked internal+external).") + return emptyList() + } + + val out = mutableListOf() + 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: Auswahl SOFORT auf Disk anwenden -------- */ + +fun applyCheatSelectionOnDisk(activity: Activity, titleId: String, enabledKeys: Set) { + // Wir wählen genau EINE BUILDID-Datei (die „beste“), und schalten darin Sections. + 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 + + // Enabled-Set normalisieren: Keys sind "-" + 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}") + } +} + +/* -------- Implementierung: Auswahl anwenden (nur ';' als Kommentar) -------- */ + +private fun pickBestBuildFile(files: List): 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 "" +} + +/** + * Entfernt EIN führendes Kommentarzeichen (';') + optionales Leerzeichen. + * Nur am absoluten Zeilenanfang (keine führenden Spaces erlaubt). + */ +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 +} + +/** + * Kommentiert die Zeile aus, wenn sie nicht bereits mit ';' beginnt. + * Atmosphère nutzt ';' – das verwenden wir ausschließlich. + */ +private fun commentOut(raw: String): String { + val t = raw.trimStart() + if (t.isEmpty()) return raw + if (t.startsWith(";")) return raw + return "; $raw" +} + +/** + * Schreibt die Datei neu: + * - Keine Marker einfügen + * - Pro Section den Body gemäß enabled/disabled (enabledSections) kommentieren/entkommentieren + * - Reine Kommentar-/Leerzeilen (nur ';') bleiben erhalten + */ +// Hilfsfunktionen: trailing Blankzeilen trimmen / Header normalisieren +private fun trimTrailingBlankLines(lines: MutableList) { + while (lines.isNotEmpty() && lines.last().trim().isEmpty()) { + lines.removeAt(lines.lastIndex) + } +} + +private fun joinHeaderBufferOnce(header: List): String { + // Header-Zeilen unverändert, aber trailing Blanks entfernen und genau 1 Leerzeile danach + val buf = header.toMutableList() + trimTrailingBlankLines(buf) + return if (buf.isEmpty()) "" else buf.joinToString("\n") + "\n\n" +} + +/** + * Schreibt die Datei neu: + * - Keine Marker einfügen + * - Pro Section den Body gemäß enabled/disabled (enabledSections) kommentieren/entkommentieren + * - Reine Kommentar-/Leerzeilen bleiben erhalten + * - Zwischen Sections genau EINE Leerzeile, am Ende genau EIN Newline. + */ +private fun rewriteCheatFile(original: String, enabledSections: Set): String { + val lines = original.replace("\uFEFF", "").lines() + + val out = StringBuilder(original.length + 1024) + + var currentSection: String? = null + val currentBlock = ArrayList() + val headerBuffer = ArrayList() + var sawAnySection = false + var wroteAnySection = false + + fun flushCurrent() { + val sec = currentSection ?: return + + // trailing Blankzeilen im Block entfernen, damit keine doppelten Abstände wachsen + trimTrailingBlankLines(currentBlock) + + val enabled = enabledSections.contains(sec.lowercase()) + + // Zwischen Sections genau eine Leerzeile einfügen (aber nicht vor der ersten) + if (wroteAnySection) out.append('\n') + + out.append('[').append(sec).append(']').append('\n') + + if (enabled) { + // Entkommentieren (nur ein führendes ';' an Spalte 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 { + // Disablen: alles, was nicht schon mit ';' beginnt und nicht leer ist, auskommentieren + 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() + + // Header vorn einsetzen (mit genau einer Leerzeile danach, falls vorhanden) + val headerText = joinHeaderBufferOnce(headerBuffer) + if (headerText.isNotEmpty()) { + out.insert(0, headerText) + } + + // Globale Normalisierung: 3+ Newlines -> 2, und am Ende genau EIN '\n' + var result = out.toString() + .replace(Regex("\n{3,}"), "\n\n") // nie mehr als 1 Leerzeile zwischen Abschnitten + .trimEnd() + "\n" // genau ein Newline am Ende + + return result +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt new file mode 100644 index 000000000..667efd61f --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt @@ -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 { + return prefs.getStringSet(key(titleId), emptySet())?.toMutableSet() ?: mutableSetOf() + } + + fun setEnabled(titleId: String, keys: Set) { + prefs.edit().putStringSet(key(titleId), keys.toSet()).apply() + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt index fd9e3c206..904f03105 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt @@ -88,6 +88,12 @@ import org.kenjinx.android.viewmodels.HomeViewModel import org.kenjinx.android.viewmodels.QuickSettings 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 { companion object { const val ListImageSize = 150 @@ -124,6 +130,15 @@ class HomeViews { var isFabVisible by remember { mutableStateOf(true) } val isNavigating = remember { mutableStateOf(false) } + // NEW: Cheats UI state + val openCheatsDialog = remember { mutableStateOf(false) } + val cheatsForSelected = remember { mutableStateOf(listOf()) } + val enabledCheatKeys = remember { mutableStateOf(mutableSetOf()) } + + // Shortcut-Dialog-State + val showShortcutDialog = remember { mutableStateOf(false) } + val shortcutName = remember { mutableStateOf("") } + val context = LocalContext.current val nestedScrollConnection = remember { @@ -241,7 +256,7 @@ class HomeViews { Icon(Icons.Filled.Settings, contentDescription = "Settings") } - } + } OutlinedTextField( value = query.value, @@ -256,13 +271,13 @@ class HomeViews { singleLine = true, shape = RoundedCornerShape(8.dp), colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - errorContainerColor = Color.Transparent, - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - ) + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + errorContainerColor = Color.Transparent, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + ) ) } }, @@ -386,8 +401,15 @@ class HomeViews { thread { showLoading.value = true + + // NEW: Push Cheats vor dem Start (Auto-Start Pfad) + val gm = viewModel.mainViewModel.loadGameModel.value!! + val tId = gm.titleId ?: "" + val act = viewModel.activity + + val success = viewModel.mainViewModel.loadGame( - viewModel.mainViewModel.loadGameModel.value!!, + gm, true, viewModel.mainViewModel.forceNceAndPptc.value ) @@ -415,10 +437,17 @@ class HomeViews { if (showAppActions.value) { IconButton(onClick = { if (viewModel.mainViewModel?.selected != null) { + + // NEW: Push Cheats vor dem Start (Run-Button) + val gmSel = viewModel.mainViewModel!!.selected!! + val tId = gmSel.titleId ?: "" + val act = viewModel.activity + + thread { showLoading.value = true val success = viewModel.mainViewModel.loadGame( - viewModel.mainViewModel.selected!! + gmSel ) if (success == 1) { launchOnUiThread { @@ -489,6 +518,23 @@ class HomeViews { 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 +546,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) Auswahl persistent speichern (UI-State) + CheatPrefs(act2).setEnabled(titleId, enabledCheatKeys.value) + + // 2) SOFORT die .txt umschreiben + applyCheatSelectionOnDisk(act2, titleId, enabledCheatKeys.value) + + // 3) Liste neu laden (damit disabled Einträge sichtbar bleiben) + 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 VersionBadge( modifier = Modifier.align(Alignment.BottomStart) @@ -540,6 +739,12 @@ class HomeViews { ) { thread { showLoading.value = true + + // NEW: Push Cheats vor dem Start + val tId = gameModel.titleId ?: "" + val act = viewModel.activity + + val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false if (success == 1) { launchOnUiThread { viewModel.mainViewModel?.navigateToGame() } @@ -631,6 +836,12 @@ class HomeViews { ) { thread { showLoading.value = true + + // NEW: Push Cheats vor dem Start + val tId = gameModel.titleId ?: "" + val act = viewModel.activity + + val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false if (success == 1) { launchOnUiThread { viewModel.mainViewModel?.navigateToGame() } From c3ec63e18ee43ecce826918f9248c8399acdfd35 Mon Sep 17 00:00:00 2001 From: BeZide93 Date: Sat, 1 Nov 2025 23:55:15 +0100 Subject: [PATCH 2/2] changed comments to english --- .../org/kenjinx/android/cheats/CheatFs.kt | 62 +++++++++---------- .../org/kenjinx/android/views/HomeViews.kt | 24 +++---- 2 files changed, 41 insertions(+), 45 deletions(-) diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt index 365822885..94cdea25c 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt @@ -9,7 +9,7 @@ data class CheatItem(val buildId: String, val name: String) { val key get() = "$buildId-$name" } -/* -------- Pfade -------- */ +/* -------- Paths -------- */ private fun cheatsDirExternal(activity: Activity, titleId: String): File { val base = activity.getExternalFilesDir(null) // /storage/emulated/0/Android/data//files @@ -22,7 +22,7 @@ private fun cheatsDirInternal(activity: Activity, titleId: String): File { } private fun allCheatDirs(activity: Activity, titleId: String): List { - // Reihenfolge: internal zuerst (hier schreibt LibKenjinx i.d.R.), dann external + // Order: internal first (LibKenjinx usually writes here), then external return listOf(cheatsDirInternal(activity, titleId), cheatsDirExternal(activity, titleId)) .distinct() .filter { it.exists() && it.isDirectory } @@ -40,7 +40,7 @@ private fun parseCheatNames(text: String): List { .toList() } -/* -------- Public: Cheats laden -------- */ +/* -------- Public: Load cheats -------- */ fun loadCheatsFromDisk(activity: Activity, titleId: String): List { val dirs = allCheatDirs(activity, titleId) @@ -65,10 +65,10 @@ fun loadCheatsFromDisk(activity: Activity, titleId: String): List { .sortedWith(compareBy({ it.buildId.lowercase() }, { it.name.lowercase() })) } -/* -------- Public: Auswahl SOFORT auf Disk anwenden -------- */ +/* -------- Public: Apply selection to disk immediately -------- */ fun applyCheatSelectionOnDisk(activity: Activity, titleId: String, enabledKeys: Set) { - // Wir wählen genau EINE BUILDID-Datei (die „beste“), und schalten darin Sections. + // 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() @@ -82,7 +82,7 @@ fun applyCheatSelectionOnDisk(activity: Activity, titleId: String, enabledKeys: val text = runCatching { buildFile.readText(Charset.forName("UTF-8")) }.getOrElse { "" } if (text.isEmpty()) return - // Enabled-Set normalisieren: Keys sind "-" + // Normalize enabled set: keys are "-" val enabledSections = enabledKeys.asSequence() .mapNotNull { key -> val dash = key.indexOf('-') @@ -100,7 +100,7 @@ fun applyCheatSelectionOnDisk(activity: Activity, titleId: String, enabledKeys: } } -/* -------- Implementierung: Auswahl anwenden (nur ';' als Kommentar) -------- */ +/* -------- Implementation: apply selection (using only ';' as comment) -------- */ private fun pickBestBuildFile(files: List): File { fun looksHexName(p: File): Boolean { @@ -124,8 +124,8 @@ private fun sectionNameFromHeader(line: String): String { } /** - * Entfernt EIN führendes Kommentarzeichen (';') + optionales Leerzeichen. - * Nur am absoluten Zeilenanfang (keine führenden Spaces erlaubt). + * 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 @@ -135,8 +135,8 @@ private fun uncommentOnce(raw: String): String { } /** - * Kommentiert die Zeile aus, wenn sie nicht bereits mit ';' beginnt. - * Atmosphère nutzt ';' – das verwenden wir ausschließlich. + * 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() @@ -146,12 +146,12 @@ private fun commentOut(raw: String): String { } /** - * Schreibt die Datei neu: - * - Keine Marker einfügen - * - Pro Section den Body gemäß enabled/disabled (enabledSections) kommentieren/entkommentieren - * - Reine Kommentar-/Leerzeilen (nur ';') bleiben erhalten + * 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 */ -// Hilfsfunktionen: trailing Blankzeilen trimmen / Header normalisieren +// Helpers: trim trailing blank lines / normalize header private fun trimTrailingBlankLines(lines: MutableList) { while (lines.isNotEmpty() && lines.last().trim().isEmpty()) { lines.removeAt(lines.lastIndex) @@ -159,18 +159,18 @@ private fun trimTrailingBlankLines(lines: MutableList) { } private fun joinHeaderBufferOnce(header: List): String { - // Header-Zeilen unverändert, aber trailing Blanks entfernen und genau 1 Leerzeile danach + // 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" } /** - * Schreibt die Datei neu: - * - Keine Marker einfügen - * - Pro Section den Body gemäß enabled/disabled (enabledSections) kommentieren/entkommentieren - * - Reine Kommentar-/Leerzeilen bleiben erhalten - * - Zwischen Sections genau EINE Leerzeile, am Ende genau EIN Newline. + * 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 { val lines = original.replace("\uFEFF", "").lines() @@ -186,18 +186,18 @@ private fun rewriteCheatFile(original: String, enabledSections: Set): St fun flushCurrent() { val sec = currentSection ?: return - // trailing Blankzeilen im Block entfernen, damit keine doppelten Abstände wachsen + // Remove trailing blank lines from the block so spacing doesn't grow trimTrailingBlankLines(currentBlock) val enabled = enabledSections.contains(sec.lowercase()) - // Zwischen Sections genau eine Leerzeile einfügen (aber nicht vor der ersten) + // 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) { - // Entkommentieren (nur ein führendes ';' an Spalte 0) + // Uncomment (only a single leading ';' at column 0) for (l in currentBlock) { val trimmed = l.trim() if (trimmed.isEmpty() || (trimmed.startsWith(";") && trimmed.length <= 1)) { @@ -213,7 +213,7 @@ private fun rewriteCheatFile(original: String, enabledSections: Set): St } } } else { - // Disablen: alles, was nicht schon mit ';' beginnt und nicht leer ist, auskommentieren + // 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(";")) { @@ -245,16 +245,16 @@ private fun rewriteCheatFile(original: String, enabledSections: Set): St } flushCurrent() - // Header vorn einsetzen (mit genau einer Leerzeile danach, falls vorhanden) + // Prepend header (with exactly one blank line after it, if present) val headerText = joinHeaderBufferOnce(headerBuffer) if (headerText.isNotEmpty()) { out.insert(0, headerText) } - // Globale Normalisierung: 3+ Newlines -> 2, und am Ende genau EIN '\n' - var result = out.toString() - .replace(Regex("\n{3,}"), "\n\n") // nie mehr als 1 Leerzeile zwischen Abschnitten - .trimEnd() + "\n" // genau ein Newline am Ende + // 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 } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt index 904f03105..28d397b6a 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt @@ -135,7 +135,7 @@ class HomeViews { val cheatsForSelected = remember { mutableStateOf(listOf()) } val enabledCheatKeys = remember { mutableStateOf(mutableSetOf()) } - // Shortcut-Dialog-State + // Shortcut dialog state val showShortcutDialog = remember { mutableStateOf(false) } val shortcutName = remember { mutableStateOf("") } @@ -402,12 +402,11 @@ class HomeViews { thread { showLoading.value = true - // NEW: Push Cheats vor dem Start (Auto-Start Pfad) + // 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( gm, true, @@ -438,12 +437,11 @@ class HomeViews { IconButton(onClick = { if (viewModel.mainViewModel?.selected != null) { - // NEW: Push Cheats vor dem Start (Run-Button) + // NEW: Push cheats before launch (Run button) val gmSel = viewModel.mainViewModel!!.selected!! val tId = gmSel.titleId ?: "" val act = viewModel.activity - thread { showLoading.value = true val success = viewModel.mainViewModel.loadGame( @@ -564,13 +562,13 @@ class HomeViews { TextButton(onClick = { val act2 = act if (act2 != null && titleId.isNotEmpty()) { - // 1) Auswahl persistent speichern (UI-State) + // 1) Persist selection (UI state) CheatPrefs(act2).setEnabled(titleId, enabledCheatKeys.value) - // 2) SOFORT die .txt umschreiben + // 2) Immediately rewrite the .txt on disk applyCheatSelectionOnDisk(act2, titleId, enabledCheatKeys.value) - // 3) Liste neu laden (damit disabled Einträge sichtbar bleiben) + // 3) Reload list (so disabled entries remain visible) cheatsForSelected.value = loadCheatsFromDisk(act2, titleId) } openCheatsDialog.value = false @@ -633,7 +631,7 @@ class HomeViews { } } - // --- Shortcut-Dialog + // --- Shortcut dialog if (showShortcutDialog.value) { val gm = viewModel.mainViewModel?.selected AlertDialog( @@ -658,7 +656,7 @@ class HomeViews { .padding(top = 8.dp) ) { TextButton(onClick = { - // App icon (Grid image) + // App icon (grid image) if (gm != null && activity != null) { val gameUri = resolveGameUri(gm) if (gameUri != null) { @@ -740,11 +738,10 @@ class HomeViews { thread { showLoading.value = true - // NEW: Push Cheats vor dem Start + // NEW: Push cheats before launch val tId = gameModel.titleId ?: "" val act = viewModel.activity - val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false if (success == 1) { launchOnUiThread { viewModel.mainViewModel?.navigateToGame() } @@ -837,11 +834,10 @@ class HomeViews { thread { showLoading.value = true - // NEW: Push Cheats vor dem Start + // NEW: Push cheats before launch val tId = gameModel.titleId ?: "" val act = viewModel.activity - val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false if (success == 1) { launchOnUiThread { viewModel.mainViewModel?.navigateToGame() }