android: Implement play time tracking (#813)

* android: Implement play time tacking

Co-Authored-By: Reg Tiangha <rtiangha@users.noreply.github.com>

* Moved playtime manager from `citra_qt` to `common`

* Reimplemented Android play time to use existing logic from desktop

* Updated license headers

* When getting current game ID fails, silently error rather than crashing

* playTimeManagerStart: Check that `play_time_manager` is initialized before using it

---------

Co-authored-by: Kleidis <167202775+kleidis@users.noreply.github.com>
Co-authored-by: Reg Tiangha <rtiangha@users.noreply.github.com>
This commit is contained in:
OpenSauce 2025-07-12 13:01:46 +01:00 committed by GitHub
parent 06ed6f3b6d
commit 7c278f9701
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 131 additions and 44 deletions

View File

@ -1,4 +1,4 @@
// Copyright 2023 Citra Emulator Project // Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version // Licensed under GPLv2 or any later version
// Refer to the license.txt file included. // Refer to the license.txt file included.
@ -58,6 +58,7 @@ class CitraApplication : Application() {
NativeLibrary.logDeviceInfo() NativeLibrary.logDeviceInfo()
logDeviceInfo() logDeviceInfo()
createNotificationChannel() createNotificationChannel()
NativeLibrary.playTimeManagerInit()
} }
fun logDeviceInfo() { fun logDeviceInfo() {

View File

@ -21,7 +21,6 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.citra.citra_emu.activities.EmulationActivity import org.citra.citra_emu.activities.EmulationActivity
import org.citra.citra_emu.utils.EmulationMenuSettings
import org.citra.citra_emu.utils.FileUtil import org.citra.citra_emu.utils.FileUtil
import org.citra.citra_emu.utils.Log import org.citra.citra_emu.utils.Log
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@ -190,6 +189,12 @@ object NativeLibrary {
external fun disableTemporaryFrameLimit() external fun disableTemporaryFrameLimit()
external fun playTimeManagerInit()
external fun playTimeManagerStart(titleId: Long)
external fun playTimeManagerStop()
external fun playTimeManagerGetPlayTime(titleId: Long): Long
external fun playTimeManagerGetCurrentTitleId(): Long
private var coreErrorAlertResult = false private var coreErrorAlertResult = false
private val coreErrorAlertLock = Object() private val coreErrorAlertLock = Object()

View File

@ -6,7 +6,6 @@ package org.citra.citra_emu.activities
import android.Manifest.permission import android.Manifest.permission
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
@ -21,6 +20,7 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.BundleCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
@ -40,12 +40,13 @@ import org.citra.citra_emu.features.settings.model.SettingsViewModel
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import org.citra.citra_emu.fragments.EmulationFragment import org.citra.citra_emu.fragments.EmulationFragment
import org.citra.citra_emu.fragments.MessageDialogFragment import org.citra.citra_emu.fragments.MessageDialogFragment
import org.citra.citra_emu.model.Game
import org.citra.citra_emu.utils.ControllerMappingHelper import org.citra.citra_emu.utils.ControllerMappingHelper
import org.citra.citra_emu.utils.FileBrowserHelper import org.citra.citra_emu.utils.FileBrowserHelper
import org.citra.citra_emu.utils.EmulationLifecycleUtil import org.citra.citra_emu.utils.EmulationLifecycleUtil
import org.citra.citra_emu.utils.EmulationMenuSettings import org.citra.citra_emu.utils.EmulationMenuSettings
import org.citra.citra_emu.utils.Log
import org.citra.citra_emu.utils.ThemeUtil import org.citra.citra_emu.utils.ThemeUtil
import org.citra.citra_emu.utils.TurboHelper
import org.citra.citra_emu.viewmodel.EmulationViewModel import org.citra.citra_emu.viewmodel.EmulationViewModel
class EmulationActivity : AppCompatActivity() { class EmulationActivity : AppCompatActivity() {
@ -110,6 +111,20 @@ class EmulationActivity : AppCompatActivity() {
instance = this instance = this
applyOrientationSettings() // Check for orientation settings at startup applyOrientationSettings() // Check for orientation settings at startup
val game = try {
intent.extras?.let { extras ->
BundleCompat.getParcelable(extras, "game", Game::class.java)
} ?: run {
Log.error("[EmulationActivity] Missing game data in intent extras")
return
}
} catch (e: Exception) {
Log.error("[EmulationActivity] Failed to retrieve game data: ${e.message}")
return
}
NativeLibrary.playTimeManagerStart(game.titleId)
} }
// On some devices, the system bars will not disappear on first boot or after some // On some devices, the system bars will not disappear on first boot or after some
@ -143,6 +158,7 @@ class EmulationActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
EmulationLifecycleUtil.clear() EmulationLifecycleUtil.clear()
NativeLibrary.playTimeManagerStop()
isEmulationRunning = false isEmulationRunning = false
instance = null instance = null
super.onDestroy() super.onDestroy()

View File

@ -45,6 +45,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.citra.citra_emu.HomeNavigationDirections import org.citra.citra_emu.HomeNavigationDirections
import org.citra.citra_emu.CitraApplication import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R import org.citra.citra_emu.R
import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder
import org.citra.citra_emu.databinding.CardGameBinding import org.citra.citra_emu.databinding.CardGameBinding
@ -351,6 +352,24 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater:
view.findNavController().navigate(action) view.findNavController().navigate(action)
} }
bottomSheetView.findViewById<TextView>(R.id.about_game_playtime).text =
buildString {
val playTimeSeconds = NativeLibrary.playTimeManagerGetPlayTime(game.titleId)
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("Playtime: ")
append(readablePlayTime)
}
bottomSheetView.findViewById<MaterialButton>(R.id.game_shortcut).setOnClickListener { bottomSheetView.findViewById<MaterialButton>(R.id.game_shortcut).setOnClickListener {
val preferences = PreferenceManager.getDefaultSharedPreferences(context) val preferences = PreferenceManager.getDefaultSharedPreferences(context)

View File

@ -355,7 +355,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
} }
R.id.menu_exit -> { R.id.menu_exit -> {
NativeLibrary.pauseEmulation() emulationState.pause()
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.emulation_close_game) .setTitle(R.string.emulation_close_game)
.setMessage(R.string.emulation_close_game_message) .setMessage(R.string.emulation_close_game_message)
@ -363,9 +363,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
EmulationLifecycleUtil.closeGame() EmulationLifecycleUtil.closeGame()
} }
.setNegativeButton(android.R.string.cancel) { _: DialogInterface?, _: Int -> .setNegativeButton(android.R.string.cancel) { _: DialogInterface?, _: Int ->
NativeLibrary.unPauseEmulation() emulationState.unpause()
} }
.setOnCancelListener { NativeLibrary.unPauseEmulation() } .setOnCancelListener { emulationState.unpause() }
.show() .show()
true true
} }
@ -470,7 +470,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
super.onResume() super.onResume()
Choreographer.getInstance().postFrameCallback(this) Choreographer.getInstance().postFrameCallback(this)
if (NativeLibrary.isRunning()) { if (NativeLibrary.isRunning()) {
NativeLibrary.unPauseEmulation() emulationState.pause()
// If the overlay is enabled, we need to update the position if changed // If the overlay is enabled, we need to update the position if changed
val position = IntSetting.PERFORMANCE_OVERLAY_POSITION.int val position = IntSetting.PERFORMANCE_OVERLAY_POSITION.int
@ -1395,6 +1395,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
// Release the surface before pausing, since emulation has to be running for that. // Release the surface before pausing, since emulation has to be running for that.
NativeLibrary.surfaceDestroyed() NativeLibrary.surfaceDestroyed()
NativeLibrary.pauseEmulation() NativeLibrary.pauseEmulation()
NativeLibrary.playTimeManagerStop()
} else { } else {
Log.warning("[EmulationFragment] Pause called while already paused.") Log.warning("[EmulationFragment] Pause called while already paused.")
} }
@ -1407,6 +1408,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
Log.debug("[EmulationFragment] Unpausing emulation.") Log.debug("[EmulationFragment] Unpausing emulation.")
NativeLibrary.unPauseEmulation() NativeLibrary.unPauseEmulation()
NativeLibrary.playTimeManagerStart(NativeLibrary.playTimeManagerGetCurrentTitleId())
} else { } else {
Log.warning("[EmulationFragment] Unpause called while already running.") Log.warning("[EmulationFragment] Unpause called while already running.")
} }
@ -1473,7 +1475,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
State.PAUSED -> { State.PAUSED -> {
Log.debug("[EmulationFragment] Resuming emulation.") Log.debug("[EmulationFragment] Resuming emulation.")
NativeLibrary.unPauseEmulation() unpause()
} }
else -> { else -> {

View File

@ -27,6 +27,7 @@
#include "common/logging/backend.h" #include "common/logging/backend.h"
#include "common/logging/log.h" #include "common/logging/log.h"
#include "common/microprofile.h" #include "common/microprofile.h"
#include "common/play_time_manager.h"
#include "common/scm_rev.h" #include "common/scm_rev.h"
#include "common/scope_exit.h" #include "common/scope_exit.h"
#include "common/settings.h" #include "common/settings.h"
@ -71,6 +72,9 @@ ANativeWindow* s_surf;
std::shared_ptr<Common::DynamicLibrary> vulkan_library{}; std::shared_ptr<Common::DynamicLibrary> vulkan_library{};
std::unique_ptr<EmuWindow_Android> window; std::unique_ptr<EmuWindow_Android> window;
std::unique_ptr<PlayTime::PlayTimeManager> play_time_manager;
jlong ptm_current_title_id = std::numeric_limits<jlong>::max(); // Arbitrary default value
std::atomic<bool> stop_run{true}; std::atomic<bool> stop_run{true};
std::atomic<bool> pause_emulation{false}; std::atomic<bool> pause_emulation{false};
@ -795,4 +799,31 @@ void Java_org_citra_citra_1emu_NativeLibrary_disableTemporaryFrameLimit(JNIEnv*
Settings::is_temporary_frame_limit = false; Settings::is_temporary_frame_limit = false;
} }
void Java_org_citra_citra_1emu_NativeLibrary_playTimeManagerInit(JNIEnv* env, jobject obj) {
play_time_manager = std::make_unique<PlayTime::PlayTimeManager>();
}
void Java_org_citra_citra_1emu_NativeLibrary_playTimeManagerStart(JNIEnv* env, jobject obj,
jlong title_id) {
ptm_current_title_id = title_id;
if (play_time_manager) {
play_time_manager->SetProgramId(static_cast<u64>(title_id));
play_time_manager->Start();
}
}
void Java_org_citra_citra_1emu_NativeLibrary_playTimeManagerStop(JNIEnv* env, jobject obj) {
play_time_manager->Stop();
}
jlong Java_org_citra_citra_1emu_NativeLibrary_playTimeManagerGetPlayTime(JNIEnv* env, jobject obj,
jlong title_id) {
return static_cast<jlong>(play_time_manager->GetPlayTime(title_id));
}
jlong Java_org_citra_citra_1emu_NativeLibrary_playTimeManagerGetCurrentTitleId(JNIEnv* env,
jobject obj) {
return ptm_current_title_id;
}
} // extern "C" } // extern "C"

