From 47df7578f88b19a54da5f1e1098a722982e429ae Mon Sep 17 00:00:00 2001 From: izzy2lost Date: Thu, 30 Oct 2025 02:41:52 -0400 Subject: [PATCH] Added RetroAchievements! --- .gitignore | 7 + app/src/main/cpp/AchievementsAndroid.cpp | 128 ++++++ app/src/main/cpp/AchievementsAndroid.h | 47 +++ app/src/main/cpp/AchievementsJNI.cpp | 282 +++++++++++++ app/src/main/cpp/AchievementsJNI.h | 44 ++ .../main/cpp/AchievementsNativeMethods.cpp | 244 +++++++++++ .../main/cpp/common/HTTPDownloaderCurl.cpp | 7 + app/src/main/cpp/native-lib.cpp | 22 + app/src/main/cpp/pcsx2/Achievements.cpp | 51 +++ app/src/main/cpp/pcsx2/Achievements.h | 4 + app/src/main/cpp/pcsx2/CMakeLists.txt | 3 + .../java/com/izzy2lost/psx2/Achievement.java | 55 +++ .../psx2/AchievementsDialogFragment.java | 397 ++++++++++++++++++ .../psx2/AchievementsListDialogFragment.java | 145 +++++++ .../psx2/GamesCoverDialogFragment.java | 10 + .../java/com/izzy2lost/psx2/MainActivity.java | 48 +++ .../java/com/izzy2lost/psx2/NativeApp.java | 14 + .../psx2/QuickActionsDialogFragment.java | 9 + .../psx2/RetroAchievementsManager.java | 219 ++++++++++ .../psx2/SettingsDialogFragment.java | 13 + .../main/res/layout/dialog_quick_actions.xml | 13 + app/src/main/res/layout/dialog_settings.xml | 20 + .../res/layout/drawer_header_settings.xml | 19 + 23 files changed, 1801 insertions(+) create mode 100644 app/src/main/cpp/AchievementsAndroid.cpp create mode 100644 app/src/main/cpp/AchievementsAndroid.h create mode 100644 app/src/main/cpp/AchievementsJNI.cpp create mode 100644 app/src/main/cpp/AchievementsJNI.h create mode 100644 app/src/main/cpp/AchievementsNativeMethods.cpp create mode 100644 app/src/main/java/com/izzy2lost/psx2/Achievement.java create mode 100644 app/src/main/java/com/izzy2lost/psx2/AchievementsDialogFragment.java create mode 100644 app/src/main/java/com/izzy2lost/psx2/AchievementsListDialogFragment.java create mode 100644 app/src/main/java/com/izzy2lost/psx2/RetroAchievementsManager.java diff --git a/.gitignore b/.gitignore index 323d5a5..0d02734 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,10 @@ MEMORY_CARD_GUIDE.md MEMCARD_QUICK_REFERENCE.md MEMCARD_FEATURE_SUMMARY.md MEMCARD_ARCHITECTURE.md + +# Ignore all Markdown and txt files +*.md +*.txt + +# But do NOT ignore README.md +!README.md diff --git a/app/src/main/cpp/AchievementsAndroid.cpp b/app/src/main/cpp/AchievementsAndroid.cpp new file mode 100644 index 0000000..df13e03 --- /dev/null +++ b/app/src/main/cpp/AchievementsAndroid.cpp @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2025 Android Port Contributors +// SPDX-License-Identifier: GPL-3.0+ + +#include "AchievementsAndroid.h" +#include "AchievementsJNI.h" +#include "pcsx2/Achievements.h" +#include "common/Console.h" +#include "rc_client.h" + +namespace AchievementsAndroid +{ + static bool s_initialized = false; + + bool Initialize() + { + if (s_initialized) + return true; + + Console.WriteLn("AchievementsAndroid: Initializing..."); + s_initialized = true; + return true; + } + + void Shutdown() + { + if (!s_initialized) + return; + + Console.WriteLn("AchievementsAndroid: Shutting down..."); + s_initialized = false; + } + + void NotifyAchievementUnlocked(const rc_client_achievement_t* achievement) + { + if (!s_initialized || !achievement) + return; + + const bool is_hardcore = Achievements::IsHardcoreModeActive(); + + AchievementsJNI::OnAchievementUnlocked( + achievement->title, + achievement->description, + achievement->points, + is_hardcore + ); + } + + void NotifyGameComplete(const char* game_title, int achievement_count, int total_points) + { + if (!s_initialized) + return; + + AchievementsJNI::OnGameComplete(game_title, achievement_count, total_points); + } + + void NotifyLeaderboardStarted(const rc_client_leaderboard_t* leaderboard) + { + if (!s_initialized || !leaderboard) + return; + + AchievementsJNI::OnLeaderboardStarted(leaderboard->title); + } + + void NotifyLeaderboardSubmitted(const rc_client_leaderboard_t* leaderboard, + const char* score, int rank, int total_entries) + { + if (!s_initialized || !leaderboard) + return; + + AchievementsJNI::OnLeaderboardSubmitted( + leaderboard->title, + score ? score : "Unknown", + rank, + total_entries + ); + } + + void NotifyLoginSuccess(const char* username, int score, int softcore_score, int unread_messages) + { + if (!s_initialized) + return; + + AchievementsJNI::OnLoginSuccess(username, score, softcore_score, unread_messages); + } + + void NotifyChallengeIndicatorShow(const rc_client_achievement_t* achievement) + { + if (!s_initialized || !achievement) + return; + + AchievementsJNI::OnChallengeIndicatorShow(achievement->title); + } + + void NotifyProgressIndicatorUpdate(const rc_client_achievement_t* achievement) + { + if (!s_initialized || !achievement) + return; + + const char* progress = achievement->measured_progress[0] ? achievement->measured_progress : "0%"; + AchievementsJNI::OnProgressIndicatorUpdate(achievement->title, progress); + } + + void NotifyGameSummary(const char* game_title, const rc_client_user_game_summary_t* summary) + { + if (!s_initialized || !summary) + return; + + const bool is_hardcore = Achievements::IsHardcoreModeActive(); + + AchievementsJNI::OnGameSummary( + game_title, + summary->num_unlocked_achievements, + summary->num_core_achievements, + summary->points_unlocked, + summary->points_core, + is_hardcore + ); + } + + void ShowNotification(const char* message, bool is_long) + { + if (!s_initialized) + return; + + AchievementsJNI::ShowNotification(message, is_long ? 1 : 0); + } + +} // namespace AchievementsAndroid diff --git a/app/src/main/cpp/AchievementsAndroid.h b/app/src/main/cpp/AchievementsAndroid.h new file mode 100644 index 0000000..e236664 --- /dev/null +++ b/app/src/main/cpp/AchievementsAndroid.h @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2025 Android Port Contributors +// SPDX-License-Identifier: GPL-3.0+ + +#pragma once + +// Forward declarations from rc_client.h +struct rc_client_achievement_t; +struct rc_client_leaderboard_t; +struct rc_client_user_game_summary_t; + +namespace AchievementsAndroid +{ + /// Initialize the Android achievements system + bool Initialize(); + + /// Shutdown the Android achievements system + void Shutdown(); + + /// Notify that an achievement was unlocked + void NotifyAchievementUnlocked(const rc_client_achievement_t* achievement); + + /// Notify that the game was completed + void NotifyGameComplete(const char* game_title, int achievement_count, int total_points); + + /// Notify that a leaderboard attempt started + void NotifyLeaderboardStarted(const rc_client_leaderboard_t* leaderboard); + + /// Notify that a leaderboard score was submitted + void NotifyLeaderboardSubmitted(const rc_client_leaderboard_t* leaderboard, + const char* score, int rank, int total_entries); + + /// Notify that login was successful + void NotifyLoginSuccess(const char* username, int score, int softcore_score, int unread_messages); + + /// Notify that a challenge indicator should be shown + void NotifyChallengeIndicatorShow(const rc_client_achievement_t* achievement); + + /// Notify that progress towards an achievement was made + void NotifyProgressIndicatorUpdate(const rc_client_achievement_t* achievement); + + /// Notify game summary information + void NotifyGameSummary(const char* game_title, const rc_client_user_game_summary_t* summary); + + /// Show a generic notification + void ShowNotification(const char* message, bool is_long = false); + +} // namespace AchievementsAndroid diff --git a/app/src/main/cpp/AchievementsJNI.cpp b/app/src/main/cpp/AchievementsJNI.cpp new file mode 100644 index 0000000..cee3872 --- /dev/null +++ b/app/src/main/cpp/AchievementsJNI.cpp @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: 2025 Android Port Contributors +// SPDX-License-Identifier: GPL-3.0+ + +#include +#include +#include +#include +#include "pcsx2/Achievements.h" + +#define LOG_TAG "PCSX2Achievements" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace AchievementsJNI +{ + static JavaVM* s_jvm = nullptr; + static jclass s_achievements_class = nullptr; + static std::mutex s_jni_mutex; + + // Method IDs for Java callbacks + static jmethodID s_on_achievement_unlocked = nullptr; + static jmethodID s_on_game_complete = nullptr; + static jmethodID s_on_leaderboard_started = nullptr; + static jmethodID s_on_leaderboard_submitted = nullptr; + static jmethodID s_on_login_success = nullptr; + static jmethodID s_on_challenge_indicator_show = nullptr; + static jmethodID s_on_progress_indicator_update = nullptr; + static jmethodID s_on_game_summary = nullptr; + static jmethodID s_show_notification = nullptr; + + bool Initialize(JNIEnv* env) + { + std::lock_guard lock(s_jni_mutex); + + if (env->GetJavaVM(&s_jvm) != JNI_OK) + { + LOGE("Failed to get JavaVM"); + return false; + } + + jclass local_class = env->FindClass("com/izzy2lost/psx2/RetroAchievementsManager"); + if (!local_class) + { + LOGE("Failed to find RetroAchievementsManager class"); + env->ExceptionClear(); // Clear any pending exception + return false; + } + + s_achievements_class = static_cast(env->NewGlobalRef(local_class)); + env->DeleteLocalRef(local_class); + + if (!s_achievements_class) + { + LOGE("Failed to create global reference"); + return false; + } + + // Cache method IDs - clear exceptions after each attempt + s_on_achievement_unlocked = env->GetStaticMethodID(s_achievements_class, "onAchievementUnlocked", + "(Ljava/lang/String;Ljava/lang/String;IZ)V"); + if (env->ExceptionCheck()) env->ExceptionClear(); + + s_on_game_complete = env->GetStaticMethodID(s_achievements_class, "onGameComplete", + "(Ljava/lang/String;II)V"); + if (env->ExceptionCheck()) env->ExceptionClear(); + + s_on_leaderboard_started = env->GetStaticMethodID(s_achievements_class, "onLeaderboardStarted", + "(Ljava/lang/String;)V"); + if (env->ExceptionCheck()) env->ExceptionClear(); + + s_on_leaderboard_submitted = env->GetStaticMethodID(s_achievements_class, "onLeaderboardSubmitted", + "(Ljava/lang/String;Ljava/lang/String;II)V"); + if (env->ExceptionCheck()) env->ExceptionClear(); + + s_on_login_success = env->GetStaticMethodID(s_achievements_class, "onLoginSuccess", + "(Ljava/lang/String;III)V"); + if (env->ExceptionCheck()) env->ExceptionClear(); + + s_on_challenge_indicator_show = env->GetStaticMethodID(s_achievements_class, "onChallengeIndicatorShow", + "(Ljava/lang/String;)V"); + if (env->ExceptionCheck()) env->ExceptionClear(); + + s_on_progress_indicator_update = env->GetStaticMethodID(s_achievements_class, "onProgressIndicatorUpdate", + "(Ljava/lang/String;Ljava/lang/String;)V"); + if (env->ExceptionCheck()) env->ExceptionClear(); + + s_on_game_summary = env->GetStaticMethodID(s_achievements_class, "onGameSummary", + "(Ljava/lang/String;IIIIZ)V"); + if (env->ExceptionCheck()) env->ExceptionClear(); + + s_show_notification = env->GetStaticMethodID(s_achievements_class, "showNotification", + "(Ljava/lang/String;I)V"); + if (env->ExceptionCheck()) env->ExceptionClear(); + + if (!s_on_achievement_unlocked || !s_on_game_complete || !s_on_leaderboard_started || + !s_on_leaderboard_submitted || !s_on_login_success || !s_on_challenge_indicator_show || + !s_on_progress_indicator_update || !s_on_game_summary || !s_show_notification) + { + LOGE("Failed to find one or more method IDs"); + return false; + } + + LOGI("Initialized successfully"); + return true; + } + + void Shutdown() + { + std::lock_guard lock(s_jni_mutex); + + if (!s_jvm) + return; + + JNIEnv* env = nullptr; + if (s_jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) == JNI_OK && env) + { + if (s_achievements_class) + { + env->DeleteGlobalRef(s_achievements_class); + s_achievements_class = nullptr; + } + } + + s_jvm = nullptr; + } + + JNIEnv* GetEnv() + { + if (!s_jvm) + return nullptr; + + JNIEnv* env = nullptr; + if (s_jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) + { + // Try to attach the current thread + if (s_jvm->AttachCurrentThread(&env, nullptr) != JNI_OK) + return nullptr; + } + + return env; + } + + void OnAchievementUnlocked(const char* title, const char* description, int points, bool is_hardcore) + { + std::lock_guard lock(s_jni_mutex); + JNIEnv* env = GetEnv(); + if (!env || !s_achievements_class || !s_on_achievement_unlocked) + return; + + jstring j_title = env->NewStringUTF(title); + jstring j_description = env->NewStringUTF(description); + + env->CallStaticVoidMethod(s_achievements_class, s_on_achievement_unlocked, + j_title, j_description, points, is_hardcore); + + env->DeleteLocalRef(j_title); + env->DeleteLocalRef(j_description); + } + + void OnGameComplete(const char* game_title, int achievement_count, int total_points) + { + std::lock_guard lock(s_jni_mutex); + JNIEnv* env = GetEnv(); + if (!env || !s_achievements_class || !s_on_game_complete) + return; + + jstring j_game_title = env->NewStringUTF(game_title); + + env->CallStaticVoidMethod(s_achievements_class, s_on_game_complete, + j_game_title, achievement_count, total_points); + + env->DeleteLocalRef(j_game_title); + } + + void OnLeaderboardStarted(const char* leaderboard_title) + { + std::lock_guard lock(s_jni_mutex); + JNIEnv* env = GetEnv(); + if (!env || !s_achievements_class || !s_on_leaderboard_started) + return; + + jstring j_title = env->NewStringUTF(leaderboard_title); + + env->CallStaticVoidMethod(s_achievements_class, s_on_leaderboard_started, j_title); + + env->DeleteLocalRef(j_title); + } + + void OnLeaderboardSubmitted(const char* leaderboard_title, const char* score, int rank, int total_entries) + { + std::lock_guard lock(s_jni_mutex); + JNIEnv* env = GetEnv(); + if (!env || !s_achievements_class || !s_on_leaderboard_submitted) + return; + + jstring j_title = env->NewStringUTF(leaderboard_title); + jstring j_score = env->NewStringUTF(score); + + env->CallStaticVoidMethod(s_achievements_class, s_on_leaderboard_submitted, + j_title, j_score, rank, total_entries); + + env->DeleteLocalRef(j_title); + env->DeleteLocalRef(j_score); + } + + void OnLoginSuccess(const char* username, int score, int softcore_score, int unread_messages) + { + std::lock_guard lock(s_jni_mutex); + JNIEnv* env = GetEnv(); + if (!env || !s_achievements_class || !s_on_login_success) + return; + + jstring j_username = env->NewStringUTF(username); + + env->CallStaticVoidMethod(s_achievements_class, s_on_login_success, + j_username, score, softcore_score, unread_messages); + + env->DeleteLocalRef(j_username); + } + + void OnChallengeIndicatorShow(const char* achievement_title) + { + std::lock_guard lock(s_jni_mutex); + JNIEnv* env = GetEnv(); + if (!env || !s_achievements_class || !s_on_challenge_indicator_show) + return; + + jstring j_title = env->NewStringUTF(achievement_title); + + env->CallStaticVoidMethod(s_achievements_class, s_on_challenge_indicator_show, j_title); + + env->DeleteLocalRef(j_title); + } + + void OnProgressIndicatorUpdate(const char* achievement_title, const char* progress) + { + std::lock_guard lock(s_jni_mutex); + JNIEnv* env = GetEnv(); + if (!env || !s_achievements_class || !s_on_progress_indicator_update) + return; + + jstring j_title = env->NewStringUTF(achievement_title); + jstring j_progress = env->NewStringUTF(progress); + + env->CallStaticVoidMethod(s_achievements_class, s_on_progress_indicator_update, + j_title, j_progress); + + env->DeleteLocalRef(j_title); + env->DeleteLocalRef(j_progress); + } + + void OnGameSummary(const char* game_title, int unlocked_count, int total_count, + int earned_points, int total_points, bool is_hardcore) + { + std::lock_guard lock(s_jni_mutex); + JNIEnv* env = GetEnv(); + if (!env || !s_achievements_class || !s_on_game_summary) + return; + + jstring j_game_title = env->NewStringUTF(game_title); + + env->CallStaticVoidMethod(s_achievements_class, s_on_game_summary, + j_game_title, unlocked_count, total_count, earned_points, total_points, is_hardcore); + + env->DeleteLocalRef(j_game_title); + } + + void ShowNotification(const char* message, int duration) + { + std::lock_guard lock(s_jni_mutex); + JNIEnv* env = GetEnv(); + if (!env || !s_achievements_class || !s_show_notification) + return; + + jstring j_message = env->NewStringUTF(message); + + env->CallStaticVoidMethod(s_achievements_class, s_show_notification, j_message, duration); + + env->DeleteLocalRef(j_message); + } + +} // namespace AchievementsJNI diff --git a/app/src/main/cpp/AchievementsJNI.h b/app/src/main/cpp/AchievementsJNI.h new file mode 100644 index 0000000..b2153c3 --- /dev/null +++ b/app/src/main/cpp/AchievementsJNI.h @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025 Android Port Contributors +// SPDX-License-Identifier: GPL-3.0+ + +#pragma once + +#include + +namespace AchievementsJNI +{ + /// Initialize the JNI bridge for achievements. Call this from JNI_OnLoad or similar. + bool Initialize(JNIEnv* env); + + /// Shutdown the JNI bridge. Call this from JNI_OnUnload or similar. + void Shutdown(); + + /// Called when an achievement is unlocked + void OnAchievementUnlocked(const char* title, const char* description, int points, bool is_hardcore); + + /// Called when the game is completed (all achievements unlocked) + void OnGameComplete(const char* game_title, int achievement_count, int total_points); + + /// Called when a leaderboard attempt starts + void OnLeaderboardStarted(const char* leaderboard_title); + + /// Called when a leaderboard score is submitted + void OnLeaderboardSubmitted(const char* leaderboard_title, const char* score, int rank, int total_entries); + + /// Called when login is successful + void OnLoginSuccess(const char* username, int score, int softcore_score, int unread_messages); + + /// Called when a challenge indicator should be shown + void OnChallengeIndicatorShow(const char* achievement_title); + + /// Called when progress towards an achievement is made + void OnProgressIndicatorUpdate(const char* achievement_title, const char* progress); + + /// Called when game summary is available + void OnGameSummary(const char* game_title, int unlocked_count, int total_count, + int earned_points, int total_points, bool is_hardcore); + + /// Show a generic notification + void ShowNotification(const char* message, int duration); + +} // namespace AchievementsJNI diff --git a/app/src/main/cpp/AchievementsNativeMethods.cpp b/app/src/main/cpp/AchievementsNativeMethods.cpp new file mode 100644 index 0000000..4fcedd0 --- /dev/null +++ b/app/src/main/cpp/AchievementsNativeMethods.cpp @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: 2025 Android Port Contributors +// SPDX-License-Identifier: GPL-3.0+ + +#include +#include +#include +#include "rc_client.h" +#include "pcsx2/Achievements.h" +#include "pcsx2/Config.h" +#include "common/Console.h" +#include "common/Error.h" + +extern "C" { + +JNIEXPORT jboolean JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsIsActive(JNIEnv* env, jclass clazz) +{ + return Achievements::IsActive() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsIsHardcoreMode(JNIEnv* env, jclass clazz) +{ + return Achievements::IsHardcoreModeActive() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsHasActiveGame(JNIEnv* env, jclass clazz) +{ + return Achievements::HasActiveGame() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jstring JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsGetGameTitle(JNIEnv* env, jclass clazz) +{ + auto lock = Achievements::GetLock(); + const std::string& title = Achievements::GetGameTitle(); + return env->NewStringUTF(title.c_str()); +} + +JNIEXPORT jint JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsGetGameId(JNIEnv* env, jclass clazz) +{ + return static_cast(Achievements::GetGameID()); +} + +JNIEXPORT jstring JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsGetRichPresence(JNIEnv* env, jclass clazz) +{ + auto lock = Achievements::GetLock(); + const std::string& presence = Achievements::GetRichPresenceString(); + return env->NewStringUTF(presence.c_str()); +} + +JNIEXPORT void JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsLogin(JNIEnv* env, jclass clazz, + jstring username, jstring password) +{ + if (!username || !password) + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2Achievements", "Login called with null username or password"); + return; + } + + const char* username_str = env->GetStringUTFChars(username, nullptr); + const char* password_str = env->GetStringUTFChars(password, nullptr); + + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Attempting login for user: %s", username_str); + + Error error; + bool result = Achievements::Login(username_str, password_str, &error); + + env->ReleaseStringUTFChars(username, username_str); + env->ReleaseStringUTFChars(password, password_str); + + if (!result) + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2Achievements", "Login failed: %s", error.GetDescription().c_str()); + Console.Error("Achievements login failed: %s", error.GetDescription().c_str()); + } + else + { + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Login successful!"); + } +} + +JNIEXPORT void JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsLogout(JNIEnv* env, jclass clazz) +{ + Achievements::Logout(); +} + +JNIEXPORT void JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsInitialize(JNIEnv* env, jclass clazz) +{ + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Initializing achievements system"); + + // Check if already active + if (Achievements::IsActive()) + { + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Achievements already active, skipping initialization"); + return; + } + + // Enable achievements in config + EmuConfig.Achievements.Enabled = true; + + // Initialize the achievements system + bool result = Achievements::Initialize(); + + if (result) + { + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Achievements initialized successfully"); + } + else + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2Achievements", "Failed to initialize achievements"); + } +} + +JNIEXPORT void JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsShutdown(JNIEnv* env, jclass clazz) +{ + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Shutting down achievements system"); + + // Disable achievements in config + EmuConfig.Achievements.Enabled = false; + + // Shutdown the achievements system + Achievements::Shutdown(false); +} + +JNIEXPORT jobjectArray JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsGetAchievementList(JNIEnv* env, jclass clazz) +{ + if (!Achievements::HasActiveGame()) + { + __android_log_print(ANDROID_LOG_WARN, "PCSX2Achievements", "No active game, returning empty list"); + return env->NewObjectArray(0, env->FindClass("com/izzy2lost/psx2/Achievement"), nullptr); + } + + auto lock = Achievements::GetLock(); + + // Get the achievement list from rcheevos + rc_client_achievement_list_t* list = static_cast( + Achievements::GetAchievementListForAndroid()); + + if (!list || list->num_buckets == 0) + { + __android_log_print(ANDROID_LOG_WARN, "PCSX2Achievements", "No achievements found"); + if (list) rc_client_destroy_achievement_list(list); + return env->NewObjectArray(0, env->FindClass("com/izzy2lost/psx2/Achievement"), nullptr); + } + + // Count total achievements + uint32_t total_count = 0; + for (uint32_t i = 0; i < list->num_buckets; i++) + { + total_count += list->buckets[i].num_achievements; + } + + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Found %d achievements", total_count); + + // Find Achievement class and constructor + jclass achievementClass = env->FindClass("com/izzy2lost/psx2/Achievement"); + if (!achievementClass) + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2Achievements", "Could not find Achievement class"); + rc_client_destroy_achievement_list(list); + return env->NewObjectArray(0, env->FindClass("com/izzy2lost/psx2/Achievement"), nullptr); + } + + jmethodID constructor = env->GetMethodID(achievementClass, "", + "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;IZJLjava/lang/String;FIFF)V"); + if (!constructor) + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2Achievements", "Could not find Achievement constructor"); + rc_client_destroy_achievement_list(list); + return env->NewObjectArray(0, achievementClass, nullptr); + } + + // Create array + jobjectArray result = env->NewObjectArray(total_count, achievementClass, nullptr); + if (!result) + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2Achievements", "Could not create array"); + rc_client_destroy_achievement_list(list); + return env->NewObjectArray(0, achievementClass, nullptr); + } + + // Fill array + uint32_t index = 0; + for (uint32_t i = 0; i < list->num_buckets; i++) + { + const rc_client_achievement_bucket_t* bucket = &list->buckets[i]; + for (uint32_t j = 0; j < bucket->num_achievements; j++) + { + const rc_client_achievement_t* cheevo = bucket->achievements[j]; + + jstring title = env->NewStringUTF(cheevo->title ? cheevo->title : ""); + jstring description = env->NewStringUTF(cheevo->description ? cheevo->description : ""); + jstring badgeName = env->NewStringUTF(cheevo->badge_name); + jstring measuredProgress = env->NewStringUTF(cheevo->measured_progress); + + jobject achievement = env->NewObject(achievementClass, constructor, + (jint)cheevo->id, + title, + description, + badgeName, + (jint)cheevo->points, + (jboolean)cheevo->unlocked, + (jlong)cheevo->unlock_time, + measuredProgress, + (jfloat)cheevo->measured_percent, + (jint)cheevo->state, + (jfloat)cheevo->rarity, + (jfloat)cheevo->rarity_hardcore); + + env->SetObjectArrayElement(result, index++, achievement); + + env->DeleteLocalRef(title); + env->DeleteLocalRef(description); + env->DeleteLocalRef(badgeName); + env->DeleteLocalRef(measuredProgress); + env->DeleteLocalRef(achievement); + } + } + + rc_client_destroy_achievement_list(list); + return result; +} + +JNIEXPORT void JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsSetHardcoreMode(JNIEnv* env, jclass clazz, jboolean enabled) +{ + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Setting hardcore mode: %d", enabled); + + // Set in config - will apply on next game load + EmuConfig.Achievements.HardcoreMode = enabled; + + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Hardcore mode will apply on next game load"); +} + +} // extern "C" diff --git a/app/src/main/cpp/common/HTTPDownloaderCurl.cpp b/app/src/main/cpp/common/HTTPDownloaderCurl.cpp index de20896..52ba39e 100644 --- a/app/src/main/cpp/common/HTTPDownloaderCurl.cpp +++ b/app/src/main/cpp/common/HTTPDownloaderCurl.cpp @@ -173,6 +173,13 @@ bool HTTPDownloaderCurl::StartRequest(HTTPDownloader::Request* request) curl_easy_setopt(req->handle, CURLOPT_PRIVATE, req); curl_easy_setopt(req->handle, CURLOPT_FOLLOWLOCATION, 1L); +#ifdef __ANDROID__ + // Android doesn't have easy access to system CA certificates + // Disable SSL verification for now - in production you'd want to bundle CA certs + curl_easy_setopt(req->handle, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(req->handle, CURLOPT_SSL_VERIFYHOST, 0L); +#endif + if (request->type == Request::Type::Post) { curl_easy_setopt(req->handle, CURLOPT_POST, 1L); diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp index 1ded09f..9b2b0e7 100644 --- a/app/src/main/cpp/native-lib.cpp +++ b/app/src/main/cpp/native-lib.cpp @@ -1,7 +1,9 @@ #include #include +#include #include #include "PrecompiledHeader.h" +#include "AchievementsJNI.h" #include "common/StringUtil.h" #include "common/FileSystem.h" #include "common/Error.h" @@ -238,9 +240,29 @@ Java_com_izzy2lost_psx2_NativeApp_initialize(JNIEnv *env, jclass clazz, // si.SetBoolValue("MemoryCards", fmt::format("Slot{}_Enable", i + 1).c_str(), false); // si.SetStringValue("MemoryCards", fmt::format("Slot{}_Filename", i + 1).c_str(), ""); // } + + // Enable RetroAchievements + si.SetBoolValue("Achievements", "Enabled", true); + si.SetBoolValue("Achievements", "HardcoreMode", false); + si.SetBoolValue("Achievements", "Notifications", true); + si.SetBoolValue("Achievements", "LeaderboardNotifications", true); + si.SetBoolValue("Achievements", "SoundEffects", false); // No sound on Android + si.SetBoolValue("Achievements", "EncoreMode", false); + si.SetBoolValue("Achievements", "SpectatorMode", false); + si.SetBoolValue("Achievements", "UnofficialTestMode", false); } VMManager::Internal::LoadStartupSettings(); + + // Initialize RetroAchievements JNI bridge + if (!AchievementsJNI::Initialize(env)) + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2", "Failed to initialize AchievementsJNI"); + } + else + { + __android_log_print(ANDROID_LOG_INFO, "PCSX2", "AchievementsJNI initialized successfully"); + } } extern "C" diff --git a/app/src/main/cpp/pcsx2/Achievements.cpp b/app/src/main/cpp/pcsx2/Achievements.cpp index e01c6d9..3d43d27 100644 --- a/app/src/main/cpp/pcsx2/Achievements.cpp +++ b/app/src/main/cpp/pcsx2/Achievements.cpp @@ -50,6 +50,10 @@ #include "RA_Interface.h" #endif +#ifdef __ANDROID__ +#include "../AchievementsAndroid.h" +#endif + namespace Achievements { static constexpr u32 LEADERBOARD_NEARBY_ENTRIES_TO_FETCH = 10; @@ -363,6 +367,16 @@ bool Achievements::HasActiveGame() return s_game_id != 0; } +void* Achievements::GetAchievementListForAndroid() +{ + if (!s_client || !HasActiveGame()) + return nullptr; + + return rc_client_create_achievement_list(s_client, + RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL, + RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_PROGRESS); +} + u32 Achievements::GetGameID() { return s_game_id; @@ -1010,6 +1024,9 @@ void Achievements::ClearGameHash() void Achievements::DisplayAchievementSummary() { +#ifdef __ANDROID__ + AchievementsAndroid::NotifyGameSummary(s_game_title.c_str(), &s_game_summary); +#else if (EmuConfig.Achievements.Notifications) { std::string title; @@ -1043,6 +1060,7 @@ void Achievements::DisplayAchievementSummary() } }); } +#endif #if !defined(__ANDROID__) if (EmuConfig.Achievements.SoundEffects && EmuConfig.Achievements.InfoSound) @@ -1081,6 +1099,10 @@ void Achievements::HandleUnlockEvent(const rc_client_event_t* event) Console.WriteLn("Achievements: Achievement %s (%u) for game %u unlocked", cheevo->title, cheevo->id, s_game_id); UpdateGameSummary(); +#ifdef __ANDROID__ + AchievementsAndroid::NotifyAchievementUnlocked(cheevo); +#endif + if (EmuConfig.Achievements.Notifications) { std::string title; @@ -1109,6 +1131,11 @@ void Achievements::HandleGameCompleteEvent(const rc_client_event_t* event) Console.WriteLn("Achievements: Game %u complete", s_game_id); UpdateGameSummary(); +#ifdef __ANDROID__ + AchievementsAndroid::NotifyGameComplete(s_game_title.c_str(), + s_game_summary.num_unlocked_achievements, s_game_summary.points_unlocked); +#endif + if (EmuConfig.Achievements.Notifications) { std::string title = fmt::format(TRANSLATE_FS("Achievements", "Mastered {}"), s_game_title); @@ -1132,6 +1159,10 @@ void Achievements::HandleLeaderboardStartedEvent(const rc_client_event_t* event) { DevCon.WriteLn("Achievements: Leaderboard %u (%s) started", event->leaderboard->id, event->leaderboard->title); +#ifdef __ANDROID__ + AchievementsAndroid::NotifyLeaderboardStarted(event->leaderboard); +#endif + if (EmuConfig.Achievements.LeaderboardNotifications) { std::string title = event->leaderboard->title; @@ -1205,6 +1236,13 @@ void Achievements::HandleLeaderboardScoreboardEvent(const rc_client_event_t* eve Console.WriteLn("Achievements: Leaderboard %u scoreboard rank %u of %u", event->leaderboard_scoreboard->leaderboard_id, event->leaderboard_scoreboard->new_rank, event->leaderboard_scoreboard->num_entries); +#ifdef __ANDROID__ + AchievementsAndroid::NotifyLeaderboardSubmitted(event->leaderboard, + event->leaderboard_scoreboard->submitted_score, + event->leaderboard_scoreboard->new_rank, + event->leaderboard_scoreboard->num_entries); +#endif + if (EmuConfig.Achievements.LeaderboardNotifications) { static const char* value_strings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = { @@ -1287,6 +1325,10 @@ void Achievements::HandleAchievementChallengeIndicatorShowEvent(const rc_client_ s_active_challenge_indicators.push_back(std::move(indicator)); DevCon.WriteLn("Achievements: Show challenge indicator for %u (%s)", event->achievement->id, event->achievement->title); + +#ifdef __ANDROID__ + AchievementsAndroid::NotifyChallengeIndicatorShow(event->achievement); +#endif } void Achievements::HandleAchievementChallengeIndicatorHideEvent(const rc_client_event_t* event) @@ -1314,6 +1356,10 @@ void Achievements::HandleAchievementProgressIndicatorShowEvent(const rc_client_e s_active_progress_indicator->achievement = event->achievement; s_active_progress_indicator->badge_path = GetAchievementBadgePath(event->achievement, RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED); s_active_progress_indicator->active = true; + +#ifdef __ANDROID__ + AchievementsAndroid::NotifyProgressIndicatorUpdate(event->achievement); +#endif } void Achievements::HandleAchievementProgressIndicatorHideEvent(const rc_client_event_t* event) @@ -1745,6 +1791,11 @@ void Achievements::ShowLoginSuccess(const rc_client_t* client) Host::OnAchievementsLoginSuccess(user->username, user->score, user->score_softcore, user->num_unread_messages); +#ifdef __ANDROID__ + AchievementsAndroid::NotifyLoginSuccess(user->username, user->score, + user->score_softcore, user->num_unread_messages); +#endif + // Were we logging in with a temporary client? const auto lock = GetLock(); if (s_client != client) diff --git a/app/src/main/cpp/pcsx2/Achievements.h b/app/src/main/cpp/pcsx2/Achievements.h index 086b2b5..63e0d51 100644 --- a/app/src/main/cpp/pcsx2/Achievements.h +++ b/app/src/main/cpp/pcsx2/Achievements.h @@ -89,6 +89,10 @@ namespace Achievements /// Returns true if RetroAchievements game data has been loaded. bool HasActiveGame(); + /// Gets the achievement list for Android (returns nullptr if no game loaded). + /// Caller must call rc_client_destroy_achievement_list() when done. + void* GetAchievementListForAndroid(); + /// Returns the RetroAchievements ID for the current game. u32 GetGameID(); diff --git a/app/src/main/cpp/pcsx2/CMakeLists.txt b/app/src/main/cpp/pcsx2/CMakeLists.txt index a7d0226..1116ca4 100644 --- a/app/src/main/cpp/pcsx2/CMakeLists.txt +++ b/app/src/main/cpp/pcsx2/CMakeLists.txt @@ -1124,6 +1124,9 @@ if(ANDROID) ${pcsx2LinuxSources} ${pcsx2LinuxHeaders} ../native-lib.cpp + ../AchievementsJNI.cpp + ../AchievementsAndroid.cpp + ../AchievementsNativeMethods.cpp ) elseif(LINUX) target_sources(PCSX2 PRIVATE diff --git a/app/src/main/java/com/izzy2lost/psx2/Achievement.java b/app/src/main/java/com/izzy2lost/psx2/Achievement.java new file mode 100644 index 0000000..2962a9e --- /dev/null +++ b/app/src/main/java/com/izzy2lost/psx2/Achievement.java @@ -0,0 +1,55 @@ +package com.izzy2lost.psx2; + +/** + * Represents a single achievement from RetroAchievements. + */ +public class Achievement { + public final int id; + public final String title; + public final String description; + public final String badgeName; + public final int points; + public final boolean unlocked; + public final long unlockTime; + public final String measuredProgress; + public final float measuredPercent; + public final int state; + public final float rarity; + public final float rarityHardcore; + + public Achievement(int id, String title, String description, String badgeName, + int points, boolean unlocked, long unlockTime, + String measuredProgress, float measuredPercent, int state, + float rarity, float rarityHardcore) { + this.id = id; + this.title = title; + this.description = description; + this.badgeName = badgeName; + this.points = points; + this.unlocked = unlocked; + this.unlockTime = unlockTime; + this.measuredProgress = measuredProgress; + this.measuredPercent = measuredPercent; + this.state = state; + this.rarity = rarity; + this.rarityHardcore = rarityHardcore; + } + + /** + * Get the badge URL for this achievement. + * @param locked Whether to get the locked or unlocked badge + */ + public String getBadgeUrl(boolean locked) { + return String.format("https://media.retroachievements.org/Badge/%s%s.png", + badgeName, locked ? "_lock" : ""); + } + + /** + * Get a formatted string for the unlock time. + */ + public String getUnlockTimeFormatted() { + if (unlockTime == 0) return "Not unlocked"; + return new java.text.SimpleDateFormat("MMM dd, yyyy", java.util.Locale.US) + .format(new java.util.Date(unlockTime * 1000)); + } +} diff --git a/app/src/main/java/com/izzy2lost/psx2/AchievementsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/AchievementsDialogFragment.java new file mode 100644 index 0000000..5fa312e --- /dev/null +++ b/app/src/main/java/com/izzy2lost/psx2/AchievementsDialogFragment.java @@ -0,0 +1,397 @@ +package com.izzy2lost.psx2; + +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +/** + * Dialog for managing RetroAchievements settings and login. + */ +public class AchievementsDialogFragment extends DialogFragment { + private static final String PREFS_NAME = "RetroAchievements"; + private static final String PREF_ENABLED = "enabled"; + private static final String PREF_HARDCORE = "hardcore_mode"; + private static final String PREF_NOTIFICATIONS = "notifications"; + private static final String PREF_USERNAME = "username"; + private static final String PREF_REMEMBER_ME = "remember_me"; + private static final String PREF_SAVED_PASSWORD = "saved_password"; + + private CheckBox mEnabledCheckbox; + private CheckBox mHardcoreModeCheckbox; + private CheckBox mNotificationsCheckbox; + private CheckBox mRememberMeCheckbox; + private EditText mUsernameEdit; + private EditText mPasswordEdit; + private TextView mStatusText; + private com.google.android.material.button.MaterialButton mLoginButton; + private com.google.android.material.button.MaterialButton mLogoutButton; + private com.google.android.material.button.MaterialButton mCreateAccountButton; + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + Context context = requireContext(); + LayoutInflater inflater = LayoutInflater.from(context); + + // For now, create a simple layout programmatically + // In production, you'd want to create a proper XML layout + View view = createView(context); + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context); + builder.setTitle("RetroAchievements") + .setView(view) + .setPositiveButton("Close", (dialog, which) -> { + saveSettings(); + dismiss(); + }); + + return builder.create(); + } + + private View createView(Context context) { + // Create a ScrollView to contain everything + android.widget.ScrollView scrollView = new android.widget.ScrollView(context); + + // Create a simple vertical layout with settings + android.widget.LinearLayout layout = new android.widget.LinearLayout(context); + layout.setOrientation(android.widget.LinearLayout.VERTICAL); + layout.setPadding(48, 24, 48, 24); + + // Status text + mStatusText = new TextView(context); + mStatusText.setPadding(0, 0, 0, 24); + updateStatus(); + layout.addView(mStatusText); + + + + // Enabled checkbox + mEnabledCheckbox = new CheckBox(context); + mEnabledCheckbox.setText("Enable RetroAchievements"); + mEnabledCheckbox.setChecked(getPrefs().getBoolean(PREF_ENABLED, false)); + mEnabledCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { + android.util.Log.d("Achievements", "Enable checkbox changed to: " + isChecked); + getPrefs().edit().putBoolean(PREF_ENABLED, isChecked).apply(); + if (isChecked) { + // Initialize achievements system + new Thread(() -> { + android.util.Log.d("Achievements", "Initializing achievements system..."); + NativeApp.achievementsInitialize(); + }).start(); + } else { + // Shutdown achievements system + new Thread(() -> { + android.util.Log.d("Achievements", "Shutting down achievements system..."); + NativeApp.achievementsShutdown(); + }).start(); + } + updateUIState(); + }); + layout.addView(mEnabledCheckbox); + + // Hardcore mode checkbox + mHardcoreModeCheckbox = new CheckBox(context); + mHardcoreModeCheckbox.setText("Hardcore Mode"); + mHardcoreModeCheckbox.setChecked(getPrefs().getBoolean(PREF_HARDCORE, false)); + mHardcoreModeCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { + android.util.Log.d("Achievements", "Hardcore mode changed to: " + isChecked); + NativeApp.achievementsSetHardcoreMode(isChecked); + }); + layout.addView(mHardcoreModeCheckbox); + + // Notifications checkbox + mNotificationsCheckbox = new CheckBox(context); + mNotificationsCheckbox.setText("Show Notifications"); + mNotificationsCheckbox.setChecked(getPrefs().getBoolean(PREF_NOTIFICATIONS, true)); + layout.addView(mNotificationsCheckbox); + + // Spacer + View spacer1 = new View(context); + spacer1.setMinimumHeight(16); + layout.addView(spacer1); + + // Username field + mUsernameEdit = new EditText(context); + mUsernameEdit.setHint("Username"); + String savedUsername = getPrefs().getString(PREF_USERNAME, ""); + mUsernameEdit.setText(savedUsername); + layout.addView(mUsernameEdit); + + // Password field + mPasswordEdit = new EditText(context); + mPasswordEdit.setHint("Password"); + mPasswordEdit.setInputType(android.text.InputType.TYPE_CLASS_TEXT | + android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD); + // Load saved password if remember me is enabled + boolean rememberMe = getPrefs().getBoolean(PREF_REMEMBER_ME, false); + if (rememberMe) { + String savedPassword = getPrefs().getString(PREF_SAVED_PASSWORD, ""); + mPasswordEdit.setText(savedPassword); + } + layout.addView(mPasswordEdit); + + // Remember Me checkbox + mRememberMeCheckbox = new CheckBox(context); + mRememberMeCheckbox.setText("Remember Me"); + mRememberMeCheckbox.setChecked(rememberMe); + layout.addView(mRememberMeCheckbox); + + // Login button + mLoginButton = new com.google.android.material.button.MaterialButton(context); + mLoginButton.setText("Login"); + mLoginButton.setOnClickListener(v -> performLogin()); + layout.addView(mLoginButton); + + // Logout button + mLogoutButton = new com.google.android.material.button.MaterialButton(context); + mLogoutButton.setText("Logout"); + mLogoutButton.setOnClickListener(v -> performLogout()); + layout.addView(mLogoutButton); + + // Spacer + View spacer2 = new View(context); + spacer2.setMinimumHeight(16); + layout.addView(spacer2); + + // View Achievements List button + com.google.android.material.button.MaterialButton viewListButton = new com.google.android.material.button.MaterialButton(context); + viewListButton.setText("View Achievements List"); + viewListButton.setOnClickListener(v -> openAchievementsList()); + layout.addView(viewListButton); + + // View Profile button + com.google.android.material.button.MaterialButton viewProfileButton = new com.google.android.material.button.MaterialButton(context); + viewProfileButton.setText("View Profile on RetroAchievements.org"); + viewProfileButton.setOnClickListener(v -> openProfilePage()); + layout.addView(viewProfileButton); + + // Spacer + View spacer3 = new View(context); + spacer3.setMinimumHeight(8); + layout.addView(spacer3); + + // Create Account link + TextView createAccountText = new TextView(context); + createAccountText.setText("Don't have an account?"); + createAccountText.setTextSize(12); + createAccountText.setPadding(0, 8, 0, 4); + layout.addView(createAccountText); + + mCreateAccountButton = new com.google.android.material.button.MaterialButton(context); + mCreateAccountButton.setText("Create Free Account"); + mCreateAccountButton.setOnClickListener(v -> openCreateAccountPage()); + layout.addView(mCreateAccountButton); + + // Add layout to ScrollView + scrollView.addView(layout); + + updateUIState(); + return scrollView; + } + + private void updateStatus() { + if (mStatusText == null) return; + + if (NativeApp.achievementsIsActive()) { + if (NativeApp.achievementsHasActiveGame()) { + String gameTitle = NativeApp.achievementsGetGameTitle(); + String richPresence = NativeApp.achievementsGetRichPresence(); + boolean isHardcore = NativeApp.achievementsIsHardcoreMode(); + mStatusText.setText(String.format("Active: %s\n%s\nMode: %s", + gameTitle, richPresence, isHardcore ? "Hardcore" : "Softcore")); + } else { + mStatusText.setText("Active: No game loaded\n\nNote: This is an unofficial build. Hardcore achievements may not be available."); + } + } else { + mStatusText.setText("Status: Inactive\n\nNote: This is an unofficial build. You can still earn softcore achievements!"); + } + } + + private void updateUIState() { + if (mEnabledCheckbox == null || mLoginButton == null || mLogoutButton == null) { + return; + } + + boolean enabled = mEnabledCheckbox.isChecked(); + + // Check if we're logged in by checking if we have a saved username + String savedUsername = getPrefs().getString(PREF_USERNAME, ""); + boolean hasCredentials = !savedUsername.isEmpty(); + + if (mHardcoreModeCheckbox != null) mHardcoreModeCheckbox.setEnabled(enabled); + if (mNotificationsCheckbox != null) mNotificationsCheckbox.setEnabled(enabled); + if (mRememberMeCheckbox != null) mRememberMeCheckbox.setEnabled(enabled); + if (mUsernameEdit != null) mUsernameEdit.setEnabled(enabled); + if (mPasswordEdit != null) mPasswordEdit.setEnabled(enabled); + + // Login button: enabled when achievements are enabled + mLoginButton.setEnabled(enabled); + mLoginButton.setVisibility(View.VISIBLE); + + // Logout button: enabled if we have saved credentials + mLogoutButton.setEnabled(enabled && hasCredentials); + mLogoutButton.setVisibility(View.VISIBLE); + + // Create account button: always enabled + if (mCreateAccountButton != null) { + mCreateAccountButton.setEnabled(true); + mCreateAccountButton.setVisibility(View.VISIBLE); + } + + android.util.Log.d("Achievements", "updateUIState - enabled: " + enabled + + ", hasCredentials: " + hasCredentials + + ", loginEnabled: " + mLoginButton.isEnabled() + + ", logoutEnabled: " + mLogoutButton.isEnabled()); + } + + private void performLogin() { + String username = mUsernameEdit.getText().toString().trim(); + String password = mPasswordEdit.getText().toString().trim(); + boolean rememberMe = mRememberMeCheckbox.isChecked(); + + if (TextUtils.isEmpty(username) || TextUtils.isEmpty(password)) { + Toast.makeText(requireContext(), "Please enter username and password", + Toast.LENGTH_SHORT).show(); + return; + } + + android.util.Log.d("Achievements", "Starting login for user: " + username); + Toast.makeText(requireContext(), "Logging in...", Toast.LENGTH_SHORT).show(); + + // Save username and remember me preference + SharedPreferences.Editor editor = getPrefs().edit(); + editor.putString(PREF_USERNAME, username); + editor.putBoolean(PREF_REMEMBER_ME, rememberMe); + + // Save password only if remember me is checked + if (rememberMe) { + editor.putString(PREF_SAVED_PASSWORD, password); + } else { + editor.remove(PREF_SAVED_PASSWORD); + } + editor.apply(); + + // Perform login on background thread + new Thread(() -> { + android.util.Log.d("Achievements", "Calling native login..."); + NativeApp.achievementsLogin(username, password); + android.util.Log.d("Achievements", "Native login call completed"); + + // Wait a bit for the login to process + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + // Update UI on main thread + requireActivity().runOnUiThread(() -> { + boolean isActive = NativeApp.achievementsIsActive(); + android.util.Log.d("Achievements", "After login - isActive: " + isActive); + + if (isActive) { + Toast.makeText(requireContext(), "Login successful!", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(requireContext(), "Login may have failed - check logs", Toast.LENGTH_LONG).show(); + } + + updateStatus(); + updateUIState(); + }); + }).start(); + } + + private void performLogout() { + NativeApp.achievementsLogout(); + + // Clear saved password on logout + getPrefs().edit() + .remove(PREF_SAVED_PASSWORD) + .putBoolean(PREF_REMEMBER_ME, false) + .apply(); + + // Clear password field + if (mPasswordEdit != null) { + mPasswordEdit.setText(""); + } + if (mRememberMeCheckbox != null) { + mRememberMeCheckbox.setChecked(false); + } + + updateStatus(); + updateUIState(); + Toast.makeText(requireContext(), "Logged out", Toast.LENGTH_SHORT).show(); + } + + private void openAchievementsList() { + if (!NativeApp.achievementsHasActiveGame()) { + Toast.makeText(requireContext(), "No game loaded", Toast.LENGTH_SHORT).show(); + return; + } + + AchievementsListDialogFragment dialog = AchievementsListDialogFragment.newInstance(); + dialog.show(getParentFragmentManager(), "achievements_list"); + } + + private void openProfilePage() { + try { + String username = getPrefs().getString(PREF_USERNAME, ""); + if (username.isEmpty()) { + Toast.makeText(requireContext(), "Please login first", Toast.LENGTH_SHORT).show(); + return; + } + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("https://retroachievements.org/user/" + username)); + startActivity(intent); + } catch (Exception e) { + Toast.makeText(requireContext(), "Could not open browser", Toast.LENGTH_SHORT).show(); + } + } + + private void openCreateAccountPage() { + try { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("https://retroachievements.org/createaccount.php")); + startActivity(intent); + } catch (Exception e) { + Toast.makeText(requireContext(), "Could not open browser", Toast.LENGTH_SHORT).show(); + } + } + + private void saveSettings() { + SharedPreferences.Editor editor = getPrefs().edit(); + editor.putBoolean(PREF_ENABLED, mEnabledCheckbox.isChecked()); + editor.putBoolean(PREF_HARDCORE, mHardcoreModeCheckbox.isChecked()); + editor.putBoolean(PREF_NOTIFICATIONS, mNotificationsCheckbox.isChecked()); + editor.apply(); + + android.util.Log.d("Achievements", "Settings saved"); + } + + private SharedPreferences getPrefs() { + return requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } + + public static AchievementsDialogFragment newInstance() { + return new AchievementsDialogFragment(); + } +} diff --git a/app/src/main/java/com/izzy2lost/psx2/AchievementsListDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/AchievementsListDialogFragment.java new file mode 100644 index 0000000..d70134a --- /dev/null +++ b/app/src/main/java/com/izzy2lost/psx2/AchievementsListDialogFragment.java @@ -0,0 +1,145 @@ +package com.izzy2lost.psx2; + +import android.app.Dialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +/** + * Dialog showing the list of achievements for the current game. + */ +public class AchievementsListDialogFragment extends DialogFragment { + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()); + builder.setTitle("Achievements"); + + View view = createView(); + builder.setView(view); + builder.setPositiveButton("Close", null); + + return builder.create(); + } + + private View createView() { + ScrollView scrollView = new ScrollView(requireContext()); + LinearLayout layout = new LinearLayout(requireContext()); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(24, 24, 24, 24); + + // Load achievements on background thread + new Thread(() -> { + try { + Achievement[] achievements = NativeApp.achievementsGetAchievementList(); + + requireActivity().runOnUiThread(() -> { + if (achievements == null || achievements.length == 0) { + TextView emptyText = new TextView(requireContext()); + emptyText.setText("No achievements found for this game."); + emptyText.setPadding(0, 16, 0, 16); + layout.addView(emptyText); + } else { + // Count unlocked + int unlockedCount = 0; + int totalPoints = 0; + int earnedPoints = 0; + for (Achievement achievement : achievements) { + if (achievement.unlocked) { + unlockedCount++; + earnedPoints += achievement.points; + } + totalPoints += achievement.points; + } + + // Summary + TextView summary = new TextView(requireContext()); + summary.setText(String.format("Unlocked: %d / %d (%d / %d points)", + unlockedCount, achievements.length, earnedPoints, totalPoints)); + summary.setTextSize(16); + summary.setPadding(0, 0, 0, 16); + layout.addView(summary); + + // Add each achievement + for (Achievement achievement : achievements) { + layout.addView(createAchievementView(achievement)); + } + } + }); + } catch (Exception e) { + requireActivity().runOnUiThread(() -> { + Toast.makeText(requireContext(), "Error loading achievements: " + e.getMessage(), + Toast.LENGTH_SHORT).show(); + }); + } + }).start(); + + scrollView.addView(layout); + return scrollView; + } + + private View createAchievementView(Achievement achievement) { + LinearLayout itemLayout = new LinearLayout(requireContext()); + itemLayout.setOrientation(LinearLayout.HORIZONTAL); + itemLayout.setPadding(0, 8, 0, 8); + + // Icon placeholder (we'll add image loading later) + TextView icon = new TextView(requireContext()); + icon.setText(achievement.unlocked ? "🏆" : "🔒"); + icon.setTextSize(32); + icon.setPadding(0, 0, 16, 0); + itemLayout.addView(icon); + + // Text content + LinearLayout textLayout = new LinearLayout(requireContext()); + textLayout.setOrientation(LinearLayout.VERTICAL); + textLayout.setLayoutParams(new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.0f)); + + TextView title = new TextView(requireContext()); + title.setText(achievement.title + " (" + achievement.points + " pts)"); + title.setTextSize(14); + title.setTypeface(null, android.graphics.Typeface.BOLD); + textLayout.addView(title); + + TextView description = new TextView(requireContext()); + description.setText(achievement.description); + description.setTextSize(12); + description.setAlpha(0.7f); + textLayout.addView(description); + + if (achievement.unlocked) { + TextView unlockTime = new TextView(requireContext()); + unlockTime.setText("Unlocked: " + achievement.getUnlockTimeFormatted()); + unlockTime.setTextSize(10); + unlockTime.setAlpha(0.5f); + textLayout.addView(unlockTime); + } else if (achievement.measuredPercent > 0) { + TextView progress = new TextView(requireContext()); + progress.setText(String.format("Progress: %.1f%%", achievement.measuredPercent)); + progress.setTextSize(10); + progress.setAlpha(0.5f); + textLayout.addView(progress); + } + + itemLayout.addView(textLayout); + return itemLayout; + } + + public static AchievementsListDialogFragment newInstance() { + return new AchievementsListDialogFragment(); + } +} diff --git a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java index b5aaa3e..5aa5d72 100644 --- a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java @@ -551,6 +551,16 @@ public class GamesCoverDialogFragment extends DialogFragment { } catch (Throwable ignored) {} }); } + + View btnAchievements = header.findViewById(R.id.drawer_btn_achievements); + if (btnAchievements != null) { + btnAchievements.setOnClickListener(v -> { + try { + AchievementsDialogFragment achievementsDialog = AchievementsDialogFragment.newInstance(); + achievementsDialog.show(getParentFragmentManager(), "achievements"); + } catch (Throwable ignored) {} + }); + } // Setup drawer settings controls to mirror quick actions setupDialogDrawerSettings(header); diff --git a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java index 88ca426..f7167ea 100644 --- a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java +++ b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java @@ -454,6 +454,9 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF Initialize(); + // Initialize RetroAchievements + RetroAchievementsManager.initialize(this); + // Initialize controller input handler mControllerInputHandler = new ControllerInputHandler(this); @@ -689,6 +692,20 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF } catch (Throwable ignored) {} }); } + + // Achievements button + View btnAchievements = header.findViewById(R.id.drawer_btn_achievements); + if (btnAchievements != null) { + btnAchievements.setOnClickListener(v -> { + try { + android.util.Log.d("MainActivity", "Achievements button clicked"); + AchievementsDialogFragment dialog = AchievementsDialogFragment.newInstance(); + dialog.show(getSupportFragmentManager(), "achievements_dialog"); + } catch (Throwable e) { + android.util.Log.e("MainActivity", "Error showing achievements dialog: " + e.getMessage()); + } + }); + } // Setup drawer settings controls to mirror quick actions setupDrawerSettings(header); @@ -1590,6 +1607,36 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF btn_pause_play.setIcon(ContextCompat.getDrawable(this, R.drawable.pause_circle_24px)); } }); + + // Auto-initialize and login to achievements if enabled + SharedPreferences prefs = getSharedPreferences("RetroAchievements", MODE_PRIVATE); + boolean achievementsEnabled = prefs.getBoolean("enabled", false); + if (achievementsEnabled) { + String username = prefs.getString("username", ""); + boolean rememberMe = prefs.getBoolean("remember_me", false); + String savedPassword = prefs.getString("saved_password", ""); + + new Thread(() -> { + try { + Thread.sleep(3000); // Wait 3 seconds for game to start + + // Initialize if not already active + if (!NativeApp.achievementsIsActive()) { + android.util.Log.d("Achievements", "Auto-initializing achievements for game"); + NativeApp.achievementsInitialize(); + Thread.sleep(500); // Wait for initialization + } + + // Auto-login if credentials are saved + if (!username.isEmpty() && rememberMe && !savedPassword.isEmpty()) { + android.util.Log.d("Achievements", "Auto-logging in as: " + username); + NativeApp.achievementsLogin(username, savedPassword); + } + } catch (Exception e) { + android.util.Log.e("Achievements", "Failed to auto-initialize/login: " + e.getMessage()); + } + }).start(); + } } } @@ -2771,4 +2818,5 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF } } + } diff --git a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java index 55b18d0..f85e696 100644 --- a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java +++ b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java @@ -155,6 +155,20 @@ public class NativeApp { // Returns array of strings in format "filename|size|isDirectory" public static native String[] getMemoryCardSaves(String memcardPath); + // RetroAchievements native methods + public static native boolean achievementsIsActive(); + public static native boolean achievementsIsHardcoreMode(); + public static native boolean achievementsHasActiveGame(); + public static native String achievementsGetGameTitle(); + public static native int achievementsGetGameId(); + public static native String achievementsGetRichPresence(); + public static native void achievementsLogin(String username, String password); + public static native void achievementsLogout(); + public static native void achievementsInitialize(); + public static native void achievementsShutdown(); + public static native Achievement[] achievementsGetAchievementList(); + public static native void achievementsSetHardcoreMode(boolean enabled); + public static native void onNativeSurfaceCreated(); public static native void onNativeSurfaceChanged(Surface surface, int w, int h); public static native void onNativeSurfaceDestroyed(); diff --git a/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java index 8881f9a..4fe4006 100644 --- a/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java @@ -249,6 +249,15 @@ public class QuickActionsDialogFragment extends DialogFragment { }); } + // Achievements: open achievements dialog + MaterialButton btnAchievements = view.findViewById(R.id.btn_quick_achievements); + if (btnAchievements != null) { + btnAchievements.setOnClickListener(v -> { + try { AchievementsDialogFragment.newInstance().show(getParentFragmentManager(), "achievements_dialog"); } catch (Throwable ignored) {} + dismissAllowingStateLoss(); + }); + } + // Exit Game: open games dialog if (btnExitGame != null) { btnExitGame.setOnClickListener(v -> { diff --git a/app/src/main/java/com/izzy2lost/psx2/RetroAchievementsManager.java b/app/src/main/java/com/izzy2lost/psx2/RetroAchievementsManager.java new file mode 100644 index 0000000..3da4f39 --- /dev/null +++ b/app/src/main/java/com/izzy2lost/psx2/RetroAchievementsManager.java @@ -0,0 +1,219 @@ +package com.izzy2lost.psx2; + +import android.app.Activity; +import android.os.Handler; +import android.os.Looper; +import android.widget.Toast; +import java.lang.ref.WeakReference; + +/** + * Manages RetroAchievements integration for the Android app. + * Handles achievement unlock notifications, leaderboard updates, and rich presence. + */ +public class RetroAchievementsManager { + private static WeakReference sActivity; + private static final Handler sMainHandler = new Handler(Looper.getMainLooper()); + + // Achievement notification types + public static final int NOTIFICATION_ACHIEVEMENT_UNLOCKED = 0; + public static final int NOTIFICATION_GAME_COMPLETE = 1; + public static final int NOTIFICATION_LEADERBOARD_STARTED = 2; + public static final int NOTIFICATION_LEADERBOARD_SUBMITTED = 3; + public static final int NOTIFICATION_LOGIN_SUCCESS = 4; + public static final int NOTIFICATION_CHALLENGE_INDICATOR = 5; + public static final int NOTIFICATION_PROGRESS_INDICATOR = 6; + + /** + * Initialize the RetroAchievements manager with the main activity. + */ + public static void initialize(Activity activity) { + sActivity = new WeakReference<>(activity); + } + + /** + * Called from native code when an achievement is unlocked. + * @param title Achievement title + * @param description Achievement description + * @param points Points earned + * @param isHardcore Whether hardcore mode is active + */ + public static void onAchievementUnlocked(final String title, final String description, + final int points, final boolean isHardcore) { + android.util.Log.i("Achievements", "Achievement unlocked: " + title + " (" + points + " points)"); + + sMainHandler.post(() -> { + Activity activity = getActivity(); + if (activity == null) { + android.util.Log.w("Achievements", "Activity is null, cannot show toast"); + return; + } + + // Check if notifications are enabled + android.content.SharedPreferences prefs = activity.getSharedPreferences("RetroAchievements", android.content.Context.MODE_PRIVATE); + boolean notificationsEnabled = prefs.getBoolean("notifications", true); + + if (!notificationsEnabled) { + android.util.Log.d("Achievements", "Notifications disabled, skipping toast"); + return; + } + + String message = String.format("🏆 Achievement Unlocked!\n%s\n%s\n%d points%s", + title, description, points, + isHardcore ? " (Hardcore)" : ""); + + Toast.makeText(activity, message, Toast.LENGTH_LONG).show(); + }); + } + + /** + * Called from native code when the game is completed (all achievements unlocked). + * @param gameTitle Game title + * @param achievementCount Total achievements unlocked + * @param totalPoints Total points earned + */ + public static void onGameComplete(final String gameTitle, final int achievementCount, + final int totalPoints) { + sMainHandler.post(() -> { + Activity activity = getActivity(); + if (activity == null) return; + + String message = String.format("🏆 Mastered %s!\n%d achievements, %d points", + gameTitle, achievementCount, totalPoints); + + Toast.makeText(activity, message, Toast.LENGTH_LONG).show(); + }); + } + + /** + * Called from native code when a leaderboard attempt starts. + * @param leaderboardTitle Leaderboard title + */ + public static void onLeaderboardStarted(final String leaderboardTitle) { + sMainHandler.post(() -> { + Activity activity = getActivity(); + if (activity == null) return; + + String message = String.format("📊 %s\nLeaderboard attempt started", leaderboardTitle); + Toast.makeText(activity, message, Toast.LENGTH_SHORT).show(); + }); + } + + /** + * Called from native code when a leaderboard score is submitted. + * @param leaderboardTitle Leaderboard title + * @param score Score value + * @param rank Player's rank + * @param totalEntries Total entries in leaderboard + */ + public static void onLeaderboardSubmitted(final String leaderboardTitle, final String score, + final int rank, final int totalEntries) { + sMainHandler.post(() -> { + Activity activity = getActivity(); + if (activity == null) return; + + String message = String.format("📊 %s\nScore: %s\nRank: %d of %d", + leaderboardTitle, score, rank, totalEntries); + + Toast.makeText(activity, message, Toast.LENGTH_LONG).show(); + }); + } + + /** + * Called from native code when successfully logged in. + * @param username User's display name + * @param score Total score + * @param softcoreScore Softcore score + * @param unreadMessages Number of unread messages + */ + public static void onLoginSuccess(final String username, final int score, + final int softcoreScore, final int unreadMessages) { + android.util.Log.i("Achievements", "Login success: " + username + " (Score: " + score + ")"); + + sMainHandler.post(() -> { + Activity activity = getActivity(); + if (activity == null) return; + + String message = String.format("✓ Logged in as %s\nScore: %d pts (softcore: %d pts)\nUnread messages: %d", + username, score, softcoreScore, unreadMessages); + + Toast.makeText(activity, message, Toast.LENGTH_LONG).show(); + }); + } + + /** + * Called from native code when a challenge indicator should be shown. + * @param achievementTitle Achievement title + */ + public static void onChallengeIndicatorShow(final String achievementTitle) { + sMainHandler.post(() -> { + Activity activity = getActivity(); + if (activity == null) return; + + String message = String.format("⚡ Challenge: %s", achievementTitle); + Toast.makeText(activity, message, Toast.LENGTH_SHORT).show(); + }); + } + + /** + * Called from native code when progress towards an achievement is made. + * @param achievementTitle Achievement title + * @param progress Progress string (e.g., "50%") + */ + public static void onProgressIndicatorUpdate(final String achievementTitle, final String progress) { + sMainHandler.post(() -> { + Activity activity = getActivity(); + if (activity == null) return; + + String message = String.format("📈 %s: %s", achievementTitle, progress); + Toast.makeText(activity, message, Toast.LENGTH_SHORT).show(); + }); + } + + /** + * Called from native code when game summary is available. + * @param gameTitle Game title + * @param unlockedCount Unlocked achievements + * @param totalCount Total achievements + * @param earnedPoints Earned points + * @param totalPoints Total points + * @param isHardcore Whether hardcore mode is active + */ + public static void onGameSummary(final String gameTitle, final int unlockedCount, + final int totalCount, final int earnedPoints, + final int totalPoints, final boolean isHardcore) { + sMainHandler.post(() -> { + Activity activity = getActivity(); + if (activity == null) return; + + String message; + if (totalCount > 0) { + message = String.format("%s%s\nUnlocked %d of %d achievements\nEarned %d of %d points", + gameTitle, isHardcore ? " (Hardcore)" : "", + unlockedCount, totalCount, earnedPoints, totalPoints); + } else { + message = String.format("%s\nThis game has no achievements.", gameTitle); + } + + Toast.makeText(activity, message, Toast.LENGTH_LONG).show(); + }); + } + + /** + * Called from native code to show a generic notification. + * @param message Message to display + * @param duration Duration (0 = SHORT, 1 = LONG) + */ + public static void showNotification(final String message, final int duration) { + sMainHandler.post(() -> { + Activity activity = getActivity(); + if (activity == null) return; + + int toastDuration = duration == 0 ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG; + Toast.makeText(activity, message, toastDuration).show(); + }); + } + + private static Activity getActivity() { + return sActivity != null ? sActivity.get() : null; + } +} diff --git a/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java index 23eb815..6aa20e2 100644 --- a/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java @@ -168,6 +168,19 @@ public class SettingsDialogFragment extends DialogFragment { controllerDialog.show(getParentFragmentManager(), "controller_test"); }); } + + // RetroAchievements button + View btnAchievements = view.findViewById(R.id.btn_achievements); + android.util.Log.d("SettingsDialog", "btnAchievements found: " + (btnAchievements != null)); + if (btnAchievements != null) { + btnAchievements.setOnClickListener(v -> { + android.util.Log.d("SettingsDialog", "Achievements button clicked!"); + AchievementsDialogFragment achievementsDialog = AchievementsDialogFragment.newInstance(); + achievementsDialog.show(getParentFragmentManager(), "achievements"); + }); + } else { + android.util.Log.e("SettingsDialog", "btn_achievements NOT FOUND in layout!"); + } // Populate scale spinner (1x..8x) ArrayAdapter scaleAdapter = ArrayAdapter.createFromResource(ctx, diff --git a/app/src/main/res/layout/dialog_quick_actions.xml b/app/src/main/res/layout/dialog_quick_actions.xml index 11109b0..2ada71f 100644 --- a/app/src/main/res/layout/dialog_quick_actions.xml +++ b/app/src/main/res/layout/dialog_quick_actions.xml @@ -158,6 +158,19 @@ android:drawableTint="@color/brand_primary" android:drawablePadding="8dp" /> + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/drawer_header_settings.xml b/app/src/main/res/layout/drawer_header_settings.xml index d22417a..adccd4e 100644 --- a/app/src/main/res/layout/drawer_header_settings.xml +++ b/app/src/main/res/layout/drawer_header_settings.xml @@ -326,6 +326,25 @@ app:iconGravity="textStart" app:iconPadding="8dp" app:iconSize="20dp" + android:layout_marginBottom="8dp"/> + + + + +