From dd542f75b9e5a94065838ee08bc8257384aa974d Mon Sep 17 00:00:00 2001 From: BeZide93 Date: Tue, 28 Oct 2025 08:57:47 +0100 Subject: [PATCH 1/3] Added Save Manager --- .../java/org/kenjinx/android/saves/SaveFs.kt | 319 ++++++++++++++++++ .../org/kenjinx/android/views/HomeViews.kt | 195 +++++++++++ 2 files changed, 514 insertions(+) create mode 100644 src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt new file mode 100644 index 000000000..aa4f6eaeb --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt @@ -0,0 +1,319 @@ +package org.kenjinx.android.saves + +import android.app.Activity +import android.content.ContentResolver +import android.net.Uri +import android.util.Log +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.nio.charset.Charset +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +/* ============================= Paths ============================= */ + +private fun savesRootExternal(activity: Activity): File { + // /storage/emulated/0/Android/data//files/bis/user/save + val base = activity.getExternalFilesDir(null) + return File(base, "bis/user/save") +} + +private fun isHex16(name: String) = + name.length == 16 && name.all { it in '0'..'9' || it.lowercaseChar() in 'a'..'f' } + +/* ========================= Metadata & Scan ======================== */ + +data class SaveFolderMeta( + val dir: File, + val indexHex: String, // z.B. 0000000000000008 + val titleId: String?, // aus TITLEID.txt (lowercase) oder geerbter Wert + val titleName: String?, // zweite Zeile aus TITLEID.txt, falls vorhanden + 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“: + * Ein Ordner ohne TITLEID.txt gehört zum zuletzt gesehenen Ordner mit TITLEID.txt davor. + */ +fun listSaveFolders(activity: Activity): List { + val root = savesRootExternal(activity) + if (!root.exists()) return emptyList() + + val dirs = root.listFiles { f -> f.isDirectory && isHex16(f.name) }?.sortedBy { it.name.lowercase() } + ?: return emptyList() + + val out = ArrayList(dirs.size) + var currentTid: String? = null + var currentName: String? = null + + for (d in dirs) { + val marker = File(d, "TITLEID.txt") + val has = marker.exists() + if (has) { + val txt = runCatching { marker.readText(Charset.forName("UTF-8")) }.getOrElse { "" } + val lines = txt.split('\n', '\r').map { it.trim() }.filter { it.isNotEmpty() } + val tid = lines.getOrNull(0)?.lowercase() + val name = lines.getOrNull(1) + if (!tid.isNullOrBlank()) { + currentTid = tid + currentName = name + } + out += SaveFolderMeta(d, d.name, currentTid, currentName, hasMarker = true) + } else { + out += SaveFolderMeta(d, d.name, currentTid, currentName, hasMarker = false) + } + } + return out +} + +/** Sucht alle Save-Ordner (oft 1–2 Stück) für eine TitleID (case-insensitive). */ +fun findSaveDirsForTitle(activity: Activity, titleId: String): List { + val tidLc = titleId.trim().lowercase(Locale.ROOT) + return listSaveFolders(activity) + .filter { it.titleId.equals(tidLc, ignoreCase = true) } + .map { it.dir } +} + +/** Nimmt den „höchsten“ (zuletzt erstellten) Ordner als Primärordner. */ +fun pickPrimarySaveDir(activity: Activity, titleId: String): File? { + return findSaveDirsForTitle(activity, titleId).maxByOrNull { it.name.lowercase() } +} + +/* =========================== Export ============================== */ + +data class ExportProgress(val bytes: Long, val total: Long, val currentPath: String) +data class ExportResult(val ok: Boolean, val error: String? = null) + +private fun sanitizeFileName(s: String): String = + s.replace(Regex("""[\\/:*?"<>|]"""), "_").trim().ifBlank { "save" } + +/** + * Schreibt eine ZIP im Format: ZIP enthält Ordner "TITLEID_UPPER/…" und darin + * den reinen Inhalt des Ordners "0" (nicht den Ordner 0 selbst). + * destUri kommt von ACTION_CREATE_DOCUMENT("application/zip"). + */ +fun exportSaveToZip( + activity: Activity, + titleId: String, + destUri: Uri, + onProgress: (ExportProgress) -> Unit +): ExportResult { + val tidUpper = titleId.trim().uppercase(Locale.ROOT) + val primary = pickPrimarySaveDir(activity, titleId) + ?: return ExportResult(false, "Save folder not found. Start game once.") + + val folder0 = File(primary, "0") + if (!folder0.exists() || !folder0.isDirectory) { + return ExportResult(false, "Missing '0' save folder.") + } + + // total bytes for progress + val files = folder0.walkTopDown().filter { it.isFile }.toList() + val total = files.sumOf { it.length() } + + return try { + activity.contentResolver.openOutputStream(destUri)?.use { os -> + ZipOutputStream(os).use { zos -> + var written = 0L + val buf = ByteArray(DEFAULT_BUFFER_SIZE) + + fun putFile(f: File, rel: String) { + val entryPath = "$tidUpper/$rel" // kein führendes "/" + val entry = ZipEntry(entryPath) + zos.putNextEntry(entry) + FileInputStream(f).use { inp -> + var n = inp.read(buf) + while (n > 0) { + zos.write(buf, 0, n) + written += n + onProgress(ExportProgress(written, total, entryPath)) + n = inp.read(buf) + } + } + zos.closeEntry() + } + + // alles aus 0/* hinein – Ordnerstruktur beibehalten + folder0.walkTopDown().forEach { f -> + if (f.isFile) { + val rel = f.relativeTo(folder0).invariantSeparatorsPath + putFile(f, rel) + } + } + } + } ?: return ExportResult(false, "Failed to open destination") + ExportResult(true, null) + } catch (t: Throwable) { + Log.w("SaveFs", "exportSaveToZip failed: ${t.message}") + ExportResult(false, t.message ?: "Export failed") + } +} + +/** Hilfsname für CreateDocument: "_save_YYYY-MM-DD.zip" */ +fun buildSuggestedExportName(activity: Activity, titleId: String): String { + val meta = pickPrimarySaveDir(activity, titleId)?.let { dir -> + val txt = File(dir, "TITLEID.txt") + val name = runCatching { + txt.takeIf { it.exists() }?.readLines(Charset.forName("UTF-8"))?.getOrNull(1) + }.getOrNull() + name ?: "Save" + } ?: "Save" + val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) + return sanitizeFileName("${meta}_save_$date") + ".zip" +} + +/* =========================== Import ============================== */ + +data class ImportProgress(val bytes: Long, val total: Long, val currentEntry: String) +data class ImportResult(val ok: Boolean, val message: String) + +/** + - Erwartet ZIP mit Struktur: TITLEID_UPPER/ (beliebige Tiefe). + - Findet zugehörigen Save-Ordner per TITLEID.txt-Zuordnung und ersetzt den Inhalt von 0 und 1 */ +fun importSaveFromZip( + activity: Activity, + zipUri: Uri, + onProgress: (ImportProgress) -> Unit +): ImportResult { + val cr: ContentResolver = activity.contentResolver + + // Gesamtlänge (falls vorhanden) für Progress + val total = runCatching { + cr.openAssetFileDescriptor(zipUri, "r")?.use { it.length } + }.getOrNull() ?: -1L + + // 1) Top-Level-Verzeichnis (= TITLEID) ermitteln + var titleIdUpper: String? = null + + // Wir lesen die ZIP zweimal nicht – stattdessen merken wir uns beim Streamen das erste Top-Level. + // Map: relPathInZip -> ByteArray? – brauchen wir nicht, wir streamen direkt in Files. + + // 2) Zielordner finden, wenn TITLEID bekannt + fun findTargetDirs(tidUpper: String): List { + // Suche per Marker (case-insensitive) + val dirs = findSaveDirsForTitle(activity, tidUpper) + if (dirs.isNotEmpty()) return dirs + // falls nicht gefunden: evtl. lower-case in TITLEID.txt gespeichert + val dirs2 = findSaveDirsForTitle(activity, tidUpper.lowercase(Locale.ROOT)) + return dirs2 + } + + // 3) 0/1 Ordner leeren & neu füllen + fun replaceZeroOne(rootDir: File, entries: List InputStream>>): Boolean { + val zero = File(rootDir, "0").apply { mkdirs() } + val one = File(rootDir, "1").apply { mkdirs() } + + // Inhalt löschen (nur Inhalt, nicht Ordner) + zero.listFiles()?.forEach { it.deleteRecursively() } + one.listFiles()?.forEach { it.deleteRecursively() } + + val buf = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = 0L + + fun writeAll(dstRoot: File) { + entries.forEach { (rel, open) -> + 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) + } + } + } + } + } + + writeAll(zero) + writeAll(one) + return true + } + + return try { + val collected = mutableListOf InputStream>>() // relPath -> lazy stream + + cr.openInputStream(zipUri).use { raw -> + if (raw == null) return@use + ZipInputStream(raw).use { zis -> + var e = zis.nextEntry + val buf = ByteArray(DEFAULT_BUFFER_SIZE) + val cacheChunks = ArrayList(4) // kleine Chunks pro Entry + + fun cacheToSupplier(): () -> InputStream { + // ByteArray zusammenführen + val totalLen = cacheChunks.sumOf { it.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) { + if (!e.isDirectory) { + val name = e.name.replace('\\', '/') + // Determine top-level + val firstSlash = name.indexOf('/') + if (firstSlash > 0 && titleIdUpper == null) { + titleIdUpper = name.substring(0, firstSlash) + } + // Nur Dateien unter TITLEID/* mitnehmen (ohne führenden Ordnernamen) + if (firstSlash > 0) { + val rel = name.substring(firstSlash + 1) // Teil nach TITLEID/ + if (rel.isNotBlank()) { + // Wir buffern die Entry-Daten im Speicher (sicher für mittlere Saves) + var n = zis.read(buf) + while (n > 0) { + val chunk = buf.copyOf(n) + cacheChunks.add(chunk) + n = zis.read(buf) + } + collected += rel to cacheToSupplier() + } + } 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() + e = zis.nextEntry + } + } + } + + val tidUpper = titleIdUpper?.trim()?.uppercase(Locale.ROOT) + ?: return ImportResult(false, "Invalid ZIP: missing top-level TITLEID") + + 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.") + } +} + +/* ========================= UI Helpers ============================ */ + +fun suggestedCreateDocNameForExport(activity: Activity, titleId: String): String = + buildSuggestedExportName(activity, titleId) 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..f1b3468cf 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,9 @@ import org.kenjinx.android.viewmodels.HomeViewModel import org.kenjinx.android.viewmodels.QuickSettings import org.kenjinx.android.widgets.SimpleAlertDialog +// NEW: Saves +import org.kenjinx.android.saves.* + class HomeViews { companion object { const val ListImageSize = 150 @@ -124,7 +127,199 @@ class HomeViews { var isFabVisible by remember { mutableStateOf(true) } val isNavigating = remember { mutableStateOf(false) } + // Save Manager State + val openSavesDialog = remember { mutableStateOf(false) } + val saveImportBusy = remember { mutableStateOf(false) } + val saveExportBusy = remember { mutableStateOf(false) } + val saveImportProgress = remember { mutableStateOf(0f) } + val saveExportProgress = remember { mutableStateOf(0f) } + val saveImportStatus = remember { mutableStateOf("") } + val saveExportStatus = remember { mutableStateOf("") } + + val activity = LocalContext.current as? Activity + val gmSel = viewModel.mainViewModel?.selected + val currentTitleId = gmSel?.titleId ?: "" + + // Import: OpenDocument (ZIP) + val importZipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null && activity != null && currentTitleId.isNotEmpty()) { + saveImportBusy.value = true + saveImportProgress.value = 0f + saveImportStatus.value = "Starting…" + + thread { + val res = importSaveFromZip(activity, uri) { prog -> + val frac = if (prog.total > 0) prog.bytes.toFloat() / prog.total else 0f + saveImportProgress.value = frac.coerceIn(0f, 1f) + saveImportStatus.value = "Importing: ${prog.currentEntry}" + } + saveImportBusy.value = false + launchOnUiThread { + Toast.makeText(activity, res.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + // Export: CreateDocument (ZIP) + val exportZipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip") + ) { uri: Uri? -> + if (uri != null && activity != null && currentTitleId.isNotEmpty()) { + saveExportBusy.value = true + saveExportProgress.value = 0f + saveExportStatus.value = "Starting…" + + thread { + val res = exportSaveToZip(activity, currentTitleId, uri) { prog -> + val frac = if (prog.total > 0) prog.bytes.toFloat() / prog.total else 0f + saveExportProgress.value = frac.coerceIn(0f, 1f) + saveExportStatus.value = "Exporting: ${prog.currentPath}" + } + saveExportBusy.value = false + launchOnUiThread { + Toast.makeText( + activity, + if (res.ok) "save exported" else (res.error ?: "export failed"), + Toast.LENGTH_SHORT + ).show() + } + } + } + } + val context = LocalContext.current + //val activity = LocalContext.current as? Activity + + // NEW: Launcher für Amiibo (OpenDocument) + val pickAmiiboLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null && activity != null) { + try { + activity.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: Exception) {} + val name = DocumentFile.fromSingleUri(activity, uri)?.name ?: "amiibo.bin" + val qs = QuickSettings(activity) + when (pendingSlot.value) { + 1 -> { qs.amiibo1Uri = uri.toString(); qs.amiibo1Name = name } + 2 -> { qs.amiibo2Uri = uri.toString(); qs.amiibo2Name = name } + 3 -> { qs.amiibo3Uri = uri.toString(); qs.amiibo3Name = name } + 4 -> { qs.amiibo4Uri = uri.toString(); qs.amiibo4Name = name } + 5 -> { qs.amiibo5Uri = uri.toString(); qs.amiibo5Name = name } + } + qs.save() + Toast.makeText(activity, "Amiibo saved to slot ${pendingSlot.value}", Toast.LENGTH_SHORT).show() + } + } + + // NEW: Cheats Import (.txt) + val importCheatLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + val titleId = gm?.titleId ?: "" + if (uri != null && act != null && titleId.isNotEmpty()) { + // nur .txt akzeptieren + val okExt = runCatching { + DocumentFile.fromSingleUri(act, uri)?.name?.lowercase()?.endsWith(".txt") == true + }.getOrElse { false } + if (!okExt) { + Toast.makeText(act, "Please select a .txt file", Toast.LENGTH_SHORT).show() + return@rememberLauncherForActivityResult + } + + val res = importCheatTxt(act, titleId, uri) + if (res.isSuccess) { + Toast.makeText(act, "Imported: ${res.getOrNull()?.name}", Toast.LENGTH_SHORT).show() + // danach Liste aktualisieren + cheatsForSelected.value = loadCheatsFromDisk(act, titleId) + } else { + Toast.makeText(act, "Import failed: ${res.exceptionOrNull()?.message}", Toast.LENGTH_LONG).show() + } + } + } + // NEW: Launcher for Mods + val pickModZipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + val titleId = gm?.titleId ?: "" + if (uri != null && act != null && titleId.isNotEmpty()) { + // Persist permission (lesen) + try { + act.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + + modsImportBusy.value = true + modsImportProgress.value = 0f + modsImportStatusText.value = "Starting…" + + thread { + val res = importModsZip( + act, + titleId, + uri + ) { prog -> + modsImportProgress.value = prog.fraction + modsImportStatusText.value = if (prog.currentEntry.isNotEmpty()) + "Copying: ${prog.currentEntry}" + else + "Copying… ${(prog.fraction * 100).toInt()}%" + } + + // Liste aktualisieren + modsForSelected.value = listMods(act, titleId) + modsImportBusy.value = false + + launchOnUiThread { + val msg = if (res.ok) + "Imported: ${res.imported.joinToString(", ")}" + else + "Import failed" + Toast.makeText(act, msg, Toast.LENGTH_SHORT).show() + } + } + + } + } + + + // Launcher for "Custom icon" (OpenDocument) + val pickImageLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val gm = viewModel.mainViewModel?.selected + if (uri != null && gm != null && activity != null) { + val bmp = runCatching { + context.contentResolver.openInputStream(uri).use { BitmapFactory.decodeStream(it) } + }.getOrNull() + + val label = shortcutName.value.ifBlank { gm.titleName ?: "Start Game" } + val gameUri = resolveGameUri(gm) + if (gameUri != null) { + ShortcutUtils.persistReadWrite(activity, gameUri) + + ShortcutUtils.pinShortcutForGame( + activity = activity, + gameUri = gameUri, + label = label, + iconBitmap = bmp + ) { + + } + } else { + showError.value = "Shortcut failed (no game URI found)." + } + } + } val nestedScrollConnection = remember { object : NestedScrollConnection { From fafbaedf98a41c0ee3cecacd3fe5fef16a8e1c29 Mon Sep 17 00:00:00 2001 From: BeZide93 Date: Tue, 28 Oct 2025 10:00:06 +0100 Subject: [PATCH 2/3] Added Save Manager --- .../java/org/kenjinx/android/saves/SaveFs.kt | 291 ++++++++------- .../org/kenjinx/android/views/HomeViews.kt | 342 +++++++++++++++++- 2 files changed, 490 insertions(+), 143 deletions(-) diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt index aa4f6eaeb..ea787ba12 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt @@ -4,9 +4,9 @@ import android.app.Activity import android.content.ContentResolver import android.net.Uri import android.util.Log +import java.io.BufferedInputStream import java.io.File import java.io.FileInputStream -import java.io.InputStream import java.nio.charset.Charset import java.text.SimpleDateFormat import java.util.Date @@ -23,28 +23,29 @@ private fun savesRootExternal(activity: Activity): File { 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' } /* ========================= Metadata & Scan ======================== */ data class SaveFolderMeta( 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 titleName: String?, // zweite Zeile aus TITLEID.txt, falls vorhanden 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. */ fun listSaveFolders(activity: Activity): List { val root = savesRootExternal(activity) 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() val out = ArrayList(dirs.size) @@ -57,7 +58,7 @@ fun listSaveFolders(activity: Activity): List { if (has) { val txt = runCatching { marker.readText(Charset.forName("UTF-8")) }.getOrElse { "" } 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) if (!tid.isNullOrBlank()) { currentTid = tid @@ -71,17 +72,33 @@ fun listSaveFolders(activity: Activity): List { return out } -/** Sucht alle Save-Ordner (oft 1–2 Stück) für eine TitleID (case-insensitive). */ -fun findSaveDirsForTitle(activity: Activity, titleId: String): List { - val tidLc = titleId.trim().lowercase(Locale.ROOT) - return listSaveFolders(activity) - .filter { it.titleId.equals(tidLc, ignoreCase = true) } - .map { it.dir } +/* ===== TitleID-Kandidaten (Base/Update tolerant) & Gruppierung ===== */ + +private fun hex16CandidatesForSaves(id: String): List { + val lc = id.trim().lowercase(Locale.ROOT) + if (!isHex16(lc)) return listOf(lc) + 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. */ -fun pickPrimarySaveDir(activity: Activity, titleId: String): File? { - return findSaveDirsForTitle(activity, titleId).maxByOrNull { it.name.lowercase() } +/** Alle Save-Ordner einer TitleID-Gruppe (Marker-Vererbung), Base/Update tolerant. */ +private fun listSaveGroupForTitle(activity: Activity, titleId: String): List { + 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 ============================== */ @@ -95,7 +112,6 @@ private fun sanitizeFileName(s: String): String = /** * Schreibt eine ZIP im Format: ZIP enthält Ordner "TITLEID_UPPER/…" und darin * den reinen Inhalt des Ordners "0" (nicht den Ordner 0 selbst). - * destUri kommt von ACTION_CREATE_DOCUMENT("application/zip"). */ fun exportSaveToZip( activity: Activity, @@ -104,7 +120,7 @@ fun exportSaveToZip( onProgress: (ExportProgress) -> Unit ): ExportResult { 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.") val folder0 = File(primary, "0") @@ -112,7 +128,6 @@ fun exportSaveToZip( return ExportResult(false, "Missing '0' save folder.") } - // total bytes for progress val files = folder0.walkTopDown().filter { it.isFile }.toList() val total = files.sumOf { it.length() } @@ -123,7 +138,7 @@ fun exportSaveToZip( val buf = ByteArray(DEFAULT_BUFFER_SIZE) fun putFile(f: File, rel: String) { - val entryPath = "$tidUpper/$rel" // kein führendes "/" + val entryPath = "$tidUpper/$rel" val entry = ZipEntry(entryPath) zos.putNextEntry(entry) FileInputStream(f).use { inp -> @@ -138,7 +153,6 @@ fun exportSaveToZip( zos.closeEntry() } - // alles aus 0/* hinein – Ordnerstruktur beibehalten folder0.walkTopDown().forEach { f -> if (f.isFile) { val rel = f.relativeTo(folder0).invariantSeparatorsPath @@ -154,17 +168,19 @@ fun exportSaveToZip( } } -/** Hilfsname für CreateDocument: "_save_YYYY-MM-DD.zip" */ +/** Hilfsname für CreateDocument: "_save_YYYY-MM-DD.zip" (aus Marker-Ordner) */ fun buildSuggestedExportName(activity: Activity, titleId: String): String { - val meta = pickPrimarySaveDir(activity, titleId)?.let { dir -> - val txt = File(dir, "TITLEID.txt") - val name = runCatching { + val primary = pickSaveDirWithMarker(activity, titleId) + val displayName = if (primary != null) { + val txt = File(primary, "TITLEID.txt") + runCatching { txt.takeIf { it.exists() }?.readLines(Charset.forName("UTF-8"))?.getOrNull(1) - }.getOrNull() - name ?: "Save" - } ?: "Save" + }.getOrNull()?.takeIf { it.isNotBlank() } ?: "Save" + } else { + "Save" + } val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) - return sanitizeFileName("${meta}_save_$date") + ".zip" + return sanitizeFileName("${displayName}_save_$date") + ".zip" } /* =========================== 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 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). - - Findet zugehörigen Save-Ordner per TITLEID.txt-Zuordnung und ersetzt den Inhalt von 0 und 1 */ + * Erwartet ZIP mit Struktur: TITLEID_UPPER/… – schreibt NUR in den Ordner mit TITLEID.txt. + */ fun importSaveFromZip( activity: Activity, zipUri: Uri, @@ -182,135 +223,117 @@ fun importSaveFromZip( ): ImportResult { val cr: ContentResolver = activity.contentResolver - // Gesamtlänge (falls vorhanden) für Progress - val total = runCatching { - cr.openAssetFileDescriptor(zipUri, "r")?.use { it.length } - }.getOrNull() ?: -1L + // TitelID-Ordner im ZIP finden + var titleIdFromZip: String? = null + var totalBytes = 0L - // 1) Top-Level-Verzeichnis (= TITLEID) ermitteln - var titleIdUpper: String? = null - - // Wir lesen die ZIP zweimal nicht – stattdessen merken wir uns beim Streamen das erste Top-Level. - // Map: relPathInZip -> ByteArray? – brauchen wir nicht, wir streamen direkt in Files. - - // 2) Zielordner finden, wenn TITLEID bekannt - fun findTargetDirs(tidUpper: String): List { - // Suche per Marker (case-insensitive) - val dirs = findSaveDirsForTitle(activity, tidUpper) - if (dirs.isNotEmpty()) return dirs - // falls nicht gefunden: evtl. lower-case in TITLEID.txt gespeichert - val dirs2 = findSaveDirsForTitle(activity, tidUpper.lowercase(Locale.ROOT)) - return dirs2 + // Pass 1: Top-Level TitelID und Total ermitteln + runCatching { + cr.openInputStream(zipUri)?.use { ins -> + ZipInputStream(BufferedInputStream(ins)).use { zis -> + val tops = mutableSetOf() + var ze = zis.nextEntry + while (ze != null) { + val name = ze.name.replace('\\', '/') + if (!ze.isDirectory) tops += topLevelSegment(name) + ze = zis.nextEntry + } + titleIdFromZip = tops.firstOrNull { isHexTitleId(it) } + } + } + }.onFailure { + return ImportResult(false, "error importing save. invalid zip") } - // 3) 0/1 Ordner leeren & neu füllen - fun replaceZeroOne(rootDir: File, entries: List InputStream>>): Boolean { - val zero = File(rootDir, "0").apply { mkdirs() } - val one = File(rootDir, "1").apply { mkdirs() } + if (titleIdFromZip == null) return ImportResult(false, "error importing save. missing TITLEID folder") + val tidZip = titleIdFromZip!!.lowercase(Locale.ROOT) - // Inhalt löschen (nur Inhalt, nicht Ordner) - zero.listFiles()?.forEach { it.deleteRecursively() } - one.listFiles()?.forEach { it.deleteRecursively() } - - val buf = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = 0L - - fun writeAll(dstRoot: File) { - entries.forEach { (rel, open) -> - 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) - } + // Größe nur unterhalb //… summieren + runCatching { + cr.openInputStream(zipUri)?.use { ins -> + ZipInputStream(BufferedInputStream(ins)).use { zis -> + var ze = zis.nextEntry + while (ze != null) { + val name = ze.name.replace('\\', '/') + if (!ze.isDirectory && topLevelSegment(name).equals(tidZip, ignoreCase = true)) { + if (ze.size >= 0) totalBytes += ze.size } + ze = zis.nextEntry } } } - - writeAll(zero) - writeAll(one) - return true } - return try { - val collected = mutableListOf InputStream>>() // relPath -> lazy stream + // Ziel: NUR der Marker-Ordner + 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 -> - if (raw == null) return@use - ZipInputStream(raw).use { zis -> - var e = zis.nextEntry - val buf = ByteArray(DEFAULT_BUFFER_SIZE) - val cacheChunks = ArrayList(4) // kleine Chunks pro Entry + // 0/1 vorbereiten (leeren) + val zero = File(targetRoot, "0").apply { mkdirs() } + val one = File(targetRoot, "1").apply { mkdirs() } + clearDirectory(zero) + clearDirectory(one) - fun cacheToSupplier(): () -> InputStream { - // ByteArray zusammenführen - val totalLen = cacheChunks.sumOf { it.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() } - } + // Pass 2: extrahieren + var written = 0L + val buf = ByteArray(DEFAULT_BUFFER_SIZE) - while (e != null) { - if (!e.isDirectory) { - val name = e.name.replace('\\', '/') - // Determine top-level - val firstSlash = name.indexOf('/') - if (firstSlash > 0 && titleIdUpper == null) { - titleIdUpper = name.substring(0, firstSlash) - } - // Nur Dateien unter TITLEID/* mitnehmen (ohne führenden Ordnernamen) - if (firstSlash > 0) { - val rel = name.substring(firstSlash + 1) // Teil nach TITLEID/ - if (rel.isNotBlank()) { - // Wir buffern die Entry-Daten im Speicher (sicher für mittlere Saves) + val ok = runCatching { + cr.openInputStream(zipUri)?.use { ins -> + ZipInputStream(BufferedInputStream(ins)).use { zis -> + var ze = zis.nextEntry + while (ze != null) { + val entryNameRaw = ze.name.replace('\\', '/').trimStart('/') + val top = topLevelSegment(entryNameRaw) + + if (!ze.isDirectory && top.equals(tidZip, ignoreCase = true)) { + val rel = entryNameRaw.substring(top.length).trimStart('/') + if (rel.isNotEmpty()) { + val out0 = File(zero, rel) + 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) while (n > 0) { - val chunk = buf.copyOf(n) - cacheChunks.add(chunk) + os0.write(buf, 0, n) + os1.write(buf, 0, n) + written += n + onProgress(ImportProgress(written, totalBytes, rel)) 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() - e = zis.nextEntry + ze = zis.nextEntry } } } - - val tidUpper = titleIdUpper?.trim()?.uppercase(Locale.ROOT) - ?: return ImportResult(false, "Invalid ZIP: missing top-level TITLEID") - - 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.") + true + }.getOrElse { + Log.w("SaveFs", "importSaveFromZip failed: ${it.message}") + false } + + return if (ok) ImportResult(true, "save imported") + else ImportResult(false, "error importing save. start game once.") } /* ========================= UI Helpers ============================ */ 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 f1b3468cf..12c0fd2aa 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 @@ -137,27 +137,28 @@ class HomeViews { val saveExportStatus = remember { mutableStateOf("") } val activity = LocalContext.current as? Activity - val gmSel = viewModel.mainViewModel?.selected - val currentTitleId = gmSel?.titleId ?: "" // Import: OpenDocument (ZIP) val importZipLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { 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 saveImportProgress.value = 0f saveImportStatus.value = "Starting…" 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 saveImportProgress.value = frac.coerceIn(0f, 1f) saveImportStatus.value = "Importing: ${prog.currentEntry}" } saveImportBusy.value = false 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( contract = ActivityResultContracts.CreateDocument("application/zip") ) { 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 saveExportProgress.value = 0f saveExportStatus.value = "Starting…" 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 saveExportProgress.value = frac.coerceIn(0f, 1f) saveExportStatus.value = "Exporting: ${prog.currentPath}" @@ -181,7 +184,7 @@ class HomeViews { saveExportBusy.value = false launchOnUiThread { Toast.makeText( - activity, + act, if (res.ok) "save exported" else (res.error ?: "export failed"), Toast.LENGTH_SHORT ).show() @@ -191,7 +194,6 @@ class HomeViews { } val context = LocalContext.current - //val activity = LocalContext.current as? Activity // NEW: Launcher für Amiibo (OpenDocument) 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 VersionBadge( modifier = Modifier.align(Alignment.BottomStart) From 6447d3512cb38dc4c99ecbe98f61846ae1e6df1c Mon Sep 17 00:00:00 2001 From: BeZide93 Date: Sun, 2 Nov 2025 00:53:58 +0100 Subject: [PATCH 3/3] changed comments to english --- .../java/org/kenjinx/android/saves/SaveFs.kt | 44 +++++++++--------- .../org/kenjinx/android/views/HomeViews.kt | 46 +++++++++---------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt index ea787ba12..9c5d4ef54 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt @@ -30,15 +30,15 @@ private fun isHex16(name: String): Boolean = data class SaveFolderMeta( val dir: File, - val indexHex: String, // z.B. 0000000000000008 oder 000000000000000a - val titleId: String?, // aus TITLEID.txt (lowercase) oder geerbter Wert - val titleName: String?, // zweite Zeile aus TITLEID.txt, falls vorhanden - val hasMarker: Boolean // true, wenn dieser Ordner die TITLEID.txt selbst hat + val indexHex: String, // e.g., 0000000000000008 or 000000000000000a + val titleId: String?, // from TITLEID.txt (lowercase) or inherited value + val titleName: String?, // second line from TITLEID.txt, if present + val hasMarker: Boolean // true if this folder itself contains TITLEID.txt ) /** - * 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. + * Scans .../bis/user/save; assigns numbered folders (16 hex chars) via “marker inheritance”: + * A folder without TITLEID.txt belongs to the most recent preceding folder that does contain TITLEID.txt. */ fun listSaveFolders(activity: Activity): List { val root = savesRootExternal(activity) @@ -72,18 +72,18 @@ fun listSaveFolders(activity: Activity): List { return out } -/* ===== TitleID-Kandidaten (Base/Update tolerant) & Gruppierung ===== */ +/* ===== TitleID candidates (base/update tolerant) & grouping ===== */ private fun hex16CandidatesForSaves(id: String): List { val lc = id.trim().lowercase(Locale.ROOT) if (!isHex16(lc)) return listOf(lc) - val head = lc.substring(0, 13) // erste 13 Zeichen + val head = lc.substring(0, 13) // first 13 characters val base = head + "000" // ...000 val upd = head + "800" // ...800 return listOf(lc, base, upd).distinct() } -/** Alle Save-Ordner einer TitleID-Gruppe (Marker-Vererbung), Base/Update tolerant. */ +/** All save folders of a TitleID group (marker inheritance), tolerant of base/update IDs. */ private fun listSaveGroupForTitle(activity: Activity, titleId: String): List { val candidates = hex16CandidatesForSaves(titleId) val metas = listSaveFolders(activity) @@ -93,7 +93,7 @@ private fun listSaveGroupForTitle(activity: Activity, titleId: String): List|]"""), "_").trim().ifBlank { "save" } /** - * Schreibt eine ZIP im Format: ZIP enthält Ordner "TITLEID_UPPER/…" und darin - * den reinen Inhalt des Ordners "0" (nicht den Ordner 0 selbst). + * Writes a ZIP with the structure: the ZIP contains folder "TITLEID_UPPER/…" and inside it + * the raw contents of folder "0" (not the folder "0" itself). */ fun exportSaveToZip( activity: Activity, @@ -168,7 +168,7 @@ fun exportSaveToZip( } } -/** Hilfsname für CreateDocument: "_save_YYYY-MM-DD.zip" (aus Marker-Ordner) */ +/** Helper name for CreateDocument: "_save_YYYY-MM-DD.zip" (derived from marker folder) */ fun buildSuggestedExportName(activity: Activity, titleId: String): String { val primary = pickSaveDirWithMarker(activity, titleId) val displayName = if (primary != null) { @@ -214,7 +214,7 @@ private fun clearDirectory(dir: File) { } /** - * Erwartet ZIP mit Struktur: TITLEID_UPPER/… – schreibt NUR in den Ordner mit TITLEID.txt. + * Expects a ZIP with structure: TITLEID_UPPER/… — writes ONLY into the folder that has TITLEID.txt. */ fun importSaveFromZip( activity: Activity, @@ -223,11 +223,11 @@ fun importSaveFromZip( ): ImportResult { val cr: ContentResolver = activity.contentResolver - // TitelID-Ordner im ZIP finden + // Find TitleID folder inside the ZIP var titleIdFromZip: String? = null var totalBytes = 0L - // Pass 1: Top-Level TitelID und Total ermitteln + // Pass 1: determine top-level TitleID and total size runCatching { cr.openInputStream(zipUri)?.use { ins -> ZipInputStream(BufferedInputStream(ins)).use { zis -> @@ -248,7 +248,7 @@ fun importSaveFromZip( if (titleIdFromZip == null) return ImportResult(false, "error importing save. missing TITLEID folder") val tidZip = titleIdFromZip!!.lowercase(Locale.ROOT) - // Größe nur unterhalb //… summieren + // Sum size only for paths under //… runCatching { cr.openInputStream(zipUri)?.use { ins -> ZipInputStream(BufferedInputStream(ins)).use { zis -> @@ -264,18 +264,18 @@ fun importSaveFromZip( } } - // Ziel: NUR der Marker-Ordner + // Target: ONLY the marker folder val targetRoot = pickSaveDirWithMarker(activity, tidZip) ?: pickSaveDirWithMarker(activity, tidZip.uppercase(Locale.ROOT)) ?: return ImportResult(false, "error importing save. start game once.") - // 0/1 vorbereiten (leeren) + // Prepare 0/1 (clear) val zero = File(targetRoot, "0").apply { mkdirs() } val one = File(targetRoot, "1").apply { mkdirs() } clearDirectory(zero) clearDirectory(one) - // Pass 2: extrahieren + // Pass 2: extract var written = 0L val buf = ByteArray(DEFAULT_BUFFER_SIZE) @@ -296,13 +296,13 @@ fun importSaveFromZip( out1.parentFile?.mkdirs() if (!ensureInside(zero, out0) || !ensureInside(one, out1)) { - // Zip-Slip Schutz + // Zip-slip protection zis.closeEntry() ze = zis.nextEntry continue } - // Einmal lesen, zweimal schreiben + // Read once, write twice val os0 = out0.outputStream() val os1 = out1.outputStream() try { 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 12c0fd2aa..53272eb83 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 @@ -127,7 +127,7 @@ class HomeViews { var isFabVisible by remember { mutableStateOf(true) } val isNavigating = remember { mutableStateOf(false) } - // Save Manager State + // Save Manager state val openSavesDialog = remember { mutableStateOf(false) } val saveImportBusy = remember { mutableStateOf(false) } val saveExportBusy = remember { mutableStateOf(false) } @@ -143,7 +143,7 @@ class HomeViews { contract = ActivityResultContracts.OpenDocument() ) { uri: Uri? -> val act = activity - // Guard auf ausgewähltes Spiel – optional + // Optional guard: ensure a selected game val tIdNow = viewModel.mainViewModel?.selected?.titleId.orEmpty() if (uri != null && act != null && tIdNow.isNotEmpty()) { saveImportBusy.value = true @@ -195,7 +195,7 @@ class HomeViews { val context = LocalContext.current - // NEW: Launcher für Amiibo (OpenDocument) + // NEW: Launcher for Amiibo (OpenDocument) val pickAmiiboLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { uri: Uri? -> @@ -220,7 +220,7 @@ class HomeViews { } } - // NEW: Cheats Import (.txt) + // NEW: Cheats import (.txt) val importCheatLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { uri: Uri? -> @@ -228,7 +228,7 @@ class HomeViews { val act = viewModel.activity val titleId = gm?.titleId ?: "" if (uri != null && act != null && titleId.isNotEmpty()) { - // nur .txt akzeptieren + // accept only .txt val okExt = runCatching { DocumentFile.fromSingleUri(act, uri)?.name?.lowercase()?.endsWith(".txt") == true }.getOrElse { false } @@ -240,7 +240,7 @@ class HomeViews { val res = importCheatTxt(act, titleId, uri) if (res.isSuccess) { Toast.makeText(act, "Imported: ${res.getOrNull()?.name}", Toast.LENGTH_SHORT).show() - // danach Liste aktualisieren + // then refresh list cheatsForSelected.value = loadCheatsFromDisk(act, titleId) } else { Toast.makeText(act, "Import failed: ${res.exceptionOrNull()?.message}", Toast.LENGTH_LONG).show() @@ -255,7 +255,7 @@ class HomeViews { val act = viewModel.activity val titleId = gm?.titleId ?: "" if (uri != null && act != null && titleId.isNotEmpty()) { - // Persist permission (lesen) + // Persist permission (read) try { act.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (_: Exception) {} @@ -277,7 +277,7 @@ class HomeViews { "Copying… ${(prog.fraction * 100).toInt()}%" } - // Liste aktualisieren + // refresh list modsForSelected.value = listMods(act, titleId) modsImportBusy.value = false @@ -438,7 +438,7 @@ class HomeViews { Icon(Icons.Filled.Settings, contentDescription = "Settings") } - } + } OutlinedTextField( value = query.value, @@ -453,13 +453,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, + ) ) } }, @@ -711,12 +711,12 @@ class HomeViews { modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - // LINKS: Import .txt + // LEFT: Import .txt TextButton(onClick = { importCheatLauncher.launch(arrayOf("text/plain", "text/*", "*/*")) }) { Text("Import .txt") } - // RECHTS: Cancel + Save + // RIGHT: Cancel + Save Row { TextButton(onClick = { openCheatsDialog.value = false }) { Text("Cancel") } TextButton(onClick = { @@ -803,7 +803,7 @@ class HomeViews { modifier = Modifier.padding(bottom = 8.dp) ) - // Import-Zeile + // Import row Row( modifier = Modifier .fillMaxWidth() @@ -833,7 +833,7 @@ class HomeViews { ) } - // Liste der Mods + // List of mods if (modsForSelected.value.isEmpty()) { Text("No mods found for this title.") } else { @@ -898,7 +898,7 @@ class HomeViews { ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - // Import-Button + // Import button androidx.compose.material3.Button( enabled = !saveImportBusy.value && !saveExportBusy.value && (viewModel.mainViewModel?.selected?.titleId?.isNotEmpty() == true), @@ -909,7 +909,7 @@ class HomeViews { } ) { Text("Import ZIP") } - // Export-Button + // Export button androidx.compose.material3.Button( enabled = !saveImportBusy.value && !saveExportBusy.value && (viewModel.mainViewModel?.selected?.titleId?.isNotEmpty() == true), @@ -953,7 +953,7 @@ class HomeViews { } } - // --- Shortcut-Dialog + // --- Shortcut dialog if (showShortcutDialog.value) { val gm = viewModel.mainViewModel?.selected AlertDialog(