Merge remote-tracking branch 'origin/main' into feat/viture-xr

# Conflicts:
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-fr/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-ko/strings.xml
#	app/src/main/res/values-ru/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values-zh-rHK/strings.xml
#	app/src/main/res/values-zh-rTW/strings.xml
This commit is contained in:
maxjivi05 committed 2026-10-13 12:27:22 -04:00
commit f0b329768e
28 files changed
+782 -27

No files matched your search

+1 -1
View File
@@ -628,7 +628,7 @@ jobs:
# Each signed main build as a prerelease of the public Droid-Deck/DroidDeck-CI (main-<sha>, the
# last KEEP kept), where anyone can download it without a GitHub account, then a post in the
# Discord's #preview channel linking to it. The release is made with a token of the org's
# Discord's #preview-builds channel linking to it. The release is made with a token of the org's
# "DroidDeck CI Publisher" app (installed on DroidDeck-CI only, Contents: write): its ID in the
# DROIDDECK_CI_APP_ID variable, its private key in the DROIDDECK_CI_APP_KEY secret.
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL. Keep this secret name when renaming
@@ -0,0 +1,263 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.URLEncoder
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.concurrent.ConcurrentHashMap
/**
* What other sites know of a store game, looked up by its title: the Metacritic score (as Steam's
* store search carries it) and HowLongToBeat's times. Neither store publishes these, so every
* lookup is a name match against someone else's catalog, kept on disk a fortnight (a miss for two
* days) and in memory for the process. Blocking: call off the main thread.
*/
object GameFacts {
private const val TAG = "GameFacts"
private const val HIT_TTL_MS = 14L * 24 * 60 * 60 * 1000
private const val MISS_TTL_MS = 2L * 24 * 60 * 60 * 1000
private const val TIMEOUT_MS = 10_000
/** Metacritic's critic score (0-100) and the game's page there. */
data class Metacritic(val score: Int, val url: String)
/** HowLongToBeat's community times, in seconds (0 when nobody has logged that kind of run), and the game's page there. */
data class Hltb(val main: Int, val extras: Int, val completionist: Int, val url: String) {
val isEmpty: Boolean get() = main <= 0 && extras <= 0 && completionist <= 0
}
/** A remembered answer: [value] null is a remembered miss. */
private class Entry<T>(val value: T?, val at: Long) {
fun fresh(now: Long) = now - at < if (value != null) HIT_TTL_MS else MISS_TTL_MS
}
private val memory = ConcurrentHashMap<String, Entry<*>>()
/** The score or the times already in memory, without touching disk or network; null when not yet known. */
fun peekMetacritic(title: String): Lookup<Metacritic>? = peek("metacritic", title)
fun peekHltb(title: String): Lookup<Hltb>? = peek("hltb", title)
/** The score, or null when Steam does not list the game or has no score for it. */
fun metacritic(context: Context, title: String): Metacritic? =
remembered(context, "metacritic", title,
read = { o -> o.optInt("score").takeIf { it > 0 }?.let { Metacritic(it, o.optString("url").ifEmpty { metacriticSearch(title) }) } },
write = { o, v -> o.put("score", v.score).put("url", v.url) },
) { searchSteamScore(title) }
fun hltb(context: Context, title: String): Hltb? =
remembered(context, "hltb", title,
read = { o -> Hltb(o.optInt("main"), o.optInt("extras"), o.optInt("completionist"), o.optString("url").ifEmpty { hltbSearch(title) }).takeUnless { it.isEmpty } },
write = { o, v -> o.put("main", v.main).put("extras", v.extras).put("completionist", v.completionist).put("url", v.url) },
) { searchHltb(title) }
// Where a game goes when its own page is not known: the site's search for its name.
private fun metacriticSearch(title: String) = "https://www.metacritic.com/search/${encodePath(TitleMatch.searchTerm(title))}/?category=13"
private fun hltbSearch(title: String) = "$HLTB/?q=${URLEncoder.encode(TitleMatch.searchTerm(title), "UTF-8")}"
private fun encodePath(s: String) = URLEncoder.encode(s, "UTF-8").replace("+", "%20")
@Suppress("UNCHECKED_CAST")
private fun <T> peek(kind: String, title: String): Lookup<T>? {
val e = memory[key(kind, title)] as Entry<T>? ?: return null
return e.value?.let { Lookup.Found(it) } ?: Lookup.None
}
/**
* The memory copy, else the disk copy while fresh, else [fetch]. [fetch] answers a [Fetched]:
* a failed request is not a miss, so it leaves the stale copy (if any) standing.
*/
@Suppress("UNCHECKED_CAST")
private fun <T : Any> remembered(
context: Context, kind: String, title: String,
read: (JSONObject) -> T?, write: (JSONObject, T) -> Unit, fetch: () -> Fetched<T>,
): T? {
val now = System.currentTimeMillis()
val k = key(kind, title)
(memory[k] as Entry<T>?)?.takeIf { it.fresh(now) }?.let { return it.value }
val file = File(context.filesDir, "game-facts/$kind/${fileName(title)}.json")
val disk = runCatching { JSONObject(file.readText()) }.getOrNull()
?.let { o -> Entry(if (o.optBoolean("none")) null else read(o), o.optLong("at")) }
if (disk != null && disk.fresh(now)) { memory[k] = disk; return disk.value }
return when (val got = fetch()) {
is Fetched.Failed -> disk?.value
is Fetched.Answer -> {
val entry = Entry(got.value, now)
memory[k] = entry
runCatching {
file.parentFile?.mkdirs()
val o = JSONObject().put("at", now)
if (got.value != null) write(o, got.value) else o.put("none", true)
file.writeText(o.toString())
}
got.value
}
}
}
private sealed interface Fetched<out T> {
object Failed : Fetched<Nothing>
class Answer<T>(val value: T?) : Fetched<T>
}
private fun key(kind: String, title: String) = "$kind:${TitleMatch.normalize(title)}"
private fun fileName(title: String) = TitleMatch.normalize(title).take(80).ifEmpty { "_" + title.hashCode().toUInt() }
// Steam's store search lists a game's Metacritic score beside its name: one request, no key.
// The game's own Metacritic page is in its app details, asked for only once there is a score.
private fun searchSteamScore(title: String): Fetched<Metacritic> {
val term = URLEncoder.encode(TitleMatch.searchTerm(title), "UTF-8")
val body = StoreNet.get("https://store.steampowered.com/api/storesearch/?l=english&cc=US&term=$term", timeoutMs = TIMEOUT_MS)
?: return Fetched.Failed
return runCatching {
val items = JSONObject(body).optJSONArray("items") ?: JSONArray()
val scored = (0 until items.length()).mapNotNull { items.optJSONObject(it) }
.filter { it.optString("type") == "app" && (it.optString("metascore").toIntOrNull() ?: 0) > 0 }
val best = TitleMatch.pick(title, scored) { listOf(it.optString("name")) }
val score = best?.optString("metascore")?.toIntOrNull() ?: return Fetched.Answer(null)
Fetched.Answer(Metacritic(score, metacriticPage(best.optLong("id")) ?: metacriticSearch(title)))
}.getOrElse { Fetched.Failed }
}
/** Steam's link to the game on Metacritic, without its tracking tag; null when Steam gives none. */
private fun metacriticPage(appId: Long): String? {
if (appId <= 0) return null
val body = StoreNet.get("https://store.steampowered.com/api/appdetails?appids=$appId&filters=metacritic", timeoutMs = TIMEOUT_MS) ?: return null
return runCatching {
JSONObject(body).optJSONObject("$appId")?.optJSONObject("data")?.optJSONObject("metacritic")?.optString("url")
?.substringBefore('?')?.takeIf { it.startsWith("https://") }
}.getOrNull()
}
// HowLongToBeat's own site search: a short-lived token from init, sent back as x-auth-token.
// The token is bound to the User-Agent that asked for it, so both calls send the same one.
private const val HLTB = "https://howlongtobeat.com"
private val hltbHeaders = mapOf("Referer" to "$HLTB/", "Origin" to HLTB)
@Volatile private var hltbToken: Pair<String, Long>? = null
private fun hltbToken(renew: Boolean): String? {
val now = System.currentTimeMillis()
hltbToken?.takeIf { !renew && now - it.second < 10 * 60 * 1000 }?.let { return it.first }
val body = StoreNet.get("$HLTB/api/search/site/init?t=$now", timeoutMs = TIMEOUT_MS, headers = hltbHeaders) ?: return null
val token = runCatching { JSONObject(body).optString("token") }.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
hltbToken = token to now
return token
}
private fun searchHltb(title: String): Fetched<Hltb> {
val terms = JSONArray().apply { TitleMatch.searchTerm(title).split(' ').filter { it.isNotBlank() }.forEach { put(it) } }
val request = JSONObject()
.put("searchType", "games").put("searchTerms", terms).put("searchPage", 1).put("size", 20)
.put("searchOptions", JSONObject()
.put("games", JSONObject().put("userId", 0).put("platform", "").put("sortCategory", "popular").put("rangeCategory", "main")
.put("rangeTime", JSONObject().put("min", 0).put("max", 0))
.put("gameplay", JSONObject().put("perspective", "").put("flow", "").put("genre", ""))
.put("year", "").put("modifier", ""))
.put("users", JSONObject().put("sortCategory", "postcount"))
.put("lists", JSONObject().put("sortCategory", "follows"))
.put("filter", "").put("sort", 0).put("randomizer", 0))
.put("useCache", true)
.toString()
// A token can lapse between init and search; one fresh one is worth a second try.
var body: String? = null
for (renew in listOf(false, true)) {
val token = hltbToken(renew) ?: return Fetched.Failed
body = StoreNet.postJson("$HLTB/api/search/site", request, timeoutMs = TIMEOUT_MS, headers = hltbHeaders + ("x-auth-token" to token))
if (body != null) break
}
if (body == null) return Fetched.Failed
return runCatching {
val data = JSONObject(body).optJSONArray("data") ?: JSONArray()
val games = (0 until data.length()).mapNotNull { data.optJSONObject(it) }
.sortedByDescending { it.optString("game_type") == "game" }
val best = TitleMatch.pick(title, games) { g -> listOf(g.optString("game_name")) + g.optString("game_alias").split(',') }
Fetched.Answer(best?.let {
val page = it.optInt("game_id").takeIf { id -> id > 0 }?.let { id -> "$HLTB/game/$id" } ?: hltbSearch(title)
Hltb(it.optInt("comp_main"), it.optInt("comp_plus"), it.optInt("comp_100"), page)
}?.takeUnless { it.isEmpty })
}.getOrElse {
Log.w(TAG, "HowLongToBeat answer unreadable: ${it.javaClass.simpleName}")
Fetched.Failed
}
}
/** A store's release date ("2015.05.19", "2015-05-19T00:00:00+02:00", ...) in the reader's own words, or null. */
fun releaseLabel(raw: String): String? {
val m = Regex("""^(\d{4})[.\-/](\d{2})[.\-/](\d{2})""").find(raw.trim()) ?: return null
val (y, mo, d) = m.destructured
// Epic dates a title it has no date for 2099-01-01.
val date = runCatching { LocalDate.of(y.toInt(), mo.toInt(), d.toInt()) }.getOrNull()?.takeIf { it.year in 1970..2098 } ?: return null
return date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
}
/** Whole hours, with a half when the time is nearer one: "51½". Under an hour, null (the caller says minutes). */
fun hours(seconds: Int): String? {
if (seconds < 3600) return null
val halves = Math.round(seconds / 1800.0).toInt()
return "${halves / 2}" + if (halves % 2 == 1) "½" else ""
}
}
/** A fact looked up off the page: still asking, found, or not to be had. */
sealed interface Lookup<out T> {
object Pending : Lookup<Nothing>
object None : Lookup<Nothing>
data class Found<T>(val value: T) : Lookup<T>
}
/**
* Whether two catalogs mean the same game. Store titles carry marks, editions and subtitles the
* other site spells differently ("The Witcher 3: Wild Hunt" is Steam's "...Wild Hunt — Remastered");
* a sequel is never the same game ("Hades" is not "Hades II").
*/
internal object TitleMatch {
private val MARKS = Regex("[™®©]")
private val EDITION = Regex(
"(?i)[\\s:\\-–—]*\\b(?:(?:complete|definitive|legendary|deluxe|ultimate|enhanced|gold|premium|standard|special|anniversary|collector'?s|digital deluxe|game of the year|goty)\\s+edition|goty|director'?s cut)\\b",
)
// Where a subtitle starts: a colon or dash with space around it, or an opening bracket.
private val SUBTITLE = Regex("\\s*[:–—]\\s*|\\s+-\\s+|\\s*\\(")
fun normalize(s: String): String = s.lowercase().replace(MARKS, "").replace("&", "and").replace(Regex("[^\\p{L}\\p{N}]"), "")
fun stripEdition(s: String): String = s.replace(EDITION, " ").trim().ifEmpty { s }
/**
* What to type into another site's search: the title without its marks, edition or subtitle
* punctuation (Steam finds nothing for "Disco Elysium - The Final Cut", all of it for the words).
*/
fun searchTerm(title: String): String =
stripEdition(title.replace(MARKS, "")).replace(Regex("\\s+[-–—]\\s+|[:–—()\\[\\]]"), " ").replace(Regex("\\s+"), " ").trim()
/** 3 the same name, 2 the same but for the edition, 1 one is the other with a subtitle, 0 not the same game. */
fun score(want: String, got: String): Int {
val w = normalize(want)
val g = normalize(got)
if (w.isEmpty() || g.isEmpty()) return 0
if (w == g) return 3
val ws = normalize(stripEdition(want))
val gs = normalize(stripEdition(got))
if (ws == gs || ws == g || w == gs) return 2
if (ws in heads(got) || gs in heads(want)) return 1
return 0
}
/** The title up to each place a subtitle could start, normalized. */
private fun heads(title: String): Set<String> =
SUBTITLE.findAll(title).map { normalize(title.substring(0, it.range.first)) }.filter { it.isNotEmpty() }.toSet()
/** The best-named of [items] in their order (the site's own ranking breaks a tie), or null when none is the game. */
fun <T> pick(want: String, items: List<T>, names: (T) -> List<String>): T? {
var best: T? = null
var bestScore = 0
for (item in items) {
val s = names(item).filter { it.isNotBlank() }.maxOfOrNull { score(want, it.trim()) } ?: 0
if (s > bestScore) { best = item; bestScore = s }
}
return best
}
}
@@ -26,6 +26,7 @@ data class CatalogItem(
/** The web product page: where "Get for free" and "View on <store>" land. */
val storeUrl: String = "",
val developer: String = "",
val publisher: String = "",
val releaseDate: String = "",
val description: String = "",
/** True for a title the signed-in account owns (a library entry). */
@@ -41,7 +41,7 @@ internal object StoreNet {
}
}
fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000): String? {
fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000, headers: Map<String, String> = emptyMap()): String? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
@@ -53,6 +53,7 @@ internal object StoreNet {
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "application/json")
if (bearer != null) setRequestProperty("Authorization", "Bearer $bearer")
headers.forEach { (k, v) -> setRequestProperty(k, v) }
}
OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(body) }
val code = conn.responseCode
@@ -103,7 +103,11 @@ public final class EpicApiClient {
if (attrs != null) {
JSONObject offline = attrs.optJSONObject("CanRunOffline");
if (offline != null) game.canRunOffline = !"false".equalsIgnoreCase(offline.optString("value", "true"));
JSONObject publisher = attrs.optJSONObject("publisherName");
if (publisher != null) game.publisher = publisher.optString("value", "").trim();
}
JSONObject seller = item.optJSONObject("seller");
if (game.publisher.isEmpty() && seller != null) game.publisher = seller.optString("name", "").trim();
String rd = item.optString("viewableDate", "");
if (rd.isEmpty()) rd = item.optString("effectiveDate", "");
if (!rd.isEmpty()) game.releaseDate = rd;
@@ -80,7 +80,12 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
val byCatalogItem = owned.associateBy { it.extra["catalogItemId"] ?: "" }
fun mark(l: List<CatalogItem>) = l.map { i ->
val match = i.extra["items"]?.split(',')?.firstNotNullOfOrNull { byCatalogItem[it] }
if (match != null) match.copy(imageUrl = i.imageUrl ?: match.imageUrl, tallImageUrl = i.tallImageUrl ?: match.tallImageUrl) else i
// The offer's art, and its facts where the library's catalog record has none.
if (match != null) match.copy(
imageUrl = i.imageUrl ?: match.imageUrl, tallImageUrl = i.tallImageUrl ?: match.tallImageUrl,
developer = match.developer.ifBlank { i.developer }, publisher = match.publisher.ifBlank { i.publisher },
releaseDate = match.releaseDate.ifBlank { i.releaseDate },
) else i
}
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
}
@@ -22,4 +22,5 @@ public class EpicGame {
public boolean isDLC = false;
public String baseGameCatalogItemId = "";
public String releaseDate = "";
public String publisher = "";
}
@@ -44,6 +44,8 @@ object EpicLibrary {
g.developer = j.optString("developer", ""); g.description = j.optString("description", ""); g.version = j.optString("version", "")
g.installSize = j.optLong("installSize", 0L).let { if (it > 1_099_511_627_776L) 0L else it }
g.canRunOffline = j.optBoolean("canRunOffline", true)
g.releaseDate = j.optString("releaseDate", "")
g.publisher = j.optString("publisher", "")
if (g.appName.isNotEmpty()) list.add(g)
}
list.distinctBy { it.appName }.sortedBy { it.title.lowercase() }
@@ -74,7 +76,6 @@ object EpicLibrary {
var done = 0
for (game in raw) {
EpicApiClient.enrichFromCatalog(token, game)
if (game.releaseDate.isNotEmpty()) p.edit().putString("release_${game.appName}", game.releaseDate).apply()
done++
if (done % 5 == 0) onStatus("Loading game details… ($done/$total)")
}
@@ -102,7 +103,8 @@ object EpicLibrary {
val arr = JSONArray()
for (g in games) arr.put(JSONObject().put("appName", g.appName).put("namespace", g.namespace).put("catalogItemId", g.catalogItemId)
.put("title", g.title).put("artCover", g.artCover).put("artSquare", g.artSquare).put("developer", g.developer)
.put("description", g.description).put("version", g.version).put("installSize", g.installSize).put("canRunOffline", g.canRunOffline))
.put("description", g.description).put("version", g.version).put("installSize", g.installSize).put("canRunOffline", g.canRunOffline)
.put("releaseDate", g.releaseDate).put("publisher", g.publisher))
prefs.edit().putString(CACHE_KEY, arr.toString()).apply()
}
}
@@ -111,7 +113,9 @@ object EpicLibrary {
store = Store.EPIC, id = g.appName, title = g.title.ifBlank { g.appName },
imageUrl = g.artSquare.ifBlank { null } ?: g.artCover.ifBlank { null },
tallImageUrl = g.artCover.ifBlank { null } ?: g.artSquare.ifBlank { null },
developer = g.developer, description = com.droiddeck.launcher.stores.cleanStoreText(g.description), owned = true,
developer = g.developer, publisher = g.publisher, description = com.droiddeck.launcher.stores.cleanStoreText(g.description), owned = true,
// A cache from before the release date was kept still has it under the older per-game key.
releaseDate = g.releaseDate.ifEmpty { EpicPrefs.get(context).getString("release_${g.appName}", "").orEmpty() },
sizeBytes = EpicPrefs.get(context).getLong("size_${g.appName}", g.installSize),
storeUrl = "https://store.epicgames.com/en-US/browse?q=${java.net.URLEncoder.encode(g.title, "UTF-8")}",
extra = mapOf("namespace" to g.namespace, "catalogItemId" to g.catalogItemId, "version" to (g.version ?: "")),
@@ -32,7 +32,7 @@ object EpicStoreCatalog {
private const val ELEMENT_FIELDS =
"title id namespace description effectiveDate releaseDate offerType productSlug urlSlug " +
"items{id namespace} keyImages{type url} seller{name} tags{name} " +
"items{id namespace} keyImages{type url} seller{name} tags{name} customAttributes{key value} " +
"catalogNs{mappings(pageType:\"productHome\"){pageSlug pageType}} " +
"price(country:\$country){totalPrice{discountPrice originalPrice discount currencyCode " +
"fmtPrice(locale:\$locale){originalPrice discountPrice}}}"
@@ -116,13 +116,21 @@ object EpicStoreCatalog {
return CatalogItem(
store = Store.EPIC, id = id, title = title, imageUrl = wide ?: thumb, tallImageUrl = tall ?: thumb, tags = tagLine,
isFree = isFree, hasPrice = hasPrice, finalPrice = finalPrice, originalPrice = if (discountPct > 0) fmtOriginal else "", discountPercent = discountPct,
storeUrl = storeUrl, developer = e.optJSONObject("seller")?.optString("name", "").orEmpty(),
storeUrl = storeUrl, developer = attribute(e, "developerName"),
publisher = attribute(e, "publisherName").ifEmpty { e.optJSONObject("seller")?.optString("name", "").orEmpty() },
releaseDate = e.optString("releaseDate", e.optString("effectiveDate", "")), description = com.droiddeck.launcher.stores.cleanStoreText(e.optString("description", "")),
extra = buildMap { put("namespace", ns); if (itemIds.isNotEmpty()) put("items", itemIds.joinToString(",")); if (slug.isNotBlank()) put("slug", slug) },
mature = com.droiddeck.launcher.stores.matureByTags(allTags),
)
}
// An offer's custom attribute (developerName, publisherName), or "".
private fun attribute(e: JSONObject, key: String): String {
val attrs = e.optJSONArray("customAttributes") ?: return ""
return (0 until attrs.length()).mapNotNull { attrs.optJSONObject(it) }.firstOrNull { it.optString("key") == key }
?.optString("value", "")?.trim().orEmpty()
}
private fun pageSlugOf(e: JSONObject): String {
e.optJSONObject("catalogNs")?.optJSONArray("mappings")?.let { m ->
for (i in 0 until m.length()) { val map = m.optJSONObject(i) ?: continue; if (map.optString("pageType") == "productHome") { val s = map.optString("pageSlug", ""); if (s.isNotBlank()) return s } }
@@ -193,7 +201,7 @@ object EpicStoreCatalog {
private fun itemToJson(i: CatalogItem): JSONObject = JSONObject().apply {
put("id", i.id); put("title", i.title); put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: ""); put("tags", i.tags)
put("free", i.isFree); put("hasPrice", i.hasPrice); put("final", i.finalPrice); put("orig", i.originalPrice); put("disc", i.discountPercent)
put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate); put("desc", i.description); put("extra", JSONObject(i.extra as Map<*, *>))
put("url", i.storeUrl); put("dev", i.developer); put("pub", i.publisher); put("rel", i.releaseDate); put("desc", i.description); put("extra", JSONObject(i.extra as Map<*, *>))
if (i.mature) put("mature", true)
}
@@ -203,7 +211,7 @@ object EpicStoreCatalog {
return CatalogItem(
store = Store.EPIC, id = o.optString("id"), title = o.optString("title"), imageUrl = o.optString("image").ifBlank { null }, tallImageUrl = o.optString("tall").ifBlank { null },
tags = o.optString("tags"), isFree = o.optBoolean("free"), hasPrice = o.optBoolean("hasPrice"), finalPrice = o.optString("final"), originalPrice = o.optString("orig"),
discountPercent = o.optInt("disc"), storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"), description = o.optString("desc"), extra = extra,
discountPercent = o.optInt("disc"), storeUrl = o.optString("url"), developer = o.optString("dev"), publisher = o.optString("pub"), releaseDate = o.optString("rel"), description = o.optString("desc"), extra = extra,
mature = o.optBoolean("mature"),
)
}
@@ -71,7 +71,7 @@ object GogBackend : StoreBackend {
override fun install(context: Context, item: CatalogItem, root: File) {
val app = context.applicationContext
val game = GogGame(item.id, item.title, item.imageUrl ?: "", item.description, item.developer, item.tags, 2, item.tallImageUrl)
val game = GogGame(item.id, item.title, item.imageUrl ?: "", item.description, item.developer, item.tags, 2, item.tallImageUrl, item.releaseDate, item.publisher)
val folder = StoreInstallRoot.folderFor(app, Store.GOG, game.gameId, game.title, root)
val entry = DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))
DownloadQueue.enqueue(app, entry) { InstallJob(app, game, item, folder) }
@@ -12,9 +12,12 @@ public class GogGame {
public final int generation;
/** gamesdb.gog.com 2:3 box art, or null when the lookup found none. */
public final String verticalCover;
/** ISO date-time of the worldwide release, or "" when GOG gave none. */
public final String releaseDate;
public final String publisher;
public GogGame(String gameId, String title, String imageUrl, String description, String developer,
String category, int generation, String verticalCover) {
String category, int generation, String verticalCover, String releaseDate, String publisher) {
this.gameId = gameId;
this.title = title;
this.imageUrl = imageUrl;
@@ -23,5 +26,7 @@ public class GogGame {
this.category = category;
this.generation = generation;
this.verticalCover = verticalCover;
this.releaseDate = releaseDate;
this.publisher = publisher;
}
}
@@ -45,7 +45,8 @@ object GogLibrary {
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
GogGame(o.optString("gameId", ""), o.optString("title", ""), o.optString("imageUrl", ""), cleanStoreText(o.optString("description", "")),
o.optString("developer", ""), o.optString("category", ""), o.optInt("generation", 1), o.optString("verticalCover", "").ifEmpty { null })
o.optString("developer", ""), o.optString("category", ""), o.optInt("generation", 1), o.optString("verticalCover", "").ifEmpty { null },
o.optString("releaseDate", ""), o.optString("publisher", ""))
}.filter { it.gameId.isNotEmpty() }.sortedBy { it.title.lowercase() }
}.getOrDefault(emptyList())
}
@@ -148,7 +149,15 @@ object GogLibrary {
val desc = cleanStoreText(prod.optJSONObject("description")?.optString("lead", ""))
.ifEmpty { cleanStoreText(v2?.optString("overview", "")) }
.ifEmpty { cleanStoreText(v2?.optString("description", "")) }
val developer = prod.optJSONObject("developers")?.optString("name", "") ?: prod.optString("developer", "")
// The products endpoint names no developer or publisher; the v2 record does, with the worldwide release date.
val embedded = v2?.optJSONObject("_embedded")
fun names(key: String) = embedded?.optJSONArray(key)
?.let { arr -> (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("name")?.takeIf(String::isNotBlank) } }
?.joinToString(", ").orEmpty()
val developer = names("developers")
val publisher = embedded?.optJSONObject("publisher")?.optString("name")?.takeIf(String::isNotBlank) ?: names("publishers")
val releaseDate = listOf(embedded?.optJSONObject("product")?.optString("globalReleaseDate"), prod.optString("release_date"))
.firstOrNull { !it.isNullOrBlank() && it != "null" }.orEmpty()
val genres = prod.optJSONArray("genres")
val category = if (genres != null && genres.length() > 0) genres.optJSONObject(0)?.optString("name", "") ?: "" else ""
@@ -164,9 +173,7 @@ object GogLibrary {
if (maxGen > 0) generation = maxGen
}
}
val ed = prefs.edit().putInt("gen_$id", generation)
prod.optString("release_date", "").takeIf { it.isNotEmpty() }?.let { ed.putString("release_$id", it) }
ed.apply()
prefs.edit().putInt("gen_$id", generation).apply()
if (prefs.getLong("size_$id", -1L) <= 0) {
val size = GogDownloadManager.fetchInstallSizeBytes(id, token)
if (size > 0) prefs.edit().putLong("size_$id", size).apply()
@@ -182,7 +189,7 @@ object GogLibrary {
verticalCover = fetchVerticalCover(id).ifEmpty { StoreNet.sgdbPoster(title) }
}
if (!verticalCover.isNullOrEmpty()) prefs.edit().putString("vcover_$id", verticalCover).apply()
return GogGame(id, title, GogStoreCatalog.absolutize(imageUrl), desc, developer, category, generation, verticalCover?.ifEmpty { null })
return GogGame(id, title, GogStoreCatalog.absolutize(imageUrl), desc, developer, category, generation, verticalCover?.ifEmpty { null }, releaseDate, publisher)
} catch (_: Exception) {
return null
}
@@ -203,7 +210,7 @@ object GogLibrary {
val arr = JSONArray()
for (g in games) {
arr.put(JSONObject().put("gameId", g.gameId).put("title", g.title).put("imageUrl", g.imageUrl).put("description", g.description)
.put("developer", g.developer).put("category", g.category).put("generation", g.generation)
.put("developer", g.developer).put("category", g.category).put("generation", g.generation).put("releaseDate", g.releaseDate).put("publisher", g.publisher)
.apply { if (!g.verticalCover.isNullOrEmpty()) put("verticalCover", g.verticalCover) })
}
prefs.edit().putString(CACHE_KEY, arr.toString()).apply()
@@ -215,7 +222,9 @@ object GogLibrary {
store = Store.GOG, id = g.gameId, title = g.title,
imageUrl = GogStoreCatalog.absolutize(g.imageUrl).ifBlank { null },
tallImageUrl = GogStoreCatalog.absolutize(g.verticalCover ?: g.imageUrl).ifBlank { null },
tags = g.category, developer = g.developer, description = g.description, owned = true,
tags = g.category, developer = g.developer, publisher = g.publisher, description = g.description, owned = true,
// A cache from before the release date was kept still has it under the older per-game key.
releaseDate = g.releaseDate.ifEmpty { GogPrefs.get(context).getString("release_${g.gameId}", "").orEmpty() },
sizeBytes = sizeOf(context, g.gameId),
storeUrl = "https://www.gog.com/en/game/${g.gameId}",
)
@@ -133,6 +133,7 @@ object GogStoreCatalog {
finalPrice = finalStr, originalPrice = if (discount > 0) baseStr else "",
discountPercent = if (discount > 0 && baseStr.isNotBlank()) discount else 0,
storeUrl = storeLink, developer = p.optJSONArray("developers")?.optString(0, "").orEmpty(),
publisher = p.optJSONArray("publishers")?.optString(0, "").orEmpty(),
releaseDate = p.optString("releaseDate", ""),
extra = buildMap { if (slug.isNotBlank()) put("slug", slug) },
mature = mature,
@@ -175,7 +176,7 @@ object GogStoreCatalog {
private fun itemToJson(i: CatalogItem): JSONObject = JSONObject().apply {
put("id", i.id); put("title", i.title); put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
put("tags", i.tags); put("free", i.isFree); put("hasPrice", i.hasPrice); put("final", i.finalPrice); put("orig", i.originalPrice)
put("disc", i.discountPercent); put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate)
put("disc", i.discountPercent); put("url", i.storeUrl); put("dev", i.developer); put("pub", i.publisher); put("rel", i.releaseDate)
if (i.mature) put("mature", true)
}
@@ -184,7 +185,7 @@ object GogStoreCatalog {
imageUrl = o.optString("image").ifBlank { null }, tallImageUrl = o.optString("tall").ifBlank { null },
tags = o.optString("tags"), isFree = o.optBoolean("free"), hasPrice = o.optBoolean("hasPrice"),
finalPrice = o.optString("final"), originalPrice = o.optString("orig"), discountPercent = o.optInt("disc"),
storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"),
storeUrl = o.optString("url"), developer = o.optString("dev"), publisher = o.optString("pub"), releaseDate = o.optString("rel"),
mature = o.optBoolean("mature"),
)
@@ -0,0 +1,291 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.GameFacts
import com.droiddeck.launcher.stores.Lookup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// A store game's facts under its hero: who made it and when, from the store; the Metacritic score
// and HowLongToBeat's times, looked up elsewhere by name. The store's facts are there at once; the
// looked-up ones hold their place while they come and step out if there is nothing to show.
internal class GameScores(val metacritic: Lookup<GameFacts.Metacritic>, val hltb: Lookup<GameFacts.Hltb>)
/** Both lookups at once and off the main thread, each landing on its own; what memory already has shows without a wait. */
@Composable
internal fun rememberGameScores(title: String?): GameScores {
val ctx = LocalContext.current.applicationContext
var metacritic by remember(title) { mutableStateOf(title?.let { GameFacts.peekMetacritic(it) } ?: if (title == null) Lookup.None else Lookup.Pending) }
var hltb by remember(title) { mutableStateOf(title?.let { GameFacts.peekHltb(it) } ?: if (title == null) Lookup.None else Lookup.Pending) }
LaunchedEffect(title) {
if (title == null) return@LaunchedEffect
launch { metacritic = withContext(Dispatchers.IO) { GameFacts.metacritic(ctx, title) }?.let { Lookup.Found(it) } ?: Lookup.None }
launch { hltb = withContext(Dispatchers.IO) { GameFacts.hltb(ctx, title) }?.let { Lookup.Found(it) } ?: Lookup.None }
}
return GameScores(metacritic, hltb)
}
/**
* The facts: who made it, when, and what critics made of it on the first row; how long it takes,
* across the page, on the second. A looked-up tile holds its place while it asks and steps out if
* the site does not have the game; nothing is drawn when there is nothing to say.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun StoreGameFacts(developer: String, publisher: String, releaseDate: String, scores: GameScores, modifier: Modifier = Modifier) {
val released = remember(releaseDate) { GameFacts.releaseLabel(releaseDate) }
// A studio that publishes itself is named once.
val publishedBy = publisher.takeUnless { it.isBlank() || it.trim().equals(developer.trim(), ignoreCase = true) }
val firstRow = developer.isNotBlank() || publishedBy != null || released != null || scores.metacritic != Lookup.None
if (!firstRow && scores.hltb == Lookup.None) return
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.fillMaxWidth().padding(top = 12.dp)) {
if (firstRow) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (developer.isNotBlank()) FactTile(stringResource(R.string.stores_fact_developer)) { FactText(developer) }
if (publishedBy != null) FactTile(stringResource(R.string.stores_fact_publisher)) { FactText(publishedBy) }
if (released != null) FactTile(stringResource(R.string.stores_fact_released)) { FactText(released) }
Looked(scores.metacritic, exit = shrinkHorizontally()) { lookup ->
val found = (lookup as? Lookup.Found)?.value
FactTile(stringResource(R.string.stores_fact_metacritic), link = found?.url) {
if (found != null) MetacriticBadge(found.score) else Placeholder(ValueHeight)
}
}
}
Looked(scores.hltb, exit = shrinkVertically()) { lookup ->
val found = (lookup as? Lookup.Found)?.value
FactTile(stringResource(R.string.stores_fact_hltb), fill = true, link = found?.url) {
if (found != null) HltbTimes(found) else Placeholder(220.dp)
}
}
}
}
/** A looked-up tile: in from the start (holding its place while it asks), out with a fade when there was nothing. */
@Composable
private fun <T> Looked(lookup: Lookup<T>, exit: ExitTransition, content: @Composable (Lookup<T>) -> Unit) {
// The last answer worth drawing, so the tile does not blank as it leaves.
var shown by remember { mutableStateOf(lookup) }
if (lookup != Lookup.None) shown = lookup
AnimatedVisibility(visible = lookup != Lookup.None, enter = fadeIn(), exit = fadeOut() + exit) { content(shown) }
}
private val FactShape = RoundedCornerShape(12.dp)
private val ValueHeight = 24.dp
/**
* A label over its value; [fill] takes the row's whole width, for a value laid out across it. With
* a [link] it is a control like any other on the page: focus, hover and A or a tap open the page.
*/
@Composable
private fun FactTile(label: String, fill: Boolean = false, link: String? = null, value: @Composable () -> Unit) {
val pal = LocalPalette.current
val ctx = LocalContext.current
val src = remember { MutableInteractionSource() }
val hot = link != null && rememberHot(src)
val open = { if (link != null) openUrl(ctx, link) }
val base = if (fill) Modifier.fillMaxWidth() else Modifier.widthIn(max = 360.dp)
val tile = if (link == null) base.clip(FactShape).background(pal.surface).border(1.dp, pal.line, FactShape)
else base.paneItem("fact:$label").clip(FactShape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else pal.surface)
.glideBorder(hot, FactShape, pal.signal, restColor = pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = open)
.controllerConfirm(onClick = open)
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = tile.padding(horizontal = 14.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, color = if (hot) pal.signal else pal.onSurfaceVariant, maxLines = 1)
if (link != null) Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = if (hot) pal.signal else pal.onSurfaceVariant, modifier = Modifier.size(12.dp))
}
Box(contentAlignment = Alignment.CenterStart, modifier = (if (fill) Modifier.fillMaxWidth() else Modifier).heightIn(min = ValueHeight)) { value() }
}
}
@Composable
private fun FactText(text: String) =
Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
/** Metacritic's own bands: green from 75, yellow from 50, red below. */
internal fun metacriticColour(score: Int): Color = when {
score >= 75 -> Color(0xFF66CC33)
score >= 50 -> Color(0xFFFFCC33)
else -> Color(0xFFFF6874)
}
@Composable
private fun MetacriticBadge(score: Int) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(ValueHeight).clip(RoundedCornerShape(6.dp)).background(metacriticColour(score))) {
Text("$score", fontSize = 13.sp, fontWeight = FontWeight.Black, color = Color(0xFF0A0B0D), maxLines = 1)
}
}
/** Each kind of run HowLongToBeat has times for, sharing the tile's width: the hours in full, what they are for beside them. */
@Composable
private fun HltbTimes(t: GameFacts.Hltb) {
val colors = MaterialTheme.colorScheme
val runs = listOf(
t.main to stringResource(R.string.stores_hltb_main),
t.extras to stringResource(R.string.stores_hltb_extras),
t.completionist to stringResource(R.string.stores_hltb_completionist),
).filter { it.first > 0 }
Row(Modifier.fillMaxWidth()) {
for ((secs, what) in runs) {
val hours = GameFacts.hours(secs)?.let { stringResource(R.string.stores_hltb_hours, it) } ?: stringResource(R.string.stores_hltb_minutes, maxOf(1, secs / 60))
val run = buildAnnotatedString {
withStyle(SpanStyle(fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)) { append(hours) }
withStyle(SpanStyle(fontSize = 12.sp, color = colors.onSurfaceVariant)) { append(" $what") }
}
Text(run, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
}
}
}
/** Where a value will be, breathing while it is asked for. */
@Composable
private fun Placeholder(width: Dp) {
val pulse by rememberInfiniteTransition(label = "fact").animateFloat(
0.35f, 0.8f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "pulse",
)
Box(Modifier.width(width).height(16.dp).alpha(pulse).clip(RoundedCornerShape(5.dp)).background(LocalPalette.current.line2))
}
// Previews: every state the row can be in, at a TV pane's width and a narrow one.
private val previewScores = GameScores(
Lookup.Found(GameFacts.Metacritic(93, "https://www.metacritic.com/game/the-witcher-3-wild-hunt/")),
Lookup.Found(GameFacts.Hltb(main = 186_070, extras = 373_519, completionist = 630_794, url = "https://howlongtobeat.com/game/10270")),
)
private fun previewScore(score: Int) = Lookup.Found(GameFacts.Metacritic(score, "https://www.metacritic.com/"))
@Composable
private fun FactsPreview(theme: String = Themes.GRAPHITE, narrow: Boolean = false, content: @Composable () -> Unit) {
DroidDeckTheme(theme = theme) {
CompositionLocalProvider(LocalNarrowPane provides narrow) {
Box(Modifier.fillMaxSize().background(LocalPalette.current.background).padding(16.dp)) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { content() }
}
}
}
}
@Composable
private fun PreviewCaption(text: String) = Text(text, fontSize = 11.sp, color = LocalPalette.current.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
@Preview(name = "Facts, all found", widthDp = 800, heightDp = 200)
@Composable
private fun FactsFoundPreview() = FactsPreview {
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores)
}
@Preview(name = "Facts, looking up", widthDp = 800, heightDp = 200)
@Composable
private fun FactsPendingPreview() = FactsPreview {
StoreGameFacts("Supergiant Games", "Supergiant Games", "2020.09.17", GameScores(Lookup.Pending, Lookup.Pending))
}
@Preview(name = "Facts, mixed", widthDp = 800, heightDp = 640)
@Composable
private fun FactsMixedPreview() = FactsPreview {
PreviewCaption("Another studio's publisher; score found, times still coming")
StoreGameFacts("New World Computing", "Ubisoft", "1999-02-28", GameScores(previewScore(87), Lookup.Pending))
PreviewCaption("Score found, no times")
StoreGameFacts("Mane6", "Maximum Entertainment", "2022-12-19T16:00:00.000Z", GameScores(previewScore(68), Lookup.None))
PreviewCaption("Neither site knows it: store facts only")
StoreGameFacts("Tiny Indie Studio", "", "2024.03.01", GameScores(Lookup.None, Lookup.None))
PreviewCaption("A low score, a short game with only a main-story time")
StoreGameFacts("", "", "2019-11-05", GameScores(previewScore(42), Lookup.Found(GameFacts.Hltb(main = 2_400, extras = 0, completionist = 0, url = "https://howlongtobeat.com/"))))
PreviewCaption("Epic's no-date placeholder (2099) is not shown")
StoreGameFacts("Studio", "", "2099-01-01T00:00:00.000Z", GameScores(previewScore(81), Lookup.None))
}
@Preview(name = "Facts, narrow pane", widthDp = 420, heightDp = 300)
@Composable
private fun FactsNarrowPreview() = FactsPreview(narrow = true) {
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores)
}
@Preview(name = "Facts, Paper theme", widthDp = 800, heightDp = 200)
@Composable
private fun FactsPaperPreview() = FactsPreview(theme = Themes.PAPER) {
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores)
}
// The real hero reads the stores' live state, so this stands one in to show where the row sits.
@Preview(name = "Game page, facts in place", widthDp = 800, heightDp = 520)
@Composable
private fun FactsOnPagePreview() = FactsPreview {
val title = "The Witcher 3: Wild Hunt"
Box(Modifier.fillMaxWidth().height(180.dp).clip(Shape16).background(artBrush(hueOf(title)))) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) {
Text(stringResource(R.string.stores_eyebrow_library).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal)
Text(title, fontSize = 30.sp, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onBackground)
}
}
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores)
Text(
"As war rages on throughout the Northern Realms, you take on the greatest contract of your life — tracking down the Child of Prophecy, a living weapon that can alter the shape of the world.",
fontSize = 13.sp, lineHeight = 19.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp),
)
}
@@ -122,11 +122,20 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
}
}
if (StoresMotion.heroLands) hero() else Rise(1) { hero() }
if (store == Store.GOG || store == Store.EPIC) {
// An owned game resolves to its library entry; the storefront's copy of it may know more.
val listed = StoresState.shelves[store]?.all.orEmpty().firstOrNull { it.id == id }
val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty()
val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty()
val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty()
val scores = rememberGameScores(title.takeIf { it != id })
Rise(2) { StoreGameFacts(developer, publisher, releaseDate, scores) }
}
// Plain text only, and nothing at all when the store sent a template key instead of words.
// A description that is only the title again (some stores fill it so) is no description.
val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty()
.takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty()
if (description.isNotBlank()) Rise(2) {
if (description.isNotBlank()) Rise(3) {
Text(description, fontSize = 13.sp, lineHeight = 19.sp, color = colors.onSurfaceVariant, maxLines = 6, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 12.dp))
}
val achievements = remember(store, id, item?.owned, installed) {
@@ -137,7 +146,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
else -> null
}
}
if (achievements != null) Rise(3) { StoreAchievementsSection(achievements) }
if (achievements != null) Rise(4) { StoreAchievementsSection(achievements) }
}
/**
@@ -1306,8 +1306,11 @@ internal fun stageLabel(stage: DownloadStage): String = when (stage) {
}
/** The game's web page, in the device's browser: this app buys nothing and claims nothing itself. */
internal fun openStoreUrl(ctx: android.content.Context, item: CatalogItem) {
val url = item.storeUrl.ifBlank { return }
internal fun openStoreUrl(ctx: android.content.Context, item: CatalogItem) = openUrl(ctx, item.storeUrl)
/** A web page in the browser; nothing when the URL is blank or no app can open it. */
internal fun openUrl(ctx: android.content.Context, url: String) {
if (url.isBlank()) return
runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
+8
View File
@@ -2184,4 +2184,12 @@
<string name="glasses_head_tracking_hint">Los sensores de las gafas giran la pantalla virtual y SteamVR con tu cabeza.</string>
<string name="glasses_recenter">Recentrar la vista</string>
<string name="glasses_playing">Jugando en %1$s</string>
<string name="stores_fact_developer">Desarrollador</string>
<string name="stores_fact_publisher">Distribuidor</string>
<string name="stores_fact_released">Lanzamiento</string>
<string name="stores_hltb_main">Principal</string>
<string name="stores_hltb_extras">+ Extras</string>
<string name="stores_hltb_completionist">Completista</string>
<string name="stores_hltb_hours">%1$s h</string>
<string name="stores_hltb_minutes">%1$d min</string>
</resources>
+8
View File
@@ -1014,4 +1014,12 @@
<string name="glasses_head_tracking_hint">Les capteurs des lunettes font tourner l\'écran virtuel et SteamVR avec votre tête.</string>
<string name="glasses_recenter">Recentrer la vue</string>
<string name="glasses_playing">En jeu sur %1$s</string>
<string name="stores_fact_developer">Développeur</string>
<string name="stores_fact_publisher">Éditeur</string>
<string name="stores_fact_released">Sortie</string>
<string name="stores_hltb_main">Principal</string>
<string name="stores_hltb_extras">+ Extras</string>
<string name="stores_hltb_completionist">Complétionniste</string>
<string name="stores_hltb_hours">%1$s h</string>
<string name="stores_hltb_minutes">%1$d min</string>
</resources>
+8
View File
@@ -2140,4 +2140,12 @@
<string name="glasses_head_tracking_hint">グラスのセンサーで、頭の動きに合わせて仮想スクリーンと SteamVR を動かします。</string>
<string name="glasses_recenter">視点をリセット</string>
<string name="glasses_playing">%1$s でプレイ中</string>
<string name="stores_fact_developer">開発元</string>
<string name="stores_fact_publisher">パブリッシャー</string>
<string name="stores_fact_released">発売日</string>
<string name="stores_hltb_main">メイン</string>
<string name="stores_hltb_extras">+ やり込み</string>
<string name="stores_hltb_completionist">完全制覇</string>
<string name="stores_hltb_hours">%1$s時間</string>
<string name="stores_hltb_minutes">%1$d分</string>
</resources>
+8
View File
@@ -2140,4 +2140,12 @@
<string name="glasses_head_tracking_hint">안경의 센서가 머리 움직임에 맞춰 가상 화면과 SteamVR을 돌립니다.</string>
<string name="glasses_recenter">시점 재설정</string>
<string name="glasses_playing">%1$s에서 플레이 중</string>
<string name="stores_fact_developer">개발사</string>
<string name="stores_fact_publisher">배급사</string>
<string name="stores_fact_released">출시일</string>
<string name="stores_hltb_main">메인</string>
<string name="stores_hltb_extras">+ 추가 요소</string>
<string name="stores_hltb_completionist">완전 정복</string>
<string name="stores_hltb_hours">%1$s시간</string>
<string name="stores_hltb_minutes">%1$d분</string>
</resources>
+8
View File
@@ -1125,4 +1125,12 @@
<string name="glasses_head_tracking_hint">Датчики очков поворачивают виртуальный экран и SteamVR вслед за головой.</string>
<string name="glasses_recenter">Сбросить вид</string>
<string name="glasses_playing">Игра на %1$s</string>
<string name="stores_fact_developer">Разработчик</string>
<string name="stores_fact_publisher">Издатель</string>
<string name="stores_fact_released">Дата выхода</string>
<string name="stores_hltb_main">Сюжет</string>
<string name="stores_hltb_extras">+ Доп. задания</string>
<string name="stores_hltb_completionist">Полностью</string>
<string name="stores_hltb_hours">%1$s ч</string>
<string name="stores_hltb_minutes">%1$d мин</string>
</resources>
@@ -2140,4 +2140,12 @@
<string name="glasses_head_tracking_hint">眼镜自带的传感器会随头部转动虚拟屏幕和 SteamVR。</string>
<string name="glasses_recenter">重新居中视角</string>
<string name="glasses_playing">正在 %1$s 上游玩</string>
<string name="stores_fact_developer">开发商</string>
<string name="stores_fact_publisher">发行商</string>
<string name="stores_fact_released">发行日期</string>
<string name="stores_hltb_main">主线</string>
<string name="stores_hltb_extras">+ 支线</string>
<string name="stores_hltb_completionist">全收集</string>
<string name="stores_hltb_hours">%1$s 小时</string>
<string name="stores_hltb_minutes">%1$d 分钟</string>
</resources>
@@ -2140,4 +2140,12 @@
<string name="glasses_head_tracking_hint">眼鏡自帶的感應器會隨頭部轉動虛擬螢幕和 SteamVR。</string>
<string name="glasses_recenter">重新置中視角</string>
<string name="glasses_playing">正在 %1$s 上遊玩</string>
<string name="stores_fact_developer">開發商</string>
<string name="stores_fact_publisher">發行商</string>
<string name="stores_fact_released">發行日期</string>
<string name="stores_hltb_main">主線</string>
<string name="stores_hltb_extras">+ 支線</string>
<string name="stores_hltb_completionist">全收集</string>
<string name="stores_hltb_hours">%1$s 小時</string>
<string name="stores_hltb_minutes">%1$d 分鐘</string>
</resources>
@@ -2140,4 +2140,12 @@
<string name="glasses_head_tracking_hint">眼鏡內建的感測器會隨頭部轉動虛擬螢幕和 SteamVR。</string>
<string name="glasses_recenter">重新置中視角</string>
<string name="glasses_playing">正在 %1$s 上遊玩</string>
<string name="stores_fact_developer">開發商</string>
<string name="stores_fact_publisher">發行商</string>
<string name="stores_fact_released">發行日期</string>
<string name="stores_hltb_main">主線</string>
<string name="stores_hltb_extras">+ 支線</string>
<string name="stores_hltb_completionist">全收集</string>
<string name="stores_hltb_hours">%1$s 小時</string>
<string name="stores_hltb_minutes">%1$d 分鐘</string>
</resources>
+11
View File
@@ -2171,6 +2171,17 @@
<string name="achievements_none">%1$s lists no achievements for this game.</string>
<string name="achievements_show_all">Show all %1$d</string>
<string name="achievements_dialog_title">Achievements · %1$s</string>
<!-- A store game's facts row: the store's own facts, then Metacritic and HowLongToBeat, looked up by name. -->
<string name="stores_fact_developer">Developer</string>
<string name="stores_fact_publisher">Publisher</string>
<string name="stores_fact_released">Released</string>
<string name="stores_fact_metacritic" translatable="false">Metacritic</string>
<string name="stores_fact_hltb" translatable="false">HowLongToBeat</string>
<string name="stores_hltb_main">Main</string>
<string name="stores_hltb_extras">+ Extras</string>
<string name="stores_hltb_completionist">Completionist</string>
<string name="stores_hltb_hours">%1$s h</string>
<string name="stores_hltb_minutes">%1$d min</string>
<string name="stores_in_steam">✓ In Steam</string>
<string name="stores_uninstall">Uninstall</string>
<string name="stores_uninstalling">Uninstalling…</string>
@@ -0,0 +1,72 @@
package com.droiddeck.launcher.stores
import java.util.Locale
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
/** The name match that finds a store game on Steam and HowLongToBeat, and how its facts read. */
class GameFactsTest {
private val saved = Locale.getDefault()
@Before fun english() = Locale.setDefault(Locale.US)
@After fun restore() = Locale.setDefault(saved)
@Test fun theSameNameMatchesWhateverItsMarksAndPunctuation() {
assertEquals(3, TitleMatch.score("The Witcher 3: Wild Hunt", "The Witcher 3: Wild Hunt"))
assertEquals(3, TitleMatch.score("DOOM Eternal™", "DOOM Eternal"))
assertEquals(3, TitleMatch.score("Ori & the Will of the Wisps", "Ori and the Will of the Wisps"))
}
@Test fun anEditionIsTheSameGame() {
assertEquals(2, TitleMatch.score("The Witcher 3: Wild Hunt - Complete Edition", "The Witcher 3: Wild Hunt"))
assertEquals(2, TitleMatch.score("Control", "Control Ultimate Edition"))
assertEquals(2, TitleMatch.score("Fallout: New Vegas - Ultimate Edition", "Fallout: New Vegas"))
}
@Test fun aSubtitleOnEitherSideStillMatchesButLessWell() {
assertEquals(1, TitleMatch.score("The Witcher 3: Wild Hunt", "The Witcher 3: Wild Hunt — Remastered"))
assertEquals(1, TitleMatch.score("Alan Wake Remastered (2021)", "Alan Wake Remastered"))
}
@Test fun aSequelOrASiblingIsNotTheSameGame() {
assertEquals(0, TitleMatch.score("Hades", "Hades II"))
assertEquals(0, TitleMatch.score("Batman: Arkham City", "Batman: Arkham Knight"))
assertEquals(0, TitleMatch.score("Portal", "Portal 2"))
assertEquals(0, TitleMatch.score("", "Portal"))
}
@Test fun thePickPrefersTheClosestNameThenTheSitesOwnOrder() {
val results = listOf("Hades II", "The Witcher 3: Wild Hunt — Songs of the Past", "Hades", "Hades")
assertEquals(2, TitleMatch.pick("Hades", results.withIndex().toList()) { listOf(it.value) }?.index)
assertNull(TitleMatch.pick("Hades", listOf("Hades II", "Hadesville")) { listOf(it) })
// An alias counts as a name.
assertEquals("x", TitleMatch.pick("The Witcher III: Wild Hunt", listOf("x" to "The Witcher 3: Wild Hunt, The Witcher III: Wild Hunt")) { it.second.split(',') }?.first)
}
@Test fun theSearchTermDropsMarksAndTheEdition() {
assertEquals("The Witcher 3 Wild Hunt", TitleMatch.searchTerm("The Witcher® 3: Wild Hunt - Game of the Year Edition"))
assertEquals("Disco Elysium The Final Cut", TitleMatch.searchTerm("Disco Elysium - The Final Cut"))
assertEquals("Marvel's Spider-Man Remastered", TitleMatch.searchTerm("Marvel's Spider-Man Remastered"))
assertEquals("Deluxe Ski Jump", TitleMatch.searchTerm("Deluxe Ski Jump"))
}
@Test fun eachStoresDateReadsTheSameAndAPlaceholderIsDropped() {
assertEquals("May 19, 2015", GameFacts.releaseLabel("2015-05-19T00:00:00+02:00"))
assertEquals("May 19, 2015", GameFacts.releaseLabel("2015.05.19"))
assertEquals("Dec 19, 2022", GameFacts.releaseLabel("2022-12-19T16:00:00.000Z"))
assertNull(GameFacts.releaseLabel("2099-01-01T00:00:00.000Z"))
assertNull(GameFacts.releaseLabel("null"))
assertNull(GameFacts.releaseLabel(""))
}
@Test fun hoursRoundToTheNearestHalf() {
assertEquals("51½", GameFacts.hours(186_070))
assertEquals("52", GameFacts.hours(52 * 3600 + 500))
assertEquals("51½", GameFacts.hours(51 * 3600 + 1900))
assertEquals("1", GameFacts.hours(3600))
assertNull(GameFacts.hours(2_400))
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ Use the same names as the app's Updates page:
| App | Discord | Builds | Existing webhook secret |
| --- | --- | --- | --- |
| Preview | [#preview](https://discord.com/channels/1552707793247539220/1554202932965015583) | Signed builds of `main`, including merged fixes | `DISCORD_BUILDS_WEBHOOK` |
| Preview | [#preview-builds](https://discord.com/channels/1552707793247539220/1554202932965015583) | Signed builds of `main`, including merged fixes | `DISCORD_BUILDS_WEBHOOK` |
| Test builds | [#test-builds](https://discord.com/channels/1552707793247539220/1557605185365217340) | Signed builds of individual open pull requests | `DISCORD_PR_BUILDS_WEBHOOK` |
Stable contains tested releases published from version tags. Preview contains the newest
+3
View File
@@ -8,3 +8,6 @@ android.nonTransitiveRClass=true
# The NDK the compositor is built with. CI passes the one the runner has installed
# (-PndkVersion=…); this is the fallback for a local build.
ndkVersion=27.3.13750724
# Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true