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
// Refer to the license.txt file included.
@ -58,6 +58,7 @@ class CitraApplication : Application() {
NativeLibrary.logDeviceInfo()
logDeviceInfo()
createNotificationChannel()
NativeLibrary.playTimeManagerInit()
}
fun logDeviceInfo() {

View File

@ -21,7 +21,6 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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.Log
import java.lang.ref.WeakReference
@ -190,6 +189,12 @@ object NativeLibrary {
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 val coreErrorAlertLock = Object()

View File

@ -6,7 +6,6 @@ package org.citra.citra_emu.activities
import android.Manifest.permission
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
@ -21,6 +20,7 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.BundleCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
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.fragments.EmulationFragment
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.FileBrowserHelper
import org.citra.citra_emu.utils.EmulationLifecycleUtil
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.TurboHelper
import org.citra.citra_emu.viewmodel.EmulationViewModel
class EmulationActivity : AppCompatActivity() {
@ -110,6 +111,20 @@ class EmulationActivity : AppCompatActivity() {
instance = this
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
@ -143,6 +158,7 @@ class EmulationActivity : AppCompatActivity() {
override fun onDestroy() {
EmulationLifecycleUtil.clear()
NativeLibrary.playTimeManagerStop()
isEmulationRunning = false
instance = null
super.onDestroy()

View File

@ -45,6 +45,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import org.citra.citra_emu.HomeNavigationDirections
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R
import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder
import org.citra.citra_emu.databinding.CardGameBinding
@ -351,6 +352,24 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater:
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 {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)

View File

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

View File

@ -27,6 +27,7 @@
#include "common/logging/backend.h"
#include "common/logging/log.h"
#include "common/microprofile.h"
#include "common/play_time_manager.h"
#include "common/scm_rev.h"
#include "common/scope_exit.h"
#include "common/settings.h"
@ -71,6 +72,9 @@ ANativeWindow* s_surf;
std::shared_ptr<Common::DynamicLibrary> vulkan_library{};
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> pause_emulation{false};
@ -795,4 +799,31 @@ void Java_org_citra_citra_1emu_NativeLibrary_disableTemporaryFrameLimit(JNIEnv*
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"

View File

@ -44,8 +44,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textSize="20sp"
app:layout_constraintStart_toStartOf="parent"
android:textSize="15sp"
android:textStyle="bold" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Application Title" />
@ -85,6 +85,15 @@
app:layout_constraintTop_toBottomOf="@+id/about_game_id"
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>
<LinearLayout

View File

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

View File

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

View File

@ -10,8 +10,8 @@
#include <QVector>
#include <QWidget>
#include "citra_qt/compatibility_list.h"
#include "citra_qt/play_time_manager.h"
#include "common/common_types.h"
#include "common/play_time_manager.h"
#include "uisettings.h"
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
// Refer to the license.txt file included.
#pragma once
#include <algorithm>
@ -18,11 +17,11 @@
#include <QStandardItem>
#include <QString>
#include <QWidget>
#include "citra_qt/play_time_manager.h"
#include "citra_qt/uisettings.h"
#include "citra_qt/util/util.h"
#include "common/file_util.h"
#include "common/logging/log.h"
#include "common/play_time_manager.h"
#include "common/string_util.h"
#include "core/loader/smdh.h"
@ -382,7 +381,7 @@ public:
void setData(const QVariant& value, int role) override {
qulonglong time_seconds = value.toULongLong();
GameListItem::setData(PlayTime::ReadablePlayTime(time_seconds), Qt::DisplayRole);
GameListItem::setData(ReadableDuration(time_seconds), Qt::DisplayRole);
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
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
@ -13,8 +17,8 @@
#include <QString>
#include <QVector>
#include "citra_qt/compatibility_list.h"
#include "citra_qt/play_time_manager.h"
#include "common/common_types.h"
#include "common/play_time_manager.h"
namespace Service::FS {
enum class MediaType : u32;

View File

@ -36,6 +36,21 @@ QString ReadableByteSize(qulonglong size) {
.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 circle_pixmap(16, 16);
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.)
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
* @param color The color the pixmap shall have

View File

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

View File

@ -1,12 +1,13 @@
// SPDX-FileCopyrightText: 2024 Citra Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include <filesystem>
#include "citra_qt/play_time_manager.h"
#include "common/alignment.h"
#include "common/common_paths.h"
#include "common/file_util.h"
#include "common/logging/log.h"
#include "common/play_time_manager.h"
#include "common/settings.h"
#include "common/thread.h"
@ -140,19 +141,4 @@ void PlayTimeManager::ResetProgramPlayTime(u64 program_id) {
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

View File

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