diff options
| author | JosJuice <josjuice@gmail.com> | 2026-11-04 19:46:41 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-11-04 19:46:41 +0100 |
| commit | 584366cb6859f5e371b1314bf0d92bb59d4d1b80 (patch) | |
| tree | eacc9d6f545183321717955ed5c4f0589d125564 /Source/Android | |
| parent | 112e1a436ae8c81ccd6d002460094172e72a600f (diff) | |
| parent | 49ebb1cfea589e66134039d4ecca911e0e14af43 (diff) | |
Android: Use TitleDatabase for game list
Diffstat (limited to 'Source/Android')
14 files changed, 193 insertions, 52 deletions
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameAdapter.kt index dbcfdd42e2..e7ea8e5519 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameAdapter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameAdapter.kt @@ -61,13 +61,14 @@ class GameAdapter : RecyclerView.Adapter<GameViewHolder>(), val gameFile = mGameFiles[position] holder.apply { + val title = gameFile.getTitle(GameFileCacheManager.getTitleDatabase()) if (BooleanSetting.MAIN_SHOW_GAME_TITLES.boolean) { - binding.textGameTitle.text = gameFile.getTitle() + binding.textGameTitle.text = title binding.textGameTitle.visibility = View.VISIBLE binding.textGameTitleInner.visibility = View.GONE binding.textGameCaption.visibility = View.VISIBLE } else { - binding.textGameTitleInner.text = gameFile.getTitle() + binding.textGameTitleInner.text = title binding.textGameTitleInner.visibility = View.VISIBLE binding.textGameTitle.visibility = View.GONE binding.textGameCaption.visibility = View.GONE diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameRowPresenter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameRowPresenter.kt index 4f43601e68..7cbb3000e2 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameRowPresenter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameRowPresenter.kt @@ -48,7 +48,7 @@ class GameRowPresenter : Presenter() { holder.apply { imageScreenshot.setImageDrawable(null) - cardParent.titleText = gameFile.getTitle() + cardParent.titleText = gameFile.getTitle(GameFileCacheManager.getTitleDatabase()) holder.gameFile = gameFile // Set the background color of the card diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/GameDetailsDialog.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/GameDetailsDialog.kt index 0384da89c0..b97fd987e6 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/GameDetailsDialog.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/dialogs/GameDetailsDialog.kt @@ -51,7 +51,7 @@ class GameDetailsDialog : DialogFragment() { textTimePlayed.visibility = View.GONE } - textGameTitle.text = gameFile.getTitle() + textGameTitle.text = gameFile.getTitle(GameFileCacheManager.getTitleDatabase()) textDescription.text = gameFile.getDescription() if (gameFile.getDescription().isEmpty()) { textDescription.visibility = View.GONE @@ -118,7 +118,7 @@ class GameDetailsDialog : DialogFragment() { textTimePlayed.visibility = View.GONE } - textGameTitle.text = gameFile.getTitle() + textGameTitle.text = gameFile.getTitle(GameFileCacheManager.getTitleDatabase()) textDescription.text = gameFile.getDescription() if (gameFile.getDescription().isEmpty()) { tvBinding.textDescription.visibility = View.GONE diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt index c3602facf4..97f456c7a6 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt @@ -112,6 +112,7 @@ import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress import org.dolphinemu.dolphinemu.features.netplay.model.TraversalState import org.dolphinemu.dolphinemu.model.GameFile +import org.dolphinemu.dolphinemu.services.GameFileCacheManager import org.dolphinemu.dolphinemu.ui.theme.DolphinScaffold import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer @@ -855,6 +856,7 @@ private fun GameGridItem( gameFile: GameFile, onClick: () -> Unit, ) { + val titleDatabase = GameFileCacheManager.getTitleDatabase() Card( onClick = onClick, ) { @@ -864,7 +866,7 @@ private fun GameGridItem( .data(gameFile) .error(R.drawable.no_banner) .build(), - contentDescription = gameFile.getTitle(), + contentDescription = gameFile.getTitle(titleDatabase), contentScale = ContentScale.Crop, imageLoader = CoilUtils.imageLoader, modifier = Modifier @@ -872,7 +874,7 @@ private fun GameGridItem( .aspectRatio(0.7f) ) Text( - text = gameFile.getTitle(), + text = gameFile.getTitle(titleDatabase), style = MaterialTheme.typography.bodySmall, maxLines = 2, minLines = 2, diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/GameFile.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/GameFile.kt index 26da174285..4794f5c1b7 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/GameFile.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/GameFile.kt @@ -12,7 +12,7 @@ class GameFile private constructor(private val pointer: Long) { external fun getPlatform(): Int - external fun getTitle(): String + external fun getTitle(titleDatabase: TitleDatabase?): String external fun getDescription(): String diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/TitleDatabase.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/TitleDatabase.kt new file mode 100644 index 0000000000..e5089673b8 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/TitleDatabase.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.model + +import androidx.annotation.Keep + +@Keep +class TitleDatabase private constructor(private val pointer: Long) { + external fun finalize() + + external fun userTitleMapEquals(other: TitleDatabase?): Boolean + + companion object { + @JvmStatic + external suspend fun load(): TitleDatabase + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheManager.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheManager.kt index 2ba2c246b3..a5e54aa0de 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheManager.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheManager.kt @@ -2,32 +2,36 @@ package org.dolphinemu.dolphinemu.services -import android.os.Handler -import android.os.Looper import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting import org.dolphinemu.dolphinemu.features.settings.model.ConfigChangedCallback import org.dolphinemu.dolphinemu.model.GameFile import org.dolphinemu.dolphinemu.model.GameFileCache +import org.dolphinemu.dolphinemu.model.TitleDatabase import org.dolphinemu.dolphinemu.ui.platform.Platform import org.dolphinemu.dolphinemu.ui.platform.PlatformTab import org.dolphinemu.dolphinemu.utils.AfterDirectoryInitializationRunner import java.util.Arrays -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference /** - * Loads game list data on a separate thread. + * Loads game list data in the background. */ object GameFileCacheManager { private var gameFileCache: GameFileCache? = null private val gameFiles = MutableLiveData(emptyArray<GameFile>()) + private var titleDatabase = AtomicReference<TitleDatabase>(null) private var firstLoadDone = false - private var runRescanAfterLoad = false private var recursiveScanEnabled = false - private val executor: ExecutorService = Executors.newFixedThreadPool(1) private val loadInProgress = MutableLiveData(false) private val rescanInProgress = MutableLiveData(false) @@ -88,6 +92,8 @@ object GameFileCacheManager { } } + fun getTitleDatabase(): TitleDatabase? = titleDatabase.get() + /** * Returns true if in the process of loading the cache for the first time. */ @@ -120,7 +126,9 @@ object GameFileCacheManager { if (!loadInProgress.value!!) { loadInProgress.value = true - AfterDirectoryInitializationRunner().runWithoutLifecycle { executor.execute(::load) } + AfterDirectoryInitializationRunner().runWithoutLifecycle { + MainScope().launch { load() } + } } } @@ -136,7 +144,9 @@ object GameFileCacheManager { if (!rescanInProgress.value!!) { rescanInProgress.value = true - AfterDirectoryInitializationRunner().runWithoutLifecycle { executor.execute(::rescan) } + AfterDirectoryInitializationRunner().runWithoutLifecycle { + MainScope().launch { rescan() } + } } } @@ -144,9 +154,9 @@ object GameFileCacheManager { fun addOrGet(gamePath: String?): GameFile { val nonNullGamePath = gamePath!! - // Common case: The game is in the cache, so just grab it from there. (GameFileCache.addOrGet + // Common case: The game is in the cache, so we grab it from there. (GameFileCache.addOrGet // actually already checks for this case, but we want to avoid calling it if possible - // because the executor thread may hold a lock on gameFileCache for extended periods of time.) + // because rescan() may hold a lock on gameFileCache for extended periods of time.) val allGames = gameFiles.value!! for (game in allGames) { if (game.getPath() == nonNullGamePath) { @@ -165,28 +175,28 @@ object GameFileCacheManager { * games are still present in the user's configured folders. * If this has already been called, calling it again has no effect. */ - private fun load() { + private suspend fun load() { if (!firstLoadDone) { - firstLoadDone = true - setUpAutomaticRescan() - gameFileCache!!.load() - if (gameFileCache!!.getSize() != 0) { - updateGameFileArray() + val titleDatabaseJob = MainScope().async(Dispatchers.IO) { + TitleDatabase.load() } - } - if (runRescanAfterLoad) { - // Without this, there will be a short blip where the loading indicator in the GUI disappears - // because neither loadInProgress nor rescanInProgress is true - rescanInProgress.postValue(true) - } + withContext(Dispatchers.IO) { + setUpAutomaticRescan() + + gameFileCache!!.load() + + titleDatabase.set(titleDatabaseJob.await()) - loadInProgress.postValue(false) + if (gameFileCache!!.getSize() != 0) { + updateGameFileArray() + } + } - if (runRescanAfterLoad) { - runRescanAfterLoad = false - rescan() + firstLoadDone = true } + + loadInProgress.value = false } /** @@ -195,19 +205,35 @@ object GameFileCacheManager { * If load hasn't been called before this, the execution of this * will be postponed until after load runs. */ - private fun rescan() { - if (!firstLoadDone) { - runRescanAfterLoad = true - } else { - val gamePaths = GameFileCache.getAllGamePaths() + private suspend fun rescan() { + while (!firstLoadDone) { + // Wait until loadInProgress becomes false + loadInProgress.asFlow().first { !it } + } + val titleDatabaseJob = MainScope().async(Dispatchers.IO) { + val newTitleDatabase = TitleDatabase.load() + val titleDatabaseChanged = + !newTitleDatabase.userTitleMapEquals(titleDatabase.get()) + if (titleDatabaseChanged) newTitleDatabase else null + } + + withContext(Dispatchers.IO) { + val gamePaths = GameFileCache.getAllGamePaths() val changed = gameFileCache!!.update(gamePaths) if (changed) { updateGameFileArray() } val additionalMetadataChanged = gameFileCache!!.updateAdditionalMetadata() - if (additionalMetadataChanged) { + + val newTitleDatabase = titleDatabaseJob.await() + val titleDatabaseChanged = newTitleDatabase != null + if (titleDatabaseChanged) { + titleDatabase.set(newTitleDatabase) + } + + if (additionalMetadataChanged || titleDatabaseChanged) { updateGameFileArray() } @@ -216,13 +242,14 @@ object GameFileCacheManager { } } - rescanInProgress.postValue(false) + rescanInProgress.value = false } private fun updateGameFileArray() { val gameFilesTemp = gameFileCache!!.getAllGames() + val titleDatabase = titleDatabase.get() Arrays.sort(gameFilesTemp) { lhs, rhs -> - lhs.getTitle().compareTo(rhs.getTitle(), ignoreCase = true) + lhs.getTitle(titleDatabase).compareTo(rhs.getTitle(titleDatabase), ignoreCase = true) } gameFiles.postValue(gameFilesTemp) } @@ -239,10 +266,10 @@ object GameFileCacheManager { private fun setUpAutomaticRescan() { recursiveScanEnabled = BooleanSetting.MAIN_RECURSIVE_ISO_PATHS.boolean ConfigChangedCallback { - Handler(Looper.getMainLooper()).post { - val recursiveScanEnabled = BooleanSetting.MAIN_RECURSIVE_ISO_PATHS.boolean - if (this.recursiveScanEnabled != recursiveScanEnabled) { - this.recursiveScanEnabled = recursiveScanEnabled + MainScope().launch { + val newRecursiveScanEnabled = BooleanSetting.MAIN_RECURSIVE_ISO_PATHS.boolean + if (recursiveScanEnabled != newRecursiveScanEnabled) { + recursiveScanEnabled = newRecursiveScanEnabled startRescan() } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncProgramsJobService.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncProgramsJobService.kt index 2d9ad354fb..fd13ff3175 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncProgramsJobService.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncProgramsJobService.kt @@ -138,7 +138,10 @@ class SyncProgramsJobService : JobService() { TvContractCompat.PreviewPrograms.COLUMN_TYPE, TvContractCompat.PreviewProgramColumns.TYPE_GAME ) - values.put(TvContract.Programs.COLUMN_TITLE, game.getTitle()) + values.put( + TvContract.Programs.COLUMN_TITLE, + game.getTitle(GameFileCacheManager.getTitleDatabase()) + ) values.put(TvContract.Programs.COLUMN_SHORT_DESCRIPTION, game.getDescription()) values.put(TvContract.Programs.COLUMN_POSTER_ART_URI, banner.toString()) values.put( diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 58cec02308..8d774dbbd9 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -151,6 +151,10 @@ static jclass s_audio_utils_class; static jmethodID s_audio_utils_get_sample_rate; static jmethodID s_audio_utils_get_frames_per_buffer; +static jclass s_title_database_class; +static jfieldID s_title_database_pointer; +static jmethodID s_title_database_constructor; + namespace IDCache { JNIEnv* GetEnvForThread() @@ -721,6 +725,21 @@ jmethodID GetAudioUtilsGetFramesPerBuffer() return s_audio_utils_get_frames_per_buffer; } +jclass GetTitleDatabaseClass() +{ + return s_title_database_class; +} + +jfieldID GetTitleDatabasePointer() +{ + return s_title_database_pointer; +} + +jmethodID GetTitleDatabaseConstructor() +{ + return s_title_database_constructor; +} + } // namespace IDCache extern "C" { @@ -1015,6 +1034,13 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) env->GetStaticMethodID(audio_utils_class, "getFramesPerBuffer", "()I"); env->DeleteLocalRef(audio_utils_class); + const jclass title_database_class = + env->FindClass("org/dolphinemu/dolphinemu/model/TitleDatabase"); + s_title_database_class = reinterpret_cast<jclass>(env->NewGlobalRef(title_database_class)); + s_title_database_pointer = env->GetFieldID(title_database_class, "pointer", "J"); + s_title_database_constructor = env->GetMethodID(title_database_class, "<init>", "(J)V"); + env->DeleteLocalRef(title_database_class); + return JNI_VERSION; } diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index 43c4f43585..1483b3bbd3 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -150,4 +150,8 @@ jclass GetAudioUtilsClass(); jmethodID GetAudioUtilsGetSampleRate(); jmethodID GetAudioUtilsGetFramesPerBuffer(); +jclass GetTitleDatabaseClass(); +jfieldID GetTitleDatabasePointer(); +jmethodID GetTitleDatabaseConstructor(); + } // namespace IDCache diff --git a/Source/Android/jni/CMakeLists.txt b/Source/Android/jni/CMakeLists.txt index a7ee0b6b3a..59e1e1138e 100644 --- a/Source/Android/jni/CMakeLists.txt +++ b/Source/Android/jni/CMakeLists.txt @@ -13,6 +13,8 @@ add_library(main SHARED GameList/GameFile.cpp GameList/GameFile.h GameList/GameFileCache.cpp + GameList/TitleDatabase.cpp + GameList/TitleDatabase.h GpuDriver.cpp HotkeyScheduler.cpp HotkeyScheduler.h diff --git a/Source/Android/jni/GameList/GameFile.cpp b/Source/Android/jni/GameList/GameFile.cpp index dc922d701c..e13f5c810a 100644 --- a/Source/Android/jni/GameList/GameFile.cpp +++ b/Source/Android/jni/GameList/GameFile.cpp @@ -16,6 +16,7 @@ #include "UICommon/GameFile.h" #include "jni/AndroidCommon/AndroidCommon.h" #include "jni/AndroidCommon/IDCache.h" +#include "jni/GameList/TitleDatabase.h" static std::shared_ptr<const UICommon::GameFile>* GetPointer(JNIEnv* env, jobject obj) { @@ -52,11 +53,19 @@ JNIEXPORT jint JNICALL Java_org_dolphinemu_dolphinemu_model_GameFile_getPlatform return static_cast<jint>(GetRef(env, obj)->GetPlatform()); } -JNIEXPORT jstring JNICALL Java_org_dolphinemu_dolphinemu_model_GameFile_getTitle(JNIEnv* env, - jobject obj) +JNIEXPORT jstring JNICALL Java_org_dolphinemu_dolphinemu_model_GameFile_getTitle( + JNIEnv* env, jobject obj, jobject j_title_database) { - return ToJString(env, - GetRef(env, obj)->GetName(UICommon::GameFile::Variant::LongAndPossiblyCustom)); + const Core::TitleDatabase* title_database = TitleDatabaseFromJava(env, j_title_database); + if (title_database) + { + return ToJString(env, GetRef(env, obj)->GetName(*title_database)); + } + else + { + return ToJString(env, + GetRef(env, obj)->GetName(UICommon::GameFile::Variant::LongAndPossiblyCustom)); + } } JNIEXPORT jstring JNICALL Java_org_dolphinemu_dolphinemu_model_GameFile_getDescription(JNIEnv* env, diff --git a/Source/Android/jni/GameList/TitleDatabase.cpp b/Source/Android/jni/GameList/TitleDatabase.cpp new file mode 100644 index 0000000000..c4aca48a25 --- /dev/null +++ b/Source/Android/jni/GameList/TitleDatabase.cpp @@ -0,0 +1,37 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "jni/GameList/TitleDatabase.h" + +#include <jni.h> + +#include "Core/TitleDatabase.h" +#include "jni/AndroidCommon/IDCache.h" + +Core::TitleDatabase* TitleDatabaseFromJava(JNIEnv* env, jobject obj) +{ + return reinterpret_cast<Core::TitleDatabase*>( + env->GetLongField(obj, IDCache::GetTitleDatabasePointer())); +} +extern "C" { + +JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_model_TitleDatabase_finalize(JNIEnv* env, + jobject obj) +{ + delete TitleDatabaseFromJava(env, obj); +} + +JNIEXPORT jboolean JNICALL Java_org_dolphinemu_dolphinemu_model_TitleDatabase_userTitleMapEquals( + JNIEnv* env, jobject obj, jobject other) +{ + return other != nullptr && TitleDatabaseFromJava(env, obj)->GetUserTitleMap() == + TitleDatabaseFromJava(env, other)->GetUserTitleMap(); +} + +JNIEXPORT jobject JNICALL Java_org_dolphinemu_dolphinemu_model_TitleDatabase_load(JNIEnv* env, + jclass) +{ + return env->NewObject(IDCache::GetTitleDatabaseClass(), IDCache::GetTitleDatabaseConstructor(), + reinterpret_cast<jlong>(new Core::TitleDatabase)); +} +} diff --git a/Source/Android/jni/GameList/TitleDatabase.h b/Source/Android/jni/GameList/TitleDatabase.h new file mode 100644 index 0000000000..fcf8f9bf32 --- /dev/null +++ b/Source/Android/jni/GameList/TitleDatabase.h @@ -0,0 +1,13 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <jni.h> + +namespace Core +{ +class TitleDatabase; +} + +Core::TitleDatabase* TitleDatabaseFromJava(JNIEnv* env, jobject obj); |
