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/android_host_interface.cpp b/android/app/src/cpp/android_host_interface.cpp index f06885fd0..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,6 +55,8 @@ 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() @@ -445,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()) { @@ -598,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; } @@ -616,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); @@ -820,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 || @@ -830,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; @@ -840,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 = @@ -876,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; @@ -1652,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_http_downloader.cpp b/android/app/src/cpp/android_http_downloader.cpp index 4f1db0997..f3a1b895c 100644 --- a/android/app/src/cpp/android_http_downloader.cpp +++ b/android/app/src/cpp/android_http_downloader.cpp @@ -114,6 +114,7 @@ void AndroidHTTPDownloader::ProcessRequest(Request* req) } env->DeleteLocalRef(obj); + AndroidHelpers::GetJavaVM()->DetachCurrentThread(); } else { diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/Achievement.java b/android/app/src/main/java/com/github/stenzek/duckstation/Achievement.java new file mode 100644 index 000000000..ee4477c83 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/Achievement.java @@ -0,0 +1,76 @@ +package com.github.stenzek.duckstation; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.preference.PreferenceManager; + +public final class Achievement { + public static final int CATEGORY_LOCAL = 0; + public static final int CATEGORY_CORE = 3; + public static final int CATEGORY_UNOFFICIAL = 5; + + private final int id; + private final String name; + private final String description; + private final String lockedBadgePath; + private final String unlockedBadgePath; + private final int points; + private final boolean locked; + + public Achievement(int id, String name, String description, String lockedBadgePath, + String unlockedBadgePath, int points, boolean locked) { + this.id = id; + this.name = name; + this.description = description; + this.lockedBadgePath = lockedBadgePath; + this.unlockedBadgePath = unlockedBadgePath; + this.points = points; + this.locked = locked; + } + + /** + * Returns true if challenge mode will be enabled when a game is started. + * Does not depend on the emulation running. + * + * @param context context to pull settings from + * @return true if challenge mode will be used, false otherwise + */ + public static boolean willChallengeModeBeEnabled(Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getBoolean("Cheevos/Enabled", false) && + prefs.getBoolean("Cheevos/ChallengeMode", false); + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getLockedBadgePath() { + return lockedBadgePath; + } + + public String getUnlockedBadgePath() { + return unlockedBadgePath; + } + + public int getPoints() { + return points; + } + + public boolean isLocked() { + return locked; + } + + public String getBadgePath() { + return locked ? lockedBadgePath : unlockedBadgePath; + } +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/AchievementListFragment.java b/android/app/src/main/java/com/github/stenzek/duckstation/AchievementListFragment.java new file mode 100644 index 000000000..04b229ad4 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/AchievementListFragment.java @@ -0,0 +1,193 @@ +package com.github.stenzek.duckstation; + +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.Configuration; +import android.os.AsyncTask; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.Arrays; +import java.util.Comparator; + +public class AchievementListFragment extends DialogFragment { + + private RecyclerView mRecyclerView; + private AchievementListFragment.ViewAdapter mAdapter; + private final Achievement[] mAchievements; + private DialogInterface.OnDismissListener mOnDismissListener; + + public AchievementListFragment(Achievement[] achievements) { + mAchievements = achievements; + sortAchievements(); + } + + public void setOnDismissListener(DialogInterface.OnDismissListener l) { + mOnDismissListener = l; + } + + @Override + public void onDismiss(@NonNull DialogInterface dialog) { + if (mOnDismissListener != null) + mOnDismissListener.onDismiss(dialog); + + super.onDismiss(dialog); + } + + @Override + public void onResume() { + super.onResume(); + + if (getDialog() == null) + return; + + final boolean isLandscape = (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE); + final float scale = (float) getContext().getResources().getDisplayMetrics().densityDpi / DisplayMetrics.DENSITY_DEFAULT; + final int width = Math.round((isLandscape ? 700.0f : 400.0f) * scale); + final int height = Math.round((isLandscape ? 400.0f : 700.0f) * scale); + getDialog().getWindow().setLayout(width, height); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_achievement_list, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mAdapter = new AchievementListFragment.ViewAdapter(getContext(), mAchievements); + + mRecyclerView = view.findViewById(R.id.recyclerView); + mRecyclerView.setAdapter(mAdapter); + mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + mRecyclerView.addItemDecoration(new DividerItemDecoration(mRecyclerView.getContext(), + DividerItemDecoration.VERTICAL)); + + fillHeading(view); + } + + private void fillHeading(@NonNull View view) { + final AndroidHostInterface hi = AndroidHostInterface.getInstance(); + final String gameTitle = hi.getCheevoGameTitle(); + if (gameTitle != null) { + final String formattedTitle = hi.isCheevosChallengeModeActive() ? + String.format(getString(R.string.achievement_title_challenge_mode_format_string), gameTitle) : + gameTitle; + ((TextView) view.findViewById(R.id.title)).setText(formattedTitle); + } + + final int cheevoCount = hi.getCheevoCount(); + final int unlockedCheevoCount = hi.getUnlockedCheevoCount(); + final String summary = String.format(getString(R.string.achievement_summary_format_string), + unlockedCheevoCount, cheevoCount, hi.getCheevoPointsForGame(), hi.getCheevoMaximumPointsForGame()); + ((TextView) view.findViewById(R.id.summary)).setText(summary); + + ProgressBar pb = ((ProgressBar) view.findViewById(R.id.progressBar)); + pb.setMax(cheevoCount); + pb.setProgress(unlockedCheevoCount); + + final ImageView icon = ((ImageView) view.findViewById(R.id.icon)); + final String badgePath = hi.getCheevoGameIconPath(); + if (badgePath != null) { + new ImageLoadTask(icon).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, badgePath); + } + } + + private void sortAchievements() { + Arrays.sort(mAchievements, (o1, o2) -> { + 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/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 @@ + + + + + + + + + + + + + + + + + + +