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\'.