[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>
This commit is contained in:
inix 2025-10-17 22:47:43 +02:00 committed by crueter
parent 9c7ed0f59d
commit 6bdf479488
No known key found for this signature in database
GPG key ID: 425ACD2D4830EBC6
23 changed files with 586 additions and 45 deletions

View file

@ -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()

View file

@ -53,6 +53,7 @@ class YuzuApplication : Application() {
application = this
documentsTree = DocumentsTree()
DirectoryInitialization.start()
NativeLibrary.playTimeManagerInit()
GpuDriverHelper.initializeDriverParameters()
NativeInput.reloadInputDevices()
NativeLibrary.logDeviceInfo()

View file

@ -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) {

View file

@ -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.")

View file

@ -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()
}

View file

@ -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 =

View file

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

View file

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

View file

@ -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"

View file

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