diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index be35c71fe..679300ed5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -11,24 +11,28 @@
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
- android:usesCleartextTraffic="true"
- android:networkSecurityConfig="@xml/network_security_config">
+ android:usesCleartextTraffic="true">
+
+ android:theme="@style/AppTheme.NoActionBar" />
+ android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/FileUtil.java b/android/app/src/main/java/com/github/stenzek/duckstation/FileUtil.java
index 26e7233b4..434aa2548 100644
--- a/android/app/src/main/java/com/github/stenzek/duckstation/FileUtil.java
+++ b/android/app/src/main/java/com/github/stenzek/duckstation/FileUtil.java
@@ -5,18 +5,24 @@ package com.github.stenzek.duckstation;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.Context;
+import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.storage.StorageManager;
import android.provider.DocumentsContract;
+import android.provider.MediaStore;
+import android.widget.Toast;
import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
+import java.io.OutputStream;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
@@ -163,6 +169,8 @@ public final class FileUtil {
if (os.length() > maxSize)
return null;
}
+
+ stream.close();
} catch (IOException e) {
return null;
}
@@ -172,4 +180,96 @@ public final class FileUtil {
return os.toString();
}
+
+ public static byte[] readBytesFromUri(final Context context, final Uri uri, int maxSize) {
+ InputStream stream = null;
+ try {
+ stream = context.getContentResolver().openInputStream(uri);
+ } catch (FileNotFoundException e) {
+ return null;
+ }
+
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ try {
+ byte[] buffer = new byte[512 * 1024];
+ int len;
+ while ((len = stream.read(buffer)) > 0) {
+ os.write(buffer, 0, len);
+ if (maxSize > 0 && os.size() > maxSize) {
+ return null;
+ }
+ }
+
+ stream.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ return null;
+ }
+
+ if (os.size() == 0)
+ return null;
+
+ return os.toByteArray();
+ }
+
+ public static boolean writeBytesToUri(final Context context, final Uri uri, final byte[] bytes) {
+ OutputStream stream = null;
+ try {
+ stream = context.getContentResolver().openOutputStream(uri);
+ } catch (FileNotFoundException e) {
+ e.printStackTrace();
+ return false;
+ }
+
+ if (bytes != null && bytes.length > 0) {
+ try {
+ stream.write(bytes);
+ stream.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public static boolean deleteFileAtUri(final Context context, final Uri uri) {
+ try {
+ if (uri.getScheme() == "file") {
+ final File file = new File(uri.getPath());
+ if (!file.isFile())
+ return false;
+
+ return file.delete();
+ }
+ return (context.getContentResolver().delete(uri, null, null) > 0);
+ } catch (Exception e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ /**
+ * Returns the name of the file pointed at by a SAF URI.
+ * @param context context to access file under
+ * @param uri uri to retrieve file name for
+ * @return the name of the file, or null
+ */
+ public static String getDocumentNameFromUri(final Context context, final Uri uri) {
+ Cursor cursor = null;
+ try {
+ final String[] proj = {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
+ cursor = context.getContentResolver().query(uri, proj, null, null, null);
+ final int columnIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME);
+ cursor.moveToFirst();
+ return cursor.getString(columnIndex);
+ } catch (Exception e) {
+ e.printStackTrace();
+ return null;
+ } finally {
+ if (cursor != null)
+ cursor.close();
+ }
+ }
}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java
index 7f87cb476..cdf32d254 100644
--- a/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java
+++ b/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java
@@ -239,6 +239,10 @@ public class MainActivity extends AppCompatActivity {
Intent intent = new Intent(this, ControllerSettingsActivity.class);
startActivity(intent);
return true;
+ } else if (id == R.id.action_memory_card_editor) {
+ Intent intent = new Intent(this, MemoryCardEditorActivity.class);
+ startActivity(intent);
+ return true;
} else if (id == R.id.action_switch_view) {
switchGameListView();
return true;
diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardEditorActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardEditorActivity.java
new file mode 100644
index 000000000..87bf6c3e2
--- /dev/null
+++ b/android/app/src/main/java/com/github/stenzek/duckstation/MemoryCardEditorActivity.java
@@ -0,0 +1,530 @@
+package com.github.stenzek.duckstation;
+
+import android.app.AlertDialog;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Bundle;
+
+import com.google.android.material.tabs.TabLayout;
+import com.google.android.material.tabs.TabLayoutMediator;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.Toolbar;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.DividerItemDecoration;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+import androidx.viewpager2.widget.ViewPager2;
+
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+
+public class MemoryCardEditorActivity extends AppCompatActivity {
+ public static final int REQUEST_IMPORT_CARD = 1;
+
+ private final ArrayList cards = new ArrayList<>();
+ private CollectionAdapter adapter;
+ private ViewPager2 viewPager;
+ private TabLayout tabLayout;
+ private TabLayoutMediator tabLayoutMediator;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_memory_card_editor);
+
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ adapter = new CollectionAdapter(this);
+ viewPager = findViewById(R.id.view_pager);
+ viewPager.setAdapter(adapter);
+
+ tabLayout = findViewById(R.id.tab_layout);
+ tabLayoutMediator = new TabLayoutMediator(tabLayout, viewPager, adapter.getTabConfigurationStrategy());
+ tabLayoutMediator.attach();
+
+ findViewById(R.id.open_card).setOnClickListener((v) -> openCard());
+ findViewById(R.id.close_card).setOnClickListener((v) -> closeCard());
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.remove("android:support:fragments");
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.menu_memory_card_editor, menu);
+
+ final boolean hasCurrentCard = (getCurrentCard() != null);
+ menu.findItem(R.id.action_delete_card).setEnabled(hasCurrentCard);
+ menu.findItem(R.id.action_format_card).setEnabled(hasCurrentCard);
+ menu.findItem(R.id.action_import_card).setEnabled(hasCurrentCard);
+
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home: {
+ onBackPressed();
+ return true;
+ }
+
+ case R.id.action_import_card: {
+ importCard();
+ return true;
+ }
+
+ case R.id.action_delete_card: {
+ deleteCard();
+ return true;
+ }
+
+ case R.id.action_format_card: {
+ formatCard();
+ return true;
+ }
+
+ default: {
+ return super.onOptionsItemSelected(item);
+ }
+ }
+ }
+
+ private void openCard() {
+ final Uri[] uris = MemoryCardImage.getCardUris(this);
+ if (uris == null) {
+ displayMessage(getString(R.string.memory_card_editor_no_cards_found));
+ return;
+ }
+
+ final String[] uriTitles = new String[uris.length];
+ for (int i = 0; i < uris.length; i++)
+ uriTitles[i] = MemoryCardImage.getTitleForUri(uris[i]);
+
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.memory_card_editor_select_card);
+ builder.setItems(uriTitles, (dialog, which) -> {
+ final Uri uri = uris[which];
+ for (int i = 0; i < cards.size(); i++) {
+ if (cards.get(i).getUri().equals(uri)) {
+ displayError(getString(R.string.memory_card_editor_card_already_open));
+ tabLayout.getTabAt(i).select();
+ return;
+ }
+ }
+
+ final MemoryCardImage card = MemoryCardImage.open(MemoryCardEditorActivity.this, uri);
+ if (card == null) {
+ displayError(getString(R.string.memory_card_editor_failed_to_open_card));
+ return;
+ }
+
+ cards.add(card);
+ refreshView(card);
+ });
+ builder.create().show();
+ }
+
+ private void closeCard() {
+ final int index = tabLayout.getSelectedTabPosition();
+ if (index < 0)
+ return;
+
+ cards.remove(index);
+ refreshView(index);
+ }
+
+ private void displayMessage(String message) {
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show();
+ }
+
+ private void displayError(String message) {
+ final AlertDialog.Builder errorBuilder = new AlertDialog.Builder(this);
+ errorBuilder.setTitle(R.string.memory_card_editor_error);
+ errorBuilder.setMessage(message);
+ errorBuilder.setPositiveButton(R.string.main_activity_ok, (dialog1, which1) -> dialog1.dismiss());
+ errorBuilder.create().show();
+ }
+
+ private void copySave(MemoryCardImage sourceCard, MemoryCardFileInfo sourceFile) {
+ if (cards.size() < 2) {
+ displayError(getString(R.string.memory_card_editor_must_have_at_least_two_cards_to_copy));
+ return;
+ }
+
+ if (cards.indexOf(sourceCard) < 0) {
+ // this shouldn't happen..
+ return;
+ }
+
+ final MemoryCardImage[] destinationCards = new MemoryCardImage[cards.size() - 1];
+ final String[] cardTitles = new String[cards.size() - 1];
+ for (int i = 0, d = 0; i < cards.size(); i++) {
+ if (cards.get(i) == sourceCard)
+ continue;
+
+ destinationCards[d] = cards.get(i);
+ cardTitles[d] = cards.get(i).getTitle();
+ d++;
+ }
+
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(getString(R.string.memory_card_editor_copy_save_to, sourceFile.getTitle()));
+ builder.setItems(cardTitles, (dialog, which) -> {
+ dialog.dismiss();
+
+ final MemoryCardImage destinationCard = destinationCards[which];
+
+ byte[] data = null;
+ if (destinationCard.getFreeBlocks() < sourceFile.getNumBlocks()) {
+ displayError(getString(R.string.memory_card_editor_copy_insufficient_blocks, sourceFile.getNumBlocks(),
+ destinationCard.getFreeBlocks()));
+ } else if (destinationCard.hasFile(sourceFile.getFilename())) {
+ displayError(getString(R.string.memory_card_editor_copy_already_exists, sourceFile.getFilename()));
+ } else if ((data = sourceCard.readFile(sourceFile.getFilename())) == null) {
+ displayError(getString(R.string.memory_card_editor_copy_read_failed, sourceFile.getFilename()));
+ } else if (!destinationCard.writeFile(sourceFile.getFilename(), data)) {
+ displayMessage(getString(R.string.memory_card_editor_copy_write_failed, sourceFile.getFilename()));
+ } else {
+ displayMessage(getString(R.string.memory_card_editor_copy_success, sourceFile.getFilename(),
+ destinationCard.getTitle()));
+ refreshView(destinationCard);
+ }
+ });
+ builder.create().show();
+ }
+
+ private void deleteSave(MemoryCardImage card, MemoryCardFileInfo file) {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setMessage(getString(R.string.memory_card_editor_delete_confirm, file.getFilename()));
+ builder.setPositiveButton(R.string.main_activity_yes, (dialog, which) -> {
+ if (card.deleteFile(file.getFilename())) {
+ displayMessage(getString(R.string.memory_card_editor_delete_success, file.getFilename()));
+ refreshView(card);
+ } else {
+ displayError(getString(R.string.memory_card_editor_delete_failed, file.getFilename()));
+ }
+ });
+ builder.setNegativeButton(R.string.main_activity_no, (dialog, which) -> dialog.dismiss());
+ builder.create().show();
+ }
+
+ private void refreshView(int newSelection) {
+ final int oldPos = viewPager.getCurrentItem();
+ tabLayoutMediator.detach();
+ viewPager.setAdapter(null);
+ viewPager.setAdapter(adapter);
+ tabLayoutMediator.attach();
+
+ if (cards.isEmpty())
+ return;
+
+ if (newSelection < 0) {
+ if (oldPos < cards.size())
+ tabLayout.getTabAt(oldPos).select();
+ else
+ tabLayout.getTabAt(cards.size() - 1).select();
+ } else {
+ tabLayout.getTabAt(newSelection).select();
+ }
+ }
+
+ private void refreshView(MemoryCardImage newSelectedCard) {
+ if (newSelectedCard == null)
+ refreshView(-1);
+ else
+ refreshView(cards.indexOf(newSelectedCard));
+
+ invalidateOptionsMenu();
+ }
+
+ private MemoryCardImage getCurrentCard() {
+ final int index = tabLayout.getSelectedTabPosition();
+ if (index < 0 || index >= cards.size())
+ return null;
+
+ return cards.get(index);
+ }
+
+ private void importCard() {
+ if (getCurrentCard() == null) {
+ displayMessage(getString(R.string.memory_card_editor_no_card_selected));
+ return;
+ }
+
+ Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
+ intent.setType("*/*");
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ startActivityForResult(Intent.createChooser(intent, getString(R.string.main_activity_choose_disc_image)), REQUEST_IMPORT_CARD);
+ }
+
+ private void importCard(Uri uri) {
+ final MemoryCardImage card = getCurrentCard();
+ if (card == null)
+ return;
+
+ final byte[] data = FileUtil.readBytesFromUri(this, uri, 16 * 1024 * 1024);
+ if (data == null) {
+ displayError(getString(R.string.memory_card_editor_import_card_read_failed, uri.toString()));
+ return;
+ }
+
+ String importFileName = FileUtil.getDocumentNameFromUri(this, uri);
+ if (importFileName == null) {
+ importFileName = uri.getPath();
+ if (importFileName == null || importFileName.isEmpty())
+ importFileName = uri.toString();
+ }
+
+ final String captureImportFileName = importFileName;
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setMessage(getString(R.string.memory_card_editor_import_card_confirm_message,
+ importFileName, card.getTitle()));
+ builder.setPositiveButton(R.string.main_activity_yes, (dialog, which) -> {
+ dialog.dismiss();
+
+ if (!card.importCard(captureImportFileName, data)) {
+ displayError(getString(R.string.memory_card_editor_import_failed));
+ return;
+ }
+
+ refreshView(card);
+ });
+ builder.setNegativeButton(R.string.main_activity_no, (dialog, which) -> dialog.dismiss());
+ builder.create().show();
+ }
+
+ private void formatCard() {
+ final MemoryCardImage card = getCurrentCard();
+ if (card == null) {
+ displayMessage(getString(R.string.memory_card_editor_no_card_selected));
+ return;
+ }
+
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setMessage(getString(R.string.memory_card_editor_format_card_confirm_message, card.getTitle()));
+ builder.setPositiveButton(R.string.main_activity_yes, (dialog, which) -> {
+ dialog.dismiss();
+
+ if (!card.format()) {
+ displayError(getString(R.string.memory_card_editor_format_card_failed, card.getUri().toString()));
+ return;
+ }
+
+ displayMessage(getString(R.string.memory_card_editor_format_card_success, card.getUri().toString()));
+ refreshView(card);
+ });
+ builder.setNegativeButton(R.string.main_activity_no, (dialog, which) -> dialog.dismiss());
+ builder.create().show();
+ }
+
+ private void deleteCard() {
+ final MemoryCardImage card = getCurrentCard();
+ if (card == null) {
+ displayMessage(getString(R.string.memory_card_editor_no_card_selected));
+ return;
+ }
+
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setMessage(getString(R.string.memory_card_editor_delete_card_confirm_message, card.getTitle()));
+ builder.setPositiveButton(R.string.main_activity_yes, (dialog, which) -> {
+ dialog.dismiss();
+
+ if (!card.delete()) {
+ displayError(getString(R.string.memory_card_editor_delete_card_failed, card.getUri().toString()));
+ return;
+ }
+
+ displayMessage(getString(R.string.memory_card_editor_delete_card_success, card.getUri().toString()));
+ cards.remove(card);
+ refreshView(-1);
+ });
+ builder.setNegativeButton(R.string.main_activity_no, (dialog, which) -> dialog.dismiss());
+ builder.create().show();
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ switch (requestCode) {
+ case REQUEST_IMPORT_CARD: {
+ if (resultCode != RESULT_OK)
+ return;
+
+ importCard(data.getData());
+ }
+ break;
+ }
+ }
+
+ private static class SaveViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
+ private MemoryCardEditorActivity mParent;
+ private View mItemView;
+ private MemoryCardImage mCard;
+ private MemoryCardFileInfo mFile;
+
+ public SaveViewHolder(MemoryCardEditorActivity parent, @NonNull View itemView) {
+ super(itemView);
+ mParent = parent;
+ mItemView = itemView;
+ mItemView.setOnClickListener(this);
+ }
+
+ public void bindToEntry(MemoryCardImage card, MemoryCardFileInfo file) {
+ mCard = card;
+ mFile = file;
+
+ ((TextView) mItemView.findViewById(R.id.title)).setText(mFile.getTitle());
+ ((TextView) mItemView.findViewById(R.id.filename)).setText(mFile.getFilename());
+
+ final String blocksText = String.format("%d Blocks", mFile.getNumBlocks());
+ final String sizeText = String.format("%.1f KB", (float)mFile.getSize() / 1024.0f);
+ ((TextView) mItemView.findViewById(R.id.block_size)).setText(blocksText);
+ ((TextView) mItemView.findViewById(R.id.file_size)).setText(sizeText);
+
+ if (mFile.getNumIconFrames() > 0) {
+ final Bitmap bitmap = mFile.getIconFrameBitmap(0);
+ if (bitmap != null) {
+ ((ImageView) mItemView.findViewById(R.id.icon)).setImageBitmap(bitmap);
+ }
+ }
+ }
+
+ @Override
+ public void onClick(View v) {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(mItemView.getContext());
+ builder.setTitle(mFile.getFilename());
+ builder.setItems(R.array.memory_card_editor_save_menu, ((dialog, which) -> {
+ switch (which) {
+ // Copy Save
+ case 0: {
+ dialog.dismiss();
+ mParent.copySave(mCard, mFile);
+ }
+ break;
+
+ // Delete Save
+ case 1: {
+ dialog.dismiss();
+ mParent.deleteSave(mCard, mFile);
+ }
+ break;
+ }
+ }));
+ builder.create().show();
+ }
+ }
+
+ private static class SaveViewAdapter extends RecyclerView.Adapter {
+ private MemoryCardEditorActivity parent;
+ private MemoryCardImage card;
+ private MemoryCardFileInfo[] files;
+
+ public SaveViewAdapter(MemoryCardEditorActivity parent, MemoryCardImage card) {
+ this.parent = parent;
+ this.card = card;
+ this.files = card.getFiles();
+ }
+
+ @NonNull
+ @Override
+ public SaveViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ final View rootView = LayoutInflater.from(parent.getContext()).inflate(R.layout.layout_memory_card_save, parent, false);
+ return new SaveViewHolder(this.parent, rootView);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull SaveViewHolder holder, int position) {
+ holder.bindToEntry(card, files[position]);
+ }
+
+ @Override
+ public int getItemCount() {
+ return (files != null) ? files.length : 0;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return R.layout.layout_memory_card_save;
+ }
+ }
+
+ public static class MemoryCardFileFragment extends Fragment {
+ private MemoryCardEditorActivity parent;
+ private MemoryCardImage card;
+ private SaveViewAdapter adapter;
+ private RecyclerView recyclerView;
+
+ public MemoryCardFileFragment(MemoryCardEditorActivity parent, MemoryCardImage card) {
+ this.parent = parent;
+ this.card = card;
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_memory_card_file, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ adapter = new SaveViewAdapter(parent, card);
+ recyclerView = view.findViewById(R.id.recyclerView);
+ recyclerView.setAdapter(adapter);
+ recyclerView.setLayoutManager(new LinearLayoutManager(recyclerView.getContext()));
+ recyclerView.addItemDecoration(new DividerItemDecoration(recyclerView.getContext(),
+ DividerItemDecoration.VERTICAL));
+ }
+ }
+
+ public static class CollectionAdapter extends FragmentStateAdapter {
+ private MemoryCardEditorActivity parent;
+ private final TabLayoutMediator.TabConfigurationStrategy tabConfigurationStrategy = (tab, position) -> {
+ tab.setText(parent.cards.get(position).getTitle());
+ };
+
+ public CollectionAdapter(MemoryCardEditorActivity parent) {
+ super(parent);
+ this.parent = parent;
+ }
+
+ public TabLayoutMediator.TabConfigurationStrategy getTabConfigurationStrategy() {
+ return tabConfigurationStrategy;
+ }
+
+ @NonNull
+ @Override
+ public Fragment createFragment(int position) {
+ return new MemoryCardFileFragment(parent, parent.cards.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return parent.cards.size();
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable/ic_baseline_close_24.xml b/android/app/src/main/res/drawable/ic_baseline_close_24.xml
new file mode 100644
index 000000000..16d6d37dd
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_baseline_close_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_baseline_create_new_folder_24.xml b/android/app/src/main/res/drawable/ic_baseline_create_new_folder_24.xml
new file mode 100644
index 000000000..78c162283
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_baseline_create_new_folder_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_baseline_delete_24.xml b/android/app/src/main/res/drawable/ic_baseline_delete_24.xml
index 2c0afcc56..3c4030b03 100644
--- a/android/app/src/main/res/drawable/ic_baseline_delete_24.xml
+++ b/android/app/src/main/res/drawable/ic_baseline_delete_24.xml
@@ -4,7 +4,7 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
-
+
diff --git a/android/app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml b/android/app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml
new file mode 100644
index 000000000..22560a4f9
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_baseline_folder_open_24.xml b/android/app/src/main/res/drawable/ic_baseline_folder_open_24.xml
index 4a793654a..f58b501e3 100644
--- a/android/app/src/main/res/drawable/ic_baseline_folder_open_24.xml
+++ b/android/app/src/main/res/drawable/ic_baseline_folder_open_24.xml
@@ -4,7 +4,7 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
-
+
diff --git a/android/app/src/main/res/drawable/ic_baseline_import_contacts_24.xml b/android/app/src/main/res/drawable/ic_baseline_import_contacts_24.xml
new file mode 100644
index 000000000..99a23c4db
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_baseline_import_contacts_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_baseline_sd_card_24.xml b/android/app/src/main/res/drawable/ic_baseline_sd_card_24.xml
new file mode 100644
index 000000000..84f6ea6ac
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_baseline_sd_card_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/layout/activity_memory_card_editor.xml b/android/app/src/main/res/layout/activity_memory_card_editor.xml
new file mode 100644
index 000000000..fa471163a
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_memory_card_editor.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/fragment_memory_card_file.xml b/android/app/src/main/res/layout/fragment_memory_card_file.xml
new file mode 100644
index 000000000..49d14e2e5
--- /dev/null
+++ b/android/app/src/main/res/layout/fragment_memory_card_file.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/layout_memory_card_save.xml b/android/app/src/main/res/layout/layout_memory_card_save.xml
new file mode 100644
index 000000000..caab01ec7
--- /dev/null
+++ b/android/app/src/main/res/layout/layout_memory_card_save.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/menu/menu_main.xml b/android/app/src/main/res/menu/menu_main.xml
index 46314c82c..caf4cf05e 100644
--- a/android/app/src/main/res/menu/menu_main.xml
+++ b/android/app/src/main/res/menu/menu_main.xml
@@ -14,14 +14,14 @@
+
diff --git a/android/app/src/main/res/menu/menu_memory_card_editor.xml b/android/app/src/main/res/menu/menu_memory_card_editor.xml
new file mode 100644
index 000000000..9411e9cde
--- /dev/null
+++ b/android/app/src/main/res/menu/menu_memory_card_editor.xml
@@ -0,0 +1,24 @@
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/arrays.xml b/android/app/src/main/res/values/arrays.xml
index 01a9ae0c5..20ad4d252 100644
--- a/android/app/src/main/res/values/arrays.xml
+++ b/android/app/src/main/res/values/arrays.xml
@@ -485,4 +485,8 @@
- Port2Only
- BothPorts
+
+ - Copy Save
+ - Delete Save
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 861f7bc3e..cdd67aedf 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -276,4 +276,39 @@
Ports
Port %d
Port %1$d%2$c
+ Switch View
+ Memory Card Editor
+ Memory Card Editor
+ Import Card
+ Open Card
+ New Card
+ Format Card
+ Delete Card
+ No memory cards found.
+ This card is already open.
+ Failed to open or read memory card.
+ Must have at least two cards open to copy.
+ Copy %s to...
+ Select Card
+ Error
+ This file requires %1$d blocks, but only %2$d blocks are free.
+ File \'%s\' already exists on destination card.
+ Failed to read file \'%s\' from source card.
+ Failed to write file \'%s\' to destination card.
+ Copied \'%1$s\' to \'%2$s\'.
+ Are you sure you want to delete the save \'%s\'?
+ Deleted save \'%s\'.
+ Failed to delete file \'%s\'.
+ No card is selected.
+ Failed to import card. It may not be a supported format.
+ Memory card \'%s\' will be deleted, and cannot be recovered. Are you sure you want to delete this card?
+ Failed to delete \'%s\'.
+ Deleted card \'%s\'.
+ Memory card \'%s\' will be formatted, clearing all saves. Are you sure you want to format this card?
+ Failed to format \'%s\'.
+ Formatted card \'%s\'.
+ Importing \'%1$s\' will remove all saves in \'%2$s\'. Do you want to continue?
+ Failed to read \'%s\'.
+ Failed to import card \'%s\'. It may not be a supported format.
+ Imported card \'%s\'.