mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
Android storage: fix folder memory cards on a custom data folder
libc mkdir() is denied on the FUSE-backed emulated storage Android hands out for a user-chosen data folder, while java.io.File.mkdirs() on the same path succeeds. FileSystem::CreateDirectoryPath went straight to mkdir() and returned failure, so every folder-memory-card save-data creation failed: "Format failed", and a crash on first save in Soul Calibur 2 / Ratchet & Clank / GT4. Reproduced only with a custom data folder, never with internal app storage. A Java bridge for exactly this existed (NativeApp.createDirectoryPath plus the FileSystem::CreateDirectoryViaJava JNI) but nothing called it after the monorepo migration - the linker was dropping it as dead code. Wire it in as a fallback on EPERM/EACCES, in both the flat and per-segment recursive paths. Also adds folder-card import, which had no working route at all: a folder card is a directory plus a _pcsx2_superblock marker, but the picker was OpenDocument() (files only), so people zipped them and the importer appended ".ps2" to the archive and copied it verbatim - producing a card the core read as unformatted. Directories can now be imported directly, zips are unpacked, and both validate the superblock instead of silently producing a broken card.
This commit is contained in:
@@ -2507,6 +2507,21 @@ bool FileSystem::CreateDirectoryPath(const char* path, bool recursive, Error* er
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef __ANDROID__
|
||||
// libc mkdir() is DENIED on the FUSE-backed emulated storage Android hands out for a
|
||||
// user-chosen data folder, while the Java File.mkdirs() path on the same directory
|
||||
// succeeds — the syscall and the SAF/MediaProvider layer disagree about permission.
|
||||
// Folder memory cards are the visible casualty: every save-data creation goes through
|
||||
// here, so "Format failed" / crash-on-first-save reproduced ONLY with a custom data
|
||||
// folder and never with internal app storage. The bridge below already existed and was
|
||||
// implemented in the JNI, but nothing called it after the monorepo migration.
|
||||
if (lastError == EPERM || lastError == EACCES)
|
||||
{
|
||||
if (CreateDirectoryViaJava(path))
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!recursive)
|
||||
{
|
||||
Error::SetErrno(error, "mkdir() failed: ", lastError);
|
||||
@@ -2530,6 +2545,14 @@ bool FileSystem::CreateDirectoryPath(const char* path, bool recursive, Error* er
|
||||
lastError = errno;
|
||||
if (lastError != EEXIST) // fine, continue to next path segment
|
||||
{
|
||||
#ifdef __ANDROID__
|
||||
// Same FUSE mkdir denial as above, per path segment.
|
||||
if ((lastError == EPERM || lastError == EACCES) &&
|
||||
CreateDirectoryViaJava(tempPath.c_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
#endif
|
||||
Error::SetErrno(error, "mkdir() failed: ", lastError);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ fun MemoryCardScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Memo
|
||||
var createDialog by remember { mutableStateOf(false) }
|
||||
var deleteTarget by remember { mutableStateOf<MemoryCardItem?>(null) }
|
||||
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.let(viewModel::import) }
|
||||
// Folder memory cards are directories, which OpenDocument() cannot return — without
|
||||
// this there was no way to import one at all, and zipping it produced a "card.zip.ps2"
|
||||
// that read as unformatted.
|
||||
val folderImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> uri?.let(viewModel::importFolder) }
|
||||
LaunchedEffect(Unit) { viewModel.refresh() }
|
||||
|
||||
ArmsBackdrop {
|
||||
@@ -70,6 +74,7 @@ fun MemoryCardScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Memo
|
||||
actions = {
|
||||
RoundAction("+", str("memcard.newCard"), { createDialog = true })
|
||||
RoundAction("⇩", str("action.import"), { importer.launch(arrayOf("application/octet-stream", "*/*")) })
|
||||
RoundAction("▣", str("memcard.importFolder"), { folderImporter.launch(null) })
|
||||
RoundAction("↻", str("games.card.refresh"), viewModel::refresh)
|
||||
},
|
||||
horizontalPadding = 0.dp,
|
||||
|
||||
@@ -60,6 +60,19 @@ class MemoryCardViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun import(uri: Uri) {
|
||||
val context = getApplication<Application>()
|
||||
val name = DocumentFile.fromSingleUri(context, uri)?.name?.ifBlank { null } ?: "Imported.ps2"
|
||||
|
||||
// A FOLDER memory card is a directory of save folders plus a "_pcsx2_superblock"
|
||||
// marker, so it cannot travel as a single file — people share them zipped. The old
|
||||
// path appended ".ps2" to whatever was picked and copied the bytes verbatim, so a
|
||||
// zip landed as "card.zip.ps2" full of zip data: the core read it as an unformatted
|
||||
// file card, and formatting it then failed too. Unpack instead, into a real folder
|
||||
// card. (Directory picking is handled by importFolder below — OpenDocument() can
|
||||
// only return files.)
|
||||
if (name.endsWith(".zip", true)) {
|
||||
importZip(uri, name)
|
||||
return
|
||||
}
|
||||
|
||||
val requested = if (name.endsWith(".ps2", true)) name else "$name.ps2"
|
||||
val target = uniqueFile(cardDirectory(), requested)
|
||||
val success = runCatching {
|
||||
@@ -72,6 +85,89 @@ class MemoryCardViewModel(application: Application) : AndroidViewModel(applicati
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Unpack a zipped folder memory card into cards/<name>/. Tolerates the two common
|
||||
* shapes: entries at the archive root, or nested under a single top-level directory. */
|
||||
private fun importZip(uri: Uri, zipName: String) {
|
||||
val context = getApplication<Application>()
|
||||
val target = uniqueFile(cardDirectory(), zipName.substringBeforeLast('.'))
|
||||
val ok = runCatching {
|
||||
target.mkdirs()
|
||||
context.contentResolver.openInputStream(uri)?.use { raw ->
|
||||
java.util.zip.ZipInputStream(raw.buffered()).use { zin ->
|
||||
var entry = zin.nextEntry
|
||||
while (entry != null) {
|
||||
// Strip a single wrapping directory so "Mcd001/_pcsx2_superblock"
|
||||
// and "_pcsx2_superblock" both land correctly.
|
||||
val rel = entry.name.replace('\\', '/').trimStart('/')
|
||||
val stripped = if (rel.count { it == '/' } > 0 && !rel.startsWith("_pcsx2_"))
|
||||
rel.substringAfter('/') else rel
|
||||
if (stripped.isNotBlank()) {
|
||||
val out = File(target, stripped)
|
||||
// Zip-slip guard: never write outside the card directory.
|
||||
if (out.canonicalPath.startsWith(target.canonicalPath + File.separator) ||
|
||||
out.canonicalPath == target.canonicalPath) {
|
||||
if (entry.isDirectory) {
|
||||
out.mkdirs()
|
||||
} else {
|
||||
out.parentFile?.mkdirs()
|
||||
out.outputStream().use { zin.copyTo(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
zin.closeEntry()
|
||||
entry = zin.nextEntry
|
||||
}
|
||||
}
|
||||
} ?: error("Unable to read the selected file.")
|
||||
// A folder card is only valid with its superblock; without it the core would
|
||||
// report "unformatted" exactly as before, so fail loudly here instead.
|
||||
File(target, "_pcsx2_superblock").isFile
|
||||
}.getOrDefault(false)
|
||||
if (!ok) target.deleteRecursively()
|
||||
state.value = if (ok) {
|
||||
state.value.copy(message = "Imported folder card ${target.name}.")
|
||||
} else {
|
||||
state.value.copy(error = "That zip isn't a folder memory card (no _pcsx2_superblock inside).")
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Import a folder memory card straight from a directory the user picks. */
|
||||
fun importFolder(uri: Uri) {
|
||||
val context = getApplication<Application>()
|
||||
val source = DocumentFile.fromTreeUri(context, uri)
|
||||
if (source == null || !source.isDirectory) {
|
||||
state.value = state.value.copy(error = "Unable to open the selected folder.")
|
||||
return
|
||||
}
|
||||
val target = uniqueFile(cardDirectory(), source.name?.ifBlank { null } ?: "Imported")
|
||||
val ok = runCatching {
|
||||
target.mkdirs()
|
||||
copyTree(source, target)
|
||||
File(target, "_pcsx2_superblock").isFile
|
||||
}.getOrDefault(false)
|
||||
if (!ok) target.deleteRecursively()
|
||||
state.value = if (ok) {
|
||||
state.value.copy(message = "Imported folder card ${target.name}.")
|
||||
} else {
|
||||
state.value.copy(error = "That folder isn't a memory card (no _pcsx2_superblock inside).")
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun copyTree(source: DocumentFile, destination: File) {
|
||||
source.listFiles().forEach { child ->
|
||||
val childName = child.name ?: return@forEach
|
||||
if (child.isDirectory) {
|
||||
copyTree(child, File(destination, childName).apply { mkdirs() })
|
||||
} else if (child.isFile) {
|
||||
getApplication<Application>().contentResolver.openInputStream(child.uri)?.use { input ->
|
||||
File(destination, childName).outputStream().use(input::copyTo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun assign(slot: Int, item: MemoryCardItem) {
|
||||
if (!MainActivityRuntime.nativeReady.value) {
|
||||
state.value = state.value.copy(error = "The emulator core is still starting.")
|
||||
|
||||
Reference in New Issue
Block a user