Android: Implement RetroAchievements

This commit is contained in:
Connor McLaughlin 2021-03-13 21:14:05 +10:00
parent 0f1dc93eaa
commit c182edf196
26 changed files with 1225 additions and 63 deletions

View File

@ -69,10 +69,6 @@ if(ANDROID)
message("Building for Android, disabling Discord Presence support") message("Building for Android, disabling Discord Presence support")
set(ENABLE_DISCORD_PRESENCE OFF) set(ENABLE_DISCORD_PRESENCE OFF)
endif() endif()
if(ENABLE_CHEEVOS)
message("Building for Android. disabling RetroAchievements support")
set(ENABLE_CHEEVOS OFF)
endif()
if(USE_SDL2) if(USE_SDL2)
message("Building for Android, disabling SDL2 support") message("Building for Android, disabling SDL2 support")
set(USE_SDL2 OFF) set(USE_SDL2 OFF)
@ -83,6 +79,9 @@ if(ANDROID)
if(USE_WAYLAND) if(USE_WAYLAND)
set(USE_WAYLAND OFF) set(USE_WAYLAND OFF)
endif() endif()
# Cheevos are always on.
set(ENABLE_CHEEVOS ON)
endif() endif()
@ -143,7 +142,7 @@ if(USE_EVDEV)
endif() endif()
if(ENABLE_CHEEVOS) if(ENABLE_CHEEVOS)
message(STATUS "RetroAchievements support enabled") message(STATUS "RetroAchievements support enabled")
if(NOT WIN32) if(NOT WIN32 AND NOT ANDROID)
find_package(CURL REQUIRED) find_package(CURL REQUIRED)
endif() endif()
endif() endif()

View File

