diff --git a/CMakeLists.txt b/CMakeLists.txt index e83ac4d4f..e615d33b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,10 +69,6 @@ if(ANDROID) message("Building for Android, disabling Discord Presence support") set(ENABLE_DISCORD_PRESENCE OFF) endif() - if(ENABLE_CHEEVOS) - message("Building for Android. disabling RetroAchievements support") - set(ENABLE_CHEEVOS OFF) - endif() if(USE_SDL2) message("Building for Android, disabling SDL2 support") set(USE_SDL2 OFF) @@ -83,6 +79,9 @@ if(ANDROID) if(USE_WAYLAND) set(USE_WAYLAND OFF) endif() + + # Cheevos are always on. + set(ENABLE_CHEEVOS ON) endif() @@ -143,7 +142,7 @@ if(USE_EVDEV) endif() if(ENABLE_CHEEVOS) message(STATUS "RetroAchievements support enabled") - if(NOT WIN32) + if(NOT WIN32 AND NOT ANDROID) find_package(CURL REQUIRED) endif() endif() diff --git a/android/app/src/cpp/CMakeLists.txt b/android/app/src/cpp/CMakeLists.txt index 22d795e50..2e3fb11ed 100644 --- a/android/app/src/cpp/CMakeLists.txt +++ b/android/app/src/cpp/CMakeLists.txt @@ -3,6 +3,8 @@ set(SRCS android_controller_interface.h android_host_interface.cpp android_host_interface.h + android_http_downloader.cpp + android_http_downloader.h android_progress_callback.cpp android_progress_callback.h android_settings_interface.cpp diff --git a/android/app/src/cpp/android_controller_interface.h b/android/app/src/cpp/android_controller_interface.h index 9374f6e39..aaa408f84 100644 --- a/android/app/src/cpp/android_controller_interface.h +++ b/android/app/src/cpp/android_controller_interface.h @@ -1,6 +1,6 @@ #pragma once -#include "frontend-common/controller_interface.h" #include "core/types.h" +#include "frontend-common/controller_interface.h" #include #include #include diff --git a/android/app/src/cpp/android_host_interface.cpp b/android/app/src/cpp/android_host_interface.cpp index 72ae1e1fa..77305d23d 100644 --- a/android/app/src/cpp/android_host_interface.cpp +++ b/android/app/src/cpp/android_host_interface.cpp @@ -15,7 +15,9 @@ #include "core/gpu.h" #include "core/host_display.h" #include "core/system.h" +#include "frontend-common/cheevos.h" #include "frontend-common/game_list.h" +#include "frontend-common/imgui_fullscreen.h" #include "frontend-common/imgui_styles.h" #include "frontend-common/opengl_host_display.h" #include "frontend-common/vulkan_host_display.h" @@ -53,8 +55,15 @@ static jclass s_GameListEntry_class; static jmethodID s_GameListEntry_constructor; static jclass s_SaveStateInfo_class; static jmethodID s_SaveStateInfo_constructor; +static jclass s_Achievement_class; +static jmethodID s_Achievement_constructor; namespace AndroidHelpers { +JavaVM* GetJavaVM() +{ + return s_jvm; +} + // helper for retrieving the current per-thread jni environment JNIEnv* GetJNIEnv() { @@ -440,6 +449,10 @@ void AndroidHostInterface::EmulationThreadLoop(JNIEnv* env) } } + // we don't do a full PollAndUpdate() here + if (Cheevos::IsActive()) + Cheevos::Update(); + // simulate the system if not paused if (System::IsRunning()) { @@ -593,8 +606,11 @@ void AndroidHostInterface::SurfaceChanged(ANativeWindow* surface, int format, in Log_InfoPrintf("SurfaceChanged %p %d %d %d", surface, format, width, height); if (m_surface == surface) { - if (m_display) + if (m_display && (width != m_display->GetWindowWidth() || height != m_display->GetWindowHeight())) + { m_display->ResizeRenderWindow(width, height); + OnHostDisplayResized(width, height, m_display->GetWindowScale()); + } return; } @@ -611,6 +627,8 @@ void AndroidHostInterface::SurfaceChanged(ANativeWindow* surface, int format, in wi.surface_scale = m_display->GetWindowScale(); m_display->ChangeRenderWindow(wi); + if (surface) + OnHostDisplayResized(width, height, m_display->GetWindowScale()); if (surface && System::GetState() == System::State::Paused) PauseSystem(false); @@ -815,7 +833,8 @@ 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, save_state_info_class; + jclass string_class, host_interface_class, patch_code_class, game_list_entry_class, save_state_info_class, + achievement_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 || @@ -825,7 +844,9 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) (game_list_entry_class = env->FindClass("com/github/stenzek/duckstation/GameListEntry")) == 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) + (s_SaveStateInfo_class = static_cast(env->NewGlobalRef(save_state_info_class))) == nullptr || + (achievement_class = env->FindClass("com/github/stenzek/duckstation/Achievement")) == nullptr || + (s_Achievement_class = static_cast(env->NewGlobalRef(achievement_class))) == nullptr) { Log_ErrorPrint("AndroidHostInterface class lookup failed"); return -1; @@ -835,6 +856,7 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) env->DeleteLocalRef(host_interface_class); env->DeleteLocalRef(patch_code_class); env->DeleteLocalRef(game_list_entry_class); + env->DeleteLocalRef(achievement_class); jclass emulation_activity_class; if ((s_AndroidHostInterface_constructor = @@ -871,7 +893,10 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) (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) + nullptr || + (s_Achievement_constructor = + env->GetMethodID(s_Achievement_class, "", + "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZ)V")) == nullptr) { Log_ErrorPrint("AndroidHostInterface lookups failed"); return -1; @@ -1647,4 +1672,107 @@ DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_toggleControllerAnalogMode, jo ctrl->SetButtonState(code.value(), true); ctrl->SetButtonState(code.value(), false); } -} \ No newline at end of file +} + +DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_setFullscreenUINotificationVerticalPosition, jobject obj, + jfloat position, jfloat direction) +{ + AndroidHostInterface* hi = AndroidHelpers::GetNativeClass(env, obj); + hi->RunOnEmulationThread( + [position, direction]() { ImGuiFullscreen::SetNotificationVerticalPosition(position, direction); }); +} + +DEFINE_JNI_ARGS_METHOD(jboolean, AndroidHostInterface_isCheevosActive, jobject obj) +{ + return Cheevos::IsActive(); +} + +DEFINE_JNI_ARGS_METHOD(jboolean, AndroidHostInterface_isCheevosChallengeModeActive, jobject obj) +{ + return Cheevos::IsChallengeModeActive(); +} + +DEFINE_JNI_ARGS_METHOD(jobjectArray, AndroidHostInterface_getCheevoList, jobject obj) +{ + if (!Cheevos::IsActive()) + return nullptr; + + std::vector cheevos; + Cheevos::EnumerateAchievements([env, &cheevos](const Cheevos::Achievement& cheevo) { + jstring title = env->NewStringUTF(cheevo.title.c_str()); + jstring description = env->NewStringUTF(cheevo.description.c_str()); + jstring locked_badge_path = + cheevo.locked_badge_path.empty() ? nullptr : env->NewStringUTF(cheevo.locked_badge_path.c_str()); + jstring unlocked_badge_path = + cheevo.unlocked_badge_path.empty() ? nullptr : env->NewStringUTF(cheevo.unlocked_badge_path.c_str()); + + jobject object = env->NewObject(s_Achievement_class, s_Achievement_constructor, static_cast(cheevo.id), title, + description, locked_badge_path, unlocked_badge_path, + static_cast(cheevo.points), static_cast(cheevo.locked)); + cheevos.push_back(object); + + if (unlocked_badge_path) + env->DeleteLocalRef(unlocked_badge_path); + if (locked_badge_path) + env->DeleteLocalRef(locked_badge_path); + env->DeleteLocalRef(description); + env->DeleteLocalRef(title); + return true; + }); + + if (cheevos.empty()) + return nullptr; + + jobjectArray ret = env->NewObjectArray(static_cast(cheevos.size()), s_Achievement_class, nullptr); + for (size_t i = 0; i < cheevos.size(); i++) + { + env->SetObjectArrayElement(ret, static_cast(i), cheevos[i]); + env->DeleteLocalRef(cheevos[i]); + } + + return ret; +} + +DEFINE_JNI_ARGS_METHOD(jint, AndroidHostInterface_getCheevoCount, jobject obj) +{ + return Cheevos::GetAchievementCount(); +} + +DEFINE_JNI_ARGS_METHOD(jint, AndroidHostInterface_getUnlockedCheevoCount, jobject obj) +{ + return Cheevos::GetUnlockedAchiementCount(); +} + +DEFINE_JNI_ARGS_METHOD(jint, AndroidHostInterface_getCheevoPointsForGame, jobject obj) +{ + return Cheevos::GetCurrentPointsForGame(); +} + +DEFINE_JNI_ARGS_METHOD(jint, AndroidHostInterface_getCheevoMaximumPointsForGame, jobject obj) +{ + return Cheevos::GetMaximumPointsForGame(); +} + +DEFINE_JNI_ARGS_METHOD(jstring, AndroidHostInterface_getCheevoGameTitle, jobject obj) +{ + const std::string& title = Cheevos::GetGameTitle(); + return title.empty() ? nullptr : env->NewStringUTF(title.c_str()); +} + +DEFINE_JNI_ARGS_METHOD(jstring, AndroidHostInterface_getCheevoGameIconPath, jobject obj) +{ + const std::string& path = Cheevos::GetGameIcon(); + return path.empty() ? nullptr : env->NewStringUTF(path.c_str()); +} + +DEFINE_JNI_ARGS_METHOD(jboolean, AndroidHostInterface_cheevosLogin, jobject obj, jstring username, jstring password) +{ + const std::string username_str(AndroidHelpers::JStringToString(env, username)); + const std::string password_str(AndroidHelpers::JStringToString(env, password)); + return Cheevos::Login(username_str.c_str(), password_str.c_str()); +} + +DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_cheevosLogout, jobject obj) +{ + return Cheevos::Logout(); +} diff --git a/android/app/src/cpp/android_host_interface.h b/android/app/src/cpp/android_host_interface.h index 813b0327f..c23193fca 100644 --- a/android/app/src/cpp/android_host_interface.h +++ b/android/app/src/cpp/android_host_interface.h @@ -109,6 +109,7 @@ private: namespace AndroidHelpers { +JavaVM* GetJavaVM(); JNIEnv* GetJNIEnv(); AndroidHostInterface* GetNativeClass(JNIEnv* env, jobject obj); std::string JStringToString(JNIEnv* env, jstring str); diff --git a/android/app/src/cpp/android_http_downloader.cpp b/android/app/src/cpp/android_http_downloader.cpp new file mode 100644 index 000000000..f3a1b895c --- /dev/null +++ b/android/app/src/cpp/android_http_downloader.cpp @@ -0,0 +1,163 @@ +#include "android_http_downloader.h" +#include "android_host_interface.h" +#include "common/assert.h" +#include "common/log.h" +#include "common/string_util.h" +#include "common/timer.h" +#include +#include +Log_SetChannel(AndroidHTTPDownloader); + +namespace FrontendCommon { + +AndroidHTTPDownloader::AndroidHTTPDownloader() : HTTPDownloader() {} + +AndroidHTTPDownloader::~AndroidHTTPDownloader() +{ + JNIEnv* env = AndroidHelpers::GetJNIEnv(); + if (m_URLDownloader_class) + env->DeleteGlobalRef(m_URLDownloader_class); +} + +std::unique_ptr HTTPDownloader::Create() +{ + std::unique_ptr instance(std::make_unique()); + if (!instance->Initialize()) + return {}; + + return instance; +} + +bool AndroidHTTPDownloader::Initialize() +{ + JNIEnv* env = AndroidHelpers::GetJNIEnv(); + jclass klass = env->FindClass("com/github/stenzek/duckstation/URLDownloader"); + if (!klass) + return false; + + m_URLDownloader_class = static_cast(env->NewGlobalRef(klass)); + if (!m_URLDownloader_class) + return false; + + m_URLDownloader_constructor = env->GetMethodID(klass, "", "()V"); + m_URLDownloader_get = env->GetMethodID(klass, "get", "(Ljava/lang/String;)Z"); + m_URLDownloader_post = env->GetMethodID(klass, "post", "(Ljava/lang/String;[B)Z"); + m_URLDownloader_getStatusCode = env->GetMethodID(klass, "getStatusCode", "()I"); + m_URLDownloader_getData = env->GetMethodID(klass, "getData", "()[B"); + if (!m_URLDownloader_constructor || !m_URLDownloader_get || !m_URLDownloader_post || !m_URLDownloader_getStatusCode || + !m_URLDownloader_getData) + { + return false; + } + + m_thread_pool = std::make_unique(m_max_active_requests); + return true; +} + +void AndroidHTTPDownloader::ProcessRequest(Request* req) +{ + std::unique_lock cancel_lock(m_cancel_mutex); + if (req->closed.load()) + return; + + cancel_lock.unlock(); + req->status_code = -1; + req->start_time = Common::Timer::GetValue(); + + // TODO: Move to Java side... + JNIEnv* env; + if (AndroidHelpers::GetJavaVM()->AttachCurrentThread(&env, nullptr) == JNI_OK) + { + jobject obj = env->NewObject(m_URLDownloader_class, m_URLDownloader_constructor); + jstring url_string = env->NewStringUTF(req->url.c_str()); + jboolean result; + if (req->post_data.empty()) + { + result = env->CallBooleanMethod(obj, m_URLDownloader_get, url_string); + } + else + { + jbyteArray post_data = env->NewByteArray(static_cast(req->post_data.size())); + env->SetByteArrayRegion(post_data, 0, static_cast(req->post_data.size()), + reinterpret_cast(req->post_data.data())); + result = env->CallBooleanMethod(obj, m_URLDownloader_post, url_string, post_data); + env->DeleteLocalRef(post_data); + } + + env->DeleteLocalRef(url_string); + + if (result) + { + req->status_code = env->CallIntMethod(obj, m_URLDownloader_getStatusCode); + + jbyteArray data = reinterpret_cast(env->CallObjectMethod(obj, m_URLDownloader_getData)); + if (data) + { + const u32 size = static_cast(env->GetArrayLength(data)); + req->data.resize(size); + if (size > 0) + { + jbyte* data_ptr = env->GetByteArrayElements(data, nullptr); + std::memcpy(req->data.data(), data_ptr, size); + env->ReleaseByteArrayElements(data, data_ptr, 0); + } + + env->DeleteLocalRef(data); + } + + Log_DevPrintf("Request for '%s' returned status code %d and %zu bytes", req->url.c_str(), req->status_code, + req->data.size()); + } + else + { + Log_ErrorPrintf("Request for '%s' failed", req->url.c_str()); + } + + env->DeleteLocalRef(obj); + AndroidHelpers::GetJavaVM()->DetachCurrentThread(); + } + else + { + Log_ErrorPrintf("AttachCurrentThread() failed"); + } + + cancel_lock.lock(); + req->state = Request::State::Complete; + if (req->closed.load()) + delete req; + else + req->closed.store(true); +} + +HTTPDownloader::Request* AndroidHTTPDownloader::InternalCreateRequest() +{ + Request* req = new Request(); + return req; +} + +void AndroidHTTPDownloader::InternalPollRequests() +{ + // noop - uses thread pool +} + +bool AndroidHTTPDownloader::StartRequest(HTTPDownloader::Request* request) +{ + Request* req = static_cast(request); + Log_DevPrintf("Started HTTP request for '%s'", req->url.c_str()); + req->state = Request::State::Started; + req->start_time = Common::Timer::GetValue(); + m_thread_pool->Schedule(std::bind(&AndroidHTTPDownloader::ProcessRequest, this, req)); + return true; +} + +void AndroidHTTPDownloader::CloseRequest(HTTPDownloader::Request* request) +{ + std::unique_lock cancel_lock(m_cancel_mutex); + Request* req = static_cast(request); + if (req->closed.load()) + delete req; + else + req->closed.store(true); +} + +} // namespace FrontendCommon diff --git a/android/app/src/cpp/android_http_downloader.h b/android/app/src/cpp/android_http_downloader.h new file mode 100644 index 000000000..420627cbb --- /dev/null +++ b/android/app/src/cpp/android_http_downloader.h @@ -0,0 +1,44 @@ +#pragma once +#include "common/thirdparty/thread_pool.h" +#include "frontend-common/http_downloader.h" +#include +#include +#include +#include + +namespace FrontendCommon { + +class AndroidHTTPDownloader final : public HTTPDownloader +{ +public: + AndroidHTTPDownloader(); + ~AndroidHTTPDownloader() override; + + bool Initialize(); + +protected: + Request* InternalCreateRequest() override; + void InternalPollRequests() override; + bool StartRequest(HTTPDownloader::Request* request) override; + void CloseRequest(HTTPDownloader::Request* request) override; + +private: + struct Request : HTTPDownloader::Request + { + std::atomic_bool closed{false}; + }; + + void ProcessRequest(Request* req); + + std::unique_ptr m_thread_pool; + std::mutex m_cancel_mutex; + + jclass m_URLDownloader_class = nullptr; + jmethodID m_URLDownloader_constructor = nullptr; + jmethodID m_URLDownloader_get = nullptr; + jmethodID m_URLDownloader_post = nullptr; + jmethodID m_URLDownloader_getStatusCode = nullptr; + jmethodID m_URLDownloader_getData = nullptr; +}; + +} // namespace FrontendCommon diff --git a/android/app/src/cpp/android_progress_callback.cpp b/android/app/src/cpp/android_progress_callback.cpp index 36efb3583..a25d02624 100644 --- a/android/app/src/cpp/android_progress_callback.cpp +++ b/android/app/src/cpp/android_progress_callback.cpp @@ -1,11 +1,10 @@ #include "android_progress_callback.h" #include "android_host_interface.h" -#include "common/log.h" #include "common/assert.h" +#include "common/log.h" Log_SetChannel(AndroidProgressCallback); -AndroidProgressCallback::AndroidProgressCallback(JNIEnv* env, jobject java_object) - : m_java_object(java_object) +AndroidProgressCallback::AndroidProgressCallback(JNIEnv* env, jobject java_object) : m_java_object(java_object) { jclass cls = env->GetObjectClass(java_object); m_set_title_method = env->GetMethodID(cls, "setTitle", "(Ljava/lang/String;)V"); @@ -15,7 +14,8 @@ AndroidProgressCallback::AndroidProgressCallback(JNIEnv* env, jobject java_objec m_modal_error_method = env->GetMethodID(cls, "modalError", "(Ljava/lang/String;)V"); m_modal_information_method = env->GetMethodID(cls, "modalInformation", "(Ljava/lang/String;)V"); m_modal_confirmation_method = env->GetMethodID(cls, "modalConfirmation", "(Ljava/lang/String;)Z"); - Assert(m_set_status_text_method && m_set_progress_range_method && m_set_progress_value_method && m_modal_error_method && m_modal_information_method && m_modal_confirmation_method); + Assert(m_set_status_text_method && m_set_progress_range_method && m_set_progress_value_method && + m_modal_error_method && m_modal_information_method && m_modal_confirmation_method); } AndroidProgressCallback::~AndroidProgressCallback() = default; diff --git a/android/app/src/cpp/android_settings_interface.cpp b/android/app/src/cpp/android_settings_interface.cpp index 0cfcf1f53..52af477c8 100644 --- a/android/app/src/cpp/android_settings_interface.cpp +++ b/android/app/src/cpp/android_settings_interface.cpp @@ -56,16 +56,25 @@ AndroidSettingsInterface::AndroidSettingsInterface(jobject java_context) Assert(m_get_boolean && m_get_int && m_get_float && m_get_string && m_get_string_set && m_set_to_array); m_edit = env->GetMethodID(m_shared_preferences_class, "edit", "()Landroid/content/SharedPreferences$Editor;"); - m_edit_set_string = env->GetMethodID(m_shared_preferences_editor_class, "putString", "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;"); + m_edit_set_string = + env->GetMethodID(m_shared_preferences_editor_class, "putString", + "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;"); m_edit_commit = env->GetMethodID(m_shared_preferences_editor_class, "commit", "()Z"); - m_edit_remove = env->GetMethodID(m_shared_preferences_editor_class, "remove", "(Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;"); + m_edit_remove = env->GetMethodID(m_shared_preferences_editor_class, "remove", + "(Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;"); Assert(m_edit && m_edit_set_string && m_edit_commit && m_edit_remove); - m_helper_clear_section = env->GetStaticMethodID(m_helper_class, "clearSection", "(Landroid/content/SharedPreferences;Ljava/lang/String;)V"); - m_helper_add_to_string_list = env->GetStaticMethodID(m_helper_class, "addToStringList", "(Landroid/content/SharedPreferences;Ljava/lang/String;Ljava/lang/String;)Z"); - m_helper_remove_from_string_list = env->GetStaticMethodID(m_helper_class, "removeFromStringList", "(Landroid/content/SharedPreferences;Ljava/lang/String;Ljava/lang/String;)Z"); - m_helper_set_string_list = env->GetStaticMethodID(m_helper_class, "setStringList", "(Landroid/content/SharedPreferences;Ljava/lang/String;[Ljava/lang/String;)V"); - Assert(m_helper_clear_section && m_helper_add_to_string_list && m_helper_remove_from_string_list && m_helper_set_string_list); + m_helper_clear_section = + env->GetStaticMethodID(m_helper_class, "clearSection", "(Landroid/content/SharedPreferences;Ljava/lang/String;)V"); + m_helper_add_to_string_list = env->GetStaticMethodID( + m_helper_class, "addToStringList", "(Landroid/content/SharedPreferences;Ljava/lang/String;Ljava/lang/String;)Z"); + m_helper_remove_from_string_list = + env->GetStaticMethodID(m_helper_class, "removeFromStringList", + "(Landroid/content/SharedPreferences;Ljava/lang/String;Ljava/lang/String;)Z"); + m_helper_set_string_list = env->GetStaticMethodID( + m_helper_class, "setStringList", "(Landroid/content/SharedPreferences;Ljava/lang/String;[Ljava/lang/String;)V"); + Assert(m_helper_clear_section && m_helper_add_to_string_list && m_helper_remove_from_string_list && + m_helper_set_string_list); } AndroidSettingsInterface::~AndroidSettingsInterface() @@ -212,7 +221,7 @@ jobject AndroidSettingsInterface::GetPreferencesEditor(JNIEnv* env) return env->CallObjectMethod(m_java_shared_preferences, m_edit); } -void AndroidSettingsInterface::CheckForException(JNIEnv *env, const char *task) +void AndroidSettingsInterface::CheckForException(JNIEnv* env, const char* task) { if (!env->ExceptionCheck()) return; @@ -230,7 +239,8 @@ void AndroidSettingsInterface::SetIntValue(const char* section, const char* key, LocalRefHolder key_string(env, env->NewStringUTF(GetSettingKey(section, key))); LocalRefHolder str_value(env, env->NewStringUTF(TinyString::FromFormat("%d", value))); - LocalRefHolder dummy(env, env->CallObjectMethod(editor, m_edit_set_string, key_string.Get(), str_value.Get())); + LocalRefHolder dummy(env, + env->CallObjectMethod(editor, m_edit_set_string, key_string.Get(), str_value.Get())); env->CallBooleanMethod(editor, m_edit_commit); CheckForException(env, "SetIntValue"); @@ -245,7 +255,8 @@ void AndroidSettingsInterface::SetFloatValue(const char* section, const char* ke LocalRefHolder key_string(env, env->NewStringUTF(GetSettingKey(section, key))); LocalRefHolder str_value(env, env->NewStringUTF(TinyString::FromFormat("%f", value))); - LocalRefHolder dummy(env, env->CallObjectMethod(editor, m_edit_set_string, key_string.Get(), str_value.Get())); + LocalRefHolder dummy(env, + env->CallObjectMethod(editor, m_edit_set_string, key_string.Get(), str_value.Get())); env->CallBooleanMethod(editor, m_edit_commit); CheckForException(env, "SetFloatValue"); @@ -260,7 +271,8 @@ void AndroidSettingsInterface::SetBoolValue(const char* section, const char* key LocalRefHolder key_string(env, env->NewStringUTF(GetSettingKey(section, key))); LocalRefHolder str_value(env, env->NewStringUTF(value ? "true" : "false")); - LocalRefHolder dummy(env, env->CallObjectMethod(editor, m_edit_set_string, key_string.Get(), str_value.Get())); + LocalRefHolder dummy(env, + env->CallObjectMethod(editor, m_edit_set_string, key_string.Get(), str_value.Get())); env->CallBooleanMethod(editor, m_edit_commit); CheckForException(env, "SetBoolValue"); @@ -275,7 +287,8 @@ void AndroidSettingsInterface::SetStringValue(const char* section, const char* k LocalRefHolder key_string(env, env->NewStringUTF(GetSettingKey(section, key))); LocalRefHolder str_value(env, env->NewStringUTF(value)); - LocalRefHolder dummy(env, env->CallObjectMethod(editor, m_edit_set_string, key_string.Get(), str_value.Get())); + LocalRefHolder dummy(env, + env->CallObjectMethod(editor, m_edit_set_string, key_string.Get(), str_value.Get())); env->CallBooleanMethod(editor, m_edit_commit); CheckForException(env, "SetStringValue"); @@ -316,10 +329,11 @@ std::vector AndroidSettingsInterface::GetStringList(const char* sec env->ExceptionClear(); // this might just be a string, not a string set - LocalRefHolder string_object( - env, reinterpret_cast(env->CallObjectMethod(m_java_shared_preferences, m_get_string, key_string.Get(), nullptr))); + LocalRefHolder string_object(env, reinterpret_cast(env->CallObjectMethod( + m_java_shared_preferences, m_get_string, key_string.Get(), nullptr))); - if (!env->ExceptionCheck()) { + if (!env->ExceptionCheck()) + { std::vector ret; if (string_object) ret.push_back(AndroidHelpers::JStringToString(env, string_object)); @@ -369,7 +383,8 @@ void AndroidSettingsInterface::SetStringList(const char* section, const char* ke } JNIEnv* env = AndroidHelpers::GetJNIEnv(); - LocalRefHolder items_array(env, env->NewObjectArray(static_cast(items.size()), AndroidHelpers::GetStringClass(), nullptr)); + LocalRefHolder items_array( + env, env->NewObjectArray(static_cast(items.size()), AndroidHelpers::GetStringClass(), nullptr)); for (size_t i = 0; i < items.size(); i++) { LocalRefHolder item_jstr(env, env->NewStringUTF(items[i].c_str())); @@ -377,7 +392,8 @@ void AndroidSettingsInterface::SetStringList(const char* section, const char* ke } LocalRefHolder key_string(env, env->NewStringUTF(GetSettingKey(section, key))); - env->CallStaticVoidMethod(m_helper_class, m_helper_set_string_list, m_java_shared_preferences, key_string.Get(), items_array.Get()); + env->CallStaticVoidMethod(m_helper_class, m_helper_set_string_list, m_java_shared_preferences, key_string.Get(), + items_array.Get()); CheckForException(env, "SetStringList"); } @@ -389,7 +405,8 @@ bool AndroidSettingsInterface::RemoveFromStringList(const char* section, const c JNIEnv* env = AndroidHelpers::GetJNIEnv(); LocalRefHolder key_string(env, env->NewStringUTF(GetSettingKey(section, key))); LocalRefHolder item_string(env, env->NewStringUTF(item)); - const bool result = env->CallStaticBooleanMethod(m_helper_class, m_helper_remove_from_string_list, m_java_shared_preferences, key_string.Get(), item_string.Get()); + const bool result = env->CallStaticBooleanMethod(m_helper_class, m_helper_remove_from_string_list, + m_java_shared_preferences, key_string.Get(), item_string.Get()); CheckForException(env, "RemoveFromStringList"); return result; } @@ -401,7 +418,8 @@ bool AndroidSettingsInterface::AddToStringList(const char* section, const char* JNIEnv* env = AndroidHelpers::GetJNIEnv(); LocalRefHolder key_string(env, env->NewStringUTF(GetSettingKey(section, key))); LocalRefHolder item_string(env, env->NewStringUTF(item)); - const bool result = env->CallStaticBooleanMethod(m_helper_class, m_helper_add_to_string_list, m_java_shared_preferences, key_string.Get(), item_string.Get()); + const bool result = env->CallStaticBooleanMethod(m_helper_class, m_helper_add_to_string_list, + m_java_shared_preferences, key_string.Get(), item_string.Get()); CheckForException(env, "AddToStringList"); return result; } diff --git a/android/app/src/cpp/opensles_audio_stream.cpp b/android/app/src/cpp/opensles_audio_stream.cpp index 13d32724d..70f2c207c 100644 --- a/android/app/src/cpp/opensles_audio_stream.cpp +++ b/android/app/src/cpp/opensles_audio_stream.cpp @@ -129,8 +129,8 @@ bool OpenSLESAudioStream::OpenDevice() for (u32 i = 0; i < NUM_BUFFERS; i++) m_buffers[i] = std::make_unique(m_buffer_size * m_channels); - Log_InfoPrintf("OpenSL ES device opened: %uhz, %u channels, %u buffer size, %u buffers", - m_output_sample_rate, m_channels, m_buffer_size, NUM_BUFFERS); + Log_InfoPrintf("OpenSL ES device opened: %uhz, %u channels, %u buffer size, %u buffers", m_output_sample_rate, + m_channels, m_buffer_size, NUM_BUFFERS); return true; } @@ -139,7 +139,8 @@ void OpenSLESAudioStream::PauseDevice(bool paused) if (m_paused == paused) return; - SLresult res = (*m_play_interface)->SetPlayState(m_play_interface, paused ? SL_PLAYSTATE_PAUSED : SL_PLAYSTATE_PLAYING); + SLresult res = + (*m_play_interface)->SetPlayState(m_play_interface, paused ? SL_PLAYSTATE_PAUSED : SL_PLAYSTATE_PLAYING); if (res != SL_RESULT_SUCCESS) Log_ErrorPrintf("SetPlayState failed: %d", res); diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5bd400aac..96af607bb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + + android:theme="@style/AppTheme" + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config"> { + if (o2.isLocked() && !o1.isLocked()) + return -1; + else if (o1.isLocked() && !o2.isLocked()) + return 1; + + return o1.getName().compareTo(o2.getName()); + }); + } + + private static class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener { + private final View mItemView; + + public ViewHolder(@NonNull View itemView) { + super(itemView); + mItemView = itemView; + mItemView.setOnClickListener(this); + mItemView.setOnLongClickListener(this); + } + + public void bindToEntry(Achievement cheevo) { + ImageView icon = ((ImageView) mItemView.findViewById(R.id.icon)); + icon.setImageDrawable(mItemView.getContext().getDrawable(R.drawable.ic_baseline_lock_24)); + + final String badgePath = cheevo.getBadgePath(); + if (badgePath != null) { + new ImageLoadTask(icon).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, badgePath); + } + + ((TextView) mItemView.findViewById(R.id.title)).setText(cheevo.getName()); + ((TextView) mItemView.findViewById(R.id.description)).setText(cheevo.getDescription()); + + ((ImageView) mItemView.findViewById(R.id.locked_icon)).setImageDrawable( + mItemView.getContext().getDrawable(cheevo.isLocked() ? R.drawable.ic_baseline_lock_24 : R.drawable.ic_baseline_lock_open_24)); + + final String pointsString = String.format(mItemView.getContext().getString(R.string.achievement_points_format_string), cheevo.getPoints()); + ((TextView) mItemView.findViewById(R.id.points)).setText(pointsString); + } + + @Override + public void onClick(View v) { + // + } + + @Override + public boolean onLongClick(View v) { + return false; + } + } + + private static class ViewAdapter extends RecyclerView.Adapter { + private final LayoutInflater mInflater; + private final Achievement[] mAchievements; + + public ViewAdapter(@NonNull Context context, Achievement[] achievements) { + mInflater = LayoutInflater.from(context); + mAchievements = achievements; + } + + @NonNull + @Override + public AchievementListFragment.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new AchievementListFragment.ViewHolder(mInflater.inflate(R.layout.layout_achievement_entry, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull AchievementListFragment.ViewHolder holder, int position) { + holder.bindToEntry(mAchievements[position]); + } + + @Override + public int getItemCount() { + return (mAchievements != null) ? mAchievements.length : 0; + } + + @Override + public int getItemViewType(int position) { + return R.layout.layout_game_list_entry; + } + } +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/AchievementSettingsFragment.java b/android/app/src/main/java/com/github/stenzek/duckstation/AchievementSettingsFragment.java new file mode 100644 index 000000000..0b4c1a149 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/AchievementSettingsFragment.java @@ -0,0 +1,266 @@ +package com.github.stenzek.duckstation; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceScreen; + +import java.net.URLEncoder; +import java.text.DateFormat; +import java.util.Date; +import java.util.Locale; + +public class AchievementSettingsFragment extends PreferenceFragmentCompat implements Preference.OnPreferenceClickListener { + private static final String REGISTER_URL = "http://retroachievements.org/createaccount.php"; + private static final String PROFILE_URL_PREFIX = "https://retroachievements.org/user/"; + + private boolean isLoggedIn = false; + private String username; + private String loginTokenTime; + + public AchievementSettingsFragment() { + } + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.achievement_preferences, rootKey); + updateViews(); + } + + private void updateViews() { + final SharedPreferences prefs = getPreferenceManager().getSharedPreferences(); + + username = prefs.getString("Cheevos/Username", ""); + isLoggedIn = (username != null && !username.isEmpty()); + if (isLoggedIn) { + try { + final String loginTokenTimeString = prefs.getString("Cheevos/LoginTimestamp", ""); + final long loginUnixTimestamp = Long.parseLong(loginTokenTimeString); + + // TODO: Extract to a helper function. + final Date date = new Date(loginUnixTimestamp * 1000); + final DateFormat format = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT, Locale.getDefault()); + loginTokenTime = format.format(date); + } catch (Exception e) { + loginTokenTime = null; + } + } + + final PreferenceScreen preferenceScreen = getPreferenceScreen(); + + Preference preference = preferenceScreen.findPreference("Cheevos/ChallengeMode"); + if (preference != null) { + // toggling this is disabled while it's running to avoid the whole power off thing + preference.setEnabled(!AndroidHostInterface.getInstance().isEmulationThreadRunning()); + } + + preference = preferenceScreen.findPreference("Cheevos/Login"); + if (preference != null) + { + preference.setVisible(!isLoggedIn); + preference.setOnPreferenceClickListener(this); + } + + preference = preferenceScreen.findPreference("Cheevos/Register"); + if (preference != null) + { + preference.setVisible(!isLoggedIn); + preference.setOnPreferenceClickListener(this); + } + + preference = preferenceScreen.findPreference("Cheevos/Logout"); + if (preference != null) + { + preference.setVisible(isLoggedIn); + preference.setOnPreferenceClickListener(this); + } + + preference = preferenceScreen.findPreference("Cheevos/Username"); + if (preference != null) + { + preference.setVisible(isLoggedIn); + preference.setSummary((username != null) ? username : ""); + } + + preference = preferenceScreen.findPreference("Cheevos/LoginTokenTime"); + if (preference != null) + { + preference.setVisible(isLoggedIn); + preference.setSummary((loginTokenTime != null) ? loginTokenTime : ""); + } + + preference = preferenceScreen.findPreference("Cheevos/ViewProfile"); + if (preference != null) + { + preference.setVisible(isLoggedIn); + preference.setOnPreferenceClickListener(this); + } + } + + @Override + public boolean onPreferenceClick(Preference preference) { + final String key = preference.getKey(); + if (key == null) + return false; + + switch (key) + { + case "Cheevos/Login": + { + handleLogin(); + return true; + } + + case "Cheevos/Logout": + { + handleLogout(); + return true; + } + + case "Cheevos/Register": + { + openUrl(REGISTER_URL); + return true; + } + + case "Cheevos/ViewProfile": + { + final String profileUrl = getProfileUrl(username); + if (profileUrl != null) + openUrl(profileUrl); + + return true; + } + + default: + return false; + } + } + + private void openUrl(String url) { + final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + startActivity(browserIntent); + } + + private void handleLogin() { + LoginDialogFragment loginDialog = new LoginDialogFragment(this); + loginDialog.show(getFragmentManager(), "fragment_achievement_login"); + } + + private void handleLogout() { + final AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); + builder.setTitle(R.string.settings_achievements_confirm_logout_title); + builder.setMessage(R.string.settings_achievements_confirm_logout_message); + builder.setPositiveButton(R.string.settings_achievements_logout, (dialog, which) -> { + AndroidHostInterface.getInstance().cheevosLogout(); + updateViews(); + }); + builder.setNegativeButton(R.string.achievement_settings_login_cancel_button, (dialog, which) -> dialog.dismiss()); + builder.create().show(); + } + + private static String getProfileUrl(String username) { + try { + final String encodedUsername = URLEncoder.encode(username, "UTF-8"); + return PROFILE_URL_PREFIX + encodedUsername; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + public static class LoginDialogFragment extends DialogFragment { + private AchievementSettingsFragment mParent; + + public LoginDialogFragment(AchievementSettingsFragment parent) { + mParent = parent; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_achievements_login, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + ((Button)view.findViewById(R.id.login)).setOnClickListener((View.OnClickListener) v -> doLogin()); + ((Button)view.findViewById(R.id.cancel)).setOnClickListener((View.OnClickListener) v -> dismiss()); + } + + private static class LoginTask extends AsyncTask { + private LoginDialogFragment mParent; + private String mUsername; + private String mPassword; + private boolean mResult; + + public LoginTask(LoginDialogFragment parent, String username, String password) { + mParent = parent; + mUsername = username; + mPassword = password; + } + + @Override + protected Void doInBackground(Void... voids) { + final Activity activity = mParent.getActivity(); + if (activity == null) + return null; + + mResult = AndroidHostInterface.getInstance().cheevosLogin(mUsername, mPassword); + + activity.runOnUiThread(() -> { + if (!mResult) { + ((TextView) mParent.getView().findViewById(R.id.error)).setText(R.string.achievement_settings_login_failed); + mParent.enableUi(true); + return; + } + + mParent.mParent.updateViews(); + mParent.dismiss(); + }); + + return null; + } + } + + private void doLogin() { + final View rootView = getView(); + final String username = ((EditText)rootView.findViewById(R.id.username)).getText().toString(); + final String password = ((EditText)rootView.findViewById(R.id.password)).getText().toString(); + if (username == null || username.length() == 0 || password == null || password.length() == 0) + return; + + enableUi(false); + ((TextView)rootView.findViewById(R.id.error)).setText(""); + new LoginDialogFragment.LoginTask(this, username, password).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private void enableUi(boolean enabled) { + final View rootView = getView(); + ((EditText)rootView.findViewById(R.id.username)).setEnabled(enabled); + ((EditText)rootView.findViewById(R.id.password)).setEnabled(enabled); + ((Button)rootView.findViewById(R.id.login)).setEnabled(enabled); + ((Button)rootView.findViewById(R.id.cancel)).setEnabled(enabled); + ((ProgressBar)rootView.findViewById(R.id.progressBar)).setVisibility(enabled ? View.GONE : View.VISIBLE); + } + } +} 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 04b374a58..d8dc29fda 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 @@ -144,6 +144,20 @@ public class AndroidHostInterface { public native SaveStateInfo[] getSaveStateInfo(boolean includeEmpty); + public native void setFullscreenUINotificationVerticalPosition(float position, float direction); + + public native boolean isCheevosActive(); + public native boolean isCheevosChallengeModeActive(); + public native Achievement[] getCheevoList(); + public native int getCheevoCount(); + public native int getUnlockedCheevoCount(); + public native int getCheevoPointsForGame(); + public native int getCheevoMaximumPointsForGame(); + public native String getCheevoGameTitle(); + public native String getCheevoGameIconPath(); + public native boolean cheevosLogin(String username, String password); + public native void cheevosLogout(); + 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 8518bb39e..1f7b145a6 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 @@ -1,6 +1,7 @@ package com.github.stenzek.duckstation; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; @@ -26,6 +27,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.FragmentManager; import androidx.preference.PreferenceManager; /** @@ -392,6 +394,21 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde AndroidHostInterface.getInstance().pauseEmulationThread(false); } + private boolean disableDialogMenuItem(AlertDialog dialog, int index) { + final ListView listView = dialog.getListView(); + if (listView == null) + return false; + + final View childItem = listView.getChildAt(index); + if (childItem == null) + return false; + + childItem.setEnabled(false); + childItem.setClickable(false); + childItem.setOnClickListener((v) -> {}); + return true; + } + private void showMenu() { if (getBooleanSetting("Main/PauseOnMenu", false) && !AndroidHostInterface.getInstance().isEmulationThreadPaused()) { @@ -401,6 +418,7 @@ 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: // Load State @@ -422,13 +440,19 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde return; } - case 3: // More Options + case 3: // Achievements + { + showAchievementsPopup(); + return; + } + + case 4: // More Options { showMoreMenu(); return; } - case 4: // Quit + case 5: // Quit { mStopRequested = true; finish(); @@ -437,7 +461,18 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde } }); builder.setOnCancelListener(dialogInterface -> onMenuClosed()); - builder.create().show(); + + final AlertDialog dialog = builder.create(); + dialog.setOnShowListener(dialogInterface -> { + // Disable cheevos if not loaded. + if (AndroidHostInterface.getInstance().getCheevoCount() == 0) + disableDialogMenuItem(dialog, 3); + + // Disable load state for challenge mode. + if (AndroidHostInterface.getInstance().isCheevosChallengeModeActive()) + disableDialogMenuItem(dialog, 0); + }); + dialog.show(); } private void showSaveStateMenu(boolean saving) { @@ -512,7 +547,14 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde } }); builder.setOnCancelListener(dialogInterface -> onMenuClosed()); - builder.create().show(); + + final AlertDialog dialog = builder.create(); + dialog.setOnShowListener(dialogInterface -> { + // Disable patch codes when challenge mode is active. + if (AndroidHostInterface.getInstance().isCheevosChallengeModeActive()) + disableDialogMenuItem(dialog, 1); + }); + dialog.show(); } private void showTouchscreenControllerMenu() { @@ -660,6 +702,18 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde builder.create().show(); } + private void showAchievementsPopup() { + final Achievement[] achievements = AndroidHostInterface.getInstance().getCheevoList(); + if (achievements == null) { + onMenuClosed(); + return; + } + + final AchievementListFragment alf = new AchievementListFragment(achievements); + alf.show(getSupportFragmentManager(), "fragment_achievement_list"); + alf.setOnDismissListener(dialog -> onMenuClosed()); + } + /** * Touchscreen controller overlay */ @@ -697,6 +751,16 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde mVibratorService = (Vibrator) getSystemService(VIBRATOR_SERVICE); else mVibratorService = null; + + // Place notifications in the middle of the screen, rather then the bottom (because touchscreen). + float notificationVerticalPosition = 1.0f; + float notificationVerticalDirection = -1.0f; + if (mTouchscreenController != null) { + notificationVerticalPosition = 0.3f; + notificationVerticalDirection = -1.0f; + } + AndroidHostInterface.getInstance().setFullscreenUINotificationVerticalPosition( + notificationVerticalPosition, notificationVerticalDirection); } private InputManager.InputDeviceListener mInputDeviceListener; diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/GameGridFragment.java b/android/app/src/main/java/com/github/stenzek/duckstation/GameGridFragment.java index 3f21c0a5a..d72b80501 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/GameGridFragment.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/GameGridFragment.java @@ -98,26 +98,7 @@ public class GameGridFragment extends Fragment implements GameList.OnRefreshList @Override public boolean onLongClick(View v) { - PopupMenu menu = new PopupMenu(mParent, v, Gravity.RIGHT | Gravity.TOP); - menu.getMenuInflater().inflate(R.menu.menu_game_list_entry, menu.getMenu()); - menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - int id = item.getItemId(); - if (id == R.id.game_list_entry_menu_start_game) { - mParent.startEmulation(mEntry.getPath(), false); - return true; - } else if (id == R.id.game_list_entry_menu_resume_game) { - mParent.startEmulation(mEntry.getPath(), true); - return true; - } else if (id == R.id.game_list_entry_menu_properties) { - mParent.openGameProperties(mEntry.getPath()); - return true; - } - return false; - } - }); - menu.show(); + mParent.openGamePopupMenu(v, mEntry); return true; } } diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/GameListFragment.java b/android/app/src/main/java/com/github/stenzek/duckstation/GameListFragment.java index 21f02f5a3..bef38bd95 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/GameListFragment.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/GameListFragment.java @@ -167,26 +167,7 @@ public class GameListFragment extends Fragment implements GameList.OnRefreshList @Override public boolean onLongClick(View v) { - androidx.appcompat.widget.PopupMenu menu = new androidx.appcompat.widget.PopupMenu(mParent, v, Gravity.RIGHT | Gravity.TOP); - menu.getMenuInflater().inflate(R.menu.menu_game_list_entry, menu.getMenu()); - menu.setOnMenuItemClickListener(new androidx.appcompat.widget.PopupMenu.OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - int id = item.getItemId(); - if (id == R.id.game_list_entry_menu_start_game) { - mParent.startEmulation(mEntry.getPath(), false); - return true; - } else if (id == R.id.game_list_entry_menu_resume_game) { - mParent.startEmulation(mEntry.getPath(), true); - return true; - } else if (id == R.id.game_list_entry_menu_properties) { - mParent.openGameProperties(mEntry.getPath()); - return true; - } - return false; - } - }); - menu.show(); + mParent.openGamePopupMenu(v, mEntry); return true; } } 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 f27287e89..9db6d5e48 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 @@ -12,6 +12,7 @@ import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; +import android.view.Gravity; import android.view.Menu; import android.view.MenuItem; import android.view.View; @@ -65,7 +66,14 @@ public class MainActivity extends AppCompatActivity { public boolean shouldResumeStateByDefault() { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - return prefs.getBoolean("Main/SaveStateOnExit", true); + if (!prefs.getBoolean("Main/SaveStateOnExit", true)) + return false; + + // don't resume with challenge mode on + if (Achievement.willChallengeModeBeEnabled(this)) + return false; + + return true; } private void setLanguage() { @@ -341,6 +349,34 @@ public class MainActivity extends AppCompatActivity { return true; } + public void openGamePopupMenu(View anchorToView, GameListEntry entry) { + androidx.appcompat.widget.PopupMenu menu = new androidx.appcompat.widget.PopupMenu(this, anchorToView, Gravity.RIGHT | Gravity.TOP); + menu.getMenuInflater().inflate(R.menu.menu_game_list_entry, menu.getMenu()); + menu.setOnMenuItemClickListener(item -> { + int id = item.getItemId(); + if (id == R.id.game_list_entry_menu_start_game) { + startEmulation(entry.getPath(), false); + return true; + } else if (id == R.id.game_list_entry_menu_resume_game) { + startEmulation(entry.getPath(), true); + return true; + } else if (id == R.id.game_list_entry_menu_properties) { + openGameProperties(entry.getPath()); + return true; + } + return false; + }); + + // disable resume state when challenge mode is on + if (Achievement.willChallengeModeBeEnabled(this)) { + MenuItem item = menu.getMenu().findItem(R.id.game_list_entry_menu_resume_game); + if (item != null) + item.setEnabled(false); + } + + menu.show(); + } + public boolean startEmulation(String bootPath, boolean resumeState) { if (!doBIOSCheck()) return false; diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/SettingsActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/SettingsActivity.java index 02cecefad..df106181f 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/SettingsActivity.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/SettingsActivity.java @@ -51,7 +51,7 @@ public class SettingsActivity extends AppCompatActivity { } public static class SettingsFragment extends PreferenceFragmentCompat { - private int resourceId; + private final int resourceId; public SettingsFragment(int resourceId) { this.resourceId = resourceId; @@ -110,7 +110,10 @@ public class SettingsActivity extends AppCompatActivity { case 4: // Controllers return new SettingsFragment(R.xml.controllers_preferences); - case 5: // Advanced + case 5: // Achievements + return new AchievementSettingsFragment(); + + case 6: // Advanced return new SettingsFragment(R.xml.advanced_preferences); default: @@ -120,7 +123,7 @@ public class SettingsActivity extends AppCompatActivity { @Override public int getItemCount() { - return 6; + return 7; } } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/URLDownloader.java b/android/app/src/main/java/com/github/stenzek/duckstation/URLDownloader.java new file mode 100644 index 000000000..3661ece6d --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/URLDownloader.java @@ -0,0 +1,92 @@ +package com.github.stenzek.duckstation; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +/** + * Helper class for exposing HTTP downloads to native code without pulling in an external + * dependency for doing so. + */ +public class URLDownloader { + private int statusCode = -1; + private byte[] data = null; + + public URLDownloader() { + } + + static private HttpURLConnection getConnection(String url) { + try { + final URL parsedUrl = new URL(url); + HttpURLConnection connection = (HttpURLConnection) parsedUrl.openConnection(); + if (connection == null) + throw new RuntimeException(String.format("openConnection(%s) returned null", url)); + + return connection; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + public int getStatusCode() { + return statusCode; + } + + public byte[] getData() { + return data; + } + + private boolean download(HttpURLConnection connection) { + try { + statusCode = connection.getResponseCode(); + if (statusCode != HttpURLConnection.HTTP_OK) + return false; + + final InputStream inStream = new BufferedInputStream(connection.getInputStream()); + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + final int CHUNK_SIZE = 128 * 1024; + final byte[] chunk = new byte[CHUNK_SIZE]; + int len; + while ((len = inStream.read(chunk)) > 0) { + outputStream.write(chunk, 0, len); + } + + data = outputStream.toByteArray(); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + public boolean get(String url) { + final HttpURLConnection connection = getConnection(url); + if (connection == null) + return false; + + return download(connection); + } + + public boolean post(String url, byte[] postData) { + final HttpURLConnection connection = getConnection(url); + if (connection == null) + return false; + + try { + connection.setDoOutput(true); + connection.setChunkedStreamingMode(0); + + OutputStream postStream = new BufferedOutputStream(connection.getOutputStream()); + postStream.write(postData); + return download(connection); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } +} diff --git a/android/app/src/main/res/drawable/ic_baseline_lock_24.xml b/android/app/src/main/res/drawable/ic_baseline_lock_24.xml new file mode 100644 index 000000000..d6191026a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_lock_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_baseline_lock_open_24.xml b/android/app/src/main/res/drawable/ic_baseline_lock_open_24.xml new file mode 100644 index 000000000..a11b70e62 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_lock_open_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/layout/fragment_achievement_list.xml b/android/app/src/main/res/layout/fragment_achievement_list.xml new file mode 100644 index 000000000..d1a64a0de --- /dev/null +++ b/android/app/src/main/res/layout/fragment_achievement_list.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_achievements_login.xml b/android/app/src/main/res/layout/fragment_achievements_login.xml new file mode 100644 index 000000000..5ab32fd3c --- /dev/null +++ b/android/app/src/main/res/layout/fragment_achievements_login.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + +