feat: store sign-in walls and the Steam home wall fill with curated featured art (#835)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-14 13:42:27 -04:00
1 parent 46fac69438
commit bf99dbbe73
12 files changed
+276 -16

No files matched your search

@@ -0,0 +1,91 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
object SteamFeatured {
class Game(val appId: Int, val name: String, val art: String)
private const val TAG = "SteamFeatured"
private const val PREFS = "steam_featured"
private const val KEY_GAMES = "games_v2"
private const val KEY_AT = "at"
private const val FRESH_MS = 6 * 60 * 60 * 1000L
private val SECTIONS = listOf("top_sellers", "new_releases", "specials")
private const val ASSET_ROOT = "https://shared.akamai.steamstatic.com/store_item_assets/"
private val HIDDEN_DESCRIPTORS = setOf(1, 3, 4)
var games by mutableStateOf<List<Game>>(emptyList())
private set
private val loading = AtomicBoolean(false)
fun load(context: Context) {
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (games.isEmpty()) parse(prefs.getString(KEY_GAMES, null))?.let { games = it }
if (System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) in 0 until FRESH_MS && games.isNotEmpty()) return
if (!loading.compareAndSet(false, true)) return
Thread({
try {
val cc = Locale.getDefault().country.takeIf { it.length == 2 } ?: "US"
val body = StoreNet.get("https://store.steampowered.com/api/featuredcategories?cc=$cc&l=english") ?: return@Thread
val root = JSONObject(body)
val ids = LinkedHashSet<Int>()
for (section in SECTIONS) {
val items = root.optJSONObject(section)?.optJSONArray("items") ?: continue
for (i in 0 until items.length()) {
val o = items.optJSONObject(i) ?: continue
val id = o.optInt("id")
if (id > 0 && o.optInt("type", 0) == 0) ids += id
}
}
val list = withArt(ids, cc)
if (list.isEmpty()) return@Thread
prefs.edit().putString(KEY_GAMES, JSONArray(list.map { JSONObject().put("id", it.appId).put("name", it.name).put("art", it.art) }).toString())
.putLong(KEY_AT, System.currentTimeMillis()).apply()
android.os.Handler(android.os.Looper.getMainLooper()).post { games = list }
Log.i(TAG, "featured games: ${list.size}")
} catch (e: Exception) {
Log.w(TAG, "featured: ${e.javaClass.simpleName}")
} finally {
loading.set(false)
}
}, "steam-featured").start()
}
private fun withArt(ids: Collection<Int>, cc: String): List<Game> {
if (ids.isEmpty()) return emptyList()
val input = JSONObject()
.put("ids", JSONArray(ids.map { JSONObject().put("appid", it) }))
.put("context", JSONObject().put("language", "english").put("country_code", cc))
.put("data_request", JSONObject().put("include_assets", true))
val body = StoreNet.get(
"https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=" + java.net.URLEncoder.encode(input.toString(), "UTF-8"),
) ?: return emptyList()
val items = JSONObject(body).optJSONObject("response")?.optJSONArray("store_items") ?: return emptyList()
return (0 until items.length()).mapNotNull { i ->
val o = items.optJSONObject(i) ?: return@mapNotNull null
val descriptors = o.optJSONArray("content_descriptorids")
if (descriptors != null && (0 until descriptors.length()).any { descriptors.optInt(it) in HIDDEN_DESCRIPTORS }) return@mapNotNull null
val assets = o.optJSONObject("assets") ?: return@mapNotNull null
val format = assets.optString("asset_url_format").takeIf { "\${FILENAME}" in it } ?: return@mapNotNull null
val file = listOf("library_capsule_2x", "library_capsule").firstNotNullOfOrNull { k -> assets.optString(k).takeIf { it.isNotEmpty() } }
?: return@mapNotNull null
Game(o.optInt("appid"), o.optString("name"), ASSET_ROOT + format.replace("\${FILENAME}", file))
}
}
private fun parse(text: String?): List<Game>? {
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.map { Game(it.optInt("id"), it.optString("name"), it.optString("art")) }
.filter { it.appId > 0 && it.art.isNotEmpty() }.takeIf { it.isNotEmpty() }
}
}
@@ -31,6 +31,7 @@ class StoreShelves(
val deals: List<CatalogItem> = emptyList(),
val free: List<CatalogItem> = emptyList(),
val trending: List<CatalogItem> = emptyList(),
val art: List<String> = emptyList(),
) {
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
@@ -55,7 +55,20 @@ object AmazonBackend : StoreBackend {
Thread({ AmazonLibrary.sync(app, force) }, "amazon-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {}
override fun loadShelves(context: Context, force: Boolean) {
val app = context.applicationContext
AmazonShelves.cached(app)?.let { s -> StoresState.post { withArt(s.art) } }
Thread({
val shelves = AmazonShelves.featured(app, force)
if (shelves != null) StoresState.post { withArt(shelves.art) }
}, "amazon-shelves").start()
}
private fun withArt(art: List<String>) {
val s = StoresState.shelves[store]
StoresState.shelves[store] = if (s == null) com.droiddeck.launcher.stores.StoreShelves(art = art)
else com.droiddeck.launcher.stores.StoreShelves(s.whatsNew, s.deals, s.free, s.trending, art)
}
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
val app = context.applicationContext
@@ -102,7 +102,7 @@ object AmazonLibrary {
StoresState.post {
StoresState.library[store] = mergeItems(StoresState.library[store], items)
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed })
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty())
}
}
@@ -0,0 +1,92 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoreShelves
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
object AmazonShelves {
private const val TAG = "AmazonShelves"
private const val PAGE = "https://luna.amazon.com/claims/home"
private const val GRAPHQL = "https://luna.amazon.com/graphql"
private const val KEY_ART = "store_art"
private const val KEY_ART_AT = "store_art_at"
private const val FRESH_MS = 3 * 60 * 60 * 1000L
private const val QUERY = "query { items(collectionType: FEATURED_CONTENT, pageSize: 50) { items { assets { title " +
"cardMedia { defaultMedia { src1x src2x } } } } } }"
@Volatile private var memory: StoreShelves? = null
fun cached(context: Context): StoreShelves? =
memory ?: fromPrefs(AmazonPrefs.get(context).getString(KEY_ART, null))?.also { memory = it }
fun featured(context: Context, force: Boolean): StoreShelves? {
val prefs = AmazonPrefs.get(context)
if (!force && System.currentTimeMillis() - prefs.getLong(KEY_ART_AT, 0L) in 0 until FRESH_MS) cached(context)?.let { return it }
val art = runCatching { fetch() }.onFailure { Log.w(TAG, "featured: ${it.javaClass.simpleName}") }.getOrNull().orEmpty()
if (art.isEmpty()) return cached(context)
prefs.edit().putString(KEY_ART, JSONArray(art).toString()).putLong(KEY_ART_AT, System.currentTimeMillis()).apply()
Log.i(TAG, "featured art: ${art.size}")
return StoreShelves(art = art).also { memory = it }
}
private fun fromPrefs(text: String?): StoreShelves? {
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
val art = List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() }
return if (art.isEmpty()) null else StoreShelves(art = art)
}
private fun fetch(): List<String> {
val (page, cookies) = openPage() ?: return emptyList()
val token = Regex("""name=['"]csrf-key['"] value=['"]([^'"]+)['"]""").find(page)?.groupValues?.get(1) ?: run {
Log.w(TAG, "claims page had no csrf key")
return emptyList()
}
val body = StoreNet.postJson(
GRAPHQL, JSONObject().put("query", QUERY).toString(),
headers = mapOf("client-id" to "CarboniteApp", "csrf-token" to token, "Cookie" to cookies, "Origin" to "https://luna.amazon.com", "Referer" to PAGE),
) ?: return emptyList()
val items = JSONObject(body).optJSONObject("data")?.optJSONObject("items")?.optJSONArray("items") ?: return emptyList()
return (0 until items.length()).mapNotNull { i ->
val media = items.optJSONObject(i)?.optJSONObject("assets")?.optJSONObject("cardMedia")?.optJSONObject("defaultMedia")
media?.optString("src2x")?.takeIf { it.isNotEmpty() } ?: media?.optString("src1x")?.takeIf { it.isNotEmpty() }
}.distinct()
}
private fun openPage(): Pair<String, String>? {
val jar = LinkedHashMap<String, String>()
var url = PAGE
repeat(5) {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 20_000
readTimeout = 20_000
instanceFollowRedirects = false
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
setRequestProperty("Accept", "text/html")
if (jar.isNotEmpty()) setRequestProperty("Cookie", jar.entries.joinToString("; ") { "${it.key}=${it.value}" })
}
try {
val code = conn.responseCode
conn.headerFields["Set-Cookie"].orEmpty().forEach { c ->
val pair = c.substringBefore(';')
val name = pair.substringBefore('=').trim()
if (name.isNotEmpty() && '=' in pair) jar[name] = pair.substringAfter('=')
}
if (code in 300..399) {
url = URL(URL(url), conn.getHeaderField("Location") ?: return null).toString()
return@repeat
}
if (code !in 200..299) { Log.w(TAG, "claims page $code"); return null }
val page = conn.inputStream.bufferedReader().use { it.readText() }
return page to jar.entries.joinToString("; ") { "${it.key}=${it.value}" }
} finally {
runCatching { conn.disconnect() }
}
}
return null
}
}
@@ -64,14 +64,16 @@ object EpicShelves {
val a = o.optJSONArray(key) ?: return emptyList()
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull(::itemFrom).distinctBy { it.id }
}
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"))
return if (s.isEmpty) null else s
val art = o.optJSONArray("art")?.let { a -> List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() } }.orEmpty()
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"), art)
return if (s.isEmpty && art.isEmpty()) null else s
}
fun toJson(s: StoreShelves): String = JSONObject().apply {
listOf(s.whatsNew, s.deals, s.free, s.trending).zip(SHELF_KEYS).forEach { (items, key) ->
put(key, JSONArray().apply { items.forEach { put(itemJson(it)) } })
}
put("art", JSONArray(s.art))
}.toString()
fun cached(context: Context): StoreShelves? =
@@ -111,6 +113,6 @@ object EpicShelves {
releaseDate = owned.releaseDate.ifBlank { shelf.releaseDate },
)
}
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
}
}
@@ -62,7 +62,7 @@ object GogBackend : StoreBackend {
private fun markOwned(s: com.droiddeck.launcher.stores.StoreShelves): com.droiddeck.launcher.stores.StoreShelves {
val owned = StoresState.library[store]?.associateBy { it.id } ?: return s
fun mark(l: List<CatalogItem>) = l.map { i -> owned[i.id]?.let { o -> i.copy(owned = true, sizeBytes = o.sizeBytes) } ?: i }
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
}
override fun install(context: Context, item: CatalogItem, root: File) {
@@ -46,10 +46,10 @@ import kotlin.math.min
import kotlin.math.sin
@Composable
internal fun EmptyLibrary(avoid: Rect?, fade: @Composable () -> Unit) {
internal fun EmptyLibrary(avoid: Rect?, wall: (@Composable () -> Unit)? = null, fade: @Composable () -> Unit) {
val clock = rememberMarkClock(avoid)
Box(Modifier.fillMaxSize().onSizeChanged { clock.page = Size(it.width.toFloat(), it.height.toFloat()) }) {
EmptyWall(clock)
if (wall != null) wall() else EmptyWall(clock)
fade()
Mark(clock)
}
@@ -68,11 +68,15 @@ import coil.transform.RoundedCornersTransformation
import kotlin.math.roundToInt
private const val WALL_MIN_GAMES = 4
private const val WALL_DISTINCT = 40
private const val DRIFT_STEP_NS = 15_000_000L
@Composable
internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
val ctx = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(Unit) { com.droiddeck.launcher.frontend.SteamFeatured.load(ctx) }
val featured = com.droiddeck.launcher.frontend.SteamFeatured.games
val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending<Library.SteamGame> { it.lastPlayed }.thenBy { it.gameId }) }
val narrow = LocalNarrowPane.current
var words by remember { mutableStateOf<Rect?>(null) }
@@ -100,9 +104,12 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
background = {
when {
games.isEmpty() -> EmptyLibrary(avoid = words) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
games.isEmpty() -> EmptyLibrary(
avoid = words,
wall = if (featured.isEmpty()) null else { { CapsuleWall(emptyList(), featured = featured) } },
) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
else -> CapsuleWall(games)
else -> CapsuleWall(games, featured = featured)
}
},
scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() },
@@ -143,10 +150,21 @@ internal fun CapsuleWall(
games: List<Library.SteamGame>,
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
modifier: Modifier = Modifier,
featured: List<com.droiddeck.launcher.frontend.SteamFeatured.Game> = emptyList(),
) {
val pal = LocalPalette.current
val blank = remember(pal) { WallCapsule(Brush.linearGradient(listOf(pal.surface, pal.background)), edge = pal.line) }
TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) }
val padding = remember(games, featured) {
val owned = games.map { it.appId }.toSet()
featured.filter { it.appId !in owned }.take((WALL_DISTINCT - games.size).coerceAtLeast(0))
}
TiltedWall(games.size + padding.size, driftMs, modifier) { i ->
when {
i < games.size -> WallCapsule(artBrush(hueOf(games[i].name)), games[i].art)
i - games.size < padding.size -> padding[i - games.size].let { WallCapsule(artBrush(hueOf(it.name)), it.art) }
else -> blank
}
}
}
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null, val icon: Any? = null, val bar: Color? = null)
@@ -649,8 +649,14 @@ private fun SignInCard(store: Store, height: Dp) {
val c = sourceColours(store.id)
LaunchedEffect(store) { StoresState.preview(ctx, store) }
val art = remember(StoresState.shelves[store]) {
StoresState.shelves[store]?.all.orEmpty().filter { !it.mature }.mapNotNull { it.tallImageUrl ?: it.imageUrl }.distinct().take(48)
.takeIf { it.size >= 8 }.orEmpty()
val shelves = StoresState.shelves[store]
val urls = (shelves?.art?.takeIf { it.isNotEmpty() }
?: shelves?.all.orEmpty().filter { !it.mature && !it.owned }.mapNotNull { it.tallImageUrl ?: it.imageUrl }).distinct().take(48)
when {
urls.size >= 8 -> urls
urls.size >= 4 -> urls + urls.drop(urls.size / 2) + urls.take(urls.size / 2)
else -> emptyList()
}
}
val shape = RoundedCornerShape(20.dp)
val blank = WallCapsule(Brush.linearGradient(listOf(c.fill, colors.background)), edge = c.dot.copy(alpha = 0.22f))
+1 -1
View File
@@ -155,7 +155,7 @@ synchronous on the calling thread (network calls block: call them off the main t
| `build.info` | `accessToken`, `namespace`, `catalogItemId`, `appName` | `info`: the opaque build description (`manifest`, `buildVersion`, `deploymentId`, `cdns[]` of `base`/`dir`/`auth`) |
| `build.cdns` | `info` | `cdns[]` |
| `build.prereq` | `info` | `name`, `path`, `read` (downloads and parses the manifest) |
| `shop.shelves` | `country` | `new`, `deals`, `free`, `trending`: arrays in the `store_featured` item shape plus `allTags` |
| `shop.shelves` | `country` | `new`, `deals`, `free`, `trending`: arrays in the `store_featured` item shape plus `allTags`; `art`: cover URLs from every promotion, for the signed-out wall |
| `achievements` | `accessToken`, `namespace`, `accountId`, `locale` | `list[]` of `id`, `name`, `description`, `hidden`, `unlocked`, `unlockedAt` (s), `icon`, `iconLocked` |
| `cloud.list` | `accessToken`, `accountId`, `appName` | `files[]` of `name`, `modified` (ms, -1 unknown), `hash`, `readLink` |
| `cloud.writeLinks` | `accessToken`, `accountId`, `appName`, `names[]` | `links`: name → write link |
+39 -2
View File
@@ -232,6 +232,28 @@ fn unique(items: impl IntoIterator<Item = Value>) -> Vec<Value> {
items.into_iter().filter(|i| seen.insert(net::str_of(i, "id").to_string())).collect()
}
const MATURE_TAGS: [&str; 8] = ["mature", "nsfw", "adult", "adult only", "adults only", "nudity", "sexual content", "sexual-content"];
fn mature_tags(item: &Value) -> bool {
item["allTags"].as_array().is_some_and(|tags| {
tags.iter().filter_map(|t| t.as_str()).any(|t| MATURE_TAGS.contains(&t.trim().to_lowercase().as_str()))
})
}
pub fn wall_art(promotion_list: &[Value]) -> Vec<String> {
let mut seen = HashSet::new();
promotion_list
.iter()
.filter_map(item_of)
.filter(|i| !mature_tags(i))
.map(|i| {
let tall = net::str_of(&i, "tall");
if tall.is_empty() { net::str_of(&i, "image").to_string() } else { tall.to_string() }
})
.filter(|u| !u.is_empty() && seen.insert(u.clone()))
.collect()
}
pub fn shelves(country: &str) -> Value {
let country = country_code(country);
let now = now_stamp();
@@ -240,10 +262,12 @@ pub fn shelves(country: &str) -> Value {
};
let new = unique(shelf(Shelf::New));
let deals = unique(shelf(Shelf::Deals).into_iter().filter(|i| i["disc"].as_i64().unwrap_or(0) > 0));
let promos: Vec<Value> = promotions(&country).iter().filter_map(free_now).collect();
let promotion_list = promotions(&country);
let art = wall_art(&promotion_list);
let promos: Vec<Value> = promotion_list.iter().filter_map(free_now).collect();
let free = unique(promos.into_iter().chain(shelf(Shelf::Free)));
let trending = unique(shelf(Shelf::Trending));
json!({ "new": new, "deals": deals, "free": free, "trending": trending })
json!({ "new": new, "deals": deals, "free": free, "trending": trending, "art": art })
}
#[cfg(test)]
@@ -304,6 +328,19 @@ mod tests {
assert!(free_now(&el).is_none());
}
#[test]
fn wall_art_takes_every_promotion_once_and_skips_mature() {
let mut clean = element();
clean["tags"] = json!([{"name": "Action"}]);
let mut wide_only = clean.clone();
wide_only["id"] = json!("offer2");
wide_only["keyImages"] = json!([{"type": "OfferImageWide", "url": "w2"}]);
let mut mature = element();
mature["id"] = json!("offer3");
mature["keyImages"] = json!([{"type": "DieselStoreFrontTall", "url": "m"}]);
assert_eq!(wall_art(&[clean.clone(), clean, wide_only, mature]), vec!["tl", "w2"]);
}
#[test]
fn query_and_dates() {
assert_eq!(country_code("de"), "DE");