diff --git a/common/FileSystem.cpp b/common/FileSystem.cpp index 0e549ca93c..d0fc3adc6b 100644 --- a/common/FileSystem.cpp +++ b/common/FileSystem.cpp @@ -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; } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardScreen.kt index b0ddd4975f..64520cd7b0 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardScreen.kt @@ -55,6 +55,10 @@ fun MemoryCardScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Memo var createDialog by remember { mutableStateOf(false) } var deleteTarget by remember { mutableStateOf(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, diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardViewModel.kt b/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardViewModel.kt index b4f0514c2b..28e39c4a7f 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardViewModel.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardViewModel.kt @@ -60,6 +60,19 @@ class MemoryCardViewModel(application: Application) : AndroidViewModel(applicati fun import(uri: Uri) { val context = getApplication() 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//. 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() + 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() + 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().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.")