View File

@ -44,8 +44,8 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:textSize="20sp" android:textSize="15sp"
app:layout_constraintStart_toStartOf="parent" android:textStyle="bold" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="Application Title" /> tools:text="Application Title" />
@ -85,6 +85,15 @@
app:layout_constraintTop_toBottomOf="@+id/about_game_id" app:layout_constraintTop_toBottomOf="@+id/about_game_id"
tools:text="Application Filename" /> tools:text="Application Filename" />
<TextView
android:id="@+id/about_game_playtime"
style="?attr/textAppearanceBodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@id/about_game_title"
app:layout_constraintTop_toBottomOf="@+id/about_game_filename"
tools:text="Game Playtime" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout <LinearLayout

View File

@ -171,8 +171,6 @@ add_library(citra_qt STATIC EXCLUDE_FROM_ALL
multiplayer/state.cpp multiplayer/state.cpp
multiplayer/state.h multiplayer/state.h
multiplayer/validation.h multiplayer/validation.h
play_time_manager.cpp
play_time_manager.h
precompiled_headers.h precompiled_headers.h
uisettings.cpp uisettings.cpp
uisettings.h uisettings.h

View File

@ -67,9 +67,9 @@
#include "citra_qt/movie/movie_play_dialog.h" #include "citra_qt/movie/movie_play_dialog.h"
#include "citra_qt/movie/movie_record_dialog.h" #include "citra_qt/movie/movie_record_dialog.h"
#include "citra_qt/multiplayer/state.h" #include "citra_qt/multiplayer/state.h"
#include "citra_qt/play_time_manager.h"
#include "citra_qt/qt_image_interface.h" #include "citra_qt/qt_image_interface.h"
#include "citra_qt/uisettings.h" #include "citra_qt/uisettings.h"
#include "common/play_time_manager.h"
#ifdef ENABLE_QT_UPDATE_CHECKER #ifdef ENABLE_QT_UPDATE_CHECKER
#include "citra_qt/update_checker.h" #include "citra_qt/update_checker.h"
#endif #endif

View File

@ -10,8 +10,8 @@
#include <QVector> #include <QVector>
#include <QWidget> #include <QWidget>
#include "citra_qt/compatibility_list.h" #include "citra_qt/compatibility_list.h"
#include "citra_qt/play_time_manager.h"
#include "common/common_types.h" #include "common/common_types.h"
#include "common/play_time_manager.h"
#include "uisettings.h" #include "uisettings.h"
namespace Service::FS { namespace Service::FS {

View File

@ -1,7 +1,6 @@
// Copyright 2015 Citra Emulator Project // Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version // Licensed under GPLv2 or any later version
// Refer to the license.txt file included. // Refer to the license.txt file included.
#pragma once #pragma once
#include <algorithm> #include <algorithm>
@ -18,11 +17,11 @@
#include <QStandardItem> #include <QStandardItem>
#include <QString> #include <QString>
#include <QWidget> #include <QWidget>
#include "citra_qt/play_time_manager.h"
#include "citra_qt/uisettings.h" #include "citra_qt/uisettings.h"
#include "citra_qt/util/util.h" #include "citra_qt/util/util.h"
#include "common/file_util.h" #include "common/file_util.h"
#include "common/logging/log.h" #include "common/logging/log.h"
#include "common/play_time_manager.h"
#include "common/string_util.h" #include "common/string_util.h"
#include "core/loader/smdh.h" #include "core/loader/smdh.h"
@ -382,7 +381,7 @@ public:
void setData(const QVariant& value, int role) override { void setData(const QVariant& value, int role) override {
qulonglong time_seconds = value.toULongLong(); qulonglong time_seconds = value.toULongLong();
GameListItem::setData(PlayTime::ReadablePlayTime(time_seconds), Qt::DisplayRole); GameListItem::setData(ReadableDuration(time_seconds), Qt::DisplayRole);
GameListItem::setData(value, PlayTimeRole); GameListItem::setData(value, PlayTimeRole);
} }

View File

@ -1,3 +1,7 @@
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
// Copyright 2018 yuzu emulator team // Copyright 2018 yuzu emulator team
// Licensed under GPLv2 or any later version // Licensed under GPLv2 or any later version
// Refer to the license.txt file included. // Refer to the license.txt file included.
@ -13,8 +17,8 @@
#include <QString> #include <QString>
#include <QVector> #include <QVector>
#include "citra_qt/compatibility_list.h" #include "citra_qt/compatibility_list.h"
#include "citra_qt/play_time_manager.h"
#include "common/common_types.h" #include "common/common_types.h"
#include "common/play_time_manager.h"
namespace Service::FS { namespace Service::FS {
enum class MediaType : u32; enum class MediaType : u32;

View File

@ -36,6 +36,21 @@ QString ReadableByteSize(qulonglong size) {
.arg(QString::fromUtf8(units[digit_groups])); .arg(QString::fromUtf8(units[digit_groups]));
} }
QString ReadableDuration(qulonglong time_seconds) {
if (time_seconds == 0) {
return {};
}
const auto time_minutes = std::max(static_cast<double>(time_seconds) / 60, 1.0);
const auto time_hours = static_cast<double>(time_seconds) / 3600;
const bool is_minutes = time_minutes < 60;
const char* unit = is_minutes ? "m" : "h";
const auto value = is_minutes ? time_minutes : time_hours;
return QStringLiteral("%L1 %2")
.arg(value, 0, 'f', !is_minutes && time_seconds % 60 != 0)
.arg(QString::fromUtf8(unit));
}
QPixmap CreateCirclePixmapFromColor(const QColor& color) { QPixmap CreateCirclePixmapFromColor(const QColor& color) {
QPixmap circle_pixmap(16, 16); QPixmap circle_pixmap(16, 16);
circle_pixmap.fill(Qt::transparent); circle_pixmap.fill(Qt::transparent);

View File

@ -15,6 +15,9 @@ QFont GetMonospaceFont();
/// Convert a size in bytes into a readable format (KiB, MiB, etc.) /// Convert a size in bytes into a readable format (KiB, MiB, etc.)
QString ReadableByteSize(qulonglong size); QString ReadableByteSize(qulonglong size);
// Converts a length of time in seconds into a readable format
QString ReadableDuration(qulonglong time_seconds);
/** /**
* Creates a circle pixmap from a specified color * Creates a circle pixmap from a specified color
* @param color The color the pixmap shall have * @param color The color the pixmap shall have

View File

@ -114,6 +114,8 @@ add_library(citra_common STATIC
microprofileui.h microprofileui.h
param_package.cpp param_package.cpp
param_package.h param_package.h
play_time_manager.cpp
play_time_manager.h
polyfill_thread.h polyfill_thread.h
precompiled_headers.h precompiled_headers.h
quaternion.h quaternion.h

View File

@ -1,12 +1,13 @@
// SPDX-FileCopyrightText: 2024 Citra Emulator Project // Copyright Citra Emulator Project / Azahar Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include <filesystem> #include <filesystem>
#include "citra_qt/play_time_manager.h"
#include "common/alignment.h" #include "common/alignment.h"
#include "common/common_paths.h" #include "common/common_paths.h"
#include "common/file_util.h" #include "common/file_util.h"
#include "common/logging/log.h" #include "common/logging/log.h"
#include "common/play_time_manager.h"
#include "common/settings.h" #include "common/settings.h"
#include "common/thread.h" #include "common/thread.h"
@ -140,19 +141,4 @@ void PlayTimeManager::ResetProgramPlayTime(u64 program_id) {
Save(); Save();
} }
QString ReadablePlayTime(qulonglong time_seconds) {
if (time_seconds == 0) {
return {};
}
const auto time_minutes = std::max(static_cast<double>(time_seconds) / 60, 1.0);
const auto time_hours = static_cast<double>(time_seconds) / 3600;
const bool is_minutes = time_minutes < 60;
const char* unit = is_minutes ? "m" : "h";
const auto value = is_minutes ? time_minutes : time_hours;
return QStringLiteral("%L1 %2")
.arg(value, 0, 'f', !is_minutes && time_seconds % 60 != 0)
.arg(QString::fromUtf8(unit));
}
} // namespace PlayTime } // namespace PlayTime

View File

@ -1,10 +1,9 @@
// SPDX-FileCopyrightText: 2024 Citra Emulator Project // Copyright Citra Emulator Project / Azahar Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#pragma once #pragma once
#include <QString>
#include <map> #include <map>
#include "common/common_funcs.h" #include "common/common_funcs.h"
@ -40,6 +39,4 @@ private:
std::jthread play_time_thread; std::jthread play_time_thread;
}; };
QString ReadablePlayTime(qulonglong time_seconds);
} // namespace PlayTime } // namespace PlayTime