@ -15,7 +15,9 @@
#include "core/gpu.h" #include "core/gpu.h"
#include "core/host_display.h" #include "core/host_display.h"
#include "core/system.h" #include "core/system.h"
#include "frontend-common/cheevos.h"
#include "frontend-common/game_list.h" #include "frontend-common/game_list.h"
#include "frontend-common/imgui_fullscreen.h"
#include "frontend-common/imgui_styles.h" #include "frontend-common/imgui_styles.h"
#include "frontend-common/opengl_host_display.h" #include "frontend-common/opengl_host_display.h"
#include "frontend-common/vulkan_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 jmethodID s_GameListEntry_constructor;
static jclass s_SaveStateInfo_class; static jclass s_SaveStateInfo_class;
static jmethodID s_SaveStateInfo_constructor; static jmethodID s_SaveStateInfo_constructor;
static jclass s_Achievement_class;
static jmethodID s_Achievement_constructor;
namespace AndroidHelpers { namespace AndroidHelpers {
JavaVM* GetJavaVM() 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 // simulate the system if not paused
if (System::IsRunning()) 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); Log_InfoPrintf("SurfaceChanged %p %d %d %d", surface, format, width, height);
if (m_surface == surface) if (m_surface == surface)
{ {
if (m_display) if (m_display && (width != m_display->GetWindowWidth() || height != m_display->GetWindowHeight()))
{
m_display->ResizeRenderWindow(width, height); m_display->ResizeRenderWindow(width, height);
OnHostDisplayResized(width, height, m_display->GetWindowScale());
}
return; return;
} }
@ -616,6 +627,8 @@ void AndroidHostInterface::SurfaceChanged(ANativeWindow* surface, int format, in
wi.surface_scale = m_display->GetWindowScale(); wi.surface_scale = m_display->GetWindowScale();
m_display->ChangeRenderWindow(wi); m_display->ChangeRenderWindow(wi);
if (surface)
OnHostDisplayResized(width, height, m_display->GetWindowScale());
if (surface && System::GetState() == System::State::Paused) if (surface && System::GetState() == System::State::Paused)
PauseSystem(false); 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. // Create global reference so it doesn't get cleaned up.
JNIEnv* env = AndroidHelpers::GetJNIEnv(); 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 || if ((string_class = env->FindClass("java/lang/String")) == nullptr ||
(s_String_class = static_cast<jclass>(env->NewGlobalRef(string_class))) == nullptr || (s_String_class = static_cast<jclass>(env->NewGlobalRef(string_class))) == nullptr ||
(host_interface_class = env->FindClass("com/github/stenzek/duckstation/AndroidHostInterface")) == 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 || (game_list_entry_class = env->FindClass("com/github/stenzek/duckstation/GameListEntry")) == nullptr ||
(s_GameListEntry_class = static_cast<jclass>(env->NewGlobalRef(game_list_entry_class))) == nullptr || (s_GameListEntry_class = static_cast<jclass>(env->NewGlobalRef(game_list_entry_class))) == nullptr ||
(save_state_info_class = env->FindClass("com/github/stenzek/duckstation/SaveStateInfo")) == nullptr || (save_state_info_class = env->FindClass("com/github/stenzek/duckstation/SaveStateInfo")) == nullptr ||
(s_SaveStateInfo_class = static_cast<jclass>(env->NewGlobalRef(save_state_info_class))) == nullptr) (s_SaveStateInfo_class = static_cast<jclass>(env->NewGlobalRef(save_state_info_class))) == nullptr ||
(achievement_class = env->FindClass("com/github/stenzek/duckstation/Achievement")) == nullptr ||
(s_Achievement_class = static_cast<jclass>(env->NewGlobalRef(achievement_class))) == nullptr)
{ {
Log_ErrorPrint("AndroidHostInterface class lookup failed"); Log_ErrorPrint("AndroidHostInterface class lookup failed");
return -1; return -1;
@ -840,6 +856,7 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved)
env->DeleteLocalRef(host_interface_class); env->DeleteLocalRef(host_interface_class);
env->DeleteLocalRef(patch_code_class); env->DeleteLocalRef(patch_code_class);
env->DeleteLocalRef(game_list_entry_class); env->DeleteLocalRef(game_list_entry_class);
env->DeleteLocalRef(achievement_class);
jclass emulation_activity_class; jclass emulation_activity_class;
if ((s_AndroidHostInterface_constructor = if ((s_AndroidHostInterface_constructor =
@ -876,7 +893,10 @@ extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved)
(s_SaveStateInfo_constructor = env->GetMethodID( (s_SaveStateInfo_constructor = env->GetMethodID(
s_SaveStateInfo_class, "<init>", s_SaveStateInfo_class, "<init>",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZII[B)V")) == "(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, "<init>",
"(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZ)V")) == nullptr)
{ {
Log_ErrorPrint("AndroidHostInterface lookups failed"); Log_ErrorPrint("AndroidHostInterface lookups failed");
return -1; return -1;
@ -1652,4 +1672,107 @@ DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_toggleControllerAnalogMode, jo
ctrl->SetButtonState(code.value(), true); ctrl->SetButtonState(code.value(), true);
ctrl->SetButtonState(code.value(), false); ctrl->SetButtonState(code.value(), false);
} }
} }
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<jobject> 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<jint>(cheevo.id), title,
description, locked_badge_path, unlocked_badge_path,
static_cast<jint>(cheevo.points), static_cast<jboolean>(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<jsize>(cheevos.size()), s_Achievement_class, nullptr);
for (size_t i = 0; i < cheevos.size(); i++)
{
env->SetObjectArrayElement(ret, static_cast<jsize>(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();
}

View File

@ -114,6 +114,7 @@ void AndroidHTTPDownloader::ProcessRequest(Request* req)
} }
env->DeleteLocalRef(obj); env->DeleteLocalRef(obj);
AndroidHelpers::GetJavaVM()->DetachCurrentThread();
} }
else else
{ {

View File

@ -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;
}
}

View File

@ -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<AchievementListFragment.ViewHolder> {
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;
}
}
}

View File

@ -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<Void, Void, Void> {
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);
}
}
}

View File

