mirror of
https://git.ryujinx.app/kenji-nx/ryujinx.git
synced 2025-12-13 22:37:07 +00:00
Merge branch 'libryujinx_bionic_SaveManager' into 'libryujinx_bionic'
Added SaveManager See merge request kenji-nx/ryujinx!12
This commit is contained in:
commit
9b108816d2
2 changed files with 869 additions and 8 deletions
|
|
@ -0,0 +1,342 @@
|
||||||
|
package org.kenjinx.android.saves
|
||||||
|
|
||||||
|
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.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/<pkg>/files/bis/user/save
|
||||||
|
val base = activity.getExternalFilesDir(null)
|
||||||
|
return File(base, "bis/user/save")
|
||||||
|
}
|
||||||
|
|
||||||
|
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, // 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
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<SaveFolderMeta> {
|
||||||
|
val root = savesRootExternal(activity)
|
||||||
|
if (!root.exists()) return emptyList()
|
||||||
|
|
||||||
|
val dirs = root.listFiles { f -> f.isDirectory && isHex16(f.name) }
|
||||||
|
?.sortedBy { it.name.lowercase(Locale.ROOT) }
|
||||||
|
?: return emptyList()
|
||||||
|
|
||||||
|
val out = ArrayList<SaveFolderMeta>(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(Locale.ROOT)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TitleID candidates (base/update tolerant) & grouping ===== */
|
||||||
|
|
||||||
|
private fun hex16CandidatesForSaves(id: String): List<String> {
|
||||||
|
val lc = id.trim().lowercase(Locale.ROOT)
|
||||||
|
if (!isHex16(lc)) return listOf(lc)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All save folders of a TitleID group (marker inheritance), tolerant of base/update IDs. */
|
||||||
|
private fun listSaveGroupForTitle(activity: Activity, titleId: String): List<SaveFolderMeta> {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer the folder with TITLEID.txt; fallback: lexicographically smallest in the group. */
|
||||||
|
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 ============================== */
|
||||||
|
|
||||||
|
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" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
titleId: String,
|
||||||
|
destUri: Uri,
|
||||||
|
onProgress: (ExportProgress) -> Unit
|
||||||
|
): ExportResult {
|
||||||
|
val tidUpper = titleId.trim().uppercase(Locale.ROOT)
|
||||||
|
val primary = pickSaveDirWithMarker(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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper name for CreateDocument: "<Name>_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) {
|
||||||
|
val txt = File(primary, "TITLEID.txt")
|
||||||
|
runCatching {
|
||||||
|
txt.takeIf { it.exists() }?.readLines(Charset.forName("UTF-8"))?.getOrNull(1)
|
||||||
|
}.getOrNull()?.takeIf { it.isNotBlank() } ?: "Save"
|
||||||
|
} else {
|
||||||
|
"Save"
|
||||||
|
}
|
||||||
|
val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
|
||||||
|
return sanitizeFileName("${displayName}_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)
|
||||||
|
|
||||||
|
/* 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expects a ZIP with structure: TITLEID_UPPER/… — writes ONLY into the folder that has TITLEID.txt.
|
||||||
|
*/
|
||||||
|
fun importSaveFromZip(
|
||||||
|
activity: Activity,
|
||||||
|
zipUri: Uri,
|
||||||
|
onProgress: (ImportProgress) -> Unit
|
||||||
|
): ImportResult {
|
||||||
|
val cr: ContentResolver = activity.contentResolver
|
||||||
|
|
||||||
|
// Find TitleID folder inside the ZIP
|
||||||
|
var titleIdFromZip: String? = null
|
||||||
|
var totalBytes = 0L
|
||||||
|
|
||||||
|
// Pass 1: determine top-level TitleID and total size
|
||||||
|
runCatching {
|
||||||
|
cr.openInputStream(zipUri)?.use { ins ->
|
||||||
|
ZipInputStream(BufferedInputStream(ins)).use { zis ->
|
||||||
|
val tops = mutableSetOf<String>()
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleIdFromZip == null) return ImportResult(false, "error importing save. missing TITLEID folder")
|
||||||
|
val tidZip = titleIdFromZip!!.lowercase(Locale.ROOT)
|
||||||
|
|
||||||
|
// Sum size only for paths under /<TITLEID>/…
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.")
|
||||||
|
|
||||||
|
// Prepare 0/1 (clear)
|
||||||
|
val zero = File(targetRoot, "0").apply { mkdirs() }
|
||||||
|
val one = File(targetRoot, "1").apply { mkdirs() }
|
||||||
|
clearDirectory(zero)
|
||||||
|
clearDirectory(one)
|
||||||
|
|
||||||
|
// Pass 2: extract
|
||||||
|
var written = 0L
|
||||||
|
val buf = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
|
||||||
|
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 protection
|
||||||
|
zis.closeEntry()
|
||||||
|
ze = zis.nextEntry
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read once, write twice
|
||||||
|
val os0 = out0.outputStream()
|
||||||
|
val os1 = out1.outputStream()
|
||||||
|
try {
|
||||||
|
var n = zis.read(buf)
|
||||||
|
while (n > 0) {
|
||||||
|
os0.write(buf, 0, n)
|
||||||
|
os1.write(buf, 0, n)
|
||||||
|
written += n
|
||||||
|
onProgress(ImportProgress(written, totalBytes, rel))
|
||||||
|
n = zis.read(buf)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runCatching { os0.close() }
|
||||||
|
runCatching { os1.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zis.closeEntry()
|
||||||
|
ze = zis.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 ============================ */
|
||||||
|
|
||||||
|
fun suggestedCreateDocNameForExport(activity: Activity, titleId: String): String =
|
||||||
|
buildSuggestedExportName(activity, titleId)
|
||||||
|
|
@ -88,6 +88,9 @@ import org.kenjinx.android.viewmodels.HomeViewModel
|
||||||
import org.kenjinx.android.viewmodels.QuickSettings
|
import org.kenjinx.android.viewmodels.QuickSettings
|
||||||
import org.kenjinx.android.widgets.SimpleAlertDialog
|
import org.kenjinx.android.widgets.SimpleAlertDialog
|
||||||
|
|
||||||
|
// NEW: Saves
|
||||||
|
import org.kenjinx.android.saves.*
|
||||||
|
|
||||||
class HomeViews {
|
class HomeViews {
|
||||||
companion object {
|
companion object {
|
||||||
const val ListImageSize = 150
|
const val ListImageSize = 150
|
||||||
|
|
@ -124,8 +127,202 @@ class HomeViews {
|
||||||
var isFabVisible by remember { mutableStateOf(true) }
|
var isFabVisible by remember { mutableStateOf(true) }
|
||||||
val isNavigating = remember { mutableStateOf(false) }
|
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
|
||||||
|
|
||||||
|
// Import: OpenDocument (ZIP)
|
||||||
|
val importZipLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
val act = activity
|
||||||
|
// Optional guard: ensure a selected game
|
||||||
|
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(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(act, res.message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export: CreateDocument (ZIP)
|
||||||
|
val exportZipLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/zip")
|
||||||
|
) { uri: Uri? ->
|
||||||
|
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(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}"
|
||||||
|
}
|
||||||
|
saveExportBusy.value = false
|
||||||
|
launchOnUiThread {
|
||||||
|
Toast.makeText(
|
||||||
|
act,
|
||||||
|
if (res.ok) "save exported" else (res.error ?: "export failed"),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// NEW: Launcher for 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()) {
|
||||||
|
// accept only .txt
|
||||||
|
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()
|
||||||
|
// then refresh list
|
||||||
|
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 (read)
|
||||||
|
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()}%"
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh list
|
||||||
|
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 {
|
val nestedScrollConnection = remember {
|
||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||||
|
|
@ -241,7 +438,7 @@ class HomeViews {
|
||||||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = query.value,
|
value = query.value,
|
||||||
|
|
@ -256,13 +453,13 @@ class HomeViews {
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedContainerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
unfocusedContainerColor = Color.Transparent,
|
unfocusedContainerColor = Color.Transparent,
|
||||||
disabledContainerColor = Color.Transparent,
|
disabledContainerColor = Color.Transparent,
|
||||||
errorContainerColor = Color.Transparent,
|
errorContainerColor = Color.Transparent,
|
||||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -500,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
|
||||||
|
) {
|
||||||
|
// LEFT: Import .txt
|
||||||
|
TextButton(onClick = {
|
||||||
|
importCheatLauncher.launch(arrayOf("text/plain", "text/*", "*/*"))
|
||||||
|
}) { Text("Import .txt") }
|
||||||
|
|
||||||
|
// RIGHT: 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 row
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List of 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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue