diff --git a/Source/Android/app/src/main/AndroidManifest.xml b/Source/Android/app/src/main/AndroidManifest.xml
index 3218ed3ed8..f6f3a4a26b 100644
--- a/Source/Android/app/src/main/AndroidManifest.xml
+++ b/Source/Android/app/src/main/AndroidManifest.xml
@@ -91,6 +91,12 @@
android:theme="@style/Theme.Dolphin.Main"
android:label="@string/cheats"/>
+
+
setIRMode()
MENU_ACTION_CHOOSE_DOUBLETAP -> chooseDoubleTapButton()
MENU_ACTION_SETTINGS -> SettingsActivity.launch(this, MenuTag.SETTINGS)
+ MENU_ACTION_ACHIEVEMENTS -> AchievementsActivity.launch(this)
MENU_ACTION_SKYLANDERS -> showSkylanderPortalSettings()
MENU_ACTION_INFINITY_BASE -> showInfinityBaseSettings()
MENU_ACTION_EXIT -> emulationFragment!!.stopEmulation()
@@ -1077,6 +1079,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider {
const val MENU_ACTION_SKYLANDERS = 36
const val MENU_ACTION_INFINITY_BASE = 37
const val MENU_ACTION_LATCHING_CONTROLS = 38
+ const val MENU_ACTION_ACHIEVEMENTS = 39
init {
buttonsActionsMap.apply {
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/model/Achievement.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/model/Achievement.kt
new file mode 100644
index 0000000000..b8796eda42
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/model/Achievement.kt
@@ -0,0 +1,23 @@
+package org.dolphinemu.dolphinemu.features.achievements.model
+
+import androidx.annotation.Keep
+
+@Keep
+class Achievement(
+ var title: String = "",
+ var description: String = "",
+ var badgeName: String = "",
+ var measuredProgress: String = "",
+ var measuredPercent: Float = 0F,
+ var id: Int = 0,
+ var points: Int = 0,
+ var unlockTime: String = "",
+ var state: Int = 0,
+ var category: Int = 0,
+ var bucket: Int = 0,
+ var unlocked: Int = 0,
+ var rarity: Float = 0F,
+ var rarityHardcore: Float = 0F,
+ var type: Int = 0,
+ var badgeUrl: String = "",
+ var badgeLockedUrl: String = "")
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/model/AchievementBucket.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/model/AchievementBucket.kt
new file mode 100644
index 0000000000..12e0713a0c
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/model/AchievementBucket.kt
@@ -0,0 +1,25 @@
+package org.dolphinemu.dolphinemu.features.achievements.model
+
+import androidx.annotation.Keep
+
+@Keep
+class AchievementBucket(
+ var numAchievements: Int) {
+ var achievements: Array = Array(numAchievements) {Achievement()}
+ var label: String = ""
+ var subsetId: Int = 0
+ var bucketType: Int = 0
+
+ companion object {
+ const val RC_CLIENT_ACHIEVEMENT_BUCKET_UNKNOWN = 0
+ const val RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED = 1
+ const val RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED = 2
+ const val RC_CLIENT_ACHIEVEMENT_BUCKET_UNSUPPORTED = 3
+ const val RC_CLIENT_ACHIEVEMENT_BUCKET_UNOFFICIAL = 4
+ const val RC_CLIENT_ACHIEVEMENT_BUCKET_RECENTLY_UNLOCKED = 5
+ const val RC_CLIENT_ACHIEVEMENT_BUCKET_ACTIVE_CHALLENGE = 6
+ const val RC_CLIENT_ACHIEVEMENT_BUCKET_ALMOST_THERE = 7
+ const val RC_CLIENT_ACHIEVEMENT_BUCKET_UNSYNCED = 8
+ const val NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS = 9
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/model/AchievementProgressViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/model/AchievementProgressViewModel.kt
new file mode 100644
index 0000000000..bb5267fd0b
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/model/AchievementProgressViewModel.kt
@@ -0,0 +1,19 @@
+package org.dolphinemu.dolphinemu.features.achievements.model
+
+import androidx.lifecycle.ViewModel
+
+class AchievementProgressViewModel : ViewModel() {
+ var buckets: ArrayList = ArrayList()
+
+ fun load() {
+ buckets.addAll(fetchProgress())
+ }
+
+ companion object {
+ @JvmStatic
+ external fun isGameLoaded(): Boolean
+
+ @JvmStatic
+ external fun fetchProgress(): Array
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementHeaderViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementHeaderViewHolder.kt
new file mode 100644
index 0000000000..aa2976ae6b
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementHeaderViewHolder.kt
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.achievements.ui
+
+import android.widget.TextView
+import org.dolphinemu.dolphinemu.databinding.ListItemHeaderBinding
+import org.dolphinemu.dolphinemu.features.achievements.ui.AchievementsActivity
+
+class AchievementHeaderViewHolder(binding: ListItemHeaderBinding) : AchievementProgressItemViewHolder(binding.root) {
+ private val headerName: TextView = binding.textHeaderName
+
+ override fun bind(activity: AchievementsActivity, item: AchievementProgressItem, position: Int) {
+ headerName.setText(item.string)
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressAdapter.kt
new file mode 100644
index 0000000000..3f9ae58dc7
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressAdapter.kt
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.achievements.ui
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import org.dolphinemu.dolphinemu.databinding.ListItemAchievementProgressBinding
+import org.dolphinemu.dolphinemu.databinding.ListItemHeaderBinding
+import org.dolphinemu.dolphinemu.features.achievements.model.AchievementProgressViewModel
+
+class AchievementProgressAdapter(
+ private val activity: AchievementsActivity,
+ private val viewModel: AchievementProgressViewModel
+) : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AchievementProgressItemViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ return when (viewType) {
+ AchievementProgressItem.TYPE_ACHIEVEMENT -> {
+ val listItemAchievementProgressBinding = ListItemAchievementProgressBinding.inflate(inflater, parent, false)
+ AchievementProgressViewHolder(listItemAchievementProgressBinding)
+ }
+ AchievementProgressItem.TYPE_HEADER -> {
+ val listItemHeaderBinding = ListItemHeaderBinding.inflate(inflater, parent, false)
+ AchievementHeaderViewHolder(listItemHeaderBinding)
+ }
+ else -> throw UnsupportedOperationException()
+ }
+ }
+
+ override fun onBindViewHolder(holder: AchievementProgressItemViewHolder, position: Int) {
+ holder.bind(activity, getItemAt(position), position)
+ }
+
+ override fun getItemCount(): Int {
+ return viewModel.buckets.size + viewModel.buckets.sumOf { bucket -> bucket.achievements.size }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return getItemAt(position).type
+ }
+
+ private fun getItemAt(position: Int): AchievementProgressItem {
+ var itemPosition = position
+ viewModel.buckets.forEach { bucket ->
+ when (itemPosition) {
+ 0 -> return AchievementProgressItem(bucket.label)
+ in 1..bucket.achievements.size -> return AchievementProgressItem(
+ bucket.achievements[itemPosition]
+ )
+ else -> itemPosition -= bucket.achievements.size
+ }
+ }
+ throw IndexOutOfBoundsException()
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressItem.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressItem.kt
new file mode 100644
index 0000000000..1c3a516a82
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressItem.kt
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.achievements.ui
+
+import org.dolphinemu.dolphinemu.features.achievements.model.Achievement
+
+class AchievementProgressItem {
+ val achievement: Achievement?
+ val string: String
+ val type: Int
+
+ constructor(achievement: Achievement) {
+ this.achievement = achievement
+ string = ""
+ type = TYPE_ACHIEVEMENT
+ }
+
+ constructor(string: String) {
+ achievement = null
+ this.string = string
+ this.type = TYPE_HEADER
+ }
+
+ companion object {
+ const val TYPE_HEADER = 0
+ const val TYPE_ACHIEVEMENT = 1
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressItemViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressItemViewHolder.kt
new file mode 100644
index 0000000000..3784bcdf0a
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressItemViewHolder.kt
@@ -0,0 +1,11 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.achievements.ui
+
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import org.dolphinemu.dolphinemu.features.achievements.ui.AchievementsActivity
+
+abstract class AchievementProgressItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ abstract fun bind(activity: AchievementsActivity, item: AchievementProgressItem, position: Int)
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressListFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressListFragment.kt
new file mode 100644
index 0000000000..10afc5e2d3
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressListFragment.kt
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.achievements.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.LinearLayoutManager
+import org.dolphinemu.dolphinemu.R
+import org.dolphinemu.dolphinemu.databinding.FragmentAchievementsListBinding
+import org.dolphinemu.dolphinemu.features.achievements.model.AchievementProgressViewModel
+import org.dolphinemu.dolphinemu.features.settings.ui.SettingsDividerItemDecoration
+
+class AchievementProgressListFragment : Fragment() {
+ private var _binding: FragmentAchievementsListBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAchievementsListBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val activity = requireActivity() as AchievementsActivity
+ val viewModel = ViewModelProvider(activity)[AchievementProgressViewModel::class.java]
+
+ binding.achievementsList.adapter = AchievementProgressAdapter(activity, viewModel)
+ binding.achievementsList.layoutManager = LinearLayoutManager(activity)
+
+ val divider = SettingsDividerItemDecoration(requireActivity())
+ binding.achievementsList.addItemDecoration(divider)
+
+ setInsets()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private fun setInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(binding.achievementsList) { v: View, windowInsets: WindowInsetsCompat ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ v.setPadding(
+ 0,
+ 0,
+ 0,
+ insets.bottom + resources.getDimensionPixelSize(R.dimen.spacing_xtralarge)
+ )
+ windowInsets
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressViewHolder.kt
new file mode 100644
index 0000000000..52706914ed
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementProgressViewHolder.kt
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.achievements.ui
+
+import android.view.View
+import android.widget.CompoundButton
+import androidx.lifecycle.ViewModelProvider
+import org.dolphinemu.dolphinemu.databinding.ListItemAchievementProgressBinding
+import org.dolphinemu.dolphinemu.features.achievements.model.AchievementProgressViewModel
+import org.dolphinemu.dolphinemu.features.achievements.model.Achievement
+import org.dolphinemu.dolphinemu.features.achievements.ui.AchievementsActivity
+
+class AchievementProgressViewHolder(private val binding: ListItemAchievementProgressBinding) :
+ AchievementProgressItemViewHolder(binding.getRoot()) {
+ private lateinit var achievement: Achievement
+
+ override fun bind(activity: AchievementsActivity, item: AchievementProgressItem, position: Int) {
+ achievement = item.achievement!!
+ binding.achievementTitle.text = achievement.title
+ binding.achievementDescription.text = achievement.description
+ binding.achievementScore.text = achievement.points.toString()
+ binding.achievementStatus.text = if (achievement.unlocked == 0) "Locked" else "Unlocked"
+ binding.achievementProgress.text = achievement.measuredProgress
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementsActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementsActivity.kt
new file mode 100644
index 0000000000..88c2e166ee
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/achievements/ui/AchievementsActivity.kt
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.achievements.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.enableEdgeToEdge
+import androidx.annotation.ColorInt
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.elevation.ElevationOverlayProvider
+import org.dolphinemu.dolphinemu.R
+import org.dolphinemu.dolphinemu.databinding.ActivityAchievementsBinding
+import org.dolphinemu.dolphinemu.features.achievements.model.AchievementProgressViewModel
+import org.dolphinemu.dolphinemu.ui.TwoPaneOnBackPressedCallback
+import org.dolphinemu.dolphinemu.ui.main.MainPresenter
+import org.dolphinemu.dolphinemu.utils.ThemeHelper
+
+class AchievementsActivity : AppCompatActivity() {
+ private lateinit var viewModel: AchievementProgressViewModel
+
+ private lateinit var binding: ActivityAchievementsBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ ThemeHelper.setTheme(this)
+ enableEdgeToEdge()
+
+ super.onCreate(savedInstanceState)
+
+ MainPresenter.skipRescanningLibrary()
+
+ title = getString(R.string.achievements_progress)
+
+ viewModel = ViewModelProvider(this)[AchievementProgressViewModel::class.java]
+ viewModel.load()
+
+ binding = ActivityAchievementsBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ onBackPressedDispatcher.addCallback(
+ this,
+ TwoPaneOnBackPressedCallback(binding.slidingPaneLayout)
+ )
+
+ setSupportActionBar(binding.toolbarAchievements)
+ supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+
+ @ColorInt val color =
+ ElevationOverlayProvider(binding.toolbarAchievements.context).compositeOverlay(
+ MaterialColors.getColor(binding.toolbarAchievements, R.attr.colorSurface),
+ resources.getDimensionPixelSize(R.dimen.elevated_app_bar).toFloat()
+ )
+ binding.toolbarAchievements.setBackgroundColor(color)
+ ThemeHelper.setStatusBarColor(this, color)
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ onBackPressed()
+ return true
+ }
+
+ companion object {
+ @JvmStatic
+ fun launch(
+ context: Context
+ ) {
+ val intent = Intent(context, AchievementsActivity::class.java)
+ context.startActivity(intent)
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/MenuFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/MenuFragment.kt
index 6bf9c7c147..32348d77cf 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/MenuFragment.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/MenuFragment.kt
@@ -19,6 +19,7 @@ import org.dolphinemu.dolphinemu.NativeLibrary
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.activities.EmulationActivity
import org.dolphinemu.dolphinemu.databinding.FragmentIngameMenuBinding
+import org.dolphinemu.dolphinemu.features.achievements.model.AchievementProgressViewModel
import org.dolphinemu.dolphinemu.features.settings.model.AchievementModel
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting
import org.dolphinemu.dolphinemu.features.settings.model.IntSetting
@@ -115,6 +116,7 @@ class MenuFragment : Fragment(), View.OnClickListener {
override fun onResume() {
super.onResume()
val savestatesEnabled = BooleanSetting.MAIN_ENABLE_SAVESTATES.boolean
+ val hasAchievements = AchievementProgressViewModel.isGameLoaded()
val hardcoreEnabled = AchievementModel.isHardcoreModeActive()
val savestateVisibility = if (savestatesEnabled) View.VISIBLE else View.GONE
binding.menuQuicksave.visibility = savestateVisibility
@@ -125,6 +127,7 @@ class MenuFragment : Fragment(), View.OnClickListener {
// will block the load and send a message to the screen.
binding.menuQuickload.paint.isStrikeThruText = hardcoreEnabled
binding.menuEmulationLoadRoot.paint.isStrikeThruText = hardcoreEnabled
+ binding.menuAchievements.visibility = if (hasAchievements) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
@@ -186,6 +189,10 @@ class MenuFragment : Fragment(), View.OnClickListener {
R.id.menu_emulation_load_root,
EmulationActivity.MENU_ACTION_LOAD_ROOT
)
+ buttonsActionsMap.append(
+ R.id.menu_achievements,
+ EmulationActivity.MENU_ACTION_ACHIEVEMENTS
+ )
buttonsActionsMap.append(
R.id.menu_overlay_controls,
EmulationActivity.MENU_ACTION_OVERLAY_CONTROLS
diff --git a/Source/Android/app/src/main/res/layout/activity_achievements.xml b/Source/Android/app/src/main/res/layout/activity_achievements.xml
new file mode 100644
index 0000000000..640a8c157d
--- /dev/null
+++ b/Source/Android/app/src/main/res/layout/activity_achievements.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Android/app/src/main/res/layout/fragment_achievements_list.xml b/Source/Android/app/src/main/res/layout/fragment_achievements_list.xml
new file mode 100644
index 0000000000..0f812715a3
--- /dev/null
+++ b/Source/Android/app/src/main/res/layout/fragment_achievements_list.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/Source/Android/app/src/main/res/layout/fragment_ingame_menu.xml b/Source/Android/app/src/main/res/layout/fragment_ingame_menu.xml
index 446706b821..b199f225bd 100644
--- a/Source/Android/app/src/main/res/layout/fragment_ingame_menu.xml
+++ b/Source/Android/app/src/main/res/layout/fragment_ingame_menu.xml
@@ -81,6 +81,12 @@
android:text="@string/emulation_loadstate"
android:visibility="gone" />
+
+