Add RetroAchievements settings

This commit is contained in:
Rafael Caetano 2023-03-07 23:39:47 +00:00
parent 796e967058
commit 1090f5c259
17 changed files with 362 additions and 26 deletions

View File

@ -9,20 +9,27 @@ class AndroidRAUserAuthStore(private val sharedPreferences: SharedPreferences) :
private companion object {
const val USERNAME_KEY = "ra_username"
const val USERNAME_TOKEN = "ra_token"
const val TOKEN_KEY = "ra_token"
}
override suspend fun storeUserAuth(userAuth: RAUserAuth) {
sharedPreferences.edit {
putString(USERNAME_KEY, userAuth.username)
putString(USERNAME_TOKEN, userAuth.token)
putString(TOKEN_KEY, userAuth.token)
}
}
override suspend fun getUserAuth(): RAUserAuth? {
val username = sharedPreferences.getString(USERNAME_KEY, null) ?: return null
val token = sharedPreferences.getString(USERNAME_TOKEN, null) ?: return null
val token = sharedPreferences.getString(TOKEN_KEY, null) ?: return null
return RAUserAuth(username, token)
}
override suspend fun clearUserAuth() {
sharedPreferences.edit {
remove(USERNAME_KEY)
remove(TOKEN_KEY)
}
}
}

View File

