mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
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:
commit
f0b329768e
28 files changed
+782
-27
No files matched your search
@@ -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)) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user