Added Save Manager

This commit is contained in:
BeZide93 2025-10-28 10:00:06 +01:00
parent dd542f75b9
commit fafbaedf98
2 changed files with 490 additions and 143 deletions

View file

@ -4,9 +4,9 @@ import android.app.Activity
import android.content.ContentResolver import android.content.ContentResolver
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import java.io.BufferedInputStream
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream
import java.nio.charset.Charset import java.nio.charset.Charset
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@ -23,28 +23,29 @@ private fun savesRootExternal(activity: Activity): File {
return File(base, "bis/user/save") return File(base, "bis/user/save")
} }
private fun isHex16(name: String) = private fun isHex16(name: String): Boolean =
name.length == 16 && name.all { it in '0'..'9' || it.lowercaseChar() in 'a'..'f' } name.length == 16 && name.all { it in '0'..'9' || it.lowercaseChar() in 'a'..'f' }
/* ========================= Metadata & Scan ======================== */ /* ========================= Metadata & Scan ======================== */
data class SaveFolderMeta( data class SaveFolderMeta(
val dir: File, val dir: File,
val indexHex: String, // z.B. 0000000000000008 val indexHex: String, // z.B. 0000000000000008 oder 000000000000000a
val titleId: String?, // aus TITLEID.txt (lowercase) oder geerbter Wert val titleId: String?, // aus TITLEID.txt (lowercase) oder geerbter Wert
val titleName: String?, // zweite Zeile aus TITLEID.txt, falls vorhanden val titleName: String?, // zweite Zeile aus TITLEID.txt, falls vorhanden
val hasMarker: Boolean // true, wenn dieser Ordner die TITLEID.txt selbst hat val hasMarker: Boolean // true, wenn dieser Ordner die TITLEID.txt selbst hat
) )
/** /**
* Scannt .../bis/user/save; ordnet nummerierte Ordner (16 Hex Zeichen) per Marker-Vererbung: * Scannt .../bis/user/save; ordnet nummerierte Ordner (16 Hex-Zeichen) per Marker-Vererbung:
* Ein Ordner ohne TITLEID.txt gehört zum zuletzt gesehenen Ordner mit TITLEID.txt davor. * Ein Ordner ohne TITLEID.txt gehört zum zuletzt gesehenen Ordner mit TITLEID.txt davor.
*/ */
fun listSaveFolders(activity: Activity): List<SaveFolderMeta> { fun listSaveFolders(activity: Activity): List<SaveFolderMeta> {
val root = savesRootExternal(activity) val root = savesRootExternal(activity)
if (!root.exists()) return emptyList() if (!root.exists()) return emptyList()
val dirs = root.listFiles { f -> f.isDirectory && isHex16(f.name) }?.sortedBy { it.name.lowercase() } val dirs = root.listFiles { f -> f.isDirectory && isHex16(f.name) }
?.sortedBy { it.name.lowercase(Locale.ROOT) }
?: return emptyList() ?: return emptyList()
val out = ArrayList<SaveFolderMeta>(dirs.size) val out = ArrayList<SaveFolderMeta>(dirs.size)
@ -57,7 +58,7 @@ fun listSaveFolders(activity: Activity): List<SaveFolderMeta> {
if (has) { if (has) {
val txt = runCatching { marker.readText(Charset.forName("UTF-8")) }.getOrElse { "" } val txt = runCatching { marker.readText(Charset.forName("UTF-8")) }.getOrElse { "" }
val lines = txt.split('\n', '\r').map { it.trim() }.filter { it.isNotEmpty() } val lines = txt.split('\n', '\r').map { it.trim() }.filter { it.isNotEmpty() }
val tid = lines.getOrNull(0)?.lowercase() val tid = lines.getOrNull(0)?.lowercase(Locale.ROOT)
val name = lines.getOrNull(1) val name = lines.getOrNull(1)
if (!tid.isNullOrBlank()) { if (!tid.isNullOrBlank()) {
currentTid = tid currentTid = tid
@ -71,17 +72,33 @@ fun listSaveFolders(activity: Activity): List<SaveFolderMeta> {
return out return out
} }
/** Sucht alle Save-Ordner (oft 12 Stück) für eine TitleID (case-insensitive). */ /* ===== TitleID-Kandidaten (Base/Update tolerant) & Gruppierung ===== */
fun findSaveDirsForTitle(activity: Activity, titleId: String): List<File> {
val tidLc = titleId.trim().lowercase(Locale.ROOT) private fun hex16CandidatesForSaves(id: String): List<String> {
return listSaveFolders(activity) val lc = id.trim().lowercase(Locale.ROOT)
.filter { it.titleId.equals(tidLc, ignoreCase = true) } if (!isHex16(lc)) return listOf(lc)
.map { it.dir } val head = lc.substring(0, 13) // erste 13 Zeichen
val base = head + "000" // ...000
val upd = head + "800" // ...800
return listOf(lc, base, upd).distinct()
} }
/** Nimmt den „höchsten“ (zuletzt erstellten) Ordner als Primärordner. */ /** Alle Save-Ordner einer TitleID-Gruppe (Marker-Vererbung), Base/Update tolerant. */
fun pickPrimarySaveDir(activity: Activity, titleId: String): File? { private fun listSaveGroupForTitle(activity: Activity, titleId: String): List<SaveFolderMeta> {
return findSaveDirsForTitle(activity, titleId).maxByOrNull { it.name.lowercase() } val candidates = hex16CandidatesForSaves(titleId)
val metas = listSaveFolders(activity)
return metas.filter { meta ->
val tid = meta.titleId ?: return@filter false
candidates.any { it.equals(tid, ignoreCase = true) }
}
}
/** Bevorzugt den Ordner mit TITLEID.txt; Fallback: lexikografisch kleinster der Gruppe. */
private fun pickSaveDirWithMarker(activity: Activity, titleId: String): File? {
val group = listSaveGroupForTitle(activity, titleId)
val marker = group.firstOrNull { it.hasMarker }?.dir
if (marker != null) return marker
return group.minByOrNull { it.indexHex.lowercase(Locale.ROOT) }?.dir
} }
/* =========================== Export ============================== */ /* =========================== Export ============================== */
@ -95,7 +112,6 @@ private fun sanitizeFileName(s: String): String =
/** /**
* Schreibt eine ZIP im Format: ZIP enthält Ordner "TITLEID_UPPER/…" und darin * Schreibt eine ZIP im Format: ZIP enthält Ordner "TITLEID_UPPER/…" und darin
* den reinen Inhalt des Ordners "0" (nicht den Ordner 0 selbst). * den reinen Inhalt des Ordners "0" (nicht den Ordner 0 selbst).
* destUri kommt von ACTION_CREATE_DOCUMENT("application/zip").
*/ */
fun exportSaveToZip( fun exportSaveToZip(
activity: Activity, activity: Activity,
@ -104,7 +120,7 @@ fun exportSaveToZip(
onProgress: (ExportProgress) -> Unit onProgress: (ExportProgress) -> Unit
): ExportResult { ): ExportResult {
val tidUpper = titleId.trim().uppercase(Locale.ROOT) val tidUpper = titleId.trim().uppercase(Locale.ROOT)
val primary = pickPrimarySaveDir(activity, titleId) val primary = pickSaveDirWithMarker(activity, titleId)
?: return ExportResult(false, "Save folder not found. Start game once.") ?: return ExportResult(false, "Save folder not found. Start game once.")
val folder0 = File(primary, "0") val folder0 = File(primary, "0")
@ -112,7 +128,6 @@ fun exportSaveToZip(
return ExportResult(false, "Missing '0' save folder.") return ExportResult(false, "Missing '0' save folder.")
} }
// total bytes for progress
val files = folder0.walkTopDown().filter { it.isFile }.toList() val files = folder0.walkTopDown().filter { it.isFile }.toList()
val total = files.sumOf { it.length() } val total = files.sumOf { it.length() }
@ -123,7 +138,7 @@ fun exportSaveToZip(
val buf = ByteArray(DEFAULT_BUFFER_SIZE) val buf = ByteArray(DEFAULT_BUFFER_SIZE)
fun putFile(f: File, rel: String) { fun putFile(f: File, rel: String) {
val entryPath = "$tidUpper/$rel" // kein führendes "/" val entryPath = "$tidUpper/$rel"
val entry = ZipEntry(entryPath) val entry = ZipEntry(entryPath)
zos.putNextEntry(entry) zos.putNextEntry(entry)
FileInputStream(f).use { inp -> FileInputStream(f).use { inp ->
@ -138,7 +153,6 @@ fun exportSaveToZip(
zos.closeEntry() zos.closeEntry()
} }
// alles aus 0/* hinein Ordnerstruktur beibehalten
folder0.walkTopDown().forEach { f -> folder0.walkTopDown().forEach { f ->
if (f.isFile) { if (f.isFile) {
val rel = f.relativeTo(folder0).invariantSeparatorsPath val rel = f.relativeTo(folder0).invariantSeparatorsPath
@ -154,17 +168,19 @@ fun exportSaveToZip(
} }
} }
/** Hilfsname für CreateDocument: "<Name>_save_YYYY-MM-DD.zip" */ /** Hilfsname für CreateDocument: "<Name>_save_YYYY-MM-DD.zip" (aus Marker-Ordner) */
fun buildSuggestedExportName(activity: Activity, titleId: String): String { fun buildSuggestedExportName(activity: Activity, titleId: String): String {
val meta = pickPrimarySaveDir(activity, titleId)?.let { dir -> val primary = pickSaveDirWithMarker(activity, titleId)
val txt = File(dir, "TITLEID.txt") val displayName = if (primary != null) {
val name = runCatching { val txt = File(primary, "TITLEID.txt")
runCatching {
txt.takeIf { it.exists() }?.readLines(Charset.forName("UTF-8"))?.getOrNull(1) txt.takeIf { it.exists() }?.readLines(Charset.forName("UTF-8"))?.getOrNull(1)
}.getOrNull() }.getOrNull()?.takeIf { it.isNotBlank() } ?: "Save"
name ?: "Save" } else {
} ?: "Save" "Save"
}
val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
return sanitizeFileName("${meta}_save_$date") + ".zip" return sanitizeFileName("${displayName}_save_$date") + ".zip"
} }
/* =========================== Import ============================== */ /* =========================== Import ============================== */
@ -172,9 +188,34 @@ fun buildSuggestedExportName(activity: Activity, titleId: String): String {
data class ImportProgress(val bytes: Long, val total: Long, val currentEntry: String) data class ImportProgress(val bytes: Long, val total: Long, val currentEntry: String)
data class ImportResult(val ok: Boolean, val message: String) data class ImportResult(val ok: Boolean, val message: String)
/* Helpers */
private fun isHexTitleId(s: String): Boolean =
s.length == 16 && s.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
private fun topLevelSegment(path: String): String {
val p = path.replace('\\', '/').trim('/')
val idx = p.indexOf('/')
return if (idx >= 0) p.substring(0, idx) else p
}
private fun ensureInside(base: File, child: File): Boolean =
try {
val basePath = base.canonicalPath
val childPath = child.canonicalPath
childPath.startsWith(basePath + File.separator)
} catch (_: Throwable) { false }
private fun clearDirectory(dir: File) {
if (!dir.isDirectory) return
dir.listFiles()?.forEach { f ->
if (f.isDirectory) f.deleteRecursively() else runCatching { f.delete() }
}
}
/** /**
- Erwartet ZIP mit Struktur: TITLEID_UPPER/ (beliebige Tiefe). * Erwartet ZIP mit Struktur: TITLEID_UPPER/ schreibt NUR in den Ordner mit TITLEID.txt.
- Findet zugehörigen Save-Ordner per TITLEID.txt-Zuordnung und ersetzt den Inhalt von 0 und 1 */ */
fun importSaveFromZip( fun importSaveFromZip(
activity: Activity, activity: Activity,
zipUri: Uri, zipUri: Uri,
@ -182,135 +223,117 @@ fun importSaveFromZip(
): ImportResult { ): ImportResult {
val cr: ContentResolver = activity.contentResolver val cr: ContentResolver = activity.contentResolver
// Gesamtlänge (falls vorhanden) für Progress // TitelID-Ordner im ZIP finden
val total = runCatching { var titleIdFromZip: String? = null
cr.openAssetFileDescriptor(zipUri, "r")?.use { it.length } var totalBytes = 0L
}.getOrNull() ?: -1L
// 1) Top-Level-Verzeichnis (= TITLEID) ermitteln // Pass 1: Top-Level TitelID und Total ermitteln
var titleIdUpper: String? = null runCatching {
cr.openInputStream(zipUri)?.use { ins ->
// Wir lesen die ZIP zweimal nicht stattdessen merken wir uns beim Streamen das erste Top-Level. ZipInputStream(BufferedInputStream(ins)).use { zis ->
// Map: relPathInZip -> ByteArray? brauchen wir nicht, wir streamen direkt in Files. val tops = mutableSetOf<String>()
var ze = zis.nextEntry
// 2) Zielordner finden, wenn TITLEID bekannt while (ze != null) {
fun findTargetDirs(tidUpper: String): List<File> { val name = ze.name.replace('\\', '/')
// Suche per Marker (case-insensitive) if (!ze.isDirectory) tops += topLevelSegment(name)
val dirs = findSaveDirsForTitle(activity, tidUpper) ze = zis.nextEntry
if (dirs.isNotEmpty()) return dirs }
// falls nicht gefunden: evtl. lower-case in TITLEID.txt gespeichert titleIdFromZip = tops.firstOrNull { isHexTitleId(it) }
val dirs2 = findSaveDirsForTitle(activity, tidUpper.lowercase(Locale.ROOT)) }
return dirs2 }
}.onFailure {
return ImportResult(false, "error importing save. invalid zip")
} }
// 3) 0/1 Ordner leeren & neu füllen if (titleIdFromZip == null) return ImportResult(false, "error importing save. missing TITLEID folder")
fun replaceZeroOne(rootDir: File, entries: List<Pair<String, () -> InputStream>>): Boolean { val tidZip = titleIdFromZip!!.lowercase(Locale.ROOT)
val zero = File(rootDir, "0").apply { mkdirs() }
val one = File(rootDir, "1").apply { mkdirs() }
// Inhalt löschen (nur Inhalt, nicht Ordner) // Größe nur unterhalb /<TITLEID>/… summieren
zero.listFiles()?.forEach { it.deleteRecursively() } runCatching {
one.listFiles()?.forEach { it.deleteRecursively() } cr.openInputStream(zipUri)?.use { ins ->
ZipInputStream(BufferedInputStream(ins)).use { zis ->
val buf = ByteArray(DEFAULT_BUFFER_SIZE) var ze = zis.nextEntry
var bytes = 0L while (ze != null) {
val name = ze.name.replace('\\', '/')
fun writeAll(dstRoot: File) { if (!ze.isDirectory && topLevelSegment(name).equals(tidZip, ignoreCase = true)) {
entries.forEach { (rel, open) -> if (ze.size >= 0) totalBytes += ze.size
val dst = File(dstRoot, rel)
dst.parentFile?.mkdirs()
open().use { ins ->
dst.outputStream().use { os ->
var n = ins.read(buf)
while (n > 0) {
os.write(buf, 0, n)
bytes += n
onProgress(ImportProgress(bytes, total, rel))
n = ins.read(buf)
}
} }
ze = zis.nextEntry
} }
} }
} }
writeAll(zero)
writeAll(one)
return true
} }
return try { // Ziel: NUR der Marker-Ordner
val collected = mutableListOf<Pair<String, () -> InputStream>>() // relPath -> lazy stream val targetRoot = pickSaveDirWithMarker(activity, tidZip)
?: pickSaveDirWithMarker(activity, tidZip.uppercase(Locale.ROOT))
?: return ImportResult(false, "error importing save. start game once.")
cr.openInputStream(zipUri).use { raw -> // 0/1 vorbereiten (leeren)
if (raw == null) return@use val zero = File(targetRoot, "0").apply { mkdirs() }
ZipInputStream(raw).use { zis -> val one = File(targetRoot, "1").apply { mkdirs() }
var e = zis.nextEntry clearDirectory(zero)
val buf = ByteArray(DEFAULT_BUFFER_SIZE) clearDirectory(one)
val cacheChunks = ArrayList<ByteArray>(4) // kleine Chunks pro Entry
fun cacheToSupplier(): () -> InputStream { // Pass 2: extrahieren
// ByteArray zusammenführen var written = 0L
val totalLen = cacheChunks.sumOf { it.size } val buf = ByteArray(DEFAULT_BUFFER_SIZE)
val data = ByteArray(totalLen)
var pos = 0
cacheChunks.forEach { chunk ->
System.arraycopy(chunk, 0, data, pos, chunk.size)
pos += chunk.size
}
cacheChunks.clear()
return { data.inputStream() }
}
while (e != null) { val ok = runCatching {
if (!e.isDirectory) { cr.openInputStream(zipUri)?.use { ins ->
val name = e.name.replace('\\', '/') ZipInputStream(BufferedInputStream(ins)).use { zis ->
// Determine top-level var ze = zis.nextEntry
val firstSlash = name.indexOf('/') while (ze != null) {
if (firstSlash > 0 && titleIdUpper == null) { val entryNameRaw = ze.name.replace('\\', '/').trimStart('/')
titleIdUpper = name.substring(0, firstSlash) val top = topLevelSegment(entryNameRaw)
}
// Nur Dateien unter TITLEID/* mitnehmen (ohne führenden Ordnernamen) if (!ze.isDirectory && top.equals(tidZip, ignoreCase = true)) {
if (firstSlash > 0) { val rel = entryNameRaw.substring(top.length).trimStart('/')
val rel = name.substring(firstSlash + 1) // Teil nach TITLEID/ if (rel.isNotEmpty()) {
if (rel.isNotBlank()) { val out0 = File(zero, rel)
// Wir buffern die Entry-Daten im Speicher (sicher für mittlere Saves) val out1 = File(one, rel)
out0.parentFile?.mkdirs()
out1.parentFile?.mkdirs()
if (!ensureInside(zero, out0) || !ensureInside(one, out1)) {
// Zip-Slip Schutz
zis.closeEntry()
ze = zis.nextEntry
continue
}
// Einmal lesen, zweimal schreiben
val os0 = out0.outputStream()
val os1 = out1.outputStream()
try {
var n = zis.read(buf) var n = zis.read(buf)
while (n > 0) { while (n > 0) {
val chunk = buf.copyOf(n) os0.write(buf, 0, n)
cacheChunks.add(chunk) os1.write(buf, 0, n)
written += n
onProgress(ImportProgress(written, totalBytes, rel))
n = zis.read(buf) n = zis.read(buf)
} }
collected += rel to cacheToSupplier() } finally {
runCatching { os0.close() }
runCatching { os1.close() }
} }
} else {
// Dateien direkt auf Top-Level ignorieren
// (die Struktur MUSS TITLEID/* sein)
var n = zis.read(buf)
while (n > 0) { n = zis.read(buf) }
} }
} }
zis.closeEntry() zis.closeEntry()
e = zis.nextEntry ze = zis.nextEntry
} }
} }
} }
true
val tidUpper = titleIdUpper?.trim()?.uppercase(Locale.ROOT) }.getOrElse {
?: return ImportResult(false, "Invalid ZIP: missing top-level TITLEID") Log.w("SaveFs", "importSaveFromZip failed: ${it.message}")
false
val targets = findTargetDirs(tidUpper)
if (targets.isEmpty()) {
return ImportResult(false, "error importing save. start game once.")
}
// Ersetze in ALLEN zugehörigen Ordnern (falls ein Spiel 2 Save-Ordner hat)
targets.forEach { replaceZeroOne(it, collected) }
ImportResult(true, "save imported")
} catch (t: Throwable) {
Log.w("SaveFs", "importSaveFromZip failed: ${t.message}")
ImportResult(false, "error importing save. start game once.")
} }
return if (ok) ImportResult(true, "save imported")
else ImportResult(false, "error importing save. start game once.")
} }
/* ========================= UI Helpers ============================ */ /* ========================= UI Helpers ============================ */

View file

@ -137,27 +137,28 @@ class HomeViews {
val saveExportStatus = remember { mutableStateOf("") } val saveExportStatus = remember { mutableStateOf("") }
val activity = LocalContext.current as? Activity val activity = LocalContext.current as? Activity
val gmSel = viewModel.mainViewModel?.selected
val currentTitleId = gmSel?.titleId ?: ""
// Import: OpenDocument (ZIP) // Import: OpenDocument (ZIP)
val importZipLauncher = rememberLauncherForActivityResult( val importZipLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument() contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? -> ) { uri: Uri? ->
if (uri != null && activity != null && currentTitleId.isNotEmpty()) { val act = activity
// Guard auf ausgewähltes Spiel optional
val tIdNow = viewModel.mainViewModel?.selected?.titleId.orEmpty()
if (uri != null && act != null && tIdNow.isNotEmpty()) {
saveImportBusy.value = true saveImportBusy.value = true
saveImportProgress.value = 0f saveImportProgress.value = 0f
saveImportStatus.value = "Starting…" saveImportStatus.value = "Starting…"
thread { thread {
val res = importSaveFromZip(activity, uri) { prog -> val res = importSaveFromZip(act, uri) { prog ->
val frac = if (prog.total > 0) prog.bytes.toFloat() / prog.total else 0f val frac = if (prog.total > 0) prog.bytes.toFloat() / prog.total else 0f
saveImportProgress.value = frac.coerceIn(0f, 1f) saveImportProgress.value = frac.coerceIn(0f, 1f)
saveImportStatus.value = "Importing: ${prog.currentEntry}" saveImportStatus.value = "Importing: ${prog.currentEntry}"
} }
saveImportBusy.value = false saveImportBusy.value = false
launchOnUiThread { launchOnUiThread {
Toast.makeText(activity, res.message, Toast.LENGTH_SHORT).show() Toast.makeText(act, res.message, Toast.LENGTH_SHORT).show()
} }
} }
} }
@ -167,13 +168,15 @@ class HomeViews {
val exportZipLauncher = rememberLauncherForActivityResult( val exportZipLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/zip") contract = ActivityResultContracts.CreateDocument("application/zip")
) { uri: Uri? -> ) { uri: Uri? ->
if (uri != null && activity != null && currentTitleId.isNotEmpty()) { val act = activity
val tIdNow = viewModel.mainViewModel?.selected?.titleId.orEmpty()
if (uri != null && act != null && tIdNow.isNotEmpty()) {
saveExportBusy.value = true saveExportBusy.value = true
saveExportProgress.value = 0f saveExportProgress.value = 0f
saveExportStatus.value = "Starting…" saveExportStatus.value = "Starting…"
thread { thread {
val res = exportSaveToZip(activity, currentTitleId, uri) { prog -> val res = exportSaveToZip(act, tIdNow, uri) { prog ->
val frac = if (prog.total > 0) prog.bytes.toFloat() / prog.total else 0f val frac = if (prog.total > 0) prog.bytes.toFloat() / prog.total else 0f
saveExportProgress.value = frac.coerceIn(0f, 1f) saveExportProgress.value = frac.coerceIn(0f, 1f)
saveExportStatus.value = "Exporting: ${prog.currentPath}" saveExportStatus.value = "Exporting: ${prog.currentPath}"
@ -181,7 +184,7 @@ class HomeViews {
saveExportBusy.value = false saveExportBusy.value = false
launchOnUiThread { launchOnUiThread {
Toast.makeText( Toast.makeText(
activity, act,
if (res.ok) "save exported" else (res.error ?: "export failed"), if (res.ok) "save exported" else (res.error ?: "export failed"),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
@ -191,7 +194,6 @@ class HomeViews {
} }
val context = LocalContext.current val context = LocalContext.current
//val activity = LocalContext.current as? Activity
// NEW: Launcher für Amiibo (OpenDocument) // NEW: Launcher für Amiibo (OpenDocument)
val pickAmiiboLauncher = rememberLauncherForActivityResult( val pickAmiiboLauncher = rememberLauncherForActivityResult(
@ -695,6 +697,328 @@ 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.SpaceBetween
) {
// LINKS: Import .txt
TextButton(onClick = {
importCheatLauncher.launch(arrayOf("text/plain", "text/*", "*/*"))
}) { Text("Import .txt") }
// RECHTS: Cancel + Save
Row {
TextButton(onClick = { openCheatsDialog.value = false }) { Text("Cancel") }
TextButton(onClick = {
val act2 = act
if (act2 != null && titleId.isNotEmpty()) {
CheatPrefs(act2).setEnabled(titleId, enabledCheatKeys.value)
applyCheatSelectionOnDisk(act2, titleId, enabledCheatKeys.value)
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)
}
}
)
}
}
}
}
}
}
}
// --- Mods Bottom Sheet ---
if (openModsDialog.value) {
ModalBottomSheet(
onDismissRequest = { openModsDialog.value = false }
) {
val gm = viewModel.mainViewModel?.selected
val act = viewModel.activity
val titleId = gm?.titleId ?: ""
Column(Modifier.padding(16.dp)) {
Text("Manage Mods", 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)
)
// Import-Zeile
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
enabled = !modsImportBusy.value,
onClick = { pickModZipLauncher.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }
) { Text("Import .zip") }
}
// Progress
if (modsImportBusy.value) {
androidx.compose.material3.LinearProgressIndicator(
progress = { modsImportProgress.value },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Text(
modsImportStatusText.value,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 8.dp)
)
}
// Liste der Mods
if (modsForSelected.value.isEmpty()) {
Text("No mods found for this title.")
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
) {
items(modsForSelected.value) { modName ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(modName, maxLines = 2, overflow = TextOverflow.Ellipsis)
Row {
TextButton(
onClick = {
val a = act
if (a != null && titleId.isNotEmpty()) {
thread {
val ok = deleteMod(a, titleId, modName)
if (ok) {
modsForSelected.value = listMods(a, titleId)
}
}
}
}
) { Text("Delete") }
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { openModsDialog.value = false }) { Text("Close") }
}
}
}
}
// --- Saves Bottom Sheet ---
if (openSavesDialog.value) {
ModalBottomSheet(
onDismissRequest = { openSavesDialog.value = false }
) {
val act = activity
Column(Modifier.padding(16.dp)) {
Text("Save Manager", style = MaterialTheme.typography.titleLarge)
Text(
text = viewModel.mainViewModel?.selected?.titleName ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(bottom = 12.dp)
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
// Import-Button
androidx.compose.material3.Button(
enabled = !saveImportBusy.value && !saveExportBusy.value &&
(viewModel.mainViewModel?.selected?.titleId?.isNotEmpty() == true),
onClick = {
saveImportProgress.value = 0f
saveImportStatus.value = ""
importZipLauncher.launch(arrayOf("application/zip", "application/octet-stream", "*/*"))
}
) { Text("Import ZIP") }
// Export-Button
androidx.compose.material3.Button(
enabled = !saveImportBusy.value && !saveExportBusy.value &&
(viewModel.mainViewModel?.selected?.titleId?.isNotEmpty() == true),
onClick = {
val actLocal = activity
val tIdNow = viewModel.mainViewModel?.selected?.titleId.orEmpty()
if (actLocal != null && tIdNow.isNotEmpty()) {
val fname = suggestedCreateDocNameForExport(actLocal, tIdNow)
saveExportProgress.value = 0f
saveExportStatus.value = ""
exportZipLauncher.launch(fname)
}
}
) { Text("Export ZIP") }
}
if (saveImportBusy.value) {
Column(Modifier.padding(top = 12.dp)) {
androidx.compose.material3.LinearProgressIndicator(progress = { saveImportProgress.value })
Text(saveImportStatus.value, modifier = Modifier.padding(top = 6.dp))
}
}
if (saveExportBusy.value) {
Column(Modifier.padding(top = 12.dp)) {
androidx.compose.material3.LinearProgressIndicator(progress = { saveExportProgress.value })
Text(saveExportStatus.value, modifier = Modifier.padding(top = 6.dp))
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
horizontalArrangement = Arrangement.End
) {
androidx.compose.material3.TextButton(
onClick = { openSavesDialog.value = false }
) { Text("Close") }
}
}
}
}
// --- 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)