Add memory cards option to import or export memory cards

This commit is contained in:
izzy2lost
2025-10-27 21:28:14 -04:00
parent cbdf947673
commit c6ef4ac95b
13 changed files with 937 additions and 0 deletions
+5
View File
@@ -23,3 +23,8 @@ build/
*.aab
*.properties
release/
mymc-pysrc-2.7/
MEMORY_CARD_GUIDE.md
MEMCARD_QUICK_REFERENCE.md
MEMCARD_FEATURE_SUMMARY.md
MEMCARD_ARCHITECTURE.md
+101
View File
@@ -1638,3 +1638,104 @@ static std::vector<std::string> SafListFilesJNI(const char* subdir)
env->DeleteLocalRef(arr);
return ret;
}
// Get list of saves on a memory card using PCSX2's native parsing
extern "C"
JNIEXPORT jobjectArray JNICALL
Java_com_izzy2lost_psx2_NativeApp_getMemoryCardSaves(JNIEnv* env, jclass, jstring p_memcard_path)
{
if (!p_memcard_path) {
return env->NewObjectArray(0, env->FindClass("java/lang/String"), nullptr);
}
const char* path_chars = env->GetStringUTFChars(p_memcard_path, nullptr);
if (!path_chars) {
return env->NewObjectArray(0, env->FindClass("java/lang/String"), nullptr);
}
std::string memcard_path(path_chars);
env->ReleaseStringUTFChars(p_memcard_path, path_chars);
std::vector<std::string> saves;
// Open the memory card file
auto fp = FileSystem::OpenManagedCFile(memcard_path.c_str(), "rb");
if (!fp) {
return env->NewObjectArray(0, env->FindClass("java/lang/String"), nullptr);
}
// Read superblock to get root directory cluster
u8 superblock[512];
if (std::fread(superblock, 1, 512, fp.get()) != 512) {
return env->NewObjectArray(0, env->FindClass("java/lang/String"), nullptr);
}
// Extract alloc_offset (at 0x34) and rootdir_cluster (at 0x3C)
u32 alloc_offset = *(u32*)&superblock[0x34];
u32 rootdir_cluster = *(u32*)&superblock[0x3C];
// Calculate directory start position (each cluster is 1024 bytes)
u64 dir_start = (u64)(alloc_offset + rootdir_cluster) * 1024;
// Seek to directory
if (FileSystem::FSeek64(fp.get(), dir_start, SEEK_SET) != 0) {
return env->NewObjectArray(0, env->FindClass("java/lang/String"), nullptr);
}
// Read directory entries (each entry is 512 bytes)
for (int i = 0; i < 100; i++) {
u8 entry[512];
if (std::fread(entry, 1, 512, fp.get()) != 512) break;
// Read mode (first 4 bytes)
u32 mode = *(u32*)&entry[0];
// Skip empty entries
if (mode == 0 || mode == 0xFFFFFFFF) continue;
// Check if used (0x8000 flag)
if (!(mode & 0x8000)) continue;
// Read filename (at offset 0x40, max 32 bytes)
u8 name_bytes[32];
std::memcpy(name_bytes, &entry[0x40], 32);
// Convert to string, stopping at null terminator
std::string name_str;
for (int j = 0; j < 32; j++) {
if (name_bytes[j] == 0) break;
// Only include printable ASCII
if (name_bytes[j] >= 32 && name_bytes[j] <= 126) {
name_str += (char)name_bytes[j];
}
}
// Skip "." and ".." entries
if (name_str == "." || name_str == "..") continue;
if (name_str.empty()) continue;
// Read length field (at offset 0x04)
u32 length = *(u32*)&entry[0x04];
// Check if it's a directory (0x0020 flag)
bool is_dir = (mode & 0x0020) != 0;
// Basic sanity check - skip if length is suspiciously large
if (length > 1000000000) continue; // 1 billion is clearly wrong
// Format: "filename|size|isDirectory"
std::string save_info = StringUtil::StdStringFromFormat("%s|%u|%d",
name_str.c_str(), length, is_dir ? 1 : 0);
saves.push_back(save_info);
}
// Convert to Java string array
jobjectArray result = env->NewObjectArray(saves.size(), env->FindClass("java/lang/String"), nullptr);
for (size_t i = 0; i < saves.size(); i++) {
jstring str = env->NewStringUTF(saves[i].c_str());
env->SetObjectArrayElement(result, i, str);
env->DeleteLocalRef(str);
}
return result;
}
@@ -526,6 +526,12 @@ public class GamesCoverDialogFragment extends DialogFragment {
try { new SavesDialogFragment().show(getParentFragmentManager(), "saves_dialog"); } catch (Throwable ignored) {}
});
}
View btnMemcardManager = header.findViewById(R.id.drawer_btn_memcard_manager);
if (btnMemcardManager != null) {
btnMemcardManager.setOnClickListener(v -> {
try { new MemoryCardManagerDialogFragment().show(getParentFragmentManager(), "memcard_manager_dialog"); } catch (Throwable ignored) {}
});
}
View btnAbout = header.findViewById(R.id.drawer_btn_about);
if (btnAbout != null) {
btnAbout.setOnClickListener(v -> {
@@ -665,6 +665,15 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
} catch (Throwable ignored) {}
});
}
View btnMemcardManager = header.findViewById(R.id.drawer_btn_memcard_manager);
if (btnMemcardManager != null) {
btnMemcardManager.setOnClickListener(v -> {
try {
MemoryCardManagerDialogFragment dialog = new MemoryCardManagerDialogFragment();
dialog.show(getSupportFragmentManager(), "memcard_manager_dialog");
} catch (Throwable ignored) {}
});
}
View btnAbout = header.findViewById(R.id.drawer_btn_about);
if (btnAbout != null) {
btnAbout.setOnClickListener(v -> {
@@ -0,0 +1,355 @@
package com.izzy2lost.psx2;
import android.app.Activity;
import android.app.Dialog;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.documentfile.provider.DocumentFile;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
public class MemoryCardManagerDialogFragment extends DialogFragment {
private ListView memcardListView;
private ArrayAdapter<String> memcardAdapter;
private List<String> memcardFiles;
private ActivityResultLauncher<Intent> importLauncher;
private ActivityResultLauncher<Intent> exportLauncher;
private String selectedMemcard;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Import launcher - pick memory card files to import
importLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
handleImport(result.getData());
}
}
);
// Export launcher - pick destination folder
exportLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
handleExport(result.getData());
}
}
);
}
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
View view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_memcard_manager, null);
memcardListView = view.findViewById(R.id.memcard_list);
MaterialButton btnImport = view.findViewById(R.id.btn_import_memcard);
MaterialButton btnExport = view.findViewById(R.id.btn_export_memcard);
// Load existing memory cards
loadMemoryCards();
memcardAdapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_list_item_single_choice, memcardFiles);
memcardListView.setAdapter(memcardAdapter);
memcardListView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
memcardListView.setOnItemClickListener((parent, v, position, id) -> {
selectedMemcard = memcardFiles.get(position);
});
btnImport.setOnClickListener(v -> showImportDialog());
btnExport.setOnClickListener(v -> {
if (selectedMemcard == null) {
Toast.makeText(requireContext(), "Select a memory card first", Toast.LENGTH_SHORT).show();
} else {
showExportDialog();
}
});
return new MaterialAlertDialogBuilder(requireContext())
.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "MEMORY CARD MANAGER"))
.setView(view)
.setNegativeButton("Close", null)
.create();
}
private void loadMemoryCards() {
memcardFiles = new ArrayList<>();
// Always use the internal Android/data location where the emulator actually stores memory cards
File memcardDir = new File(requireContext().getExternalFilesDir(null), "memcards");
if (memcardDir.exists() && memcardDir.isDirectory()) {
File[] files = memcardDir.listFiles();
if (files != null) {
for (File file : files) {
if (file.getName().toLowerCase().endsWith(".ps2")) {
memcardFiles.add(file.getName());
}
}
}
}
}
private void showImportDialog() {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
intent.addCategory(Intent.CATEGORY_OPENABLE);
importLauncher.launch(intent);
}
private void handleImport(Intent data) {
try {
if (data.getClipData() != null) {
// Multiple files
int count = data.getClipData().getItemCount();
int imported = 0;
for (int i = 0; i < count; i++) {
Uri uri = data.getClipData().getItemAt(i).getUri();
if (importMemoryCard(uri)) {
imported++;
}
}
Toast.makeText(requireContext(), "Imported " + imported + " memory card(s)", Toast.LENGTH_SHORT).show();
} else if (data.getData() != null) {
// Single file
if (importMemoryCard(data.getData())) {
Toast.makeText(requireContext(), "Memory card imported successfully", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(requireContext(), "Failed to import memory card", Toast.LENGTH_SHORT).show();
}
}
loadMemoryCards();
memcardAdapter.clear();
memcardAdapter.addAll(memcardFiles);
memcardAdapter.notifyDataSetChanged();
} catch (Exception e) {
Toast.makeText(requireContext(), "Error importing: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
private boolean importMemoryCard(Uri sourceUri) {
try {
String filename = getFileName(sourceUri);
if (filename == null || !filename.toLowerCase().endsWith(".ps2")) {
return false;
}
InputStream in = requireContext().getContentResolver().openInputStream(sourceUri);
if (in == null) return false;
// Always import to internal Android/data location where emulator uses them
File memcardDir = new File(requireContext().getExternalFilesDir(null), "memcards");
if (!memcardDir.exists()) memcardDir.mkdirs();
File destFile = new File(memcardDir, filename);
boolean success;
try (OutputStream out = new FileOutputStream(destFile)) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
success = true;
}
in.close();
return success;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
private void showExportDialog() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
exportLauncher.launch(intent);
}
private void handleExport(Intent data) {
try {
Uri treeUri = data.getData();
if (treeUri == null || selectedMemcard == null) return;
DocumentFile destDir = DocumentFile.fromTreeUri(requireContext(), treeUri);
if (destDir == null) return;
DocumentFile destFile = destDir.createFile("application/octet-stream", selectedMemcard);
if (destFile == null) return;
// Always export from internal Android/data location
File memcardFile = new File(new File(requireContext().getExternalFilesDir(null), "memcards"), selectedMemcard);
InputStream in = new FileInputStream(memcardFile);
if (in == null) {
Toast.makeText(requireContext(), "Failed to read memory card", Toast.LENGTH_SHORT).show();
return;
}
OutputStream out = requireContext().getContentResolver().openOutputStream(destFile.getUri());
if (out == null) {
in.close();
Toast.makeText(requireContext(), "Failed to write to destination", Toast.LENGTH_SHORT).show();
return;
}
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
in.close();
out.close();
Toast.makeText(requireContext(), "Memory card exported successfully", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
Toast.makeText(requireContext(), "Export failed: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
private void showDeleteConfirmation() {
new MaterialAlertDialogBuilder(requireContext())
.setTitle("Delete Memory Card")
.setMessage("Are you sure you want to delete " + selectedMemcard + "? This cannot be undone.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Delete", (dialog, which) -> deleteMemoryCard())
.show();
}
private void deleteMemoryCard() {
try {
// Always delete from internal Android/data location
File memcardFile = new File(new File(requireContext().getExternalFilesDir(null), "memcards"), selectedMemcard);
boolean success = memcardFile.delete();
if (success) {
Toast.makeText(requireContext(), "Memory card deleted", Toast.LENGTH_SHORT).show();
selectedMemcard = null;
loadMemoryCards();
memcardAdapter.clear();
memcardAdapter.addAll(memcardFiles);
memcardAdapter.notifyDataSetChanged();
} else {
Toast.makeText(requireContext(), "Failed to delete memory card", Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Toast.makeText(requireContext(), "Error: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
private void showCreateNewDialog() {
// Check which slots are available
boolean slot1Exists = memcardFiles.contains("Mcd001.ps2");
boolean slot2Exists = memcardFiles.contains("Mcd002.ps2");
// Build list of available slots
List<String> availableSlots = new ArrayList<>();
List<String> availableFilenames = new ArrayList<>();
if (!slot1Exists) {
availableSlots.add("Mcd001.ps2 (Slot 1)");
availableFilenames.add("Mcd001.ps2");
}
if (!slot2Exists) {
availableSlots.add("Mcd002.ps2 (Slot 2)");
availableFilenames.add("Mcd002.ps2");
}
if (availableSlots.isEmpty()) {
Toast.makeText(requireContext(), "Both memory card slots are already in use", Toast.LENGTH_SHORT).show();
return;
}
new MaterialAlertDialogBuilder(requireContext())
.setTitle("Create New Memory Card")
.setMessage("Choose a memory card slot:")
.setItems(availableSlots.toArray(new String[0]), (dialog, which) -> {
String filename = availableFilenames.get(which);
createNewMemoryCard(filename);
})
.setNegativeButton("Cancel", null)
.show();
}
private void createNewMemoryCard(String filename) {
try {
// Create an 8MB empty memory card file
byte[] emptyCard = new byte[8 * 1024 * 1024];
// Always create in internal Android/data location
File memcardDir = new File(requireContext().getExternalFilesDir(null), "memcards");
if (!memcardDir.exists()) memcardDir.mkdirs();
File memcardFile = new File(memcardDir, filename);
boolean success;
try (FileOutputStream out = new FileOutputStream(memcardFile)) {
out.write(emptyCard);
success = true;
}
if (success) {
Toast.makeText(requireContext(), "Memory card created: " + filename, Toast.LENGTH_SHORT).show();
loadMemoryCards();
memcardAdapter.clear();
memcardAdapter.addAll(memcardFiles);
memcardAdapter.notifyDataSetChanged();
} else {
Toast.makeText(requireContext(), "Failed to create memory card", Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Toast.makeText(requireContext(), "Error: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
private String getFileName(Uri uri) {
String result = null;
if (uri.getScheme() != null && uri.getScheme().equals("content")) {
try (android.database.Cursor cursor = requireContext().getContentResolver().query(uri, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME);
if (nameIndex >= 0) {
result = cursor.getString(nameIndex);
}
}
}
}
if (result == null) {
result = uri.getPath();
if (result != null) {
int cut = result.lastIndexOf('/');
if (cut != -1) {
result = result.substring(cut + 1);
}
}
}
return result;
}
}
@@ -0,0 +1,293 @@
package com.izzy2lost.psx2;
import android.app.Dialog;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.io.File;
import java.io.RandomAccessFile;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class MemoryCardSavesDialogFragment extends DialogFragment {
private static final String ARG_MEMCARD_NAME = "memcard_name";
public static MemoryCardSavesDialogFragment newInstance(String memcardName) {
MemoryCardSavesDialogFragment fragment = new MemoryCardSavesDialogFragment();
Bundle args = new Bundle();
args.putString(ARG_MEMCARD_NAME, memcardName);
fragment.setArguments(args);
return fragment;
}
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
View view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_memcard_saves, null);
String memcardName = getArguments() != null ? getArguments().getString(ARG_MEMCARD_NAME) : "";
TextView tvMemcardName = view.findViewById(R.id.tv_memcard_name);
TextView tvMemcardInfo = view.findViewById(R.id.tv_memcard_info);
ListView lvSaves = view.findViewById(R.id.lv_saves);
TextView tvNoSaves = view.findViewById(R.id.tv_no_saves);
tvMemcardName.setText(memcardName);
// Get memory card file
File memcardDir = new File(requireContext().getExternalFilesDir(null), "memcards");
File memcardFile = new File(memcardDir, memcardName);
if (memcardFile.exists()) {
// Show file info
long fileSize = memcardFile.length();
long lastModified = memcardFile.lastModified();
SimpleDateFormat sdf = new SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault());
String dateStr = sdf.format(new Date(lastModified));
String info = String.format(Locale.getDefault(),
"Size: %.2f MB\nLast Modified: %s",
fileSize / (1024.0 * 1024.0),
dateStr);
tvMemcardInfo.setText(info);
// Use native PCSX2 code to parse saves
List<String> saves = parseSaveFilesNative(memcardFile);
if (saves.isEmpty()) {
lvSaves.setVisibility(View.GONE);
tvNoSaves.setVisibility(View.VISIBLE);
} else {
lvSaves.setVisibility(View.VISIBLE);
tvNoSaves.setVisibility(View.GONE);
ArrayAdapter<String> adapter = new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_list_item_1,
saves
);
lvSaves.setAdapter(adapter);
}
} else {
tvMemcardInfo.setText("Memory card file not found");
lvSaves.setVisibility(View.GONE);
tvNoSaves.setVisibility(View.VISIBLE);
}
return new MaterialAlertDialogBuilder(requireContext())
.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "MEMORY CARD SAVES"))
.setView(view)
.setPositiveButton("Close", null)
.create();
}
private List<String> parseSaveFilesNative(File memcardFile) {
List<String> saves = new ArrayList<>();
try {
// Call native PCSX2 code to parse the memory card
String[] nativeSaves = NativeApp.getMemoryCardSaves(memcardFile.getAbsolutePath());
if (nativeSaves != null) {
for (String saveInfo : nativeSaves) {
// Format is "filename|size|isDirectory"
String[] parts = saveInfo.split("\\|");
if (parts.length >= 3) {
String filename = parts[0];
int size = Integer.parseInt(parts[1]);
boolean isDir = parts[2].equals("1");
String displayInfo;
if (isDir) {
displayInfo = String.format(Locale.getDefault(),
"%s (%d files)",
filename,
size
);
} else {
displayInfo = String.format(Locale.getDefault(),
"%s (%d KB)",
filename,
size / 1024
);
}
saves.add(displayInfo);
}
}
}
} catch (Exception e) {
android.util.Log.e("MemcardSaves", "Error parsing memory card with native code: " + e.getMessage(), e);
}
return saves;
}
private List<String> parseSaveFilesOld(File memcardFile) {
List<String> saves = new ArrayList<>();
try (RandomAccessFile raf = new RandomAccessFile(memcardFile, "r")) {
android.util.Log.d("MemcardSaves", "Parsing memory card: " + memcardFile.getName() + " (" + memcardFile.length() + " bytes)");
// Verify this is a PS2 memory card by checking magic
byte[] magic = new byte[28];
raf.seek(0);
raf.read(magic);
String magicStr = new String(magic, 0, 28).trim();
android.util.Log.d("MemcardSaves", "Magic: " + magicStr);
// Standard PS2 memory card: directory starts at page 13 (0x1A00)
// Each page is 512 bytes
long[] possibleDirStarts = {
0x1A00, // Standard location (page 13)
0x2000, // Alternative location
0x4000 // Another possible location
};
for (long dirStart : possibleDirStarts) {
if (dirStart >= raf.length()) continue;
android.util.Log.d("MemcardSaves", "Trying directory at offset: 0x" + Long.toHexString(dirStart));
// Scan for directory entries
// Each entry is 512 bytes
int foundCount = 0;
for (int i = 0; i < 100; i++) {
long entryPos = dirStart + (i * 512);
if (entryPos + 512 > raf.length()) break;
raf.seek(entryPos);
// Read entry mode (first 4 bytes, little-endian)
int mode = readInt32LE(raf);
// Skip if empty
if (mode == 0 || mode == 0xFFFFFFFF) continue;
// Read filename first
raf.seek(entryPos + 0x40);
byte[] nameBytes = new byte[32];
raf.read(nameBytes);
String filename = extractString(nameBytes);
// Skip "." and ".." entries
if (filename.equals(".") || filename.equals("..")) continue;
// Skip if no filename
if (filename.isEmpty()) continue;
// Read length/size field
raf.seek(entryPos + 0x04);
int lengthOrSize = readInt32LE(raf);
// Log the entry for debugging
android.util.Log.d("MemcardSaves", String.format("Entry: mode=0x%08X, name=%s, length=%d", mode, filename, lengthOrSize));
// Check flags
boolean isUsed = (mode & 0x8000) != 0;
boolean isDir = (mode & 0x0020) != 0;
boolean isFile = (mode & 0x0010) != 0;
// Accept if it's used and has a reasonable size/count
if (isUsed && lengthOrSize > 0 && lengthOrSize < 10 * 1024 * 1024) {
String saveInfo;
if (isDir) {
// Directory - show number of files
saveInfo = String.format(Locale.getDefault(),
"%s (%d files)",
filename,
lengthOrSize
);
android.util.Log.d("MemcardSaves", "Found save directory: " + filename + " (" + lengthOrSize + " files)");
} else {
// File - show size
saveInfo = String.format(Locale.getDefault(),
"%s (%d KB)",
filename,
lengthOrSize / 1024
);
android.util.Log.d("MemcardSaves", "Found file: " + filename + " (" + lengthOrSize + " bytes)");
}
saves.add(saveInfo);
foundCount++;
}
}
// If we found saves at this location, stop searching
if (foundCount > 0) {
android.util.Log.d("MemcardSaves", "Found " + foundCount + " saves at offset 0x" + Long.toHexString(dirStart));
break;
}
}
if (saves.isEmpty()) {
android.util.Log.w("MemcardSaves", "No saves found in memory card");
}
} catch (Exception e) {
android.util.Log.e("MemcardSaves", "Error parsing memory card: " + e.getMessage(), e);
}
return saves;
}
private int readInt32LE(RandomAccessFile raf) throws Exception {
byte[] bytes = new byte[4];
raf.read(bytes);
return (bytes[0] & 0xFF) |
((bytes[1] & 0xFF) << 8) |
((bytes[2] & 0xFF) << 16) |
((bytes[3] & 0xFF) << 24);
}
private String extractString(byte[] bytes) {
// Find null terminator
int length = 0;
for (int i = 0; i < bytes.length; i++) {
if (bytes[i] == 0) {
length = i;
break;
}
}
if (length == 0) length = bytes.length;
// Try to decode as ASCII/Latin-1
try {
String str = new String(bytes, 0, length, "ISO-8859-1").trim();
// Filter out non-printable characters
StringBuilder sb = new StringBuilder();
for (char c : str.toCharArray()) {
if (c >= 32 && c < 127) {
sb.append(c);
} else if (c >= 160) {
// Extended ASCII characters
sb.append(c);
}
}
return sb.toString().trim();
} catch (Exception e) {
// Fallback to simple ASCII extraction
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
byte b = bytes[i];
if (b >= 32 && b < 127) {
sb.append((char) b);
}
}
return sb.toString().trim();
}
}
}
@@ -151,6 +151,10 @@ public class NativeApp {
}
}
// Get list of saves on a memory card
// Returns array of strings in format "filename|size|isDirectory"
public static native String[] getMemoryCardSaves(String memcardPath);
public static native void onNativeSurfaceCreated();
public static native void onNativeSurfaceChanged(Surface surface, int w, int h);
public static native void onNativeSurfaceDestroyed();
@@ -240,6 +240,15 @@ public class QuickActionsDialogFragment extends DialogFragment {
});
}
// Memory Cards: open memory card manager dialog
MaterialButton btnMemcards = view.findViewById(R.id.btn_quick_memcards);
if (btnMemcards != null) {
btnMemcards.setOnClickListener(v -> {
try { new MemoryCardManagerDialogFragment().show(getParentFragmentManager(), "memcard_manager_dialog"); } catch (Throwable ignored) {}
dismissAllowingStateLoss();
});
}
// Exit Game: open games dialog
if (btnExitGame != null) {
btnExitGame.setOnClickListener(v -> {
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18,2h-8L4.02,8 4,20c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM12,8h-2V4h2v4zM15,8h-2V4h2v4zM18,8h-2V4h2v4z"/>
</vector>
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Manage your PS2 memory cards. Import existing Mcd001.ps2 or Mcd002.ps2 files, or export them for backup."
android:textSize="14sp"
android:layout_marginBottom="16dp"
android:textColor="?android:attr/textColorSecondary" />
<ListView
android:id="@+id/memcard_list"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_marginBottom="16dp"
android:background="?attr/colorSurfaceVariant"
android:padding="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_import_memcard"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="Import"
style="@style/Widget.Material3.Button.OutlinedButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_export_memcard"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Export"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_memcard_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="8dp"
android:textColor="?attr/colorPrimary" />
<TextView
android:id="@+id/tv_memcard_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:layout_marginBottom="16dp"
android:textColor="?android:attr/textColorSecondary" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save Files:"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="8dp"
android:textColor="?attr/colorPrimary" />
<ListView
android:id="@+id/lv_saves"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="?attr/colorSurfaceVariant"
android:padding="8dp" />
<TextView
android:id="@+id/tv_no_saves"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="No save files found or unable to read memory card"
android:textAlignment="center"
android:padding="32dp"
android:visibility="gone"
android:textColor="?android:attr/textColorSecondary" />
</LinearLayout>
</ScrollView>
@@ -139,6 +139,20 @@
android:drawablePadding="8dp" />
</LinearLayout>
<!-- Row 3.25: Memory Cards -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_quick_memcards"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="MEMORY CARDS"
android:textColor="@android:color/white"
android:textAllCaps="true"
android:drawableStart="@drawable/sd_card_24px"
android:drawableTint="@color/brand_primary"
android:drawablePadding="8dp" />
<!-- Row 3.5: Exit Game -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_quick_exit_game"
@@ -146,6 +146,22 @@
app:iconSize="20dp"
android:layout_marginTop="4dp" />
<!-- Memory Card Manager -->
<com.google.android.material.button.MaterialButton
android:id="@+id/drawer_btn_memcard_manager"
style="@style/Widget.Material3Expressive.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Memory Cards"
android:textColor="@android:color/white"
android:textAllCaps="false"
app:icon="@drawable/sd_card_24px"
app:iconTint="@color/brand_primary"
app:iconGravity="textStart"
app:iconPadding="8dp"
app:iconSize="20dp"
android:layout_marginTop="4dp" />
<!-- Aspect Ratio -->
<TextView
android:layout_width="match_parent"