diff --git a/android/app/src/cpp/android_host_interface.cpp b/android/app/src/cpp/android_host_interface.cpp index 336cbf5de..366433f28 100644 --- a/android/app/src/cpp/android_host_interface.cpp +++ b/android/app/src/cpp/android_host_interface.cpp @@ -51,6 +51,8 @@ static jclass s_PatchCode_class; static jmethodID s_PatchCode_constructor; static jclass s_GameListEntry_class; static jmethodID s_GameListEntry_constructor; +static jclass s_SaveStateInfo_class; +static jmethodID s_SaveStateInfo_constructor; namespace AndroidHelpers { // helper for retrieving the current per-thread jni environment @@ -350,7 +352,7 @@ void AndroidHostInterface::RunOnEmulationThread(std::function function, m_mutex.unlock(); } -void AndroidHostInterface::RunLater(std::function func) +void AndroidHostInterface::RunLater(std::function func) { std::unique_lock lock(m_mutex); m_callback_queue.push_back(std::move(func)); @@ -887,7 +889,7 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) // Create global reference so it doesn't get cleaned up. JNIEnv* env = AndroidHelpers::GetJNIEnv(); - jclass string_class, host_interface_class, patch_code_class, game_list_entry_class; + jclass string_class, host_interface_class, patch_code_class, game_list_entry_class, save_state_info_class; if ((string_class = env->FindClass("java/lang/String")) == nullptr || (s_String_class = static_cast(env->NewGlobalRef(string_class))) == nullptr || (host_interface_class = env->FindClass("com/github/stenzek/duckstation/AndroidHostInterface")) == nullptr || @@ -895,7 +897,9 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) (patch_code_class = env->FindClass("com/github/stenzek/duckstation/PatchCode")) == nullptr || (s_PatchCode_class = static_cast(env->NewGlobalRef(patch_code_class))) == nullptr || (game_list_entry_class = env->FindClass("com/github/stenzek/duckstation/GameListEntry")) == nullptr || - (s_GameListEntry_class = static_cast(env->NewGlobalRef(game_list_entry_class))) == nullptr) + (s_GameListEntry_class = static_cast(env->NewGlobalRef(game_list_entry_class))) == nullptr || + (save_state_info_class = env->FindClass("com/github/stenzek/duckstation/SaveStateInfo")) == nullptr || + (s_SaveStateInfo_class = static_cast(env->NewGlobalRef(save_state_info_class))) == nullptr) { Log_ErrorPrint("AndroidHostInterface class lookup failed"); return -1; @@ -937,7 +941,11 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) (s_GameListEntry_constructor = env->GetMethodID( s_GameListEntry_class, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/" - "String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V")) == nullptr) + "String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V")) == nullptr || + (s_SaveStateInfo_constructor = env->GetMethodID( + s_SaveStateInfo_class, "", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZII[B)V")) == + nullptr) { Log_ErrorPrint("AndroidHostInterface lookups failed"); return -1; @@ -1574,3 +1582,96 @@ DEFINE_JNI_ARGS_METHOD(jboolean, AndroidHostInterface_setMediaFilename, jstring return true; } + +static jobject CreateSaveStateInfo(JNIEnv* env, const CommonHostInterface::ExtendedSaveStateInfo& ssi) +{ + LocalRefHolder path(env, env->NewStringUTF(ssi.path.c_str())); + LocalRefHolder title(env, env->NewStringUTF(ssi.title.c_str())); + LocalRefHolder code(env, env->NewStringUTF(ssi.game_code.c_str())); + LocalRefHolder media_path(env, env->NewStringUTF(ssi.media_path.c_str())); + LocalRefHolder timestamp(env, env->NewStringUTF(Timestamp::FromUnixTimestamp(ssi.timestamp).ToString("%c"))); + LocalRefHolder screenshot_data; + if (!ssi.screenshot_data.empty()) + { + const jsize data_size = static_cast(ssi.screenshot_data.size() * sizeof(u32)); + screenshot_data = LocalRefHolder(env, env->NewByteArray(data_size)); + env->SetByteArrayRegion(screenshot_data.Get(), 0, data_size, + reinterpret_cast(ssi.screenshot_data.data())); + } + + return env->NewObject(s_SaveStateInfo_class, s_SaveStateInfo_constructor, path.Get(), title.Get(), code.Get(), + media_path.Get(), timestamp.Get(), static_cast(ssi.slot), + static_cast(ssi.global), static_cast(ssi.screenshot_width), + static_cast(ssi.screenshot_height), screenshot_data.Get()); +} + +static jobject CreateEmptySaveStateInfo(JNIEnv* env, s32 slot, bool global) +{ + return env->NewObject(s_SaveStateInfo_class, s_SaveStateInfo_constructor, nullptr, nullptr, nullptr, nullptr, nullptr, + static_cast(slot), static_cast(global), static_cast(0), + static_cast(0), nullptr); +} + +DEFINE_JNI_ARGS_METHOD(jobjectArray, AndroidHostInterface_getSaveStateInfo, jobject obj, jboolean includeEmpty) +{ + if (!System::IsValid()) + return nullptr; + + AndroidHostInterface* hi = AndroidHelpers::GetNativeClass(env, obj); + std::vector infos; + + // +1 for the quick save only in android. + infos.reserve(1 + CommonHostInterface::PER_GAME_SAVE_STATE_SLOTS + CommonHostInterface::GLOBAL_SAVE_STATE_SLOTS); + + const std::string& game_code = System::GetRunningCode(); + if (!game_code.empty()) + { + for (u32 i = 0; i <= CommonHostInterface::PER_GAME_SAVE_STATE_SLOTS; i++) + { + std::optional esi = + hi->GetExtendedSaveStateInfo(game_code.c_str(), static_cast(i)); + if (esi.has_value()) + { + jobject obj = CreateSaveStateInfo(env, esi.value()); + if (obj) + infos.push_back(obj); + } + else if (includeEmpty) + { + jobject obj = CreateEmptySaveStateInfo(env, static_cast(i), false); + if (obj) + infos.push_back(obj); + } + } + } + + for (u32 i = 1; i <= CommonHostInterface::GLOBAL_SAVE_STATE_SLOTS; i++) + { + std::optional esi = + hi->GetExtendedSaveStateInfo(nullptr, static_cast(i)); + if (esi.has_value()) + { + jobject obj = CreateSaveStateInfo(env, esi.value()); + if (obj) + infos.push_back(obj); + } + else if (includeEmpty) + { + jobject obj = CreateEmptySaveStateInfo(env, static_cast(i), true); + if (obj) + infos.push_back(obj); + } + } + + if (infos.empty()) + return nullptr; + + jobjectArray ret = env->NewObjectArray(static_cast(infos.size()), s_SaveStateInfo_class, nullptr); + for (size_t i = 0; i < infos.size(); i++) + { + env->SetObjectArrayElement(ret, static_cast(i), infos[i]); + env->DeleteLocalRef(infos[i]); + } + + return ret; +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/AndroidHostInterface.java b/android/app/src/main/java/com/github/stenzek/duckstation/AndroidHostInterface.java index 5225fc55e..b7bf4ca80 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/AndroidHostInterface.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/AndroidHostInterface.java @@ -136,6 +136,8 @@ public class AndroidHostInterface { public native boolean setMediaFilename(String filename); + public native SaveStateInfo[] getSaveStateInfo(boolean includeEmpty); + static { System.loadLibrary("duckstation-native"); } diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/EmulationActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/EmulationActivity.java index ba7cd0786..3278e30ba 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/EmulationActivity.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/EmulationActivity.java @@ -19,6 +19,7 @@ import android.view.View; import android.view.Window; import android.view.WindowManager; import android.widget.FrameLayout; +import android.widget.ListView; import android.widget.Toast; import androidx.annotation.NonNull; @@ -41,7 +42,6 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde private boolean mApplySettingsOnSurfaceRestored = false; private String mGameTitle = null; private EmulationSurfaceView mContentView; - private int mSaveStateSlot = 0; private boolean getBooleanSetting(String key, boolean defaultValue) { return mPreferences.getBoolean(key, defaultValue); @@ -398,42 +398,36 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde } AlertDialog.Builder builder = new AlertDialog.Builder(this); + if (mGameTitle != null && !mGameTitle.isEmpty()) + builder.setTitle(mGameTitle); builder.setItems(R.array.emulation_menu, (dialogInterface, i) -> { switch (i) { - case 0: // Quick Load + case 0: // Load State { - AndroidHostInterface.getInstance().loadState(false, mSaveStateSlot); - onMenuClosed(); + showSaveStateMenu(false); return; } - case 1: // Quick Save + case 1: // Save State { - AndroidHostInterface.getInstance().saveState(false, mSaveStateSlot); - onMenuClosed(); + showSaveStateMenu(true); return; } - case 2: // Save State Slot - { - showSaveStateSlotMenu(); - return; - } - - case 3: // Toggle Fast Forward + case 2: // Toggle Fast Forward { AndroidHostInterface.getInstance().setFastForwardEnabled(!AndroidHostInterface.getInstance().isFastForwardEnabled()); onMenuClosed(); return; } - case 4: // More Options + case 3: // More Options { showMoreMenu(); return; } - case 5: // Quit + case 4: // Quit { mStopRequested = true; finish(); @@ -445,15 +439,34 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde builder.create().show(); } - private void showSaveStateSlotMenu() { - AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setSingleChoiceItems(R.array.emulation_save_state_slot_menu, mSaveStateSlot, (dialogInterface, i) -> { - mSaveStateSlot = i; - dialogInterface.dismiss(); + private void showSaveStateMenu(boolean saving) { + final SaveStateInfo[] infos = AndroidHostInterface.getInstance().getSaveStateInfo(true); + if (infos == null) { + onMenuClosed(); + return; + } + + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + final ListView listView = new ListView(this); + listView.setAdapter(new SaveStateInfo.ListAdapter(this, infos)); + builder.setView(listView); + builder.setOnDismissListener((dialog) -> { onMenuClosed(); }); - builder.setOnCancelListener(dialogInterface -> onMenuClosed()); - builder.create().show(); + + final AlertDialog dialog = builder.create(); + + listView.setOnItemClickListener((parent, view, position, id) -> { + SaveStateInfo info = infos[position]; + if (saving) { + AndroidHostInterface.getInstance().saveState(info.isGlobal(), info.getSlot()); + } else { + AndroidHostInterface.getInstance().loadState(info.isGlobal(), info.getSlot()); + } + dialog.dismiss(); + }); + + dialog.show(); } private void showMoreMenu() { diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/GameDirectoriesActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/GameDirectoriesActivity.java index 3ab72be36..098d3f1d7 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/GameDirectoriesActivity.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/GameDirectoriesActivity.java @@ -298,7 +298,7 @@ public class GameDirectoriesActivity extends AppCompatActivity { .setTitle(R.string.edit_game_directories_add_path) .setMessage(R.string.edit_game_directories_add_path_summary) .setView(text) - .setPositiveButton("Add", (dialog, which) -> { + .setPositiveButton("Add", (dialog, which) -> { final String path = text.getText().toString(); if (!path.isEmpty()) { addSearchDirectory(GameDirectoriesActivity.this, path, true); diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/SaveStateInfo.java b/android/app/src/main/java/com/github/stenzek/duckstation/SaveStateInfo.java new file mode 100644 index 000000000..7acd33f32 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/SaveStateInfo.java @@ -0,0 +1,151 @@ +package com.github.stenzek.duckstation; + +import android.content.Context; +import android.graphics.Bitmap; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import java.nio.ByteBuffer; + +public class SaveStateInfo { + private String mPath; + private String mGameTitle; + private String mGameCode; + private String mMediaPath; + private String mTimestamp; + private int mSlot; + private boolean mGlobal; + private Bitmap mScreenshot; + + public SaveStateInfo(String path, String gameTitle, String gameCode, String mediaPath, String timestamp, int slot, boolean global, + int screenshotWidth, int screenshotHeight, byte[] screenshotData) { + mPath = path; + mGameTitle = gameTitle; + mGameCode = gameCode; + mMediaPath = mediaPath; + mTimestamp = timestamp; + mSlot = slot; + mGlobal = global; + + if (screenshotData != null) { + try { + mScreenshot = Bitmap.createBitmap(screenshotWidth, screenshotHeight, Bitmap.Config.ARGB_8888); + mScreenshot.copyPixelsFromBuffer(ByteBuffer.wrap(screenshotData)); + } catch (Exception e) { + mScreenshot = null; + } + } + } + + public boolean exists() { + return mPath != null; + } + + public String getPath() { + return mPath; + } + + public String getGameTitle() { + return mGameTitle; + } + + public String getGameCode() { + return mGameCode; + } + + public String getMediaPath() { + return mMediaPath; + } + + public String getTimestamp() { + return mTimestamp; + } + + public int getSlot() { + return mSlot; + } + + public boolean isGlobal() { + return mGlobal; + } + + public Bitmap getScreenshot() { + return mScreenshot; + } + + private void fillView(Context context, View view) { + ImageView imageView = (ImageView) view.findViewById(R.id.image); + TextView summaryView = (TextView) view.findViewById(R.id.summary); + TextView gameView = (TextView) view.findViewById(R.id.game); + TextView pathView = (TextView) view.findViewById(R.id.path); + TextView timestampView = (TextView) view.findViewById(R.id.timestamp); + + if (mScreenshot != null) + imageView.setImageBitmap(mScreenshot); + else + imageView.setImageDrawable(context.getDrawable(R.drawable.ic_baseline_not_interested_60)); + + String summaryText; + if (mGlobal) + summaryView.setText(String.format(context.getString(R.string.save_state_info_global_save_n), mSlot)); + else if (mSlot == 0) + summaryView.setText(R.string.save_state_info_quick_save); + else + summaryView.setText(String.format(context.getString(R.string.save_state_info_game_save_n), mSlot)); + + if (exists()) { + gameView.setText(String.format("%s - %s", mGameCode, mGameTitle)); + + int lastSlashPosition = mMediaPath.lastIndexOf('/'); + if (lastSlashPosition >= 0) + pathView.setText(mMediaPath.substring(lastSlashPosition + 1)); + else + pathView.setText(mMediaPath); + + timestampView.setText(mTimestamp); + } else { + gameView.setText(R.string.save_state_info_slot_is_empty); + pathView.setText(""); + timestampView.setText(""); + } + } + + public static class ListAdapter extends BaseAdapter { + private final Context mContext; + private final SaveStateInfo[] mInfos; + + public ListAdapter(Context context, SaveStateInfo[] infos) { + mContext = context; + mInfos = infos; + } + + @Override + public int getCount() { + return mInfos.length; + } + + @Override + public Object getItem(int position) { + return mInfos[position]; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = LayoutInflater.from(mContext).inflate(R.layout.save_state_view_entry, parent, false); + } + + mInfos[position].fillView(mContext, convertView); + return convertView; + } + } +} diff --git a/android/app/src/main/res/drawable/ic_baseline_not_interested_60.xml b/android/app/src/main/res/drawable/ic_baseline_not_interested_60.xml new file mode 100644 index 000000000..f5f7da3ae --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_not_interested_60.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/app/src/main/res/layout/save_state_view_entry.xml b/android/app/src/main/res/layout/save_state_view_entry.xml new file mode 100644 index 000000000..eaaff4f7a --- /dev/null +++ b/android/app/src/main/res/layout/save_state_view_entry.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values-es/arrays.xml b/android/app/src/main/res/values-es/arrays.xml index dac9e3da8..1a9f28c8a 100644 --- a/android/app/src/main/res/values-es/arrays.xml +++ b/android/app/src/main/res/values-es/arrays.xml @@ -81,7 +81,6 @@ Cargar Estado Guardar Estado - Guardar Estado en Ranura Activar Avance Rápido Más Opciones Salir @@ -94,19 +93,6 @@ Cambiar Control de Pantalla Tactil Editar Diseño del Control de Pantalla Tactil - - Ranura Rápida - Ranura 1 - Ranura 2 - Ranura 3 - Ranura 4 - Ranura 5 - Ranura 6 - Ranura 7 - Ranura 8 - Ranura 9 - Ranura 10 - Ninguno (Velocidad Doble) 2x (Velocidad Cuádruple) diff --git a/android/app/src/main/res/values-it/arrays.xml b/android/app/src/main/res/values-it/arrays.xml index 43b7563f1..7b63ff217 100644 --- a/android/app/src/main/res/values-it/arrays.xml +++ b/android/app/src/main/res/values-it/arrays.xml @@ -81,7 +81,6 @@ Carica Stato Salva Stato - Slot Salvataggio Stato Abilita/Disabilita Avanti Veloce Altre Opzioni Esci @@ -94,19 +93,6 @@ Cambia Controller Touchscreen Edit Touchscreen Controller Layout - - Slot Veloce - Slot Gioco 1 - Slot Gioco 2 - Slot Gioco 3 - Slot Gioco 4 - Slot Gioco 5 - Slot Gioco 6 - Slot Gioco 7 - Slot Gioco 8 - Slot Gioco 9 - Slot Gioco 10 - Nessuna Velocità Doppia) 2x (Velocità Quadrupla diff --git a/android/app/src/main/res/values-nl/arrays.xml b/android/app/src/main/res/values-nl/arrays.xml index be650c233..c9743250c 100644 --- a/android/app/src/main/res/values-nl/arrays.xml +++ b/android/app/src/main/res/values-nl/arrays.xml @@ -81,7 +81,6 @@ Staat Laden Staat Opslaan - Staat Nummer Doorspoelen aan/uitzetten Meer Opties Afsluiten @@ -94,19 +93,6 @@ Touchscreen Controller Aanpassen Edit Touchscreen Controller Layout - - Snel Slot - Game Slot 1 - Game Slot 2 - Game Slot 3 - Game Slot 4 - Game Slot 5 - Game Slot 6 - Game Slot 7 - Game Slot 8 - Game Slot 9 - Game Slot 10 - Geen (Dubbele Snelheid) 2x (Vierdubbele Snelheid) diff --git a/android/app/src/main/res/values-pt-rBR/arrays.xml b/android/app/src/main/res/values-pt-rBR/arrays.xml index 507535631..cd5139102 100644 --- a/android/app/src/main/res/values-pt-rBR/arrays.xml +++ b/android/app/src/main/res/values-pt-rBR/arrays.xml @@ -81,7 +81,6 @@ Carregar Estado Salvar Estado - Salvar para Compartimento Avanço (Fixo) Mais Opções Sair @@ -94,19 +93,6 @@ Mudar controle em Tela Editar Posição dos Controles (Tela) - - Armazenamento Rápido - Armazenamento 1 - Armazenamento 2 - Armazenamento 3 - Armazenamento 4 - Armazenamento 5 - Armazenamento 6 - Armazenamento 7 - Armazenamento 8 - Armazenamento 9 - Armazenamento 10 - Nenhum 2x (4x Veloz) diff --git a/android/app/src/main/res/values-ru/arrays.xml b/android/app/src/main/res/values-ru/arrays.xml index 082311b83..98f4f5d54 100644 --- a/android/app/src/main/res/values-ru/arrays.xml +++ b/android/app/src/main/res/values-ru/arrays.xml @@ -81,7 +81,6 @@ Загрузить состояние Сохранить состояние - Слот сохранения Включить ускорение Другие опции Выход diff --git a/android/app/src/main/res/values/arrays.xml b/android/app/src/main/res/values/arrays.xml index fee22a9be..8bba1c7de 100644 --- a/android/app/src/main/res/values/arrays.xml +++ b/android/app/src/main/res/values/arrays.xml @@ -160,7 +160,6 @@ Load State Save State - Save State Slot Toggle Fast Forward More Options Quit @@ -173,19 +172,6 @@ Change Touchscreen Controller Edit Touchscreen Controller Layout - - Quick Slot - Game Slot 1 - Game Slot 2 - Game Slot 3 - Game Slot 4 - Game Slot 5 - Game Slot 6 - Game Slot 7 - Game Slot 8 - Game Slot 9 - Game Slot 10 - None (Double Speed) 2x (Quad Speed) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index d3b805e9d..36bfd3268 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -206,4 +206,8 @@ Add Path Add Path Enter the full path to the directory with games.\n\nYou can get this from a file manager app.\n\nExample: /storage/emulated/0/games + Slot Is Empty + Game Save %d + Global Save %d + Quick Save