diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt index 64e035afbe..411b764e36 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -31,6 +31,7 @@ object GameHelper { fun getGames(): List { val games = mutableListOf() + val gamesByProgramId = mutableMapOf() val context = YuzuApplication.appContext preferences = PreferenceManager.getDefaultSharedPreferences(context) @@ -63,6 +64,7 @@ object GameHelper { addGamesRecursive( games, + gamesByProgramId, FileUtil.listFiles(gameDirUri), scanDepth, mountedContainerUris @@ -136,6 +138,7 @@ object GameHelper { private fun addGamesRecursive( games: MutableList, + gamesByProgramId: MutableMap, files: Array, depth: Int, mountedContainerUris: MutableSet @@ -148,6 +151,7 @@ object GameHelper { if (it.isDirectory) { addGamesRecursive( games, + gamesByProgramId, FileUtil.listFiles(it.uri), depth - 1, mountedContainerUris @@ -156,8 +160,9 @@ object GameHelper { val extension = FileUtil.getExtension(it.uri).lowercase() val filePath = it.uri.toString() - if (externalContentExtensions.contains(extension) && - mountedContainerUris.add(filePath)) { + val mountedContainer = externalContentExtensions.contains(extension) && + mountedContainerUris.add(filePath) + if (mountedContainer) { NativeLibrary.addGameFolderFileToFilesystemProvider(filePath) } @@ -165,6 +170,20 @@ object GameHelper { val game = getGame(it.uri, true, false) if (game != null) { games.add(game) + if (game.programId != "0") { + gamesByProgramId[game.programId] = game + } + } else if (mountedContainer) { + GameMetadata.getProgramId(filePath).toLongOrNull()?.let { programId -> + gamesByProgramId[(programId and 0x800L.inv()).toString()] + }?.let { existingGame -> + NativeLibrary.getPatchesForFile(existingGame.path, existingGame.programId) + existingGame.version = GameMetadata.getVersion( + existingGame.path, + true + ) + GameIconUtils.refreshGameIcon(existingGame) + } } } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt index d05020560a..efe68a4ecf 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -24,6 +27,15 @@ import coil.request.Options import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.model.Game +import java.util.Collections +import java.util.WeakHashMap + +private val gameIconHashes = Collections.synchronizedMap(mutableMapOf()) +private val gameIconTargets = Collections.synchronizedMap(WeakHashMap()) + +private fun Game.iconCacheKey(): String = "$path|$version" + +private data class GameIconTarget(val game: Game, var iconHash: Int? = null) class GameIconFetcher( private val game: Game, @@ -31,14 +43,15 @@ class GameIconFetcher( ) : Fetcher { override suspend fun fetch(): FetchResult { return DrawableResult( - drawable = decodeGameIcon(game.path)!!.toDrawable(options.context.resources), + drawable = decodeGameIcon(game)!!.toDrawable(options.context.resources), isSampled = false, dataSource = DataSource.DISK ) } - private fun decodeGameIcon(uri: String): Bitmap? { - val data = GameMetadata.getIcon(uri) + private fun decodeGameIcon(game: Game): Bitmap? { + val data = GameMetadata.getIcon(game.path) + gameIconHashes[game.iconCacheKey()] = data.contentHashCode() return BitmapFactory.decodeByteArray( data, 0, @@ -54,7 +67,7 @@ class GameIconFetcher( } class GameIconKeyer : Keyer { - override fun key(data: Game, options: Options): String = data.path + override fun key(data: Game, options: Options): String = data.iconCacheKey() } object GameIconUtils { @@ -71,14 +84,58 @@ object GameIconUtils { .build() fun loadGameIcon(game: Game, imageView: ImageView) { + gameIconTargets[imageView] = GameIconTarget(game) val request = ImageRequest.Builder(YuzuApplication.appContext) .data(game) .target(imageView) .error(R.drawable.default_icon) + .listener( + onSuccess = { _, _ -> + val target = gameIconTargets[imageView] + if (target?.game?.iconCacheKey() == game.iconCacheKey()) { + gameIconHashes[game.iconCacheKey()]?.let { + target.iconHash = it + } + } + }, + onError = { _, _ -> + gameIconTargets[imageView]?.iconHash = null + } + ) .build() imageLoader.enqueue(request) } + fun refreshGameIcon(game: Game) { + val targets = synchronized(gameIconTargets) { + gameIconTargets + .filterValues { it.game.path == game.path && it.game.programId == game.programId } + .keys + .toList() + } + if (targets.isEmpty()) { + return + } + + val iconHash = GameMetadata.getIcon(game.path).contentHashCode() + val targetsToRefresh = targets.filter { gameIconTargets[it]?.iconHash != iconHash } + if (targetsToRefresh.isEmpty()) { + return + } + + imageLoader.memoryCache?.remove(MemoryCache.Key(game.iconCacheKey())) + targetsToRefresh.forEach { imageView -> + imageView.post { + val target = gameIconTargets[imageView] ?: return@post + if (target.game.path == game.path && target.game.programId == game.programId) { + if (target.iconHash != iconHash) { + loadGameIcon(game, imageView) + } + } + } + } + } + suspend fun getGameIcon(lifecycleOwner: LifecycleOwner, game: Game): Bitmap { val request = ImageRequest.Builder(YuzuApplication.appContext) .data(game)