Browse Source

[android, ui] refresh game icons when updates are discovered (#4284)

- [x] I have read and followed the [Contribution Guidelines](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/CONTRIBUTING.md#code-contributions).
- [x] I have read and followed the [AI Policy](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/docs/policies/AI.md)
- [x] I have read and followed the [Coding Guidelines](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/docs/policies/Coding.md) to the best of my ability.

-------------------

This small impl aims to fix an android old visual bug where it could cache/read a base game icon before its update was mounted, so games with update-specific icons kept showing the base icon like almost always (in a way that some players may never have seen the updated icons).

Fix: when scan detects an update matching an already found base game, reload that game’s metadata and refresh only its visible icon if the icon actually changed.

I believe it's polished to the max. No find/scans. When base is found it is tracked via hashmap, and when update is found and conditions met the refresh is triggered.

Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/4284
Reviewed-by: Samuel <lizzie@eden-emu.dev>
Reviewed-by: MaranBr <maranbr@eden-emu.dev>
tmp/WY9DGm9aTuc
xbzk 4 days ago
committed by crueter
parent
commit
d24e79c991
No known key found for this signature in database GPG Key ID: 425ACD2D4830EBC6
  1. 23
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
  2. 65
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt

23
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt

@ -31,6 +31,7 @@ object GameHelper {
fun getGames(): List<Game> { fun getGames(): List<Game> {
val games = mutableListOf<Game>() val games = mutableListOf<Game>()
val gamesByProgramId = mutableMapOf<String, Game>()
val context = YuzuApplication.appContext val context = YuzuApplication.appContext
preferences = PreferenceManager.getDefaultSharedPreferences(context) preferences = PreferenceManager.getDefaultSharedPreferences(context)
@ -63,6 +64,7 @@ object GameHelper {
addGamesRecursive( addGamesRecursive(
games, games,
gamesByProgramId,
FileUtil.listFiles(gameDirUri), FileUtil.listFiles(gameDirUri),
scanDepth, scanDepth,
mountedContainerUris mountedContainerUris
@ -136,6 +138,7 @@ object GameHelper {
private fun addGamesRecursive( private fun addGamesRecursive(
games: MutableList<Game>, games: MutableList<Game>,
gamesByProgramId: MutableMap<String, Game>,
files: Array<MinimalDocumentFile>, files: Array<MinimalDocumentFile>,
depth: Int, depth: Int,
mountedContainerUris: MutableSet<String> mountedContainerUris: MutableSet<String>
@ -148,6 +151,7 @@ object GameHelper {
if (it.isDirectory) { if (it.isDirectory) {
addGamesRecursive( addGamesRecursive(
games, games,
gamesByProgramId,
FileUtil.listFiles(it.uri), FileUtil.listFiles(it.uri),
depth - 1, depth - 1,
mountedContainerUris mountedContainerUris
@ -156,8 +160,9 @@ object GameHelper {
val extension = FileUtil.getExtension(it.uri).lowercase() val extension = FileUtil.getExtension(it.uri).lowercase()
val filePath = it.uri.toString() 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) NativeLibrary.addGameFolderFileToFilesystemProvider(filePath)
} }
@ -165,6 +170,20 @@ object GameHelper {
val game = getGame(it.uri, true, false) val game = getGame(it.uri, true, false)
if (game != null) { if (game != null) {
games.add(game) 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)
}
} }
} }
} }

65
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-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // 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.R
import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.model.Game import org.yuzu.yuzu_emu.model.Game
import java.util.Collections
import java.util.WeakHashMap
private val gameIconHashes = Collections.synchronizedMap(mutableMapOf<String, Int>())
private val gameIconTargets = Collections.synchronizedMap(WeakHashMap<ImageView, GameIconTarget>())
private fun Game.iconCacheKey(): String = "$path|$version"
private data class GameIconTarget(val game: Game, var iconHash: Int? = null)
class GameIconFetcher( class GameIconFetcher(
private val game: Game, private val game: Game,
@ -31,14 +43,15 @@ class GameIconFetcher(
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult { override suspend fun fetch(): FetchResult {
return DrawableResult( return DrawableResult(
drawable = decodeGameIcon(game.path)!!.toDrawable(options.context.resources),
drawable = decodeGameIcon(game)!!.toDrawable(options.context.resources),
isSampled = false, isSampled = false,
dataSource = DataSource.DISK 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( return BitmapFactory.decodeByteArray(
data, data,
0, 0,
@ -54,7 +67,7 @@ class GameIconFetcher(
} }
class GameIconKeyer : Keyer<Game> { class GameIconKeyer : Keyer<Game> {
override fun key(data: Game, options: Options): String = data.path
override fun key(data: Game, options: Options): String = data.iconCacheKey()
} }
object GameIconUtils { object GameIconUtils {
@ -71,14 +84,58 @@ object GameIconUtils {
.build() .build()
fun loadGameIcon(game: Game, imageView: ImageView) { fun loadGameIcon(game: Game, imageView: ImageView) {
gameIconTargets[imageView] = GameIconTarget(game)
val request = ImageRequest.Builder(YuzuApplication.appContext) val request = ImageRequest.Builder(YuzuApplication.appContext)
.data(game) .data(game)
.target(imageView) .target(imageView)
.error(R.drawable.default_icon) .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() .build()
imageLoader.enqueue(request) 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 { suspend fun getGameIcon(lifecycleOwner: LifecycleOwner, game: Game): Bitmap {
val request = ImageRequest.Builder(YuzuApplication.appContext) val request = ImageRequest.Builder(YuzuApplication.appContext)
.data(game) .data(game)

Loading…
Cancel
Save