From 2514407451d20d3184a30822e50b893a06b5377d Mon Sep 17 00:00:00 2001 From: JosJuice Date: Mon, 3 Aug 2026 20:09:37 +0200 Subject: Android: Clean up setting rescanInProgress in GameFileCacheManager We can avoid the workaround of setting rescanInProgress to true in load() if we don't set it to false in rescan()'s early exit. --- .../org/dolphinemu/dolphinemu/services/GameFileCacheManager.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) (limited to 'Source/Android/app/src/main/java/org') 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..1dad6107f3 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 @@ -175,12 +175,6 @@ object GameFileCacheManager { } } - 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) - } - loadInProgress.postValue(false) if (runRescanAfterLoad) { @@ -214,9 +208,9 @@ object GameFileCacheManager { if (changed || additionalMetadataChanged) { gameFileCache!!.save() } - } - rescanInProgress.postValue(false) + rescanInProgress.postValue(false) + } } private fun updateGameFileArray() { -- cgit v1.2.3 From 9db6daadd89b2c3f01a434366d7295d43d7faf32 Mon Sep 17 00:00:00 2001 From: JosJuice Date: Mon, 3 Aug 2026 18:33:04 +0200 Subject: Android: Use coroutines in GameFileCacheManager Some modernization to prepare for an upcoming commit. --- .../dolphinemu/services/GameFileCacheManager.kt | 58 ++++++++++++---------- 1 file changed, 33 insertions(+), 25 deletions(-) (limited to 'Source/Android/app/src/main/java/org') 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 1dad6107f3..dc344bc3de 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,10 +2,12 @@ package org.dolphinemu.dolphinemu.services -import android.os.Handler -import android.os.Looper import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +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 @@ -14,11 +16,9 @@ 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 /** - * Loads game list data on a separate thread. + * Loads game list data in the background. */ object GameFileCacheManager { private var gameFileCache: GameFileCache? = null @@ -27,7 +27,6 @@ object GameFileCacheManager { 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) @@ -120,7 +119,9 @@ object GameFileCacheManager { if (!loadInProgress.value!!) { loadInProgress.value = true - AfterDirectoryInitializationRunner().runWithoutLifecycle { executor.execute(::load) } + AfterDirectoryInitializationRunner().runWithoutLifecycle { + MainScope().launch { load() } + } } } @@ -136,7 +137,9 @@ object GameFileCacheManager { if (!rescanInProgress.value!!) { rescanInProgress.value = true - AfterDirectoryInitializationRunner().runWithoutLifecycle { executor.execute(::rescan) } + AfterDirectoryInitializationRunner().runWithoutLifecycle { + MainScope().launch { rescan() } + } } } @@ -144,9 +147,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,17 +168,19 @@ 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() + withContext(Dispatchers.IO) { + setUpAutomaticRescan() + gameFileCache!!.load() + if (gameFileCache!!.getSize() != 0) { + updateGameFileArray() + } } + firstLoadDone = true } - loadInProgress.postValue(false) + loadInProgress.value = false if (runRescanAfterLoad) { runRescanAfterLoad = false @@ -189,10 +194,13 @@ object GameFileCacheManager { * If load hasn't been called before this, the execution of this * will be postponed until after load runs. */ - private fun rescan() { + private suspend fun rescan() { if (!firstLoadDone) { runRescanAfterLoad = true - } else { + return + } + + withContext(Dispatchers.IO) { val gamePaths = GameFileCache.getAllGamePaths() val changed = gameFileCache!!.update(gamePaths) @@ -208,9 +216,9 @@ object GameFileCacheManager { if (changed || additionalMetadataChanged) { gameFileCache!!.save() } - - rescanInProgress.postValue(false) } + + rescanInProgress.value = false } private fun updateGameFileArray() { @@ -233,10 +241,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() } } -- cgit v1.2.3 From c64b02dace5e5e71dae9fbfbd258ffa3562573ef Mon Sep 17 00:00:00 2001 From: JosJuice Date: Mon, 3 Aug 2026 20:17:54 +0200 Subject: Android: Remove runRescanAfterLoad from GameFileCacheManager A small additional cleanup made possible by the previous commit. --- .../dolphinemu/dolphinemu/services/GameFileCacheManager.kt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) (limited to 'Source/Android/app/src/main/java/org') 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 dc344bc3de..51ce4a4f2f 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 @@ -4,8 +4,10 @@ package org.dolphinemu.dolphinemu.services import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting @@ -24,7 +26,6 @@ object GameFileCacheManager { private var gameFileCache: GameFileCache? = null private val gameFiles = MutableLiveData(emptyArray()) private var firstLoadDone = false - private var runRescanAfterLoad = false private var recursiveScanEnabled = false private val loadInProgress = MutableLiveData(false) @@ -181,11 +182,6 @@ object GameFileCacheManager { } loadInProgress.value = false - - if (runRescanAfterLoad) { - runRescanAfterLoad = false - rescan() - } } /** @@ -195,9 +191,9 @@ object GameFileCacheManager { * will be postponed until after load runs. */ private suspend fun rescan() { - if (!firstLoadDone) { - runRescanAfterLoad = true - return + while (!firstLoadDone) { + // Wait until loadInProgress becomes false + loadInProgress.asFlow().first { !it } } withContext(Dispatchers.IO) { -- cgit v1.2.3 From 49ebb1cfea589e66134039d4ecca911e0e14af43 Mon Sep 17 00:00:00 2001 From: JosJuice Date: Mon, 3 Aug 2026 20:02:47 +0200 Subject: Android: Use TitleDatabase for game list By using the title database we ship with Dolphin, we get more uniform game titles than if we grab what's in each game's opening.bnr file. It also becomes possible for users to supply a custom titles.txt file. This has been implemented in DolphinQt since a long time ago. --- .../dolphinemu/dolphinemu/adapters/GameAdapter.kt | 5 ++-- .../dolphinemu/adapters/GameRowPresenter.kt | 2 +- .../dolphinemu/dialogs/GameDetailsDialog.kt | 4 +-- .../features/netplay/ui/NetplayScreen.kt | 6 ++-- .../org/dolphinemu/dolphinemu/model/GameFile.kt | 2 +- .../dolphinemu/dolphinemu/model/TitleDatabase.kt | 17 +++++++++++ .../dolphinemu/services/GameFileCacheManager.kt | 35 ++++++++++++++++++++-- .../dolphinemu/services/SyncProgramsJobService.kt | 5 +++- 8 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/TitleDatabase.kt (limited to 'Source/Android/app/src/main/java/org') 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(), 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 789a3cef17..3c641c6695 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 @@ -104,6 +104,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 @@ -753,6 +754,7 @@ private fun GameGridItem( gameFile: GameFile, onClick: () -> Unit, ) { + val titleDatabase = GameFileCacheManager.getTitleDatabase() Card( onClick = onClick, ) { @@ -762,7 +764,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 @@ -770,7 +772,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 51ce4a4f2f..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 @@ -7,6 +7,7 @@ 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 @@ -14,10 +15,12 @@ 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.atomic.AtomicReference /** * Loads game list data in the background. @@ -25,6 +28,7 @@ import java.util.Arrays object GameFileCacheManager { private var gameFileCache: GameFileCache? = null private val gameFiles = MutableLiveData(emptyArray()) + private var titleDatabase = AtomicReference(null) private var firstLoadDone = false private var recursiveScanEnabled = 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. */ @@ -171,13 +177,22 @@ object GameFileCacheManager { */ private suspend fun load() { if (!firstLoadDone) { + val titleDatabaseJob = MainScope().async(Dispatchers.IO) { + TitleDatabase.load() + } + withContext(Dispatchers.IO) { setUpAutomaticRescan() + gameFileCache!!.load() + + titleDatabase.set(titleDatabaseJob.await()) + if (gameFileCache!!.getSize() != 0) { updateGameFileArray() } } + firstLoadDone = true } @@ -196,16 +211,29 @@ object GameFileCacheManager { 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() } @@ -219,8 +247,9 @@ object GameFileCacheManager { 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) } 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( -- cgit v1.2.3