fix: game shortcuts use the cover art when Steam's icon is too small (#825)

Steam keeps a 32x32 icon for each game, and the home screen stretched
it to launcher size. A square from the middle of the cover art is used
instead unless the icon is at least 128 px; the small icon is still
the fallback when there is no art.

Co-authored-by: Kirk <[email protected]>
This commit is contained in:
KirkandKirk authored and GitHub committed 2026-10-14 13:51:56 -04:00
1 parent b3ff93e80f
commit e45d6f7192
2 files changed
+94 -10

No files matched your search

@@ -5,12 +5,14 @@ import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.widget.Toast
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import java.io.File
object GameShortcuts {
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
@@ -19,22 +21,35 @@ object GameShortcuts {
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
val name = game.name.ifBlank { context.getString(R.string.app_name) }
val icon = listOfNotNull(game.icon, game.art).firstNotNullOfOrNull { file -> runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
val options = BitmapFactory.Options().apply {
inSampleSize = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2
}
BitmapFactory.decodeFile(file.path, options)?.let(Icon::createWithBitmap)
}.getOrNull() } ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
.setIntent(launchIntent(context, game.gameIdString)).build()
}
internal fun iconBitmap(game: Library.SteamGame): Bitmap? {
val icon = game.icon?.let(::decode)
if (icon != null && minOf(icon.width, icon.height) >= SHARP_ICON_PX) return icon
return game.art?.let(::decode)?.let(::square) ?: icon
}
private fun decode(file: File): Bitmap? = runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
val options = BitmapFactory.Options().apply {
inSampleSize = 1
while (minOf(bounds.outWidth, bounds.outHeight) / (inSampleSize * 2) >= ICON_PX) inSampleSize *= 2
}
BitmapFactory.decodeFile(file.path, options)
}.getOrNull()
private fun square(bitmap: Bitmap): Bitmap {
val side = minOf(bitmap.width, bitmap.height)
return Bitmap.createBitmap(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side)
}
fun pin(activity: Activity, game: Library.SteamGame) {
val manager = activity.getSystemService(ShortcutManager::class.java)
val requested = runCatching { manager.isRequestPinShortcutSupported &&
@@ -44,4 +59,7 @@ object GameShortcuts {
fun result(activity: Activity, game: Library.SteamGame): Intent =
activity.getSystemService(ShortcutManager::class.java).createShortcutResultIntent(info(activity, game))
private const val ICON_PX = 256
private const val SHARP_ICON_PX = 128
}
@@ -0,0 +1,66 @@
package com.droiddeck.launcher.frontend
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import java.io.File
import java.nio.file.Files
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], manifest = Config.NONE)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class GameShortcutsIconTest {
private lateinit var dir: File
@Before fun setUp() {
dir = Files.createTempDirectory("shortcut-icons").toFile()
}
@Test fun aSmallSteamIconGivesWayToTheMiddleOfTheCoverArt() {
val icon = image("icon.png", 32, 32, Color.GREEN)
val cover = image("cover.png", 300, 450, Color.BLUE, band = 75, bandColor = Color.RED)
val bitmap = GameShortcuts.iconBitmap(game(icon = icon, art = cover))!!
assertEquals(300, bitmap.width)
assertEquals(300, bitmap.height)
assertEquals(Color.BLUE, bitmap.getPixel(0, 0))
assertEquals(Color.BLUE, bitmap.getPixel(299, 299))
}
@Test fun anIconLargeEnoughForTheHomeScreenIsKept() {
val icon = image("icon.png", 256, 256, Color.GREEN)
val cover = image("cover.png", 300, 450, Color.BLUE)
val bitmap = GameShortcuts.iconBitmap(game(icon = icon, art = cover))!!
assertEquals(256, bitmap.width)
assertEquals(Color.GREEN, bitmap.getPixel(128, 128))
}
@Test fun theSmallIconIsStillUsedWhenThereIsNoArt() {
val icon = image("icon.png", 32, 32, Color.GREEN)
val bitmap = GameShortcuts.iconBitmap(game(icon = icon, art = null))!!
assertEquals(32, bitmap.width)
assertEquals(Color.GREEN, bitmap.getPixel(16, 16))
}
private fun game(icon: File?, art: File?) =
Library.SteamGame(appId = 17480, name = "Game", art = art, library = "test", icon = icon)
private fun image(name: String, width: Int, height: Int, color: Int, band: Int = 0, bandColor: Int = color): File {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(color)
if (band > 0) {
val paint = Paint().apply { this.color = bandColor }
canvas.drawRect(0f, 0f, width.toFloat(), band.toFloat(), paint)
canvas.drawRect(0f, (height - band).toFloat(), width.toFloat(), height.toFloat(), paint)
}
return File(dir, name).also { file -> file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } }
}
}