aboutsummaryrefslogtreecommitdiff
path: root/src/android
diff options
context:
space:
mode:
authorinix <Nixy01@proton.me>2025-10-17 22:47:43 +0200
committercrueter <crueter@eden-emu.dev>2025-10-17 22:47:43 +0200
commit6bdf479488dd87a35bf5ccf9ec334f49bf43ffc7 (patch)
tree166376be36272e3177cc2fd474cc24c7eb9d1360 /src/android
parent9c7ed0f59dd41ec0bbbfbc81678209ae60a36016 (diff)
[core, android] Initial playtime implementation (#2535)
So firstly, playtime code is moved to src/common and qt specific code to yuzu/utils.cpp. The dependency on ProfileManager was removed because it was working properly on Android, and I think a shared playtime is better behavior. Now, playtime is stored in a file called "playtime.bin". JNI code is from Azahar although modified by me, as well as that I added code to reset the game's playtime which was missing for some reason on there. Before this gets merged, I plan to add the ability to manually edit the database as well. Note: Code still needs a bit of cleanup. Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/2535 Reviewed-by: CamilleLaVey <camillelavey99@gmail.com> Reviewed-by: Lizzie <lizzie@eden-emu.dev> Reviewed-by: crueter <crueter@eden-emu.dev> Reviewed-by: MaranBr <maranbr@eden-emu.dev> Co-authored-by: inix <Nixy01@proton.me> Co-committed-by: inix <Nixy01@proton.me>
Diffstat (limited to 'src/android')
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt11
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt1
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt135
-rw-r--r--src/android/app/src/main/jni/native.cpp54
-rw-r--r--src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml10
-rw-r--r--src/android/app/src/main/res/layout/dialog_edit_playtime.xml59
-rw-r--r--src/android/app/src/main/res/layout/fragment_game_properties.xml12
-rw-r--r--src/android/app/src/main/res/values/strings.xml12
10 files changed, 300 insertions, 4 deletions
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
index ba50bcad34..ef8e2bafe9 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
@@ -206,6 +206,17 @@ object NativeLibrary {
ErrorUnknown
}
+ /**
+ * playtime tracking
+ */
+ external fun playTimeManagerInit()
+ external fun playTimeManagerStart()
+ external fun playTimeManagerStop()
+ external fun playTimeManagerGetPlayTime(programId: String): Long
+ external fun playTimeManagerGetCurrentTitleId(): Long
+ external fun playTimeManagerResetProgramPlayTime(programId: String)
+ external fun playTimeManagerSetPlayTime(programId: String, playTimeSeconds: Long)
+
var coreErrorAlertResult = false
val coreErrorAlertLock = Object()
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
index e9a0d5a75c..daea2b0370 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
@@ -53,6 +53,7 @@ class YuzuApplication : Application() {
application = this
documentsTree = DocumentsTree()
DirectoryInitialization.start()
+ NativeLibrary.playTimeManagerInit()
GpuDriverHelper.initializeDriverParameters()
NativeInput.reloadInputDevices()
NativeLibrary.logDeviceInfo()
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
index ba60aeace2..cee68689c3 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
@@ -61,6 +61,7 @@ import org.yuzu.yuzu_emu.utils.ThemeHelper
import java.text.NumberFormat
import kotlin.math.roundToInt
import org.yuzu.yuzu_emu.utils.ForegroundService
+import androidx.core.os.BundleCompat
class EmulationActivity : AppCompatActivity(), SensorEventListener {
private lateinit var binding: ActivityEmulationBinding
@@ -326,6 +327,11 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
override fun onAccuracyChanged(sensor: Sensor, i: Int) {}
+ override fun onDestroy() {
+ super.onDestroy()
+ NativeLibrary.playTimeManagerStop()
+ }
+
private fun enableFullscreenImmersive() {
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -530,6 +536,8 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
fun onEmulationStarted() {
emulationViewModel.setEmulationStarted(true)
+ NativeLibrary.playTimeManagerStart()
+
}
fun onEmulationStopped(status: Int) {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
index b2d6135372..5f49399406 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
@@ -1635,6 +1635,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
Log.debug("[EmulationFragment] Pausing emulation.")
NativeLibrary.pauseEmulation()
+ NativeLibrary.playTimeManagerStop()
state = State.PAUSED
} else {
@@ -1725,6 +1726,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
State.PAUSED -> {
Log.debug("[EmulationFragment] Resuming emulation.")
NativeLibrary.unpauseEmulation()
+ NativeLibrary.playTimeManagerStart()
}
else -> Log.debug("[EmulationFragment] Bug, run called while already running.")
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
index 6979409cad..bc6272b857 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt
@@ -1,9 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: 2025 Eden Emulator Project
-// SPDX-License-Identifier: GPL-3.0-or-later
-
package org.yuzu.yuzu_emu.fragments
import android.content.Intent
@@ -31,6 +28,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.yuzu.yuzu_emu.HomeNavigationDirections
+import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter
@@ -112,6 +110,8 @@ class GamePropertiesFragment : Fragment() {
binding.title.text = args.game.title
binding.title.marquee()
+ getPlayTime()
+
binding.buttonStart.setOnClickListener {
LaunchGameDialogFragment.newInstance(args.game)
.show(childFragmentManager, LaunchGameDialogFragment.TAG)
@@ -136,6 +136,109 @@ class GamePropertiesFragment : Fragment() {
gamesViewModel.reloadGames(true)
}
+ private fun getPlayTime() {
+ binding.playtime.text = buildString {
+ val playTimeSeconds = NativeLibrary.playTimeManagerGetPlayTime(args.game.programId)
+
+ val hours = playTimeSeconds / 3600
+ val minutes = (playTimeSeconds % 3600) / 60
+ val seconds = playTimeSeconds % 60
+
+ val readablePlayTime = when {
+ hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
+ minutes > 0 -> "${minutes}m ${seconds}s"
+ else -> "${seconds}s"
+ }
+
+ append(getString(R.string.playtime))
+ append(readablePlayTime)
+ }
+
+ binding.playtime.setOnClickListener {
+ showEditPlaytimeDialog()
+ }
+ }
+
+ private fun showEditPlaytimeDialog() {
+ val dialogView = layoutInflater.inflate(R.layout.dialog_edit_playtime, null)
+ val hoursLayout =
+ dialogView.findViewById<com.google.android.material.textfield.TextInputLayout>(R.id.layout_hours)
+ val minutesLayout =
+ dialogView.findViewById<com.google.android.material.textfield.TextInputLayout>(R.id.layout_minutes)
+ val secondsLayout =
+ dialogView.findViewById<com.google.android.material.textfield.TextInputLayout>(R.id.layout_seconds)
+ val hoursInput =
+ dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.input_hours)
+ val minutesInput =
+ dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.input_minutes)
+ val secondsInput =
+ dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.input_seconds)
+
+ val playTimeSeconds = NativeLibrary.playTimeManagerGetPlayTime(args.game.programId)
+ val hours = playTimeSeconds / 3600
+ val minutes = (playTimeSeconds % 3600) / 60
+ val seconds = playTimeSeconds % 60
+
+ hoursInput.setText(hours.toString())
+ minutesInput.setText(minutes.toString())
+ secondsInput.setText(seconds.toString())
+
+ val dialog = com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.edit_playtime)
+ .setView(dialogView)
+ .setPositiveButton(android.R.string.ok, null)
+ .setNegativeButton(android.R.string.cancel, null)
+ .create()
+
+ dialog.setOnShowListener {
+ val positiveButton = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
+ positiveButton.setOnClickListener {
+ hoursLayout.error = null
+ minutesLayout.error = null
+ secondsLayout.error = null
+
+ val hoursText = hoursInput.text.toString()
+ val minutesText = minutesInput.text.toString()
+ val secondsText = secondsInput.text.toString()
+
+ val hoursValue = hoursText.toLongOrNull() ?: 0
+ val minutesValue = minutesText.toLongOrNull() ?: 0
+ val secondsValue = secondsText.toLongOrNull() ?: 0
+
+ var hasError = false
+
+ // normally cant be above 9999
+ if (hoursValue < 0 || hoursValue > 9999) {
+ hoursLayout.error = getString(R.string.hours_must_be_between_0_and_9999)
+ hasError = true
+ }
+
+ if (minutesValue < 0 || minutesValue > 59) {
+ minutesLayout.error = getString(R.string.minutes_must_be_between_0_and_59)
+ hasError = true
+ }
+
+ if (secondsValue < 0 || secondsValue > 59) {
+ secondsLayout.error = getString(R.string.seconds_must_be_between_0_and_59)
+ hasError = true
+ }
+
+ if (!hasError) {
+ val totalSeconds = hoursValue * 3600 + minutesValue * 60 + secondsValue
+ NativeLibrary.playTimeManagerSetPlayTime(args.game.programId, totalSeconds)
+ getPlayTime()
+ Toast.makeText(
+ requireContext(),
+ R.string.playtime_updated_successfully,
+ Toast.LENGTH_SHORT
+ ).show()
+ dialog.dismiss()
+ }
+ }
+ }
+
+ dialog.show()
+ }
private fun reloadList() {
_binding ?: return
@@ -324,6 +427,31 @@ class GamePropertiesFragment : Fragment() {
)
)
}
+ if (NativeLibrary.playTimeManagerGetPlayTime(args.game.programId) > 0) {
+ add(
+ SubmenuProperty(
+ R.string.reset_playtime,
+ R.string.reset_playtime_description,
+ R.drawable.ic_delete
+ ) {
+ MessageDialogFragment.newInstance(
+ requireActivity(),
+ titleId = R.string.reset_playtime,
+ descriptionId = R.string.reset_playtime_warning_description,
+ positiveAction = {
+ NativeLibrary.playTimeManagerResetProgramPlayTime( args.game.programId)
+ Toast.makeText(
+ YuzuApplication.appContext,
+ R.string.playtime_reset_successfully,
+ Toast.LENGTH_SHORT
+ ).show()
+ getPlayTime()
+ homeViewModel.reloadPropertiesList(true)
+ }
+ ).show(parentFragmentManager, MessageDialogFragment.TAG)
+ }
+ )
+ }
}
}
binding.listProperties.apply {
@@ -336,6 +464,7 @@ class GamePropertiesFragment : Fragment() {
override fun onResume() {
super.onResume()
driverViewModel.updateDriverNameForGame(args.game)
+ getPlayTime()
reloadList()
}
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index 306b7e2a4c..78f0ec1521 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -36,6 +36,7 @@
#include "common/scope_exit.h"
#include "common/settings.h"
#include "common/string_util.h"
+#include "frontend_common/play_time_manager.h"
#include "core/core.h"
#include "core/cpu_manager.h"
#include "core/crypto/key_manager.h"
@@ -85,6 +86,9 @@ std::atomic<int> g_battery_percentage = {100};
std::atomic<bool> g_is_charging = {false};
std::atomic<bool> g_has_battery = {true};
+// playtime
+std::unique_ptr<PlayTime::PlayTimeManager> play_time_manager;
+
EmulationSession::EmulationSession() {
m_vfs = std::make_shared<FileSys::RealVfsFilesystem>();
}
@@ -733,6 +737,56 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeEmptyUserDirectory(JNIEnv*
}
}
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerInit(JNIEnv* env, jobject obj) {
+ // for some reason the full user directory isnt initialized in Android, so we need to create it
+ const auto play_time_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::PlayTimeDir);
+ if (!Common::FS::IsDir(play_time_dir)) {
+ if (!Common::FS::CreateDir(play_time_dir)) {
+ LOG_WARNING(Frontend, "Failed to create play time directory");
+ }
+ }
+
+ play_time_manager = std::make_unique<PlayTime::PlayTimeManager>();
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerStart(JNIEnv* env, jobject obj) {
+ if (play_time_manager) {
+ play_time_manager->SetProgramId(EmulationSession::GetInstance().System().GetApplicationProcessProgramID());
+ play_time_manager->Start();
+ }
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerStop(JNIEnv* env, jobject obj) {
+ play_time_manager->Stop();
+}
+
+jlong Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerGetPlayTime(JNIEnv* env, jobject obj,
+ jstring jprogramId) {
+ u64 program_id = EmulationSession::GetProgramId(env, jprogramId);
+ return play_time_manager->GetPlayTime(program_id);
+}
+
+jlong Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerGetCurrentTitleId(JNIEnv* env,
+ jobject obj) {
+ return EmulationSession::GetInstance().System().GetApplicationProcessProgramID();
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerResetProgramPlayTime(JNIEnv* env, jobject obj,
+ jstring jprogramId) {
+ u64 program_id = EmulationSession::GetProgramId(env, jprogramId);
+ if (play_time_manager) {
+ play_time_manager->ResetProgramPlayTime(program_id);
+ }
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerSetPlayTime(JNIEnv* env, jobject obj,
+ jstring jprogramId, jlong playTimeSeconds) {
+ u64 program_id = EmulationSession::GetProgramId(env, jprogramId);
+ if (play_time_manager) {
+ play_time_manager->SetPlayTime(program_id, static_cast<u64>(playTimeSeconds));
+ }
+}
+
jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletLaunchPath(JNIEnv* env, jclass clazz,
jlong jid) {
auto bis_system =
diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml b/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml
index 7cdef569f6..0fa2fa2a29 100644
--- a/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml
+++ b/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml
@@ -105,6 +105,16 @@
android:textAlignment="center"
tools:text="deko_basic" />
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/playtime"
+ style="?attr/textAppearanceBodyMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginBottom="8dp"
+ android:textAlignment="center"
+ tools:text="Game Playtime" />
+
</LinearLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
diff --git a/src/android/app/src/main/res/layout/dialog_edit_playtime.xml b/src/android/app/src/main/res/layout/dialog_edit_playtime.xml
new file mode 100644
index 0000000000..e20508125f
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_edit_playtime.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="16dp">
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/layout_hours"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginEnd="8dp"
+ android:hint="@string/hours"
+ app:boxBackgroundMode="outline">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/input_hours"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="number"
+ android:maxLength="4" />
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/layout_minutes"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginEnd="8dp"
+ android:hint="@string/minutes"
+ app:boxBackgroundMode="outline">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/input_minutes"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="number"
+ android:maxLength="2" />
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/layout_seconds"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:hint="@string/seconds"
+ app:boxBackgroundMode="outline">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/input_seconds"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="number"
+ android:maxLength="2" />
+ </com.google.android.material.textfield.TextInputLayout>
+
+</LinearLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_game_properties.xml b/src/android/app/src/main/res/layout/fragment_game_properties.xml
index 5e3f3cf280..9a3437404f 100644
--- a/src/android/app/src/main/res/layout/fragment_game_properties.xml
+++ b/src/android/app/src/main/res/layout/fragment_game_properties.xml
@@ -74,12 +74,22 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
- android:layout_marginBottom="12dp"
+ android:layout_marginBottom="2dp"
android:layout_marginHorizontal="16dp"
android:requiresFadingEdge="horizontal"
android:textAlignment="center"
tools:text="deko_basic" />
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/playtime"
+ style="?attr/textAppearanceBodyMedium"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="8dp"
+ android:textAlignment="center"
+ tools:text="Game Playtime" />
+
+
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_properties"
android:layout_width="match_parent"
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index fd1e7d42fc..37f215e871 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -756,6 +756,18 @@
<string name="copy_details">Copy details</string>
<string name="add_ons">Add-ons</string>
<string name="add_ons_description">Toggle mods, updates and DLC</string>
+ <string name="playtime">Playtime:</string>
+ <string name="reset_playtime">Clear Playtime</string>
+ <string name="reset_playtime_description">Reset the current game\'s playtime back to 0 seconds</string>
+ <string name="reset_playtime_warning_description">This will clear the current game\'s playtime data. Are you sure?</string>
+ <string name="playtime_reset_successfully">Playtime has been reset</string>
+ <string name="edit_playtime">Edit Playtime</string>
+ <string name="hours">Hours</string>
+ <string name="minutes">Minutes</string>
+ <string name="hours_must_be_between_0_and_9999">Hours must be between 0 and 9999</string>
+ <string name="minutes_must_be_between_0_and_59">Minutes must be between 0 and 59</string>
+ <string name="seconds_must_be_between_0_and_59">Seconds must be between 0 and 59</string>
+ <string name="playtime_updated_successfully">Playtime updated successfully</string>
<string name="clear_shader_cache">Clear shader cache</string>
<string name="clear_shader_cache_description">Removes all shaders built while playing this game</string>
<string name="clear_shader_cache_warning_description">You will experience more stuttering as the shader cache regenerates</string>