@ -144,6 +144,20 @@ public class AndroidHostInterface {
public native SaveStateInfo[] getSaveStateInfo(boolean includeEmpty); 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 { static {
System.loadLibrary("duckstation-native"); System.loadLibrary("duckstation-native");
} }

View File

@ -1,6 +1,7 @@
package com.github.stenzek.duckstation; package com.github.stenzek.duckstation;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.pm.ActivityInfo; import android.content.pm.ActivityInfo;
@ -26,6 +27,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.FragmentManager;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
/** /**
@ -392,6 +394,21 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
AndroidHostInterface.getInstance().pauseEmulationThread(false); 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() { private void showMenu() {
if (getBooleanSetting("Main/PauseOnMenu", false) && if (getBooleanSetting("Main/PauseOnMenu", false) &&
!AndroidHostInterface.getInstance().isEmulationThreadPaused()) { !AndroidHostInterface.getInstance().isEmulationThreadPaused()) {
@ -401,6 +418,7 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
AlertDialog.Builder builder = new AlertDialog.Builder(this); AlertDialog.Builder builder = new AlertDialog.Builder(this);
if (mGameTitle != null && !mGameTitle.isEmpty()) if (mGameTitle != null && !mGameTitle.isEmpty())
builder.setTitle(mGameTitle); builder.setTitle(mGameTitle);
builder.setItems(R.array.emulation_menu, (dialogInterface, i) -> { builder.setItems(R.array.emulation_menu, (dialogInterface, i) -> {
switch (i) { switch (i) {
case 0: // Load State case 0: // Load State
@ -422,13 +440,19 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
return; return;
} }
case 3: // More Options case 3: // Achievements
{
showAchievementsPopup();
return;
}
case 4: // More Options
{ {
showMoreMenu(); showMoreMenu();
return; return;
} }
case 4: // Quit case 5: // Quit
{ {
mStopRequested = true; mStopRequested = true;
finish(); finish();
@ -437,7 +461,18 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
} }
}); });
builder.setOnCancelListener(dialogInterface -> onMenuClosed()); 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) { private void showSaveStateMenu(boolean saving) {
@ -512,7 +547,14 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
} }
}); });
builder.setOnCancelListener(dialogInterface -> onMenuClosed()); 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() { private void showTouchscreenControllerMenu() {
@ -660,6 +702,18 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
builder.create().show(); 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 * Touchscreen controller overlay
*/ */
@ -697,6 +751,16 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
mVibratorService = (Vibrator) getSystemService(VIBRATOR_SERVICE); mVibratorService = (Vibrator) getSystemService(VIBRATOR_SERVICE);
else else
mVibratorService = null; 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; private InputManager.InputDeviceListener mInputDeviceListener;

View File

@ -98,26 +98,7 @@ public class GameGridFragment extends Fragment implements GameList.OnRefreshList
@Override @Override
public boolean onLongClick(View v) { public boolean onLongClick(View v) {
PopupMenu menu = new PopupMenu(mParent, v, Gravity.RIGHT | Gravity.TOP); mParent.openGamePopupMenu(v, mEntry);
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();
return true; return true;
} }
} }

View File

@ -167,26 +167,7 @@ public class GameListFragment extends Fragment implements GameList.OnRefreshList
@Override @Override
public boolean onLongClick(View v) { public boolean onLongClick(View v) {
androidx.appcompat.widget.PopupMenu menu = new androidx.appcompat.widget.PopupMenu(mParent, v, Gravity.RIGHT | Gravity.TOP); mParent.openGamePopupMenu(v, mEntry);
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();
return true; return true;
} }
} }

View File

