diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.kt index 8d9bf24e3..92d2a5785 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.kt @@ -45,6 +45,9 @@ import org.citra.citra_emu.utils.EmulationLifecycleUtil import org.citra.citra_emu.utils.EmulationMenuSettings import org.citra.citra_emu.utils.ThemeUtil import org.citra.citra_emu.viewmodel.EmulationViewModel +import androidx.core.os.BundleCompat +import org.citra.citra_emu.utils.PlayTimeTracker +import org.citra.citra_emu.model.Game class EmulationActivity : AppCompatActivity() { private val preferences: SharedPreferences @@ -66,6 +69,8 @@ class EmulationActivity : AppCompatActivity() { private var isEmulationRunning: Boolean = false + private var emulationStartTime: Long = 0 + override fun onCreate(savedInstanceState: Bundle?) { ThemeUtil.setTheme(this) @@ -105,6 +110,8 @@ class EmulationActivity : AppCompatActivity() { isEmulationRunning = true instance = this + emulationStartTime = System.currentTimeMillis() + applyOrientationSettings() // Check for orientation settings at startup } @@ -139,6 +146,17 @@ class EmulationActivity : AppCompatActivity() { override fun onDestroy() { EmulationLifecycleUtil.clear() + val sessionTime = System.currentTimeMillis() - emulationStartTime + + val game = try { + intent.extras?.let { extras -> + BundleCompat.getParcelable(extras, "game", Game::class.java) + } ?: throw IllegalStateException("Missing game data in intent extras") + } catch (e: Exception) { + throw IllegalStateException("Failed to retrieve game data: ${e.message}", e) + } + + PlayTimeTracker.addPlayTime(game, sessionTime) isEmulationRunning = false instance = null super.onDestroy() diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt index f3400e2f3..1dcf16c49 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt @@ -47,6 +47,7 @@ import org.citra.citra_emu.features.settings.utils.SettingsFile import org.citra.citra_emu.model.Game import org.citra.citra_emu.utils.GameIconUtils import org.citra.citra_emu.viewmodel.GamesViewModel +import org.citra.citra_emu.utils.PlayTimeTracker class GameAdapter(private val activity: AppCompatActivity, private val inflater: LayoutInflater) : ListAdapter(AsyncDifferConfig.Builder(DiffCallback()).build()), @@ -221,6 +222,12 @@ class GameAdapter(private val activity: AppCompatActivity, private val inflater: view.findNavController().navigate(action) } + bottomSheetView.findViewById(R.id.about_game_playtime).text = + buildString { + append("Playtime: ") + append(PlayTimeTracker.getPlayTime(game.titleId)) + } + bottomSheetView.findViewById(R.id.game_shortcut).setOnClickListener { val shortcutManager = activity.getSystemService(ShortcutManager::class.java) diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PlayTimeTracker.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/PlayTimeTracker.kt new file mode 100644 index 000000000..79042bc01 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PlayTimeTracker.kt @@ -0,0 +1,102 @@ +// Copyright 2025 Mandarine Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import androidx.documentfile.provider.DocumentFile +import org.citra.citra_emu.model.Game +import org.citra.citra_emu.CitraApplication +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.BufferedReader +import java.io.InputStreamReader + +@Serializable +data class PlayTimeData( + val titleId: Long, + val title: String, + var totalPlayTimeMs: Long = 0 +) + +object PlayTimeTracker { + private const val PLAYTIME_FILENAME = "playtime.json" + private val playTimes = mutableMapOf() + + init { + loadPlayTimes() + } + + fun addPlayTime(game: Game, sessionTimeMs: Long) { + val data = playTimes.getOrPut(game.titleId) { + PlayTimeData(game.titleId, game.title) + } + data.totalPlayTimeMs += sessionTimeMs + savePlayTimes() + } + + fun getPlayTime(titleId: Long): String { + // Reload playtime in case of manual file editing + loadPlayTimes() + + val totalSeconds = (playTimes[titleId]?.totalPlayTimeMs ?: 0) / 1000 + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + return when { + hours > 0 -> "${hours}h ${minutes}m ${seconds}s" + minutes > 0 -> "${minutes}m ${seconds}s" + else -> "${seconds}s" + } + } + + private fun loadPlayTimes() { + try { + val root = DocumentFile.fromTreeUri(CitraApplication.appContext, PermissionsHandler.citraDirectory) + val logDir = root?.findFile("log") ?: return + val playTimeFile = logDir.findFile(PLAYTIME_FILENAME) ?: return + + val context = CitraApplication.appContext + val inputStream = context.contentResolver.openInputStream(playTimeFile.uri) ?: return + val reader = BufferedReader(InputStreamReader(inputStream)) + val jsonString = reader.readText() + + val loadedData = Json.decodeFromString>(jsonString) + playTimes.clear() + loadedData.forEach { playTimes[it.titleId] = it } + + reader.close() + } catch (e: Exception) { + Log.error("Failed to load play times: ${e.message}") + } + } + + private fun savePlayTimes() { + try { + val root = DocumentFile.fromTreeUri(CitraApplication.appContext, PermissionsHandler.citraDirectory) + val logDir = root?.findFile("log") + ?: root?.createDirectory("log") + ?: return + + var playTimeFile = logDir.findFile(PLAYTIME_FILENAME) + if (playTimeFile == null) { + playTimeFile = logDir.createFile("application/json", PLAYTIME_FILENAME) ?: return + } + + val jsonString = Json.encodeToString(playTimes.values.toList()) + val outputStream = CitraApplication.appContext.contentResolver.openOutputStream(playTimeFile.uri) ?: return + outputStream.write(jsonString.toByteArray()) + outputStream.close() + } catch (e: Exception) { + Log.error("Failed to save play times: ${e.message}") + } + } + + // Will be used on a later PR + fun deletePlayTime(titleId: Long) { + playTimes.remove(titleId) + savePlayTimes() + } +} \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/dialog_about_game.xml b/src/android/app/src/main/res/layout/dialog_about_game.xml index 8b1ded71a..1ede62efa 100644 --- a/src/android/app/src/main/res/layout/dialog_about_game.xml +++ b/src/android/app/src/main/res/layout/dialog_about_game.xml @@ -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" /> + +