@ -4,28 +4,34 @@ import androidx.room.*
import me.magnum.melonds.database.entities.retroachievements.*
@Dao
interface RAAchievementsDao {
abstract class RAAchievementsDao {
@Query("SELECT * FROM ra_game_set_metadata WHERE game_id = :gameId")
suspend fun getGameSetMetadata(gameId: Long): RAGameSetMetadata?
abstract suspend fun getGameSetMetadata(gameId: Long): RAGameSetMetadata?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateGameSetMetadata(gameSetMetadata: RAGameSetMetadata)
abstract suspend fun updateGameSetMetadata(gameSetMetadata: RAGameSetMetadata)
@Query("UPDATE ra_game_set_metadata SET last_user_data_updated = NULL")
protected abstract suspend fun clearAllGameSetMetadataLastUserDataUpdate()
@Query("SELECT * FROM ra_achievement WHERE game_id = :gameId")
suspend fun getGameAchievements(gameId: Long): List<RAAchievementEntity>
abstract suspend fun getGameAchievements(gameId: Long): List<RAAchievementEntity>
@Query("SELECT * FROM ra_achievement WHERE id = :achievementId")
abstract suspend fun getAchievement(achievementId: Long): RAAchievementEntity?
@Query("DELETE FROM ra_achievement WHERE game_id = :gameId")
suspend fun deleteGameAchievements(gameId: Long)
protected abstract suspend fun deleteGameAchievements(gameId: Long)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGameAchievements(achievements: List<RAAchievementEntity>)
protected abstract suspend fun insertGameAchievements(achievements: List<RAAchievementEntity>)
@Upsert
suspend fun updateGameData(gameData: RAGameEntity)
protected abstract suspend fun updateGameData(gameData: RAGameEntity)
@Transaction
suspend fun updateGameData(gameId: Long, achievements: List<RAAchievementEntity>, richPresencePatch: String?) {
open suspend fun updateGameData(gameId: Long, achievements: List<RAAchievementEntity>, richPresencePatch: String?) {
deleteGameAchievements(gameId)
insertGameAchievements(achievements)
@ -34,50 +40,60 @@ interface RAAchievementsDao {
}
@Query("SELECT * FROM ra_game WHERE game_id = :gameId")
suspend fun getGame(gameId: Long): RAGameEntity?
abstract suspend fun getGame(gameId: Long): RAGameEntity?
@Query("SELECT * FROM ra_user_achievement WHERE game_id = :gameId AND is_unlocked = 1")
suspend fun getGameUserUnlockedAchievements(gameId: Long): List<RAUserAchievementEntity>
abstract suspend fun getGameUserUnlockedAchievements(gameId: Long): List<RAUserAchievementEntity>
@Query("DELETE FROM ra_user_achievement WHERE game_id = :gameId")
suspend fun deleteGameUserUnlockedAchievements(gameId: Long)
protected abstract suspend fun deleteGameUserUnlockedAchievements(gameId: Long)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addUserAchievement(userAchievement: RAUserAchievementEntity)
abstract suspend fun addUserAchievement(userAchievement: RAUserAchievementEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGameUserUnlockedAchievements(userAchievements: List<RAUserAchievementEntity>)
protected abstract suspend fun insertGameUserUnlockedAchievements(userAchievements: List<RAUserAchievementEntity>)
@Transaction
suspend fun updateGameUserUnlockedAchievements(gameId: Long, userAchievements: List<RAUserAchievementEntity>) {
open suspend fun updateGameUserUnlockedAchievements(gameId: Long, userAchievements: List<RAUserAchievementEntity>) {
deleteGameUserUnlockedAchievements(gameId)
insertGameUserUnlockedAchievements(userAchievements)
}
@Query("SELECT * FROM ra_achievement WHERE id = :achievementId")
suspend fun getAchievement(achievementId: Long): RAAchievementEntity?
@Query("DELETE FROM ra_user_achievement")
protected abstract suspend fun deleteAllUserUnlockedAchievements()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addPendingAchievementSubmission(pendingAchievementSubmission: RAPendingAchievementSubmissionEntity)
abstract suspend fun addPendingAchievementSubmission(pendingAchievementSubmission: RAPendingAchievementSubmissionEntity)
@Query("SELECT * FROM ra_pending_achievement_award")
suspend fun getPendingAchievementSubmissions(): List<RAPendingAchievementSubmissionEntity>
abstract suspend fun getPendingAchievementSubmissions(): List<RAPendingAchievementSubmissionEntity>
@Delete
suspend fun removePendingAchievementSubmission(pendingAchievementSubmission: RAPendingAchievementSubmissionEntity)
abstract suspend fun removePendingAchievementSubmission(pendingAchievementSubmission: RAPendingAchievementSubmissionEntity)
@Query("DELETE FROM ra_pending_achievement_award")
protected abstract suspend fun deleteAllPendingAchievementSubmissions()
@Query("DELETE FROM ra_game_hash_library")
suspend fun deleteGameHashLibrary()
abstract suspend fun deleteGameHashLibrary()
@Insert
suspend fun insertGameHashLibrary(hashLibrary: List<RAGameHashEntity>)
abstract suspend fun insertGameHashLibrary(hashLibrary: List<RAGameHashEntity>)
@Query("SELECT * FROM ra_game_hash_library WHERE game_hash = :gameHash")
suspend fun getGameHashEntity(gameHash: String): RAGameHashEntity?
abstract suspend fun getGameHashEntity(gameHash: String): RAGameHashEntity?
@Transaction
suspend fun updateGameHashLibrary(hashLibrary: List<RAGameHashEntity>) {
open suspend fun updateGameHashLibrary(hashLibrary: List<RAGameHashEntity>) {
deleteGameHashLibrary()
insertGameHashLibrary(hashLibrary)
}
@Transaction
open suspend fun deleteAllAchievementUserData() {
clearAllGameSetMetadataLastUserDataUpdate()
deleteAllUserUnlockedAchievements()
deleteAllPendingAchievementSubmissions()
}
}

View File

@ -2,10 +2,13 @@ package me.magnum.melonds.domain.repositories
import me.magnum.melonds.domain.model.retroachievements.RAUserAchievement
import me.magnum.rcheevosapi.model.RAAchievement
import me.magnum.rcheevosapi.model.RAUserAuth
interface RetroAchievementsRepository {
suspend fun isUserAuthenticated(): Boolean
suspend fun getUserAuthentication(): RAUserAuth?
suspend fun login(username: String, password: String): Result<Unit>
suspend fun logout()
suspend fun getGameUserAchievements(gameHash: String): Result<List<RAUserAchievement>>
suspend fun getGameRichPresencePatch(gameHash: String): String?
suspend fun getAchievement(achievementId: Long): Result<RAAchievement?>

View File

@ -50,6 +50,8 @@ interface SettingsRepository {
fun getTouchHapticFeedbackStrength(): Int
fun getSoftInputOpacity(): Int
fun isRetroAchievementsRichPresenceEnabled(): Boolean
fun areCheatsEnabled(): Boolean
fun observeTheme(): Observable<Theme>

View File

@ -12,12 +12,14 @@ import me.magnum.melonds.database.entities.retroachievements.RAPendingAchievemen
import me.magnum.melonds.database.entities.retroachievements.RAUserAchievementEntity
import me.magnum.melonds.domain.model.retroachievements.RAUserAchievement
import me.magnum.melonds.domain.repositories.RetroAchievementsRepository
import me.magnum.melonds.domain.repositories.SettingsRepository
import me.magnum.melonds.impl.mappers.retroachievements.mapToEntity
import me.magnum.melonds.impl.mappers.retroachievements.mapToModel
import me.magnum.rcheevosapi.RAApi
import me.magnum.rcheevosapi.RAUserAuthStore
import me.magnum.rcheevosapi.model.RAAchievement
import me.magnum.rcheevosapi.model.RAGameId
import me.magnum.rcheevosapi.model.RAUserAuth
import java.time.Duration
import java.time.Instant
import java.util.concurrent.TimeUnit
@ -26,6 +28,7 @@ class AndroidRetroAchievementsRepository(
private val raApi: RAApi,
private val achievementsDao: RAAchievementsDao,
private val raUserAuthStore: RAUserAuthStore,
private val settingsRepository: SettingsRepository,
private val sharedPreferences: SharedPreferences,
private val context: Context,
) : RetroAchievementsRepository {
@ -39,10 +42,19 @@ class AndroidRetroAchievementsRepository(
return raUserAuthStore.getUserAuth() != null
}
override suspend fun getUserAuthentication(): RAUserAuth? {
return raUserAuthStore.getUserAuth()
}
override suspend fun login(username: String, password: String): Result<Unit> {
return raApi.login(username, password)
}
override suspend fun logout() {
achievementsDao.deleteAllAchievementUserData()
raUserAuthStore.clearUserAuth()
}
override suspend fun getGameUserAchievements(gameHash: String): Result<List<RAUserAchievement>> {
val gameIdResult = getGameIdFromGameHash(gameHash)
if (gameIdResult.isFailure) {
@ -80,6 +92,10 @@ class AndroidRetroAchievementsRepository(
}
override suspend fun getGameRichPresencePatch(gameHash: String): String? {
if (!settingsRepository.isRetroAchievementsRichPresenceEnabled()) {
return null
}
val gameId = getGameIdFromGameHash(gameHash).getOrNull() ?: return null
return achievementsDao.getGame(gameId.id)?.richPresencePatch
}

View File

@ -366,6 +366,10 @@ class SharedPreferencesSettingsRepository(
return preferences.getInt("input_opacity", 50)
}
override fun isRetroAchievementsRichPresenceEnabled(): Boolean {
return preferences.getBoolean("ra_rich_presence", true)
}
override fun areCheatsEnabled(): Boolean {
return preferences.getBoolean("cheats_enabled", false)
}

View File

@ -0,0 +1,19 @@
package me.magnum.melonds.ui.common
import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import me.magnum.melonds.R
class LoadingDialog(context: Context) : Dialog(context) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.dialog_loading)
setCancelable(false)
setCanceledOnTouchOutside(false)
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
}
}

View File

@ -0,0 +1,64 @@
package me.magnum.melonds.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import me.magnum.melonds.domain.repositories.RetroAchievementsRepository
import me.magnum.melonds.ui.settings.model.RetroAchievementsAccountState
import javax.inject.Inject
@HiltViewModel
class RetroAchievementsSettingsViewModel @Inject constructor(
private val retroAchievementsRepository: RetroAchievementsRepository,
): ViewModel() {
private val _accountState = MutableStateFlow<RetroAchievementsAccountState>(RetroAchievementsAccountState.Unknown)
val accountState by lazy {
viewModelScope.launch {
updateLoggedInState()
}
_accountState.asStateFlow()
}
private val _loggingIn = MutableStateFlow(false)
val loggingIn = _loggingIn.asStateFlow()
private val _loginErrorEvent = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val loginErrorEvent = _loginErrorEvent.asSharedFlow()
fun logoutFromRetroAchievements() {
viewModelScope.launch {
retroAchievementsRepository.logout()
_accountState.value = RetroAchievementsAccountState.LoggedOut
}
}
fun login(username: String, password: String) {
viewModelScope.launch {
_loggingIn.value = true
retroAchievementsRepository.login(username, password)
.onSuccess {
updateLoggedInState()
}
.onFailure {
_loginErrorEvent.tryEmit(Unit)
}
_loggingIn.value = false
}
}
private suspend fun updateLoggedInState() {
val userAuth = retroAchievementsRepository.getUserAuthentication()
if (userAuth == null) {
_accountState.value = RetroAchievementsAccountState.LoggedOut
} else {
_accountState.value = RetroAchievementsAccountState.LoggedIn(userAuth.username)
}
}
}

View File

@ -0,0 +1,123 @@
package me.magnum.melonds.ui.settings.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import kotlinx.coroutines.launch
import me.magnum.melonds.R
import me.magnum.melonds.databinding.DialogRetroachievementsLoginBinding
import me.magnum.melonds.ui.common.LoadingDialog
import me.magnum.melonds.ui.settings.PreferenceFragmentTitleProvider
import me.magnum.melonds.ui.settings.RetroAchievementsSettingsViewModel
import me.magnum.melonds.ui.settings.model.RetroAchievementsAccountState
class RetroAchievementsPreferencesFragment : PreferenceFragmentCompat(), PreferenceFragmentTitleProvider {
private val viewModel by activityViewModels<RetroAchievementsSettingsViewModel>()
private lateinit var accountPreference: Preference
private var loginProgressDialog: LoadingDialog? = null
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.pref_retroachievements, rootKey)
accountPreference = findPreference("ra_login")!!
accountPreference.setOnPreferenceClickListener {
val accountState = viewModel.accountState.value
when (accountState) {
is RetroAchievementsAccountState.LoggedIn -> showLogoutConfirmationDialog()
RetroAchievementsAccountState.LoggedOut -> showLoginDialog()
RetroAchievementsAccountState.Unknown -> {
// Do nothing until a proper state is known
}
}
true
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.accountState.collect {
when (it) {
is RetroAchievementsAccountState.LoggedIn -> {
accountPreference.title = getString(R.string.retroachievements_logout)
accountPreference.summary = getString(R.string.retroachievements_login_status, it.accountName)
}
RetroAchievementsAccountState.LoggedOut -> {
accountPreference.title = getString(R.string.login_with_retro_achievements)
accountPreference.summary = getString(R.string.retroachievements_login_summary)
}
RetroAchievementsAccountState.Unknown -> {
accountPreference.title = getString(R.string.ellipsis)
accountPreference.summary = getString(R.string.ellipsis)
}
}
}
}
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.loggingIn.collect { loggingIn ->
loginProgressDialog = if (loggingIn) {
LoadingDialog(requireContext()).apply {
show()
}
} else {
loginProgressDialog?.dismiss()
null
}
}
}
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.loginErrorEvent.collect {
Toast.makeText(requireContext(), R.string.retro_achievements_login_error_short, Toast.LENGTH_LONG).show()
}
}
}
}
private fun showLoginDialog() {
val binding = DialogRetroachievementsLoginBinding.inflate(LayoutInflater.from(context))
AlertDialog.Builder(requireContext())
.setTitle(R.string.login_with_retro_achievements)
.setView(binding.root)
.setPositiveButton(R.string.login) { dialog, _ ->
viewModel.login(
binding.textUsername.text?.toString() ?: "",
binding.textPassword.text?.toString() ?: "",
)
dialog.dismiss()
}
.setNegativeButton(R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
}
private fun showLogoutConfirmationDialog() {
AlertDialog.Builder(requireContext())
.setTitle(R.string.retroachievements_logout)
.setMessage(R.string.retroachievements_logout_confirmation)
.setPositiveButton(R.string.retroachievements_logout) { dialog, _ ->
viewModel.logoutFromRetroAchievements()
dialog.dismiss()
}
.setNegativeButton(R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
}
override fun getTitle() = getString(R.string.retroachievements)
}

View File

@ -0,0 +1,7 @@
package me.magnum.melonds.ui.settings.model
sealed class RetroAchievementsAccountState {
object Unknown : RetroAchievementsAccountState()
object LoggedOut : RetroAchievementsAccountState()
data class LoggedIn(val accountName: String) : RetroAchievementsAccountState()
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,5h-2V3H7v2H5C3.9,5 3,5.9 3,7v1c0,2.55 1.92,4.63 4.39,4.94c0.63,1.5 1.98,2.63 3.61,2.96V19H7v2h10v-2h-4v-3.1c1.63,-0.33 2.98,-1.46 3.61,-2.96C19.08,12.63 21,10.55 21,8V7C21,5.9 20.1,5 19,5zM5,8V7h2v3.82C5.84,10.4 5,9.3 5,8zM19,8c0,1.3 -0.84,2.4 -2,2.82V7h2V8z"/>
</vector>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingStart="?attr/dialogPreferredPadding"
android:paddingEnd="?attr/dialogPreferredPadding"
android:orientation="vertical">
<EditText
android:id="@+id/text_username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username"
android:inputType="text"
android:autofillHints="username" />
<EditText
android:id="@+id/text_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:hint="@string/password"
android:inputType="textPassword"
android:autofillHints="password" />
</LinearLayout>

View File

@ -17,6 +17,7 @@
<string name="update">Update</string>
<string name="close">Close</string>
<string name="play">Play</string>
<string name="ellipsis"></string>
<string name="version_alpha">Alpha</string>
<string name="version_beta">Beta</string>
@ -191,6 +192,10 @@
<string name="vibrate_on_touch">Vibrate on touch</string>
<string name="vibration_strength">Vibration strength</string>
<string name="soft_input_opacity">Soft input opacity</string>
<string name="retroachievements">RetroAchievements</string> <!-- Keep words together because this is the official name -->
<string name="retroachievements_summary">Login &amp; manage RetroAchievements integration</string> <!-- Keep words together because this is the official name -->
<string name="enable_rich_presence">Enable rich presence</string>
<string name="rich_presence_summary">Allows other RetroAchievement users to see what you are playing</string>
<string name="cheats">Cheats</string>
<string name="cheats_summary">Enable &amp; import cheat codes</string>
<string name="enable_cheats">Enable cheats</string>
@ -294,7 +299,12 @@
<string name="retro_achievements_login_description">Login with RetroAchievements to view achievements that you can unlock while playing this game!</string>
<string name="login_with_retro_achievements">Login with RetroAchievements</string>
<string name="login">Login</string>
<string name="retroachievements_login_summary">Login with RetroAchievements to unlock achievements while playing!</string>
<string name="retroachievements_login_status">Logged in as %1$s</string>
<string name="retroachievements_logout">Logout</string>
<string name="retroachievements_logout_confirmation">Are you sure you want to logout from RetroAchievements? You won\'t be able to unlock achievements while playing.</string>
<string name="retro_achievements_login_error">There was a problem when trying to login. Make sure that your username and password are correct, and that you are connected to the internet</string>
<string name="retro_achievements_login_error_short">There was a problem when trying to login</string>
<string name="retro_achievements_load_error">There was a problem when trying to load the achievements for this game. Make sure you are connected to the internet and try again later</string>
<string name="retro_achievements_no_achievements">No RetroAchievements were found for this game</string>
<string name="username">Username</string>

View File

@ -43,6 +43,12 @@
app:icon="@drawable/ic_input"
android:fragment="me.magnum.melonds.ui.settings.fragments.InputPreferencesFragment" />
<Preference
android:title="@string/retroachievements"
android:summary="@string/retroachievements_summary"
app:icon="@drawable/ic_trophy"
android:fragment="me.magnum.melonds.ui.settings.fragments.RetroAchievementsPreferencesFragment" />
<Preference
android:title="@string/cheats"
android:summary="@string/cheats_summary"

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Preference
android:key="ra_login"
android:title="@string/login_with_retro_achievements"
app:iconSpaceReserved="false" />
<SwitchPreference
android:key="ra_rich_presence"
android:title="@string/enable_rich_presence"
android:summary="@string/rich_presence_summary"
app:iconSpaceReserved="false"
android:defaultValue="true" />
</PreferenceScreen>

View File

@ -5,4 +5,5 @@ import me.magnum.rcheevosapi.model.RAUserAuth
interface RAUserAuthStore {
suspend fun storeUserAuth(userAuth: RAUserAuth)
suspend fun getUserAuth(): RAUserAuth?
suspend fun clearUserAuth()
}