mirror of
https://github.com/izzy2lost/PSX2.git
synced 2026-07-05 15:18:36 -07:00
Add memory cards option to import or export memory cards
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user