@ -12,6 +12,7 @@ import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.Gravity;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
@ -65,7 +66,14 @@ public class MainActivity extends AppCompatActivity {
public boolean shouldResumeStateByDefault() { public boolean shouldResumeStateByDefault() {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); 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() { private void setLanguage() {
@ -341,6 +349,34 @@ public class MainActivity extends AppCompatActivity {
return true; 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) { public boolean startEmulation(String bootPath, boolean resumeState) {
if (!doBIOSCheck()) if (!doBIOSCheck())
return false; return false;

View File

@ -51,7 +51,7 @@ public class SettingsActivity extends AppCompatActivity {
} }
public static class SettingsFragment extends PreferenceFragmentCompat { public static class SettingsFragment extends PreferenceFragmentCompat {
private int resourceId; private final int resourceId;
public SettingsFragment(int resourceId) { public SettingsFragment(int resourceId) {
this.resourceId = resourceId; this.resourceId = resourceId;
@ -110,7 +110,10 @@ public class SettingsActivity extends AppCompatActivity {
case 4: // Controllers case 4: // Controllers
return new SettingsFragment(R.xml.controllers_preferences); 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); return new SettingsFragment(R.xml.advanced_preferences);
default: default:
@ -120,7 +123,7 @@ public class SettingsActivity extends AppCompatActivity {
@Override @Override
public int getItemCount() { public int getItemCount() {
return 6; return 7;
} }
} }
} }

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM12,17c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM15.1,8L8.9,8L8.9,6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM18,20L6,20L6,10h12v10z"/>
</vector>

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:id="@+id/navigation_header_container"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:layout_alignParentTop="true"
android:layout_alignParentStart="true">
<ImageView
android:id="@+id/icon"
android:layout_width="70dp"
android:layout_height="70dp"
android:foregroundGravity="center_vertical"
android:layout_alignParentTop="true"
android:layout_alignParentStart="true"
tools:srcCompat="@drawable/ic_media_cdrom" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:focusable="false"
android:focusableInTouchMode="false"
android:text="Game Title"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
android:layout_alignParentTop="true"
android:layout_toRightOf="@+id/icon"
android:layout_alignParentEnd="true" />
<TextView
android:id="@+id/summary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:focusable="false"
android:focusableInTouchMode="false"
android:text="You have unlocked %d of %d achievements, earning %d of %d possible points."
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:layout_below="@+id/title"
android:layout_toRightOf="@+id/icon"
android:layout_alignParentEnd="true" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="16dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_below="@+id/summary"
android:layout_toRightOf="@+id/icon"
android:layout_alignParentEnd="true" />
</RelativeLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="fill_parent"
android:scrollbars="vertical"
android:layout_below="@+id/navigation_header_container"
android:layout_alignParentBottom="true"
android:layout_alignParentLeft="true"
android:layout_alignParentEnd="true" />
</RelativeLayout>

View File

@ -0,0 +1,133 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minWidth="280dp"
>
<LinearLayout
android:id="@+id/panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp"
>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:text="@string/achievement_settings_login_title"
android:textAppearance="@style/TextAppearance.AppCompat.Large" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:layout_marginTop="4dp"
android:text="@string/achievement_settings_login_help"
/>
<EditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:inputType="textVisiblePassword"
android:nextFocusDown="@+id/password"
android:imeOptions="actionNext"
android:singleLine="true"
android:hint="@string/achievement_settings_login_username_hint"
/>
<EditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:inputType="textPassword"
android:imeOptions="actionDone"
android:singleLine="true"
android:hint="@string/achievement_settings_login_password_hint"
/>
<TextView
android:id="@+id/error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginTop="4dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="4dp"
android:visibility="visible" />
</LinearLayout>
<LinearLayout
android:id="@+id/buttonPanel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:divider="?android:attr/dividerHorizontal"
android:dividerPadding="0dip"
android:minHeight="48dp"
android:orientation="vertical"
android:showDividers="beginning"
android:padding="5dp"
>
<LinearLayout
style="?android:attr/buttonBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layoutDirection="locale"
android:orientation="horizontal"
tools:ignore="UselessParent"
>
<Space
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1" />
<Button
android:id="@+id/cancel"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:focusable="true"
android:maxLines="2"
android:minHeight="48dp"
android:text="@string/achievement_settings_login_cancel_button"
android:textSize="14sp" />
<Button
android:id="@+id/login"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:focusable="true"
android:maxLines="2"
android:minHeight="48dp"
android:text="@string/achievement_settings_login_login_button"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminateOnly="true"
android:visibility="gone"
/>
</FrameLayout>

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/linearLayout"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:background="?android:attr/selectableItemBackground">
<ImageView
android:id="@+id/icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:foregroundGravity="center_vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/ic_media_cdrom" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginRight="80dp"
android:focusable="false"
android:focusableInTouchMode="false"
android:text="Achievement Title"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/icon"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginRight="80dp"
android:focusable="false"
android:focusableInTouchMode="false"
android:paddingBottom="8px"
android:text="Achievement Description"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/icon"
app:layout_constraintTop_toBottomOf="@+id/title" />
<ImageView
android:id="@+id/locked_icon"
android:layout_width="32dp"
android:layout_height="28dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="24dp"
android:focusable="false"
android:focusableInTouchMode="false"
android:paddingBottom="8px"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_baseline_lock_24" />
<TextView
android:id="@+id/points"
android:layout_width="64dp"
android:layout_height="16dp"
android:layout_marginEnd="8dp"
android:focusable="false"
android:focusableInTouchMode="false"
android:text="5 Points"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:srcCompat="@drawable/ic_star_5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/locked_icon" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -82,6 +82,7 @@
<item>Cargar Estado</item> <item>Cargar Estado</item>
<item>Guardar Estado</item> <item>Guardar Estado</item>
<item>Activar Avance Rápido</item> <item>Activar Avance Rápido</item>
<item>Achievements</item>
<item>Más Opciones</item> <item>Más Opciones</item>
<item>Salir</item> <item>Salir</item>
</string-array> </string-array>
@ -139,6 +140,7 @@
<item>Audio</item> <item>Audio</item>
<item>Mejoras</item> <item>Mejoras</item>
<item>Controles</item> <item>Controles</item>
<item>Achievements</item>
<item>Avanzado</item> <item>Avanzado</item>
</string-array> </string-array>
<string-array name="settings_gpu_msaa_entries"> <string-array name="settings_gpu_msaa_entries">

View File

@ -82,6 +82,7 @@
<item>Carica Stato</item> <item>Carica Stato</item>
<item>Salva Stato</item> <item>Salva Stato</item>
<item>Abilita/Disabilita Avanti Veloce</item> <item>Abilita/Disabilita Avanti Veloce</item>
<item>Achievements</item>
<item>Altre Opzioni</item> <item>Altre Opzioni</item>
<item>Esci</item> <item>Esci</item>
</string-array> </string-array>
@ -139,6 +140,7 @@
<item>Audio</item> <item>Audio</item>
<item>Miglioramenti</item> <item>Miglioramenti</item>
<item>Controller</item> <item>Controller</item>
<item>Achievements</item>
<item>Avanzate</item> <item>Avanzate</item>
</string-array> </string-array>
<string-array name="settings_gpu_msaa_entries"> <string-array name="settings_gpu_msaa_entries">

View File

@ -82,6 +82,7 @@
<item>Staat Laden</item> <item>Staat Laden</item>
<item>Staat Opslaan</item> <item>Staat Opslaan</item>
<item>Doorspoelen aan/uitzetten</item> <item>Doorspoelen aan/uitzetten</item>
<item>Achievements</item>
<item>Meer Opties</item> <item>Meer Opties</item>
<item>Afsluiten</item> <item>Afsluiten</item>
</string-array> </string-array>
@ -139,6 +140,7 @@
<item>Audio</item> <item>Audio</item>
<item>Verbeteringen</item> <item>Verbeteringen</item>
<item>Controllers</item> <item>Controllers</item>
<item>Achievements</item>
<item>Geavanceerd</item> <item>Geavanceerd</item>
</string-array> </string-array>
<string-array name="settings_gpu_msaa_entries"> <string-array name="settings_gpu_msaa_entries">

View File

@ -82,6 +82,7 @@
<item>Carregar Estado</item> <item>Carregar Estado</item>
<item>Salvar Estado</item> <item>Salvar Estado</item>
<item>Avanço (Fixo)</item> <item>Avanço (Fixo)</item>
<item>Achievements</item>
<item>Mais Opções</item> <item>Mais Opções</item>
<item>Sair</item> <item>Sair</item>
</string-array> </string-array>
@ -139,6 +140,7 @@
<item>Áudio</item> <item>Áudio</item>
<item>Melhorias</item> <item>Melhorias</item>
<item>Controles</item> <item>Controles</item>
<item>Achievements</item>
<item>Avançado</item> <item>Avançado</item>
</string-array> </string-array>
<string-array name="settings_gpu_msaa_entries"> <string-array name="settings_gpu_msaa_entries">

View File

@ -82,6 +82,7 @@
<item>Загрузить состояние</item> <item>Загрузить состояние</item>
<item>Сохранить состояние</item> <item>Сохранить состояние</item>
<item>Включить ускорение</item> <item>Включить ускорение</item>
<item>Achievements</item>
<item>Другие опции</item> <item>Другие опции</item>
<item>Выход</item> <item>Выход</item>
</string-array> </string-array>
@ -93,11 +94,11 @@
<item>Настройки эмулятора</item> <item>Настройки эмулятора</item>
</string-array> </string-array>
<string-array name="emulation_touchscreen_menu"> <string-array name="emulation_touchscreen_menu">
<item>Сменить вид</item> <item>Сменить вид</item>
<item>Настроить видимость</item> <item>Настроить видимость</item>
<item>Добавить/убрать кнопки</item> <item>Добавить/убрать кнопки</item>
<item>Изменить макет</item> <item>Изменить макет</item>
</string-array> </string-array>
<string-array name="settings_cdrom_read_speedup_entries"> <string-array name="settings_cdrom_read_speedup_entries">
<item>Нет (двойная скорость)</item> <item>Нет (двойная скорость)</item>
<item>2x (скорость 4x)</item> <item>2x (скорость 4x)</item>
@ -145,6 +146,7 @@
<item>Звук</item> <item>Звук</item>
<item>Улучшения</item> <item>Улучшения</item>
<item>Контроллеры</item> <item>Контроллеры</item>
<item>Achievements</item>
<item>Расширенные</item> <item>Расширенные</item>
</string-array> </string-array>
<string-array name="settings_gpu_msaa_entries"> <string-array name="settings_gpu_msaa_entries">

View File

@ -161,6 +161,7 @@
<item>Load State</item> <item>Load State</item>
<item>Save State</item> <item>Save State</item>
<item>Toggle Fast Forward</item> <item>Toggle Fast Forward</item>
<item>Achievements</item>
<item>More Options</item> <item>More Options</item>
<item>Quit</item> <item>Quit</item>
</string-array> </string-array>
@ -265,6 +266,7 @@
<item>Audio</item> <item>Audio</item>
<item>Enhancements</item> <item>Enhancements</item>
<item>Controllers</item> <item>Controllers</item>
<item>Achievements</item>
<item>Advanced</item> <item>Advanced</item>
</string-array> </string-array>
<string-array name="settings_gpu_msaa_entries"> <string-array name="settings_gpu_msaa_entries">

View File

@ -233,4 +233,37 @@
<string name="settings_summary_touch_gliding">Allows you to press multiple controller face buttons by dragging your finger along the screen.</string> <string name="settings_summary_touch_gliding">Allows you to press multiple controller face buttons by dragging your finger along the screen.</string>
<string name="menu_game_list_entry_game_properties">Game Properties</string> <string name="menu_game_list_entry_game_properties">Game Properties</string>
<string name="emulation_activity_change_disc_select_new_file">Select New File...</string> <string name="emulation_activity_change_disc_select_new_file">Select New File...</string>
<string name="settings_achievements_enable">Enable RetroAchievements</string>
<string name="settings_summary_achievements_enable">When enabled and logged in, DuckStation will scan for achievements on startup.</string>
<string name="settings_achievements_challenge_mode">Enable Hardcore Mode</string>
<string name="settings_summary_achievements_challenge_mode">Challenge mode. Disables save states, patch code, and slowdown functions, but you receive double the achievement points. Cannot be toggled while ingame.</string>
<string name="settings_achievements_rich_presence">Enable Rich Presence</string>
<string name="settings_summary_achievements_rich_presence">Rich presence information will be collected and sent to the server where supported.</string>
<string name="settings_achievements_username">User Name</string>
<string name="settings_achievements_token_generation_time">Token Generation Time</string>
<string name="settings_achievements_login">Login</string>
<string name="settings_summary_achievements_login">Logs in to your account to record achievements.</string>
<string name="settings_achievements_register">Register</string>
<string name="settings_summary_achievements_register">Opens a link to create a new account.</string>
<string name="settings_achievements_logout">Logout</string>
<string name="settings_summary_achievements_logout">Logs out of your account. No new achievements will be recorded.</string>
<string name="settings_achievements_view_profile">View Profile</string>
<string name="settings_summary_achievements_view_profile">Opens a link to your profile.</string>
<string name="settings_achievements_test_mode">Enable Test Mode</string>
<string name="settings_summary_achievements_test_mode">When enabled, DuckStation will assume all achievements are locked and not send any unlock notifications to the server.</string>
<string name="settings_achievements_use_first_disc_from_playlist">Use First Disc From Playlist</string>
<string name="settings_summary_achievements_use_first_disc_from_playlist">When enabled, the first disc in a playlist will be used for achievements, regardless of which disc is active.</string>
<string name="achievement_settings_login_title">RetroAchievements Login</string>
<string name="achievement_settings_login_help">Please enter user name and password for retroachievements.org below. Your password will not be saved in DuckStation, an access token will be generated and used instead.</string>
<string name="achievement_settings_login_username_hint">Username</string>
<string name="achievement_settings_login_password_hint">Password</string>
<string name="achievement_settings_login_login_button">Login</string>
<string name="achievement_settings_login_cancel_button">Cancel</string>
<string name="achievement_settings_login_failed">Login failed. Please check your username and password, and try again.</string>
<string name="achievement_points_format_string">%d points</string>
<string name="achievement_summary_format_string">You have unlocked %1$d of %2$d achievements, earning %3$d of %4$d possible points.</string>
<string name="achievement_title_challenge_mode_format_string">%s (Hardcore Mode)</string>
<string name="settings_achievements_disclaimer">DuckStation uses RetroAchievements (retroachievements.org) as an achievement database and for tracking progress.</string>
<string name="settings_achievements_confirm_logout_title">Confirm Logout</string>
<string name="settings_achievements_confirm_logout_message">After logging out, no more achievements will be unlocked until you log back in again. Achievements already unlocked will not be lost.</string>
</resources> </resources>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory app:title="Global Settings" app:iconSpaceReserved="false">
<SwitchPreferenceCompat
app:key="Cheevos/Enabled"
app:title="@string/settings_achievements_enable"
app:summary="@string/settings_summary_achievements_enable"
app:defaultValue="false"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
app:key="Cheevos/ChallengeMode"
app:title="@string/settings_achievements_challenge_mode"
app:summary="@string/settings_summary_achievements_challenge_mode"
app:dependency="Cheevos/Enabled"
app:defaultValue="false"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
app:key="Cheevos/RichPresence"
app:title="@string/settings_achievements_rich_presence"
app:summary="@string/settings_summary_achievements_rich_presence"
app:dependency="Cheevos/Enabled"
app:defaultValue="true"
app:iconSpaceReserved="false" />
</PreferenceCategory>
<PreferenceCategory app:title="Account" app:iconSpaceReserved="false">
<Preference
app:key="Cheevos/Username"
app:title="@string/settings_achievements_username"
app:iconSpaceReserved="false" />
<Preference
app:key="Cheevos/LoginTokenTime"
app:title="@string/settings_achievements_token_generation_time"
app:iconSpaceReserved="false" />
<PreferenceScreen
app:key="Cheevos/Login"
app:title="@string/settings_achievements_login"
app:summary="@string/settings_summary_achievements_login"
app:iconSpaceReserved="false" />
<PreferenceScreen
app:key="Cheevos/Register"
app:title="@string/settings_achievements_register"
app:summary="@string/settings_summary_achievements_register"
app:iconSpaceReserved="false" />
<PreferenceScreen
app:key="Cheevos/Logout"
app:title="@string/settings_achievements_logout"
app:summary="@string/settings_summary_achievements_logout"
app:iconSpaceReserved="false" />
<PreferenceScreen
app:key="Cheevos/ViewProfile"
app:title="@string/settings_achievements_view_profile"
app:summary="@string/settings_summary_achievements_view_profile"
app:iconSpaceReserved="false" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_achievements_disclaimer" app:iconSpaceReserved="false">
</PreferenceCategory>
</PreferenceScreen>

View File

@ -217,4 +217,19 @@
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory app:title="Achievement Settings" app:iconSpaceReserved="false">
<SwitchPreferenceCompat
app:key="Cheevos/TestMode"
app:title="@string/settings_achievements_test_mode"
app:summary="@string/settings_summary_achievements_test_mode"
app:defaultValue="false"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
app:key="Cheevos/UseFirstDiscFromPlaylist"
app:title="@string/settings_achievements_use_first_disc_from_playlist"
app:summary="@string/settings_summary_achievements_use_first_disc_from_playlist"
app:defaultValue="true"
app:iconSpaceReserved="false" />
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>