feat(): Achievements UI for GOG & Epic game details page (#641)

* feat(): gog & epic achievements UI

* feat(): AI-generated translations - Please get review for these

* build_local: run on macOS hosts with Docker Desktop

Two steps of tools/build_local.sh failed on a Mac:

- The uruntime staging runs on the host, where macOS's BSD install has
  no -D, so `install -Dm644` stopped with "No such file or directory".
  The folders are now made first and the files copied with install -m644.
- Inside the cross-compile container, Docker Desktop's VirtioFS share
  now and then refuses the chmod that install -m makes (EPERM, on a
  different file each run, e.g. "setting permissions for
  .../droiddeck-esync: Operation not permitted"). The overlay and
  desktop scripts are copied with a plain write instead. Nothing is
  lost: the APK keeps no file modes, and the app sets them when it
  stages the runtime.

* feat(): adjust the store

* feat(): store-achievements adjustment for UI

* feat(): translation fixes and separate hidden achievements

* Show all achievements directly on store game details

---------

Co-authored-by: xXJsonDeruloXx <[email protected]>
This commit is contained in:
Phobos665andxXJsonDeruloXx authored and GitHub committed 2026-10-12 19:49:55 +00:00
1 parent a249e896f7
commit a7662ae13a
21 files changed
+922 -15

No files matched your search

+3
View File
@@ -287,6 +287,9 @@ dependencies {
implementation platform('androidx.compose:compose-bom:2024.02.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
// @Preview for the composables; the tooling that renders them is a debug-only dependency.
implementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.activity:activity-compose:1.8.2'
// The File Manager, Bannerlator's: its icon set, image thumbnails and the coroutines it
// copies on. Same versions as Bannerlator so the screen stays a straight copy.
@@ -28,10 +28,12 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toArgb
@@ -41,6 +43,12 @@ import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.files.InAppFilePicker
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.agent.AgentGuest
import com.droiddeck.launcher.frontend.LibraryCache
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreLaunchRequests
import com.droiddeck.launcher.ui.SessionAchievements
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.ScreenEffectLooks
import com.droiddeck.launcher.gpu.ScreenEffects
@@ -281,6 +289,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
private var pcKeyboardOpen by mutableStateOf(false)
/** The game in front, when its source has achievements. */
private var achievementsInFront by mutableStateOf<AchievementsInFront?>(null)
private var achievementsOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
@@ -495,6 +506,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
when {
::loading.isInitialized && loading.ended -> finish()
drawerOpen -> drawerOpen = false
achievementsOpen -> closeAchievements()
pcKeyboardOpen -> pcKeyboardOpen = false
else -> routeBackAction()
}
@@ -515,7 +527,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
defaultFocusHighlightEnabled = false
setContent {
// The drawer, the PC keyboard and picture in picture take the mouse back.
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
DroidDeckTheme {
if (pipUi) {
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
@@ -550,6 +562,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
syncLossless()
refreshAchievementsInFront()
padBridge?.releaseAll()
androidx.compose.runtime.withFrameNanos { }
val requested = sessionOverlay.requestFocus()
@@ -636,6 +649,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
onAchievements = achievementsInFront?.let { { drawerOpen = false; achievementsOpen = true } },
achievementsProgress = achievementsInFront?.progress,
// Only where the session set the pad up to be switched (SessionService: deckPad).
padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null,
onPadType = { id ->
@@ -732,6 +747,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
onGameComponent = ::setDrawerGameComponent,
))
// The panel takes the pad, as the PC keyboard does.
LaunchedEffect(achievementsOpen) {
if (achievementsOpen) {
padBridge?.releaseAll()
withFrameNanos { }
sessionOverlay.requestFocus()
}
}
achievementsInFront?.takeIf { achievementsOpen }?.let { inFront ->
SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements)
}
if (SessionState.suspended) SessionPausedOverlay(
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
@@ -772,6 +798,37 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
padBridge?.triggerQam()
}
/** Re-reads so the drawer's count picks up the panel's fresh list. */
private fun closeAchievements() {
achievementsOpen = false
refreshAchievementsInFront()
}
private fun refreshAchievementsInFront() {
if (SessionState.mode != SessionService.MODE_STEAM) { achievementsInFront = null; return }
Thread({
val found = runCatching { resolveAchievementsInFront() }.onFailure { Log.w(TAG, "achievements in front: ${it.javaClass.simpleName}") }.getOrNull()
uiHandler.post { achievementsInFront = found }
}, "achievements-in-front").start()
}
/** Steam's focused game (a Steam title means none), else the last store launch, else the session's own game. */
private fun resolveAchievementsInFront(): AchievementsInFront? {
val games = LibraryCache.load(this)
fun storeOf(g: Library.SteamGame) = Store.byId(g.source)?.let { st -> g.storeId?.let { st to it } }
val byKey = games.mapNotNull { g -> g.profileKey.toLongOrNull()?.let { it to g } }.toMap()
val focused = runCatching { AgentGuest.activeSteamAppId(this, byKey.keys) }.getOrNull()?.let(byKey::get)
val url = SessionState.steamUrl?.substringAfter("rungameid/", "")?.takeIf { it.isNotEmpty() }
val (store, id) = (if (focused != null) storeOf(focused)
else StoreLaunchRequests.lastLaunched ?: games.firstOrNull { it.gameIdString == url }?.let(::storeOf))
?: return null
val source = StoreAchievements.forSource(store.id) ?: return null
val sidecar = StoreGameSidecar.find(this, store, id) ?: return null
val game = StoreAchievements.Game.of(sidecar)
val saved = source.cached(this, game)?.takeIf { it.isNotEmpty() }
return AchievementsInFront(game, sidecar.title, saved?.let { l -> l.count { it.unlocked } to l.size })
}
private fun togglePcKeyboard() {
drawerOpen = false
keyboard?.hide()
@@ -1662,7 +1719,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
return true
}
if (pcKeyboardOpen && fromController) return super.dispatchKeyEvent(event)
if ((pcKeyboardOpen || achievementsOpen) && fromController) return super.dispatchKeyEvent(event)
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
// activity, which opens the drawer.
@@ -1715,7 +1772,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
if (pcKeyboardOpen && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
if (padBridge?.onMotionEvent(event) == true) return true
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
return super.dispatchGenericMotionEvent(event)
@@ -2153,7 +2210,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun updatePointerCapture() {
if (!::surfaceView.isInitialized) return
val view = window.decorView
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !pipUi && mouseAttached()
if (want == view.hasPointerCapture()) return
if (want) view.requestPointerCapture() else view.releasePointerCapture()
}
@@ -2325,6 +2382,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (show) {
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
pendingBackAction?.let(uiHandler::removeCallbacks)
pendingBackAction = null
releaseDrawerDirection()
@@ -2452,6 +2510,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// drawer's dim layer can otherwise remain over Steam when this activity returns.
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
releaseDrawerDirection()
// A button held when the app goes away would stay held in the ring for the whole session.
@@ -2626,3 +2685,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
)
}
}
/** [progress]: (unlocked, total) from the cached list. */
private class AchievementsInFront(val game: StoreAchievements.Game, val title: String, val progress: Pair<Int, Int>?)
@@ -137,7 +137,7 @@ object CloudSaves {
val galaxy = GogAuth.validToken(context)
val clientId = gogClientId(context, id, folder)
if (userId.isEmpty() || galaxy == null || clientId == null) null
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId, account?.optString("refresh_token").orEmpty()) ?: galaxy)
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId) ?: galaxy)
}
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context)
@@ -148,17 +148,9 @@ object CloudSaves {
}
/** GOG cloud storage wants a token issued to the game's own client: the Galaxy refresh token, exchanged with the game's id and secret. */
private fun gogScopedToken(context: Context, id: String, clientId: String, refresh: String): String? {
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
val secret = GogPrefs.get(context).getString("client_secret_$id", null)?.ifEmpty { null } ?: return null
if (refresh.isEmpty()) return null
return try {
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
val c = URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(secret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}").openConnection() as HttpURLConnection
c.connectTimeout = 15_000; c.readTimeout = 15_000
c.setRequestProperty("User-Agent", "GOG Galaxy")
if (c.responseCode != 200) { c.disconnect(); return null }
JSONObject(c.inputStream.bufferedReader().readText()).optString("access_token").ifEmpty { null }.also { c.disconnect() }
} catch (e: Exception) { null }
return GogAuth.gameToken(context, clientId, secret)?.accessToken
}
// ---- sync -----------------------------------------------------------------------------------
@@ -0,0 +1,103 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.epic.EpicAchievements
import com.droiddeck.launcher.stores.gog.GogAchievements
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
import java.util.Locale
/**
* A source's achievements with the account's progress, the last good answer cached on disk. Blocking.
* [source] is the library's id for where a game came from ("gog", "epic"; "steam" once it has one).
*/
abstract class StoreAchievements(val source: String, val label: String) {
/** [extra]: what the source needs to find the game (Epic: "namespace"). */
data class Game(val source: String, val id: String, val extra: Map<String, String> = emptyMap()) {
companion object {
fun of(item: CatalogItem) = Game(item.store.id, item.id, item.extra)
fun of(sidecar: StoreGameSidecar) = Game(sidecar.store.id, sidecar.id, sidecar.extra)
}
}
class Achievement(
val id: String,
val name: String,
val description: String,
val hidden: Boolean,
val unlocked: Boolean,
/** Unix seconds; 0 when locked or unknown. */
val unlockedAt: Long,
val icon: String?,
val iconLocked: String?,
)
sealed class Result {
class Found(val list: List<Achievement>) : Result()
class Failed(val reason: Reason) : Result()
}
enum class Reason { SIGNED_OUT, UNREACHABLE, NO_SERVICE }
protected abstract fun request(context: Context, game: Game): Result
fun fetch(context: Context, game: Game): Result {
val result = runCatching { request(context, game) }.getOrElse { e ->
Log.w(TAG, "$source:${game.id}: ${e.javaClass.simpleName}")
Result.Failed(Reason.UNREACHABLE)
}
if (result is Result.Found) runCatching {
cacheFile(context, game).apply { parentFile?.mkdirs() }.writeText(toJson(result.list).toString())
}
return result
}
fun cached(context: Context, game: Game): List<Achievement>? = runCatching {
val file = cacheFile(context, game)
if (!file.exists()) null else fromJson(JSONArray(file.readText()))
}.getOrNull()
private fun cacheFile(context: Context, game: Game) =
File(context.filesDir, "achievements/$source/${game.id.replace(Regex("[^A-Za-z0-9._-]"), "_")}.json")
protected fun locale(): String = Locale.getDefault().toLanguageTag()
/** ISO-8601 (including GOG's "+0000" offsets) to Unix seconds; 0 if unreadable. */
protected fun epochSeconds(iso: String?): Long {
if (iso.isNullOrBlank()) return 0
val normalized = iso.replace(Regex("([+-]\\d{2})(\\d{2})$"), "$1:$2")
return runCatching { OffsetDateTime.parse(normalized).toEpochSecond() }
.recoverCatching { Instant.parse(normalized).epochSecond }
.getOrDefault(0)
}
private fun toJson(list: List<Achievement>) = JSONArray().apply {
for (a in list) put(
JSONObject().put("id", a.id).put("name", a.name).put("description", a.description).put("hidden", a.hidden)
.put("unlocked", a.unlocked).put("unlockedAt", a.unlockedAt).put("icon", a.icon ?: "").put("iconLocked", a.iconLocked ?: ""),
)
}
private fun fromJson(arr: JSONArray) = List(arr.length()) { i ->
val o = arr.getJSONObject(i)
Achievement(
id = o.getString("id"), name = o.optString("name"), description = o.optString("description"),
hidden = o.optBoolean("hidden"), unlocked = o.optBoolean("unlocked"), unlockedAt = o.optLong("unlockedAt"),
icon = o.optString("icon").ifEmpty { null }, iconLocked = o.optString("iconLocked").ifEmpty { null },
)
}
companion object {
private const val TAG = "StoreAchievements"
private val all: List<StoreAchievements> by lazy { listOf(GogAchievements, EpicAchievements) }
/** Null for a source without achievements. */
fun forSource(source: String?): StoreAchievements? = all.firstOrNull { it.source == source }
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.stores
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
@@ -90,6 +91,12 @@ class StoreGameSidecar(
fun file(folder: File): File = File(folder, FILE_NAME)
/** An installed game's sidecar; walks the install folders. */
fun find(context: Context, store: Store, id: String): StoreGameSidecar? =
StoreInstallRoot.gameFolders(context.applicationContext).asSequence()
.mapNotNull { read(it) }
.firstOrNull { it.store == store && it.id == id }
/**
* Changes an Epic game's launch choices on disk: reads the sidecar fresh (never a copy held
* by a screen), applies [change], writes it, and reads it back. The written sidecar, or null
@@ -19,6 +19,9 @@ object StoreLaunchRequests {
private val lock = Any()
private var observer: FileObserver? = null
/** The store game this session last started; covers Steam's own Play button. */
@Volatile var lastLaunched: Pair<Store, String>? = null
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "stores")
/** Before each session: nothing of the last one's requests; then watch for new ones. */
@@ -28,6 +31,7 @@ object StoreLaunchRequests {
observer?.stopWatching()
val root = dir(app)
root.deleteRecursively()
lastLaunched = null
val req = File(root, "req").apply { mkdirs() }
File(root, "resp").mkdirs()
@Suppress("DEPRECATION")
@@ -59,6 +63,7 @@ object StoreLaunchRequests {
val id = request.optString("id")
if (id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-id")
else {
lastLaunched = Store.EPIC to id
val code = StoreLaunch.epicCode(app, id)
JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason)
}
@@ -70,6 +75,7 @@ object StoreLaunchRequests {
val id = request.optString("id")
if (store == null || id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-game")
else {
lastLaunched = store to id
// Marked before the game runs: whatever ends it, an upload follows (CloudSaves.uploadDirty).
CloudSaves.markDirty(app, store, id)
CloudSaves.downloadOrDefer(app, store, id).let { JSONObject().put("ok", it.ok).put("result", it.result).put("files", it.files).put("reason", it.reason) }
@@ -0,0 +1,91 @@
package com.droiddeck.launcher.stores.epic
import android.content.Context
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONObject
/** Epic's GraphQL: the sandbox's achievements, then the player's records in it. */
object EpicAchievements : StoreAchievements(Store.EPIC.id, Store.EPIC.shortLabel) {
private const val GRAPHQL = "https://launcher.store.epicgames.com/graphql"
private const val USER_AGENT = "EpicGamesLauncher/14.0.8-22004686+++Portal+Release-Live"
override fun request(context: Context, game: Game): Result {
val namespace = game.extra["namespace"].orEmpty()
if (namespace.isEmpty()) return Result.Failed(Reason.NO_SERVICE)
val token = EpicCredentialStore.getValidAccessToken(context) ?: return Result.Failed(Reason.SIGNED_OUT)
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
val defs = query(token, GAME_ACHIEVEMENTS, JSONObject().put("sandboxId", namespace).put("locale", locale()))
?: return Result.Failed(Reason.UNREACHABLE)
val list = defs.optJSONObject("data")?.optJSONObject("Achievement")?.optJSONObject("productAchievementsRecordBySandbox")
?.optJSONArray("achievements") ?: return Result.Found(emptyList())
val unlocked = if (account.isEmpty()) emptyMap() else unlockTimes(token, account, namespace)
val out = ArrayList<Achievement>()
for (i in 0 until list.length()) {
val a = list.optJSONObject(i)?.optJSONObject("achievement") ?: continue
val id = a.optString("name")
if (id.isEmpty()) continue
val at = unlocked[id]
val done = at != null
out += Achievement(
id = id,
name = (if (done) a.optString("unlockedDisplayName") else a.optString("lockedDisplayName"))
.ifEmpty { a.optString("unlockedDisplayName") }.ifEmpty { id },
description = (if (done) a.optString("unlockedDescription") else a.optString("lockedDescription"))
.ifEmpty { a.optString("unlockedDescription") },
hidden = a.optBoolean("hidden"),
unlocked = done,
unlockedAt = at ?: 0,
icon = a.optString("unlockedIconLink").ifEmpty { null },
iconLocked = a.optString("lockedIconLink").ifEmpty { null },
)
}
return Result.Found(out)
}
/** Achievement name to unlock time, for the ones the player has. */
private fun unlockTimes(token: String, account: String, namespace: String): Map<String, Long> {
val out = HashMap<String, Long>()
val records = query(token, PLAYER_ACHIEVEMENTS, JSONObject().put("epicAccountId", account).put("sandboxId", namespace))
?.optJSONObject("data")?.optJSONObject("PlayerAchievement")?.optJSONObject("playerAchievementGameRecordsBySandbox")
?.optJSONArray("records") ?: return out
for (r in 0 until records.length()) {
val player = records.optJSONObject(r)?.optJSONArray("playerAchievements") ?: continue
for (i in 0 until player.length()) {
val pa = player.optJSONObject(i)?.optJSONObject("playerAchievement") ?: continue
if (pa.optBoolean("unlocked")) out[pa.optString("achievementName")] = epochSeconds(pa.optString("unlockDate"))
}
}
return out
}
private fun query(token: String, query: String, variables: JSONObject): JSONObject? =
StoreNet.postJson(GRAPHQL, JSONObject().put("query", query).put("variables", variables).toString(), bearer = token, userAgent = USER_AGENT)
?.let(::JSONObject)
private val GAME_ACHIEVEMENTS = """
query Achievement(${'$'}sandboxId: String!, ${'$'}locale: String!) {
Achievement {
productAchievementsRecordBySandbox(sandboxId: ${'$'}sandboxId, locale: ${'$'}locale) {
achievements {
achievement {
name hidden unlockedDisplayName lockedDisplayName unlockedDescription lockedDescription
unlockedIconLink lockedIconLink XP
}
}
}
}
}
""".trimIndent()
private val PLAYER_ACHIEVEMENTS = """
query PlayerAchievement(${'$'}epicAccountId: String!, ${'$'}sandboxId: String!) {
PlayerAchievement {
playerAchievementGameRecordsBySandbox(epicAccountId: ${'$'}epicAccountId, sandboxId: ${'$'}sandboxId) {
records { playerAchievements { playerAchievement { achievementName unlocked unlockDate XP } } }
}
}
}
""".trimIndent()
}
@@ -0,0 +1,83 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoreLog
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.InflaterInputStream
/** gameplay.gog.com, with a token scoped to the game's own client. */
object GogAchievements : StoreAchievements(Store.GOG.id, Store.GOG.shortLabel) {
private const val TAG = "GogAchievements"
override fun request(context: Context, game: Game): Result {
val galaxy = GogAuth.validToken(context) ?: return Result.Failed(Reason.SIGNED_OUT)
val (clientId, clientSecret) = client(context, game.id, galaxy) ?: return Result.Failed(Reason.NO_SERVICE)
val scoped = GogAuth.gameToken(context, clientId, clientSecret) ?: return Result.Failed(Reason.UNREACHABLE)
val body = StoreNet.get(
"https://gameplay.gog.com/clients/$clientId/users/${scoped.userId}/achievements",
bearer = scoped.accessToken, userAgent = GogAuth.GALAXY_UA, headers = mapOf("X-Gog-Lc" to locale()),
) ?: return Result.Failed(Reason.UNREACHABLE)
val items = body.takeIf { it.isNotBlank() }?.let(::JSONObject)?.optJSONArray("items") ?: return Result.Found(emptyList())
val out = ArrayList<Achievement>()
for (i in 0 until items.length()) {
val item = items.optJSONObject(i) ?: continue
val id = item.optString("achievement_key").ifEmpty { item.optString("achievement_id") }
if (id.isEmpty()) continue
val date = item.optString("date_unlocked").takeIf { it.isNotBlank() && it != "null" }
out += Achievement(
id = id,
name = item.optString("name").ifEmpty { id },
description = item.optString("description"),
hidden = !item.optBoolean("visible", true),
unlocked = date != null,
unlockedAt = date?.let(::epochSeconds) ?: 0,
icon = item.optString("image_url_unlocked").ifEmpty { item.optString("image_url_locked") }.ifEmpty { null },
iconLocked = item.optString("image_url_locked").ifEmpty { null },
)
}
return Result.Found(out)
}
/** The game's client id and secret: saved at install, else from the newest build's manifest. */
private fun client(context: Context, productId: String, token: String): Pair<String, String>? {
val prefs = GogPrefs.get(context)
val savedId = prefs.getString("client_id_$productId", null)?.ifEmpty { null }
val savedSecret = prefs.getString("client_secret_$productId", null)?.ifEmpty { null }
if (savedId != null && savedSecret != null) return savedId to savedSecret
val builds = StoreNet.get("https://content-system.gog.com/products/$productId/os/windows/builds?generation=2", bearer = token)
?.let(::JSONObject) ?: return null
val link = builds.optJSONArray("items")?.optJSONObject(0)?.optString("link")?.ifEmpty { null } ?: return null
val manifest = bytes(link, token)?.let { JSONObject(inflate(it)) } ?: return null
val id = manifest.optString("clientId").ifEmpty { return null }
val secret = manifest.optString("clientSecret").ifEmpty { return null }
prefs.edit().putString("client_id_$productId", id).putString("client_secret_$productId", secret).apply()
return id to secret
}
private fun bytes(url: String, bearer: String): ByteArray? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 20_000; readTimeout = 20_000
setRequestProperty("Authorization", "Bearer $bearer")
}
if (conn.responseCode !in 200..299) null else conn.inputStream.use { it.readBytes() }
} catch (e: Exception) {
Log.w(TAG, "GET failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
/** Gen2 manifests are zlib-compressed; plain JSON passes through. */
private fun inflate(data: ByteArray): String =
if (data.isNotEmpty() && data[0] == '{'.code.toByte()) String(data, Charsets.UTF_8)
else InflaterInputStream(data.inputStream()).use { String(it.readBytes(), Charsets.UTF_8) }
}
@@ -87,6 +87,30 @@ object GogAuth {
}
}
class GameToken(val accessToken: String, val userId: String)
/** The Galaxy refresh token exchanged for one scoped to a game's own client. Blocking. */
fun gameToken(context: Context, clientId: String, clientSecret: String): GameToken? {
val json = StoreAccounts.read(context, Store.GOG) ?: return null
val refresh = json.optString("refresh_token", "").ifEmpty { return null }
var conn: HttpURLConnection? = null
return try {
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
conn = (URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(clientSecret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}")
.openConnection() as HttpURLConnection).apply { connectTimeout = 15_000; readTimeout = 15_000 }
conn.setRequestProperty("User-Agent", "GOG Galaxy")
if (conn.responseCode != 200) { Log.w(TAG, "game token: HTTP ${conn.responseCode}"); return null }
val o = JSONObject(conn.inputStream.bufferedReader().readText())
val access = o.optString("access_token").ifEmpty { return null }
GameToken(access, o.optString("user_id").ifEmpty { json.optString("user_id") })
} catch (e: Exception) {
Log.w(TAG, "game token failed: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
/** Hosts the GOG login page hands off to for a social sign-in; they refuse an embedded browser's identity. */
fun isIdentityProviderHost(host: String?): Boolean {
val h = host?.lowercase() ?: return false
@@ -223,6 +223,10 @@ class DrawerActions(
val onHardwareKeyboard: () -> Unit,
val onSteamMenu: (() -> Unit)?,
val onQam: (() -> Unit)?,
/** Null hides the achievements row. */
val onAchievements: (() -> Unit)? = null,
/** (unlocked, total), or null when unknown. */
val achievementsProgress: Pair<Int, Int>? = null,
val onOsc: (String) -> Unit,
/** Edits the Steam touch layout of the game in front, when Steam's controls are up. */
val onEditSteamTouch: (() -> Unit)? = null,
@@ -409,6 +413,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true }
}
a.onAchievements?.let { show ->
DrawerAchievementsRow(a.achievementsProgress, Modifier.padding(top = if (short) 6.dp else 10.dp).then(focus.track(page, "achievements"))) {
host.open = null; show()
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -0,0 +1,381 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EmojiEvents
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.runtime.withFrameNanos
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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 coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoreAchievements.Achievement
import com.droiddeck.launcher.stores.StoreAchievements.Reason
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
private val TrophyGold = Color(0xFFF5C542)
internal class AchievementsState(val list: List<Achievement>?, val reason: Reason?, val reading: Boolean) {
val done: Int get() = list?.count { it.unlocked } ?: 0
val all: Boolean get() = !list.isNullOrEmpty() && list.all { it.unlocked }
companion object { val READING = AchievementsState(null, null, true) }
}
/** The cached list at once, then the store's answer (the cache stays if the store fails). */
@Composable
internal fun rememberAchievements(game: StoreAchievements.Game?): AchievementsState? {
val ctx = LocalContext.current.applicationContext
var state by remember(game) { mutableStateOf(if (game == null) null else AchievementsState.READING) }
LaunchedEffect(game) {
if (game == null) return@LaunchedEffect
val source = StoreAchievements.forSource(game.source) ?: return@LaunchedEffect
val cached = withContext(Dispatchers.IO) { source.cached(ctx, game) }
if (cached != null) state = AchievementsState(cached, null, true)
state = when (val r = withContext(Dispatchers.IO) { source.fetch(ctx, game) }) {
is StoreAchievements.Result.Found -> AchievementsState(r.list, null, false)
is StoreAchievements.Result.Failed -> AchievementsState(cached, r.reason, false)
}
}
return state
}
@Composable
internal fun achievementsNote(state: AchievementsState, label: String): String? = when {
!state.list.isNullOrEmpty() -> null
state.reading -> stringResource(R.string.achievements_reading)
state.reason == Reason.SIGNED_OUT -> stringResource(R.string.achievements_signed_out, label)
state.reason == Reason.UNREACHABLE -> stringResource(R.string.achievements_unreachable, label)
else -> stringResource(R.string.achievements_none, label)
}
@Composable
internal fun Trophy(size: Dp = 18.dp, modifier: Modifier = Modifier) {
Icon(Icons.Filled.EmojiEvents, stringResource(R.string.achievements_all), modifier.size(size), tint = TrophyGold)
}
@Composable
internal fun AchievementsProgress(state: AchievementsState, compact: Boolean = false) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val list = state.list ?: return
if (list.isEmpty()) return
Column(verticalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(stringResource(R.string.achievements_count, state.done, list.size), fontSize = 13.sp, color = colors.onSurfaceVariant)
if (state.all) Trophy(16.dp)
}
LinearProgressIndicator(
progress = { state.done / list.size.toFloat() },
color = pal.signal, trackColor = pal.line,
modifier = Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
)
}
}
@Composable
private fun AchievementGroupLabel(label: String) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) {
Text(label.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
Box(Modifier.weight(1f).height(1.dp).background(pal.line))
}
}
/** Unlocked (newest first), locked, then hidden ones not yet unlocked. */
private fun groups(list: List<Achievement>): List<Pair<Int, List<Achievement>>> = listOf(
R.string.achievements_group_unlocked to list.filter { it.unlocked }.sortedByDescending { it.unlockedAt },
R.string.achievements_group_locked to list.filter { !it.unlocked && !it.hidden },
R.string.achievements_group_hidden to list.filter { !it.unlocked && it.hidden },
).filter { it.second.isNotEmpty() }
/** Focusable so the pad can scroll the list. */
@Composable
internal fun AchievementRow(a: Achievement, compact: Boolean, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val interaction = remember { MutableInteractionSource() }
val hot = interaction.collectIsFocusedAsState().value
val shape = RoundedCornerShape(12.dp)
val secret = a.hidden && !a.unlocked
val icon = if (compact) 36.dp else 48.dp
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, shape, pal.signal, pal.line)
.focusable(interactionSource = interaction)
.padding(if (compact) 8.dp else 10.dp),
) {
Box(Modifier.size(icon).clip(RoundedCornerShape(8.dp)).background(pal.surfaceVariant)) {
val url = if (a.unlocked) a.icon else a.iconLocked ?: a.icon
if (url != null) AsyncImage(
model = url, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().alpha(if (a.unlocked) 1f else 0.55f),
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
if (secret) stringResource(R.string.achievements_hidden) else a.name,
fontSize = if (compact) 14.sp else 15.sp, fontWeight = FontWeight.SemiBold,
color = if (a.unlocked) colors.onBackground else colors.onSurfaceVariant,
maxLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis,
)
val desc = if (secret) stringResource(R.string.achievements_hidden_detail) else a.description
if (desc.isNotEmpty()) Text(
desc, fontSize = if (compact) 12.sp else 13.sp, lineHeight = if (compact) 16.sp else 18.sp,
color = colors.onSurfaceVariant, maxLines = if (compact) 1 else 3, overflow = TextOverflow.Ellipsis,
)
}
if (a.unlocked && a.unlockedAt > 0) {
Spacer(Modifier.width(10.dp))
Text(
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(a.unlockedAt * 1000)),
fontSize = 12.sp, fontWeight = FontWeight.Medium, color = pal.signal,
)
}
}
}
@Composable
internal fun StoreAchievementsSection(game: StoreAchievements.Game) {
val label = StoreAchievements.forSource(game.source)?.label ?: return
StoreAchievementsSectionContent(label, rememberAchievements(game) ?: return)
}
@Composable
internal fun StoreAchievementsSectionContent(label: String, state: AchievementsState) {
val colors = MaterialTheme.colorScheme
val list = state.list
// Hidden for games without achievements.
if (list.isNullOrEmpty() && !state.reading && state.reason != Reason.SIGNED_OUT && state.reason != Reason.UNREACHABLE) return
val narrow = LocalNarrowPane.current
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = 18.dp)) {
Text(stringResource(R.string.achievements_title), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
achievementsNote(state, label)?.let { Text(it, fontSize = 13.sp, color = colors.onSurfaceVariant) }
if (!list.isNullOrEmpty()) {
AchievementsProgress(state)
for ((label, group) in groups(list)) {
AchievementGroupLabel(stringResource(label, group.size))
for (a in group) AchievementRow(a, compact = narrow)
}
}
}
}
private fun LazyListScope.achievementItems(list: List<Achievement>, compact: Boolean, first: FocusRequester) {
var index = 0
for ((label, group) in groups(list)) {
item(key = "group-$label") { AchievementGroupLabel(stringResource(label, group.size)) }
val offset = index
itemsIndexed(group, key = { _, a -> "a-" + a.id }) { i, a ->
AchievementRow(a, compact, if (offset + i == 0) Modifier.focusRequester(first) else Modifier)
}
index += group.size
}
}
@Composable
internal fun SessionAchievements(game: StoreAchievements.Game, title: String?, onClose: () -> Unit) {
val label = StoreAchievements.forSource(game.source)?.label.orEmpty()
SessionAchievementsPanel(label, rememberAchievements(game) ?: AchievementsState.READING, title, onClose)
}
@Composable
internal fun SessionAchievementsPanel(label: String, state: AchievementsState, title: String?, onClose: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
BackHandler(onBack = onClose)
BoxWithConstraints(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClose),
contentAlignment = Alignment.CenterEnd,
) {
val short = maxHeight < 440.dp
Column(
Modifier.fillMaxHeight().width(minOf(420.dp, maxWidth * 0.92f))
.background(pal.background.copy(alpha = 0.97f))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.controllerBack(onClose)
.padding(if (short) 12.dp else 16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.achievements_title), fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
if (title != null) Text(title, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
state.list?.takeIf { it.isNotEmpty() }?.let { list ->
if (state.all) Trophy(20.dp, Modifier.padding(end = 6.dp))
Text(stringResource(R.string.achievements_count_short, state.done, list.size), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
}
}
val list = state.list
if (list.isNullOrEmpty()) {
Text(achievementsNote(state, label).orEmpty(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 14.dp))
} else {
LinearProgressIndicator(
progress = { state.done / list.size.toFloat() },
color = pal.signal, trackColor = pal.line,
modifier = Modifier.padding(top = if (short) 6.dp else 10.dp).fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
)
val first = remember { FocusRequester() }
LazyColumn(
Modifier.weight(1f).padding(top = 6.dp),
contentPadding = PaddingValues(vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) { achievementItems(list, short, first) }
LaunchedEffect(list.isNotEmpty()) {
withFrameNanos {} // rows lay out a frame after composition
runCatching { first.requestFocus() }
}
}
}
}
}
/** [progress]: (unlocked, total), or null when unknown. */
@Composable
internal fun DrawerAchievementsRow(progress: Pair<Int, Int>?, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val interaction = remember { MutableInteractionSource() }
val hot = interaction.collectIsFocusedAsState().value
val shape = RoundedCornerShape(12.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier.fillMaxWidth().height(44.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface)
.glideBorder(hot, shape, pal.signal, pal.line)
.clickable(interactionSource = interaction, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 12.dp),
) {
val all = progress != null && progress.second > 0 && progress.first == progress.second
Icon(Icons.Filled.EmojiEvents, null, tint = if (all) TrophyGold else if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
Text(stringResource(R.string.achievements_title), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, modifier = Modifier.weight(1f))
if (progress != null && progress.second > 0) {
Text(stringResource(R.string.achievements_count_short, progress.first, progress.second), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
}
}
}
private val previewList = listOf(
Achievement("first", "First Steps", "Finish the tutorial.", hidden = false, unlocked = true, unlockedAt = 1_759_000_000, icon = null, iconLocked = null),
Achievement("boss", "Giant Slayer", "Defeat the warden of the old keep without taking damage.", hidden = false, unlocked = true, unlockedAt = 1_760_100_000, icon = null, iconLocked = null),
Achievement("collector", "Collector", "Find every relic in the valley.", hidden = false, unlocked = false, unlockedAt = 0, icon = null, iconLocked = null),
Achievement("secret", "The Last Door", "Open it.", hidden = true, unlocked = false, unlockedAt = 0, icon = null, iconLocked = null),
)
@Composable
private fun PreviewSurface(content: @Composable () -> Unit) {
DroidDeckTheme(theme = Themes.GRAPHITE) {
Box(Modifier.fillMaxSize().background(LocalPalette.current.background).padding(16.dp)) { content() }
}
}
@Preview(name = "Store page section", widthDp = 800, heightDp = 600)
@Composable
private fun StoreAchievementsSectionPreview() = PreviewSurface {
Column(Modifier.width(560.dp)) { StoreAchievementsSectionContent("GOG", AchievementsState(previewList, null, false)) }
}
@Preview(name = "Store page section, signed out", widthDp = 800, heightDp = 200)
@Composable
private fun StoreAchievementsSectionSignedOutPreview() = PreviewSurface {
Column(Modifier.width(560.dp)) { StoreAchievementsSectionContent("Epic", AchievementsState(null, Reason.SIGNED_OUT, false)) }
}
@Preview(name = "Session panel", widthDp = 800, heightDp = 500)
@Composable
private fun SessionAchievementsPanelPreview() = DroidDeckTheme(theme = Themes.GRAPHITE) {
SessionAchievementsPanel("Epic", AchievementsState(previewList, null, false), "Hollow Valley", onClose = {})
}
@Preview(name = "Session panel, 4:3", widthDp = 560, heightDp = 420)
@Composable
private fun SessionAchievementsPanelShortPreview() = DroidDeckTheme(theme = Themes.GRAPHITE) {
SessionAchievementsPanel("GOG", AchievementsState(previewList.map {
Achievement(it.id, it.name, it.description, it.hidden, true, 1_760_000_000, null, null)
}, null, false), "Hollow Valley", onClose = {})
}
@Preview(name = "Session panel, offline", widthDp = 800, heightDp = 500)
@Composable
private fun SessionAchievementsPanelOfflinePreview() = DroidDeckTheme(theme = Themes.GRAPHITE) {
SessionAchievementsPanel("GOG", AchievementsState(null, Reason.UNREACHABLE, false), "Hollow Valley", onClose = {})
}
@Preview(name = "Drawer row", widthDp = 360, heightDp = 160)
@Composable
private fun DrawerAchievementsRowPreview() = PreviewSurface {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
DrawerAchievementsRow(2 to 4) {}
DrawerAchievementsRow(4 to 4) {}
DrawerAchievementsRow(null) {}
}
}
@Preview(name = "Rows", widthDp = 480, heightDp = 300)
@Composable
private fun AchievementRowPreview() = PreviewSurface {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
AchievementsProgress(AchievementsState(previewList, null, false))
for (a in previewList) AchievementRow(a, compact = false)
AchievementRow(previewList[0], compact = true)
}
}
@@ -36,6 +36,7 @@ import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadState
import com.droiddeck.launcher.stores.formatBytes
@@ -128,6 +129,15 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
if (description.isNotBlank()) Rise(2) {
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) {
when {
StoreAchievements.forSource(store.id) == null -> null
item?.owned == true -> StoreAchievements.Game.of(item)
installed != null -> StoreAchievements.Game.of(installed.sidecar)
else -> null
}
}
if (achievements != null) Rise(3) { StoreAchievementsSection(achievements) }
}
/**
+15
View File
@@ -2132,4 +2132,19 @@
<item quantity="many">%1$d ya en Juegos</item>
<item quantity="other">%1$d ya en Juegos</item>
</plurals>
<string name="achievements_title">Logros</string>
<string name="achievements_count">%1$d de %2$d desbloqueados</string>
<string name="achievements_count_short">%1$d / %2$d</string>
<string name="achievements_group_unlocked">Desbloqueados · %1$d</string>
<string name="achievements_group_locked">Bloqueados · %1$d</string>
<string name="achievements_group_hidden">Ocultos · %1$d</string>
<string name="achievements_hidden">Logro oculto</string>
<string name="achievements_hidden_detail">Los detalles se muestran al desbloquearlo.</string>
<string name="achievements_all">Todos los logros desbloqueados</string>
<string name="achievements_reading">Leyendo…</string>
<string name="achievements_signed_out">Inicia sesión en %1$s para ver tus logros.</string>
<string name="achievements_unreachable">No se pudo conectar con %1$s. Vuelve a intentarlo cuando tengas conexión.</string>
<string name="achievements_none">%1$s no indica logros para este juego.</string>
<string name="achievements_show_all">Ver los %1$d</string>
<string name="achievements_dialog_title">Logros · %1$s</string>
</resources>
+15
View File
@@ -962,4 +962,19 @@
<item quantity="one">%1$d déjà dans Jeux</item>
<item quantity="other">%1$d déjà dans Jeux</item>
</plurals>
<string name="achievements_title">Succès</string>
<string name="achievements_count">%1$d sur %2$d débloqués</string>
<string name="achievements_count_short">%1$d / %2$d</string>
<string name="achievements_group_unlocked">Débloqués · %1$d</string>
<string name="achievements_group_locked">Verrouillés · %1$d</string>
<string name="achievements_group_hidden">Cachés · %1$d</string>
<string name="achievements_hidden">Succès caché</string>
<string name="achievements_hidden_detail">Les détails s\'affichent une fois débloqué.</string>
<string name="achievements_all">Tous les succès débloqués</string>
<string name="achievements_reading">Lecture…</string>
<string name="achievements_signed_out">Connectez-vous à %1$s pour voir vos succès.</string>
<string name="achievements_unreachable">Impossible de joindre %1$s. Réessayez une fois en ligne.</string>
<string name="achievements_none">%1$s n\'indique aucun succès pour ce jeu.</string>
<string name="achievements_show_all">Tout afficher (%1$d)</string>
<string name="achievements_dialog_title">Succès · %1$s</string>
</resources>
+15
View File
@@ -2088,4 +2088,19 @@
<plurals name="games_already_n">
<item quantity="other">%1$d 本はすでにゲームにあります</item>
</plurals>
<string name="achievements_title">実績</string>
<string name="achievements_count">%2$d 個中 %1$d 個を解除</string>
<string name="achievements_count_short">%1$d / %2$d</string>
<string name="achievements_group_unlocked">解除済み · %1$d</string>
<string name="achievements_group_locked">未解除 · %1$d</string>
<string name="achievements_group_hidden">隠し実績 · %1$d</string>
<string name="achievements_hidden">隠し実績</string>
<string name="achievements_hidden_detail">解除すると詳細が表示されます。</string>
<string name="achievements_all">すべての実績を解除</string>
<string name="achievements_reading">読み込み中…</string>
<string name="achievements_signed_out">実績を表示するには %1$s にサインインしてください。</string>
<string name="achievements_unreachable">%1$s に接続できませんでした。オンラインになってから再度確認してください。</string>
<string name="achievements_none">%1$s にはこのゲームの実績がありません。</string>
<string name="achievements_show_all">すべて表示(%1$d)</string>
<string name="achievements_dialog_title">実績 · %1$s</string>
</resources>
+15
View File
@@ -2088,4 +2088,19 @@
<plurals name="games_already_n">
<item quantity="other">%1$d개는 이미 게임에 있음</item>
</plurals>
<string name="achievements_title">도전 과제</string>
<string name="achievements_count">%2$d개 중 %1$d개 달성</string>
<string name="achievements_count_short">%1$d / %2$d</string>
<string name="achievements_group_unlocked">달성 · %1$d</string>
<string name="achievements_group_locked">미달성 · %1$d</string>
<string name="achievements_group_hidden">숨겨짐 · %1$d</string>
<string name="achievements_hidden">숨겨진 도전 과제</string>
<string name="achievements_hidden_detail">달성하면 세부 정보가 표시됩니다.</string>
<string name="achievements_all">모든 도전 과제 달성</string>
<string name="achievements_reading">불러오는 중…</string>
<string name="achievements_signed_out">도전 과제를 보려면 %1$s에 로그인하세요.</string>
<string name="achievements_unreachable">%1$s에 연결할 수 없습니다. 온라인 상태에서 다시 확인하세요.</string>
<string name="achievements_none">%1$s에 이 게임의 도전 과제가 없습니다.</string>
<string name="achievements_show_all">모두 보기(%1$d)</string>
<string name="achievements_dialog_title">도전 과제 · %1$s</string>
</resources>
+15
View File
@@ -1073,4 +1073,19 @@
<item quantity="many">%1$d уже в «Играх»</item>
<item quantity="other">%1$d уже в «Играх»</item>
</plurals>
<string name="achievements_title">Достижения</string>
<string name="achievements_count">Получено %1$d из %2$d</string>
<string name="achievements_count_short">%1$d / %2$d</string>
<string name="achievements_group_unlocked">Получены · %1$d</string>
<string name="achievements_group_locked">Не получены · %1$d</string>
<string name="achievements_group_hidden">Скрытые · %1$d</string>
<string name="achievements_hidden">Скрытое достижение</string>
<string name="achievements_hidden_detail">Подробности появятся после получения.</string>
<string name="achievements_all">Все достижения получены</string>
<string name="achievements_reading">Загрузка…</string>
<string name="achievements_signed_out">Войдите в %1$s, чтобы увидеть свои достижения.</string>
<string name="achievements_unreachable">Не удалось связаться с %1$s. Проверьте снова, когда будете в сети.</string>
<string name="achievements_none">В %1$s нет достижений для этой игры.</string>
<string name="achievements_show_all">Показать все (%1$d)</string>
<string name="achievements_dialog_title">Достижения · %1$s</string>
</resources>
@@ -2088,4 +2088,19 @@
<plurals name="games_already_n">
<item quantity="other">%1$d 个已在游戏中</item>
</plurals>
<string name="achievements_title">成就</string>
<string name="achievements_count">已解锁 %1$d / %2$d</string>
<string name="achievements_count_short">%1$d / %2$d</string>
<string name="achievements_group_unlocked">已解锁 · %1$d</string>
<string name="achievements_group_locked">未解锁 · %1$d</string>
<string name="achievements_group_hidden">隐藏 · %1$d</string>
<string name="achievements_hidden">隐藏成就</string>
<string name="achievements_hidden_detail">解锁后显示详情。</string>
<string name="achievements_all">已解锁全部成就</string>
<string name="achievements_reading">读取中…</string>
<string name="achievements_signed_out">登录 %1$s 以查看你的成就。</string>
<string name="achievements_unreachable">无法连接到 %1$s。请在联网后再查看。</string>
<string name="achievements_none">%1$s 未列出此游戏的成就。</string>
<string name="achievements_show_all">显示全部 %1$d 项</string>
<string name="achievements_dialog_title">成就 · %1$s</string>
</resources>
@@ -2088,4 +2088,19 @@
<plurals name="games_already_n">
<item quantity="other">%1$d 個已在遊戲中</item>
</plurals>
<string name="achievements_title">成就</string>
<string name="achievements_count">已解鎖 %1$d / %2$d</string>
<string name="achievements_count_short">%1$d / %2$d</string>
<string name="achievements_group_unlocked">已解鎖 · %1$d</string>
<string name="achievements_group_locked">未解鎖 · %1$d</string>
<string name="achievements_group_hidden">隱藏 · %1$d</string>
<string name="achievements_hidden">隱藏成就</string>
<string name="achievements_hidden_detail">解鎖後會顯示詳情。</string>
<string name="achievements_all">已解鎖全部成就</string>
<string name="achievements_reading">正在讀取…</string>
<string name="achievements_signed_out">登入 %1$s 以查看你的成就。</string>
<string name="achievements_unreachable">無法連接 %1$s。請在連線後再查看。</string>
<string name="achievements_none">%1$s 未列出此遊戲的成就。</string>
<string name="achievements_show_all">顯示全部 %1$d 項</string>
<string name="achievements_dialog_title">成就 · %1$s</string>
</resources>
@@ -2088,4 +2088,19 @@
<plurals name="games_already_n">
<item quantity="other">%1$d 個已在遊戲中</item>
</plurals>
<string name="achievements_title">成就</string>
<string name="achievements_count">已解鎖 %1$d / %2$d</string>
<string name="achievements_count_short">%1$d / %2$d</string>
<string name="achievements_group_unlocked">已解鎖 · %1$d</string>
<string name="achievements_group_locked">未解鎖 · %1$d</string>
<string name="achievements_group_hidden">隱藏 · %1$d</string>
<string name="achievements_hidden">隱藏成就</string>
<string name="achievements_hidden_detail">解鎖後會顯示詳細資訊。</string>
<string name="achievements_all">已解鎖所有成就</string>
<string name="achievements_reading">正在讀取…</string>
<string name="achievements_signed_out">登入 %1$s 以查看你的成就。</string>
<string name="achievements_unreachable">無法連線到 %1$s。請在連上網路後再查看。</string>
<string name="achievements_none">%1$s 沒有列出這款遊戲的成就。</string>
<string name="achievements_show_all">顯示全部 %1$d 項</string>
<string name="achievements_dialog_title">成就 · %1$s</string>
</resources>
+16
View File
@@ -2125,6 +2125,22 @@
<string name="stores_eyebrow_installed">installed</string>
<string name="stores_eyebrow_library">in your library</string>
<string name="stores_eyebrow_store">store</string>
<!-- A GOG or Epic game's achievements: the store page's section and the session panel. -->
<string name="achievements_title">Achievements</string>
<string name="achievements_count">%1$d of %2$d unlocked</string>
<string name="achievements_count_short">%1$d / %2$d</string>
<string name="achievements_group_unlocked">Unlocked · %1$d</string>
<string name="achievements_group_locked">Locked · %1$d</string>
<string name="achievements_group_hidden">Hidden · %1$d</string>
<string name="achievements_hidden">Hidden achievement</string>
<string name="achievements_hidden_detail">Details are shown once it is unlocked.</string>
<string name="achievements_all">Every achievement unlocked</string>
<string name="achievements_reading">Reading…</string>
<string name="achievements_signed_out">Sign in to %1$s to see your achievements.</string>
<string name="achievements_unreachable">Could not reach %1$s. Check back once you are online.</string>
<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>
<string name="stores_in_steam">✓ In Steam</string>
<string name="stores_uninstall">Uninstall</string>
<string name="stores_uninstalling">Uninstalling…</string>