Stores: GOG end to end - sign-in, library, public shelves, installs through the queue

GOG's OAuth page in the new StoreLoginActivity (the Galaxy client identity,
a browser identity for a social sign-in hop), the token kept in the app's
private credentials file and refreshed through auth.gog.com. The library
syncs the owned ids against a cache and fetches each product's details;
the Store tab reads catalog.gog.com's public feeds (new, deals, free,
trending). An install runs Bannerlator's GOG pipeline made blocking under
the download queue: gen2 chunks through the native engine when the library
is there (secure-link refresh on expiry), the built-in pool otherwise,
gen1 ranges, and the plain installer as the last resort; the sidecar is
written and the game registered with Steam when it is done.
This commit is contained in:
The412Banner committed 2026-10-08 16:51:47 -04:00
1 parent dad1aa2dc7
commit f467bb22e8
16 files changed
+2266 -3

No files matched your search

+7
View File
@@ -127,6 +127,13 @@
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
<!-- A store's own sign-in page (GOG, Epic Games, Amazon Games) in a WebView. -->
<activity
android:name=".stores.StoreLoginActivity"
android:exported="false"
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
<activity
android:name=".agent.AgentStartActivity"
android:exported="true"
@@ -29,5 +29,7 @@ class App : Application() {
GpuClockPin.clearLeftover(this)
BwrapSpawner.start(this)
com.droiddeck.launcher.frontend.GameFileSync.start(this)
// The game stores the Stores section can talk to; the native engine loads only when one needs it.
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
}
}
@@ -0,0 +1,55 @@
package com.droiddeck.launcher.stores
import android.net.Uri
import android.util.Log
import android.webkit.WebView
import com.droiddeck.launcher.stores.gog.GogAuth
/** The three stores' sign-in flows for [StoreLoginActivity]. */
internal object LoginFlows {
private const val TAG = "StoreLogin"
fun forStore(store: Store, state: String): StoreLoginActivity.Flow? = when (store) {
Store.GOG -> GogFlow(state)
Store.EPIC -> epicFlow()
Store.AMAZON -> amazonFlow()
}
/** Set by the Epic and Amazon backends when they are in the build; null keeps the chip's card honest. */
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
@Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null }
/**
* GOG's implicit grant: the token arrives in the redirect URL's fragment. The CSRF state is
* checked only when GOG echoes one back - its social-login path returns none, and treating
* that as a mismatch threw away good tokens.
*/
private class GogFlow(private val state: String) : StoreLoginActivity.Flow {
override val startUrl: String get() = GogAuth.authUrl(state)
override val userAgent: String get() = GogAuth.GALAXY_UA
override fun isRedirect(url: String): Boolean = url.startsWith(GogAuth.REDIRECT_PREFIX)
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
val fragment = Uri.parse(url).fragment ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
val frag = Uri.parse("x://x?$fragment")
val returned = frag.getQueryParameter("state")
if (returned != null && returned != state) {
Log.w(TAG, "gog: state mismatch; redirect rejected")
return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
}
val access = frag.getQueryParameter("access_token")
if (access.isNullOrEmpty()) {
// The keys only, never the values: the fragment carries live tokens.
Log.w(TAG, "gog: redirect without access_token; keys=${frag.queryParameterNames}")
return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
}
return try {
GogAuth.saveLogin(activity, access, frag.getQueryParameter("refresh_token"), frag.getQueryParameter("user_id"))
null
} catch (e: Exception) {
Log.w(TAG, "gog: saving the sign-in failed: ${e.javaClass.simpleName}")
activity.getString(com.droiddeck.launcher.R.string.stores_login_error_generic)
}
}
}
}
@@ -0,0 +1,308 @@
package com.droiddeck.launcher.stores
import android.annotation.SuppressLint
import android.graphics.Color
import android.net.Uri
import android.net.http.SslError
import android.os.Bundle
import android.os.Message
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.SslErrorHandler
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.gog.GogAuth
import java.util.concurrent.atomic.AtomicBoolean
/**
* A store's own sign-in page in a WebView, one activity for the three stores: GOG's OAuth page
* (the token comes back in the redirect's fragment), Epic's web login (an authorization code on
* a JSON page), Amazon's device sign-in (a PKCE code on the return URL). Plain views, as the
* proven GOG flow was: the page is rendered with the store's own client identity, and a social
* sign-in hop to Google / Facebook / Apple runs under a real browser identity in a popup, since
* those providers refuse an embedded browser.
*
* Nothing from the page is logged but redacted URLs and key names; the tokens go straight to
* StoreAccounts' private file and the activity finishes.
*/
class StoreLoginActivity : ComponentActivity() {
companion object {
private const val TAG = "StoreLogin"
const val EXTRA_STORE = "store"
private const val KEY_STATE = "oauth_state"
private const val CHROME_UA = "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36"
fun generateState(): String {
val bytes = ByteArray(24)
java.security.SecureRandom().nextBytes(bytes)
return android.util.Base64.encodeToString(bytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING)
}
}
/** What one store's flow decides: the page to open, the identity to open it with, and the redirect that ends it. */
internal interface Flow {
val startUrl: String
val userAgent: String
/** True when [url] is the flow's end; the activity then calls [finish]. */
fun isRedirect(url: String): Boolean
/** Runs off the main thread with the redirect; returns null on success, else a message to show. */
fun finish(activity: StoreLoginActivity, view: WebView, url: String): String?
/** Whether a JSON page at the redirect must be read out of the document instead of the URL (Epic). */
val readsPage: Boolean get() = false
}
private var store: Store? = null
private var flow: Flow? = null
private var webViewRef: WebView? = null
private var popupWebView: WebView? = null
private lateinit var contentHost: FrameLayout
private lateinit var popupHost: FrameLayout
private lateinit var progressBar: ProgressBar
private lateinit var errorPanel: LinearLayout
private lateinit var errorText: TextView
private lateinit var titleText: TextView
private var oauthState: String? = null
private val captured = AtomicBoolean(false)
private fun dp(value: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
store = Store.byId(intent.getStringExtra(EXTRA_STORE))
oauthState = savedInstanceState?.getString(KEY_STATE) ?: generateState()
flow = store?.let { LoginFlows.forStore(it, oauthState!!) }
if (flow == null) { finish(); return }
setContentView(buildChrome())
val webView = newWebView(flow!!.userAgent, isPopup = false)
webViewRef = webView
contentHost.addView(webView, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when {
popupWebView != null -> dismissPopup()
webViewRef?.canGoBack() == true -> webViewRef?.goBack()
else -> finish()
}
}
})
webView.loadUrl(flow!!.startUrl)
}
private fun buildChrome(): View {
val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(0xFF0A0B0D.toInt()) }
val bar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL; setBackgroundColor(0xFF121417.toInt())
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48))
}
titleText = TextView(this).apply {
text = getString(R.string.stores_login_title, store?.label ?: "")
setTextColor(0xFFF2F4F7.toInt()); textSize = 16f; setPadding(dp(16), 0, dp(8), 0)
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
val reload = ImageButton(this).apply {
setImageResource(android.R.drawable.ic_menu_rotate); setBackgroundColor(Color.TRANSPARENT)
contentDescription = getString(R.string.stores_login_reload); layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
setOnClickListener { reloadLogin() }
}
val close = ImageButton(this).apply {
setImageResource(android.R.drawable.ic_menu_close_clear_cancel); setBackgroundColor(Color.TRANSPARENT)
contentDescription = getString(R.string.stores_login_close); layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
setOnClickListener { finish() }
}
bar.addView(titleText); bar.addView(reload); bar.addView(close)
progressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
max = 100; isIndeterminate = false
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(3))
}
contentHost = FrameLayout(this).apply { setBackgroundColor(0xFF0A0B0D.toInt()); layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) }
popupHost = FrameLayout(this).apply { setBackgroundColor(0xFF0A0B0D.toInt()); visibility = View.GONE }
contentHost.addView(popupHost, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
errorPanel = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL; gravity = Gravity.CENTER; setBackgroundColor(0xFF0A0B0D.toInt())
setPadding(dp(24), dp(24), dp(24), dp(24)); visibility = View.GONE
}
errorText = TextView(this).apply { setTextColor(0xFFF2F4F7.toInt()); textSize = 15f; gravity = Gravity.CENTER }
val retry = Button(this).apply { text = getString(R.string.stores_login_retry); setOnClickListener { reloadLogin() } }
errorPanel.addView(errorText)
errorPanel.addView(retry, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(16) })
contentHost.addView(errorPanel, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
root.addView(bar); root.addView(progressBar); root.addView(contentHost)
return root
}
private fun showError(message: String) {
runOnUiThread { errorText.text = message; errorPanel.visibility = View.VISIBLE; errorPanel.bringToFront(); progressBar.visibility = View.GONE }
}
private fun clearError() { runOnUiThread { errorPanel.visibility = View.GONE } }
private fun reloadLogin() {
dismissPopup(); clearError()
captured.set(false)
val fresh = generateState()
oauthState = fresh
flow = store?.let { LoginFlows.forStore(it, fresh) } ?: return
webViewRef?.let { it.settings.userAgentString = flow!!.userAgent; it.loadUrl(flow!!.startUrl) }
}
@SuppressLint("SetJavaScriptEnabled")
private fun newWebView(userAgent: String, isPopup: Boolean): WebView = WebView(this).apply {
setBackgroundColor(0xFF0A0B0D.toInt())
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.userAgentString = userAgent
// The stores' social-login buttons are window.open() popups; without these the call is dropped silently.
settings.setSupportMultipleWindows(true)
settings.javaScriptCanOpenWindowsAutomatically = true
if (isPopup) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
webViewClient = Client(isPopup)
webChromeClient = Chrome(isPopup)
}
private inner class Chrome(private val isPopup: Boolean) : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progressBar.progress = newProgress
progressBar.visibility = if (newProgress in 1..99) View.VISIBLE else View.GONE
}
override fun onCreateWindow(view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message?): Boolean {
if (resultMsg == null) return false
dismissPopup()
val child = newWebView(CHROME_UA, isPopup = true)
popupWebView = child
popupHost.addView(child, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
popupHost.visibility = View.VISIBLE
popupHost.bringToFront()
titleText.text = getString(R.string.stores_login_provider_title)
(resultMsg.obj as WebView.WebViewTransport).webView = child
resultMsg.sendToTarget()
return true
}
override fun onCloseWindow(window: WebView?) { if (isPopup || window === popupWebView) dismissPopup() }
}
/** Deferred: destroying a WebView from inside one of its own callbacks crashes chromium. */
private fun dismissPopup() {
val child = popupWebView ?: return
popupWebView = null
popupHost.post { destroyPopupView(child) }
}
private fun destroyPopupView(child: WebView) {
child.stopLoading(); child.webChromeClient = null; child.loadUrl("about:blank")
popupHost.removeView(child); child.destroy()
if (popupWebView == null) {
popupHost.visibility = View.GONE
if (!isFinishing) titleText.text = getString(R.string.stores_login_title, store?.label ?: "")
}
}
private inner class Client(private val isPopup: Boolean) : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
Log.d(TAG, "page[popup=$isPopup]: ${StoreLog.redactUrl(url)}")
// Amazon's return URL lands as a page load rather than a navigation we can intercept.
if (url != null && view != null && flow?.isRedirect(url) == true && flow?.readsPage != true) complete(view, url)
}
override fun onPageFinished(view: WebView?, url: String?) {
// Epic's redirect is a JSON page: the code is in the document, read once it is loaded.
if (url != null && view != null && flow?.readsPage == true && flow?.isRedirect(url) == true) complete(view, url)
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
if (request?.isForMainFrame == true) showError(getString(R.string.stores_login_error_network, error?.description ?: ""))
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) {
val status = errorResponse?.statusCode ?: 0
if (request?.isForMainFrame != true) return
if (GogAuth.isIdentityProviderHost(request.url?.host) && (status == 403 || status == 400)) showError(getString(R.string.stores_login_error_idp_blocked))
else showError(getString(R.string.stores_login_error_http, status))
}
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
handler?.cancel()
showError(getString(R.string.stores_login_error_ssl))
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = route(view, request.url)
@Suppress("DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = route(view, Uri.parse(url))
private fun route(view: WebView, uri: Uri): Boolean {
val f = flow ?: return false
val url = uri.toString()
if (f.isRedirect(url) && !f.readsPage) { complete(view, url); return true }
// A social sign-in hop that stays in the main view: the provider gets a browser identity,
// and the store gets its own identity back afterwards (a plain Chrome UA makes GOG's
// page serve a form that never renders in a WebView).
if (!isPopup && store == Store.GOG) {
if (GogAuth.isGogHost(uri.host) && view.settings.userAgentString != f.userAgent) { view.settings.userAgentString = f.userAgent; return false }
if (GogAuth.isIdentityProviderHost(uri.host) && view.settings.userAgentString != CHROME_UA) {
view.settings.userAgentString = CHROME_UA
CookieManager.getInstance().setAcceptThirdPartyCookies(view, true)
view.loadUrl(url)
return true
}
}
return false
}
}
private fun complete(view: WebView, url: String) {
if (!captured.compareAndSet(false, true)) return
val f = flow ?: return
dismissPopup()
runOnUiThread {
clearError()
titleText.text = getString(R.string.stores_login_finishing)
if (!f.readsPage) view.stopLoading()
}
if (f.readsPage) {
// The document's text, then the exchange off the main thread.
view.evaluateJavascript("(function(){ try { return document.body.innerText; } catch(e){ return ''; } })()") { json ->
Thread({ settle(f.finish(this, view, json ?: "")) }, "store-login").start()
}
} else Thread({ settle(f.finish(this, view, url)) }, "store-login").start()
}
private fun settle(problem: String?) {
runOnUiThread {
if (problem == null) { StoresState.refresh(this); finish(); return }
captured.set(false)
if (!isFinishing && !isDestroyed) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show()
reloadLogin()
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
oauthState?.let { outState.putString(KEY_STATE, it) }
}
override fun onDestroy() {
popupWebView?.let { popupWebView = null; destroyPopupView(it) }
webViewRef?.let { it.webChromeClient = null; (it.parent as? ViewGroup)?.removeView(it); it.destroy() }
webViewRef = null
super.onDestroy()
}
}
@@ -0,0 +1,125 @@
package com.droiddeck.launcher.stores
import android.util.Log
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
/**
* The small HTTP the storefront catalogs and account calls need: a GET or a JSON POST with the
* right User-Agent, a bearer token when the call is signed in, and a body or null. Store URLs
* carry their authorization in the query string or the path, so a URL is only ever logged
* through [StoreLog.redactUrl]. Plain HttpURLConnection, like the rest of the app.
*/
internal object StoreNet {
private const val TAG = "StoreNet"
/** A desktop browser: GOG's catalog and Epic's store GraphQL answer it; Epic's edge refuses the bare Java UA. */
const val BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
fun get(url: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 20_000, headers: Map<String, String> = emptyMap()): String? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = timeoutMs
readTimeout = timeoutMs
instanceFollowRedirects = true
setRequestProperty("User-Agent", userAgent)
setRequestProperty("Accept", "application/json, text/plain, */*")
if (bearer != null) setRequestProperty("Authorization", "Bearer $bearer")
headers.forEach { (k, v) -> setRequestProperty(k, v) }
}
val code = conn.responseCode
if (code !in 200..299) { Log.w(TAG, "GET $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn)
} catch (e: Exception) {
Log.w(TAG, "GET failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
fun postJson(url: String, body: String, bearer: String? = null, userAgent: String = BROWSER_UA, timeoutMs: Int = 25_000): String? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = timeoutMs
readTimeout = timeoutMs
requestMethod = "POST"
doOutput = true
setRequestProperty("User-Agent", userAgent)
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "application/json")
if (bearer != null) setRequestProperty("Authorization", "Bearer $bearer")
}
OutputStreamWriter(conn.outputStream, "UTF-8").use { it.write(body) }
val code = conn.responseCode
if (code !in 200..299) { Log.w(TAG, "POST $code ${StoreLog.redactUrl(url)}"); null } else readAll(conn)
} catch (e: Exception) {
Log.w(TAG, "POST failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
null
} finally {
runCatching { conn?.disconnect() }
}
}
/**
* A 600x900 poster for [title] from SteamGridDB by name, or "" - the lookup the GOG library and
* the Amazon poster backfill use, since those stores publish no tall art of their own.
*/
fun sgdbPoster(title: String): String = try {
val enc = java.net.URLEncoder.encode(title, "UTF-8")
val search = get("https://www.steamgriddb.com/api/v2/search/autocomplete/$enc", bearer = SGDB_KEY)
val data = search?.let { org.json.JSONObject(it).optJSONArray("data") }
if (data == null || data.length() == 0) "" else {
val gameId = data.getJSONObject(0).getInt("id")
val grids = get("https://www.steamgriddb.com/api/v2/grids/game/$gameId?dimensions=600x900&mimes=image/jpeg,image/png&limit=1", bearer = SGDB_KEY)
?.let { org.json.JSONObject(it).optJSONArray("data") }
if (grids == null || grids.length() == 0) "" else grids.getJSONObject(0).optString("url", "")
}
} catch (_: Exception) { "" }
private const val SGDB_KEY = "cf89227f12c773bb1117b6b109ae1659"
private fun readAll(conn: HttpURLConnection): String {
val sb = StringBuilder()
BufferedReader(InputStreamReader(conn.inputStream, "UTF-8")).use { br ->
val buf = CharArray(8192)
var n: Int
while (br.read(buf).also { n = it } > 0) sb.append(buf, 0, n)
}
return sb.toString()
}
}
/**
* Keeps credentials out of the logs. A store's CDN, manifest and token URLs carry their
* authorization in the query string (signed, time-limited tokens, `client_secret`,
* `refresh_token`) or in the authority's userinfo; one such line in logcat or a shared log hands a
* reader a live download. Every store URL that is written anywhere goes through [redactUrl] first.
*/
object StoreLog {
/** `scheme://host/path` only: query, fragment and `user:pass@` dropped; "[url]" when it cannot be parsed. */
@JvmStatic
fun redactUrl(url: String?): String? {
if (url == null) return null
return try {
var out = url
out.indexOf('#').takeIf { it >= 0 }?.let { out = out.substring(0, it) }
out.indexOf('?').takeIf { it >= 0 }?.let { out = out.substring(0, it) }
val scheme = out.indexOf("://")
if (scheme >= 0) {
val authStart = scheme + 3
val pathStart = out.indexOf('/', authStart)
val authority = if (pathStart >= 0) out.substring(authStart, pathStart) else out.substring(authStart)
val at = authority.lastIndexOf('@')
if (at >= 0) out = out.substring(0, authStart) + authority.substring(at + 1) + (if (pathStart >= 0) out.substring(pathStart) else "")
}
out.ifEmpty { "[url]" }
} catch (e: Exception) {
"[url]"
}
}
}
@@ -48,6 +48,7 @@ object StoresNative {
@Synchronized
fun forget() { loaded = null }
/** The crate's version string; only after [ensureLoaded] said yes. */
@JvmStatic
private external fun nativeVersion(): String
external fun nativeVersion(): String
}
@@ -34,10 +34,13 @@ object DownloadQueue {
class JobHandle internal constructor(val key: String, internal val cancelled: AtomicBoolean) {
val isCancelled: Boolean get() = cancelled.get()
fun stage(stage: DownloadStage, detail: String = "") = update(key) { it.copy(stage = stage, detail = detail.ifEmpty { it.detail }, state = DownloadState.RUNNING) }
/** Bytes so far (negative = unchanged), the total (<= 0 = unchanged), a line, the speed (negative = unchanged). */
fun progress(bytesDone: Long, bytesTotal: Long, detail: String? = null, speedBps: Long = -1L) = update(key) {
val done = if (bytesDone >= 0) bytesDone else it.bytesDone
val total = if (bytesTotal > 0) bytesTotal else it.bytesTotal
val speed = if (speedBps >= 0) speedBps else it.speedBps
val eta = if (speed > 0 && bytesTotal > bytesDone) (bytesTotal - bytesDone) / speed else -1L
it.copy(state = DownloadState.RUNNING, stage = DownloadStage.DOWNLOAD, bytesDone = bytesDone, bytesTotal = if (bytesTotal > 0) bytesTotal else it.bytesTotal, detail = detail ?: it.detail, speedBps = speed, etaSeconds = eta)
val eta = if (speed > 0 && total > done) (total - done) / speed else -1L
it.copy(state = DownloadState.RUNNING, stage = DownloadStage.DOWNLOAD, bytesDone = done, bytesTotal = total, detail = detail ?: it.detail, speedBps = speed, etaSeconds = eta)
}
fun log(line: String) = StoresState.logLine(line)
}
@@ -0,0 +1,101 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.net.Uri
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAccounts
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
/**
* The GOG sign-in: the token the OAuth page hands back, kept in StoreAccounts' private file,
* refreshed through auth.gog.com when it has expired (the client id and secret are GOG Galaxy's
* own, public ones, as every third-party GOG client uses). Nothing here is ever logged: the token
* endpoint's URL itself carries the secret and the refresh token.
*/
object GogAuth {
private const val TAG = "GogAuth"
private const val CLIENT_ID = "46899977096215655"
private const val CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
private const val TOKEN_URL = "https://auth.gog.com/token?client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&grant_type=refresh_token&refresh_token="
/** The login page, as GOG Galaxy opens it: an implicit grant that returns the token in the redirect's fragment. */
const val AUTH_URL = "https://auth.gog.com/auth?client_id=$CLIENT_ID" +
"&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=token&layout=client2"
const val REDIRECT_PREFIX = "https://embed.gog.com/on_login_success"
const val GALAXY_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) GOG Galaxy/2.0"
fun authUrl(state: String): String = "$AUTH_URL&state=${Uri.encode(state)}"
fun isSignedIn(context: Context): Boolean = StoreAccounts.read(context, Store.GOG)?.optString("access_token", "")?.isNotEmpty() == true
/** Saves a fresh sign-in and looks the account's name up for the chip. Blocking. */
fun saveLogin(context: Context, accessToken: String, refreshToken: String?, userId: String?) {
var username = ""
runCatching {
val body = StoreNet.get("https://embed.gog.com/userData.json", bearer = accessToken, userAgent = GALAXY_UA)
if (body != null) username = JSONObject(body).optString("username", "")
}
val json = JSONObject().put("access_token", accessToken).put("username", username)
.put("login_time", System.currentTimeMillis() / 1000L).put("expires_in", 3600)
if (!refreshToken.isNullOrEmpty()) json.put("refresh_token", refreshToken)
if (!userId.isNullOrEmpty()) json.put("user_id", userId)
StoreAccounts.write(context, Store.GOG, json)
Log.i(TAG, "signed in")
}
/** The raw stored token, expired or not; null when signed out. */
fun storedToken(context: Context): String? = StoreAccounts.read(context, Store.GOG)?.optString("access_token", "")?.ifEmpty { null }
/** A usable access token, refreshed when the recorded login time says it has expired; null when signed out or the refresh failed. */
@JvmStatic
fun validToken(context: Context): String? {
val json = StoreAccounts.read(context, Store.GOG) ?: return null
val token = json.optString("access_token", "").ifEmpty { return null }
val loginTime = json.optLong("login_time", 0L)
val expiresIn = json.optLong("expires_in", 3600L)
val now = System.currentTimeMillis() / 1000L
return if (loginTime == 0L || now >= loginTime + expiresIn - 60) refresh(context) else token
}
/** Refreshes the access token; the new one, or null on any failure. Blocking. */
@JvmStatic
fun refresh(context: Context): String? {
val json = StoreAccounts.read(context, Store.GOG) ?: return null
val refreshToken = json.optString("refresh_token", "").ifEmpty { return null }
var conn: HttpURLConnection? = null
return try {
conn = (URL(TOKEN_URL + refreshToken).openConnection() as HttpURLConnection).apply { connectTimeout = 15_000; readTimeout = 15_000 }
if (conn.responseCode != 200) { Log.w(TAG, "refresh: HTTP ${conn.responseCode}"); return null }
val body = conn.inputStream.bufferedReader().readText()
val o = JSONObject(body)
val access = o.optString("access_token", "").ifEmpty { return null }
json.put("access_token", access)
o.optString("refresh_token", "").takeIf { it.isNotEmpty() }?.let { json.put("refresh_token", it) }
json.put("login_time", System.currentTimeMillis() / 1000L).put("expires_in", o.optLong("expires_in", 3600L))
StoreAccounts.write(context, Store.GOG, json)
access
} catch (e: Exception) {
// Not the throwable: HttpURLConnection's messages echo the URL, which embeds the secrets.
Log.w(TAG, "refresh 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
return h == "google.com" || h.endsWith(".google.com") || h == "youtube.com" || h.endsWith(".youtube.com") ||
h == "facebook.com" || h.endsWith(".facebook.com") || h == "apple.com" || h.endsWith(".apple.com")
}
fun isGogHost(host: String?): Boolean {
val h = host?.lowercase() ?: return false
return h == "gog.com" || h.endsWith(".gog.com")
}
}
@@ -0,0 +1,115 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.content.Intent
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/** GOG in the Stores section: sign-in, the library, the public shelves, installs. */
object GogBackend : StoreBackend {
override val store: Store = Store.GOG
override fun signIn(context: Context) {
context.startActivity(Intent(context, StoreLoginActivity::class.java).putExtra(StoreLoginActivity.EXTRA_STORE, store.id).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun signOut(context: Context) {
GogPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply()
}
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
// The cache first, so the tab has something while the sync runs.
val cached = GogLibrary.cached(app)
if (cached.isNotEmpty()) StoresState.post { StoresState.library[store] = cached.map { GogLibrary.toCatalogItem(app, it) } }
StoresState.post { StoresState.status[store] = "Fetching your library…" }
Thread({
val result = GogLibrary.sync(app, force) { line -> StoresState.post { StoresState.status[store] = line } }
StoresState.post {
StoresState.status.remove(store)
when (result) {
is GogLibrary.SyncResult.Ok -> { StoresState.library[store] = result.games.map { GogLibrary.toCatalogItem(app, it) }; StoresState.problems.remove(store) }
is GogLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
GogLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = "GOG session expired: sign in again."
GogLibrary.SyncResult.Busy -> {}
}
}
}, "gog-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {
val app = context.applicationContext
GogStoreCatalog.cachedFeatured(app)?.let { StoresState.post { StoresState.shelves[store] = it } }
Thread({
val shelves = GogStoreCatalog.featured(app, force)
StoresState.post { if (shelves != null) StoresState.shelves[store] = markOwned(shelves) }
}, "gog-shelves").start()
}
/** The shelves with the library's titles marked owned, so their cards offer Install. */
private fun markOwned(s: com.droiddeck.launcher.stores.StoreShelves): com.droiddeck.launcher.stores.StoreShelves {
val owned = StoresState.library[store]?.associateBy { it.id } ?: return s
fun mark(l: List<CatalogItem>) = l.map { i -> owned[i.id]?.let { o -> i.copy(owned = true, sizeBytes = o.sizeBytes) } ?: i }
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
}
override fun install(context: Context, item: CatalogItem) {
val app = context.applicationContext
val game = GogGame(item.id, item.title, item.imageUrl ?: "", item.description, item.developer, item.tags, 2, item.tallImageUrl)
val entry = DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes)
DownloadQueue.enqueue(app, entry) { InstallJob(app, game, item) }
}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
/** One GOG install, as the queue runs it. */
private class InstallJob(val app: Context, val game: GogGame, val item: CatalogItem) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
private val folder: File = StoreInstallRoot.folderFor(app, Store.GOG, game.gameId, game.title)
override fun run(handle: DownloadQueue.JobHandle): String? {
handle.stage(DownloadStage.MANIFEST)
var downloading = false
val result = GogDownloadManager.install(app, game, folder, object : GogDownloadManager.Callback {
override fun onProgress(message: String, pct: Int) {
when {
message.startsWith("Finishing") -> handle.stage(DownloadStage.INSTALL, message)
message.startsWith("Downloading") || message.startsWith("Verified") || message.startsWith("Resuming") -> {
if (!downloading) { downloading = true; handle.stage(DownloadStage.DOWNLOAD, message) } else handle.progress(-1, -1, message)
}
else -> handle.stage(DownloadStage.MANIFEST, message)
}
}
override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) }
override fun onLog(line: String) { handle.log(line) }
}, cancelled) ?: return null
if (cancelled.get()) return null
handle.stage(DownloadStage.INSTALL, "Registering with Steam…")
val sidecar = StoreGameSidecar(
Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" },
installVersion = result.buildId, installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
)
if (sidecar.exe.isEmpty()) handle.log("gog: no exe found in ${folder.name}; pick one in Steam settings › Added games")
StoreInstalls.complete(app, folder, if (sidecar.exe.isEmpty()) sidecar.copy(exe = "game.exe") else sidecar)
return folder.path
}
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
if (deleteFiles) Thread({ StoreInstalls.deleteTree(folder) }, "gog-cancel-clean").start()
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,27 @@
package com.droiddeck.launcher.stores.gog;
/** One GOG game as the library cache holds it. */
public class GogGame {
public final String gameId;
public final String title;
public final String imageUrl;
public final String description;
public final String developer;
public final String category;
/** Content-system generation of the Windows build: 1 or 2 (0 = unknown). */
public final int generation;
/** gamesdb.gog.com 2:3 box art, or null when the lookup found none. */
public final String verticalCover;
public GogGame(String gameId, String title, String imageUrl, String description, String developer,
String category, int generation, String verticalCover) {
this.gameId = gameId;
this.title = title;
this.imageUrl = imageUrl;
this.description = description;
this.developer = developer;
this.category = category;
this.generation = generation;
this.verticalCover = verticalCover;
}
}
@@ -0,0 +1,194 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Callable
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* The signed-in account's GOG library: the owned ids from embed.gog.com, each product's details
* from api.gog.com, and a cache so the tab paints at once. A sync fetches only what the cache
* lacks unless forced or 15 minutes stale. Ported from Bannerlator's GogLibraryRepo.
*/
object GogLibrary {
private const val TAG = "GogLibrary"
private const val CACHE_KEY = "library_cache"
private const val LAST_SYNC_KEY = "library_synced_at"
private const val THROTTLE_MS = 15L * 60L * 1000L
private val syncing = AtomicBoolean(false)
sealed interface SyncResult {
class Ok(val games: List<GogGame>, val fetched: Int) : SyncResult
class Failed(val message: String) : SyncResult
object NotLoggedIn : SyncResult
object Busy : SyncResult
}
/** The cached library, alphabetical; empty until the first sync. */
fun cached(context: Context): List<GogGame> {
val json = GogPrefs.get(context).getString(CACHE_KEY, null) ?: return emptyList()
return runCatching {
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
GogGame(o.optString("gameId", ""), o.optString("title", ""), o.optString("imageUrl", ""), o.optString("description", ""),
o.optString("developer", ""), o.optString("category", ""), o.optInt("generation", 1), o.optString("verticalCover", "").ifEmpty { null })
}.filter { it.gameId.isNotEmpty() }.sortedBy { it.title.lowercase() }
}.getOrDefault(emptyList())
}
fun sizeOf(context: Context, gameId: String): Long = GogPrefs.get(context).getLong("size_$gameId", 0L)
/** Owned ids diffed against the cache; fetches what is new, or everything when forced / empty / stale. Blocking. */
fun sync(context: Context, force: Boolean = false, onStatus: (String) -> Unit = {}): SyncResult {
if (!syncing.compareAndSet(false, true)) return SyncResult.Busy
try {
val app = context.applicationContext
val p = GogPrefs.get(app)
val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn
onStatus("Fetching game list…")
val gamesJson = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token, userAgent = GogAuth.GALAXY_UA)
?: return SyncResult.Failed("Couldn't reach GOG")
val ids = ArrayList<String>()
val parsed = runCatching {
val owned = JSONObject(gamesJson).optJSONArray("owned")
if (owned != null) for (i in 0 until owned.length()) {
val id = owned.optLong(i).toString()
// 1801418160 is GOG's own "Galaxy" entry, in every library.
if (id != "1801418160" && id != "0") ids.add(id)
}
}.isSuccess
if (!parsed) return SyncResult.Failed("Error parsing library")
val ownedSet = ids.toHashSet()
val cachedList = cached(app)
val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS
val heavy = force || cachedList.isEmpty() || stale
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
val idsToFetch = if (heavy) ids else ids.filter { it !in cachedIds }
val fetchSet = idsToFetch.toHashSet()
val merged = LinkedHashMap<String, GogGame>()
for (g in cachedList) if (g.gameId in ownedSet && g.gameId !in fetchSet) merged[g.gameId] = g
if (idsToFetch.isEmpty()) {
val list = merged.values.toList()
saveCache(p, list)
return SyncResult.Ok(list.sortedBy { it.title.lowercase() }, 0)
}
onStatus("Syncing ${idsToFetch.size} game${if (idsToFetch.size == 1) "" else "s"}…")
val pool = Executors.newFixedThreadPool(5)
val futures = idsToFetch.map { id -> pool.submit(Callable<GogGame?> { fetchGame(p, id, token) }) }
pool.shutdown()
var fetched = 0
for ((idx, f) in futures.withIndex()) {
val g = runCatching { f.get() }.getOrNull()
if (g != null) { merged[g.gameId] = g; fetched++ }
if ((idx + 1) % 5 == 0) onStatus("Syncing… ${idx + 1}/${futures.size}")
}
val finalList = merged.values.toList()
saveCache(p, finalList)
if (heavy) p.edit().putLong(LAST_SYNC_KEY, System.currentTimeMillis()).apply()
Log.i(TAG, "sync: owned=${ids.size} fetched=$fetched cached=${finalList.size} heavy=$heavy")
return SyncResult.Ok(finalList.sortedBy { it.title.lowercase() }, fetched)
} catch (e: Exception) {
Log.w(TAG, "sync failed: ${e.message}")
return SyncResult.Failed(e.message ?: "Sync failed")
} finally {
syncing.set(false)
}
}
/** One product's details, or null for anything that is not a Windows game (DLC, movies, other platforms). */
private fun fetchGame(prefs: SharedPreferences, id: String, token: String): GogGame? {
try {
val productJson = StoreNet.get("https://api.gog.com/products/$id?expand=downloads,description", bearer = token) ?: return null
val prod = JSONObject(productJson)
if (prod.optBoolean("is_secret", false)) return null
val gameType = prod.optString("game_type", "")
if (gameType.isNotEmpty() && gameType != "game" && gameType != "pack") return null
var title = prod.optJSONObject("title")?.optString("*")
if (title.isNullOrEmpty()) title = prod.optString("title")
if (title.isNullOrEmpty()) return null
val images = prod.optJSONObject("images")
var imageUrl = images?.optString("background", "") ?: ""
if (imageUrl.isEmpty()) imageUrl = images?.optString("icon", "") ?: ""
val desc = prod.optJSONObject("description")?.optString("lead", "") ?: ""
val developer = prod.optJSONObject("developers")?.optString("name", "") ?: prod.optString("developer", "")
val genres = prod.optJSONArray("genres")
val category = if (genres != null && genres.length() > 0) genres.optJSONObject(0)?.optString("name", "") ?: "" else ""
var generation = 1
var hasWindowsBuild = false
runCatching {
val buildsJson = StoreNet.get("https://content-system.gog.com/products/$id/os/windows/builds?generation=2", bearer = token, userAgent = GogAuth.GALAXY_UA)
val items = buildsJson?.let { JSONObject(it).optJSONArray("items") }
if (items != null && items.length() > 0) {
hasWindowsBuild = true
var maxGen = 0
for (bi in 0 until items.length()) maxGen = maxOf(maxGen, items.optJSONObject(bi)?.optInt("generation", 0) ?: 0)
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()
if (prefs.getLong("size_$id", -1L) <= 0) {
val size = GogDownloadManager.fetchInstallSizeBytes(id, token)
if (size > 0) prefs.edit().putLong("size_$id", size).apply()
}
var hasWindowsInstaller = false
prod.optJSONObject("downloads")?.optJSONArray("installers")?.let { arr ->
for (di in 0 until arr.length()) if (arr.optJSONObject(di)?.optString("os", "") == "windows") { hasWindowsInstaller = true; break }
}
if (!hasWindowsBuild && !hasWindowsInstaller) return null
var verticalCover: String? = prefs.getString("vcover_$id", null)
if (verticalCover.isNullOrEmpty()) {
verticalCover = fetchVerticalCover(id).ifEmpty { StoreNet.sgdbPoster(title) }
if (verticalCover.isNotEmpty()) prefs.edit().putString("vcover_$id", verticalCover).apply()
}
return GogGame(id, title, GogStoreCatalog.absolutize(imageUrl), desc, developer, category, generation, verticalCover?.ifEmpty { null })
} catch (_: Exception) {
return null
}
}
private fun fetchVerticalCover(productId: String): String = try {
val extJson = StoreNet.get("https://gamesdb.gog.com/platforms/gog/external_releases/$productId")
val gameId = extJson?.let { JSONObject(it).optString("game_id", "") }.orEmpty()
if (gameId.isEmpty()) "" else {
val gameJson = StoreNet.get("https://gamesdb.gog.com/games/$gameId")
val fmt = gameJson?.let { JSONObject(it).optJSONObject("vertical_cover") }?.optString("url_format", "").orEmpty()
if (fmt.isEmpty()) "" else fmt.replace("{formatter}", "").replace("{ext}", "webp")
}
} catch (_: Exception) { "" }
private fun saveCache(prefs: SharedPreferences, games: List<GogGame>) {
runCatching {
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)
.apply { if (!g.verticalCover.isNullOrEmpty()) put("verticalCover", g.verticalCover) })
}
prefs.edit().putString(CACHE_KEY, arr.toString()).apply()
}
}
/** A cached game as the page's card. */
fun toCatalogItem(context: Context, g: GogGame): CatalogItem = CatalogItem(
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,
sizeBytes = sizeOf(context, g.gameId),
storeUrl = "https://www.gog.com/en/game/${g.gameId}",
)
}
@@ -0,0 +1,103 @@
// JNI symbols depend on this package path and class name (app/src/main/rust/stores/JNI.md).
package com.droiddeck.launcher.stores.gog
import android.util.Log
import com.droiddeck.launcher.stores.StoresNative
/**
* The native GOG download engine in `libdroiddeckstores.so`: the gen2 chunk loop (depot manifests
* → chunk fetch + inflate + MD5 → assembled files) and the gen1 range loop. One [start] is one
* download loop; the manager owns everything before and after it. Method names and signatures
* are Bannerlator's `BlGogDownload`, only the package and class changed.
*/
object GogNative {
private const val TAG = "GogNative"
/** gen2: depot manifests → chunk fetch + inflate + MD5 (base install, DLC, dependencies). */
const val KIND_GEN2_CHUNKS = 0
/** gen1: build manifest → per-file HTTP Range GET streamed to disk. */
const val KIND_GEN1_RANGES = 1
/** Callbacks from the engine; every method runs on a native thread. */
interface Listener {
/**
* One file reached its final state. [verified] = resume-skip (existing file passed size+MD5;
* no bytes credited); otherwise a freshly assembled, verified and renamed file ([fileBytes]
* = its decompressed size). [filesDone] counts both; [bytesDone] counts assembled bytes only.
*/
fun onProgress(bytesDone: Long, bytesTotal: Long, filesDone: Int, filesTotal: Int, file: String, fileBytes: Long, verified: Boolean)
/** Engine diagnostics (already in logcat under `GogNative`). */
fun onLog(line: String)
/**
* Exactly once per [start] that returned a non-zero handle. [linkExpiry] = the run died on an
* HTTP 401/403/404/500, the codes an expired secure link returns: refresh it and run again.
*/
fun onComplete(success: Boolean, cancelled: Boolean, linkExpiry: Boolean, error: String, bytesWritten: Long, filesDone: Int)
}
@Volatile private var available: Boolean? = null
/** True when the library loads and the GOG exports bind. Cached after the first call. */
@JvmStatic
fun isAvailable(): Boolean {
available?.let { return it }
val ok = try {
StoresNative.ensureLoaded() && nativeProbe() == 1
} catch (t: Throwable) {
Log.w(TAG, "native GOG engine unavailable: ${t.javaClass.simpleName}: ${t.message}")
false
}
available = ok
return ok
}
/**
* Starts one download loop on a native thread; returns its handle (0 = not started, no
* callbacks). See JNI.md for the parameters; they are what the manager already holds.
*/
@JvmStatic
fun start(
kind: Int, depotManifests: Array<String>, cdnBase: String, installDir: String, skipPaths: Array<String>,
caBundlePath: String, maxWorkers: Int, processWorkers: Int, sortLargestFirst: Boolean, label: String, listener: Listener,
): Long {
if (!isAvailable()) return 0L
return try {
nativeStart(kind, depotManifests, cdnBase, installDir, skipPaths, caBundlePath, maxWorkers, processWorkers, sortLargestFirst, label, listener)
} catch (t: Throwable) {
Log.e(TAG, "nativeStart threw: ${t.javaClass.simpleName}: ${t.message}")
0L
}
}
/** Requests cancellation; `onComplete(cancelled = true)` follows. Idempotent; 0 is a no-op. */
@JvmStatic
fun cancel(handle: Long) {
if (handle == 0L) return
runCatching { nativeCancel(handle) }
}
/** Releases the handle, once, after `onComplete`. */
@JvmStatic
fun release(handle: Long) {
if (handle == 0L) return
runCatching { nativeRelease(handle) }
}
@JvmStatic
private external fun nativeProbe(): Int
@JvmStatic
private external fun nativeStart(
kind: Int, depotManifests: Array<String>, cdnBase: String, installDir: String, skipPaths: Array<String>,
caBundlePath: String, maxWorkers: Int, processWorkers: Int, sortLargestFirst: Boolean, label: String, listener: Listener,
): Long
@JvmStatic
private external fun nativeCancel(handle: Long)
@JvmStatic
private external fun nativeRelease(handle: Long)
}
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.content.SharedPreferences
/**
* GOG's own records that are not credentials: the library cache, per-game facts the sync learnt
* (generation, release date, size, cover), a build's client id. App-private preferences; the
* sign-in itself lives in StoreAccounts. Public, not internal: the Java download manager reads it
* and Kotlin mangles internal members' names for Java.
*/
object GogPrefs {
@JvmStatic
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences("stores.gog", Context.MODE_PRIVATE)
}
@@ -0,0 +1,189 @@
package com.droiddeck.launcher.stores.gog
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoreShelves
import org.json.JSONArray
import org.json.JSONObject
import java.net.URLEncoder
import java.util.Calendar
import java.util.Currency
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
/**
* GOG's public catalog - the same unauthenticated service the gog.com store page uses:
* `catalog.gog.com/v1/catalog?order=desc:trending&productType=in:game,pack&...` with `discounted`,
* `price=between:0,0` and `query=like:` filters. Products carry pre-formatted prices and a
* discount string; Windows-only titles are kept. Cached in-process (shelves 30 min, searches
* 5 min) and the last good shelves mirrored to prefs so the tab paints offline too.
*/
object GogStoreCatalog {
private const val TAG = "GogStore"
private const val CATALOG = "https://catalog.gog.com/v1/catalog"
private const val FEATURED_TTL_MS = 30 * 60 * 1000L
private const val SEARCH_TTL_MS = 5 * 60 * 1000L
private const val KEY_FEATURED = "store_featured"
private val supportedCurrencies = setOf(
"USD", "EUR", "GBP", "AUD", "CAD", "CHF", "DKK", "NOK", "PLN", "SEK", "BRL", "CNY",
"HKD", "ILS", "JPY", "KRW", "MXN", "NZD", "SGD", "TRY", "UAH", "ZAR", "INR", "RUB",
)
private class Cached<T>(val value: T, val at: Long)
private var featuredCache: Cached<StoreShelves>? = null
private val searchCache = ConcurrentHashMap<String, Cached<List<CatalogItem>>>()
private fun country(): String = Locale.getDefault().country.takeIf { it.length == 2 }?.uppercase(Locale.ROOT) ?: "US"
private fun currency(): String {
val cc = runCatching { Currency.getInstance(Locale("", country())).currencyCode }.getOrNull()
return if (cc != null && cc in supportedCurrencies) cc else "USD"
}
private fun baseQuery(limit: Int, order: String, extra: String = ""): String =
"$CATALOG?limit=$limit&order=$order&productType=in:game,pack&page=1&countryCode=${country()}&locale=en-US&currencyCode=${currency()}$extra"
/** GET the catalog; on a refusal of the locale's currency, retry as US / USD. */
private fun fetchProducts(url: String): List<CatalogItem> {
var body = StoreNet.get(url)
if (body == null && !url.contains("countryCode=US&locale=en-US&currencyCode=USD")) {
body = StoreNet.get(url.replace(Regex("countryCode=[A-Z]{2}"), "countryCode=US").replace(Regex("currencyCode=[A-Z]{3}"), "currencyCode=USD"))
}
return if (body == null) emptyList() else parseProducts(body)
}
/** The Store tab's shelves. Null only when every feed failed and nothing is cached. Blocking. */
fun featured(context: Context, force: Boolean = false): StoreShelves? {
val now = System.currentTimeMillis()
featuredCache?.let { if (!force && now - it.at < FEATURED_TTL_MS) return it.value }
val trending = fetchProducts(baseQuery(24, "desc:trending"))
val newest = fetchProducts(baseQuery(40, "desc:releaseDate")).filter { !isFuture(it.releaseDate) }.take(24)
val deals = fetchProducts(baseQuery(24, "desc:discount", "&discounted=eq:true"))
val free = fetchProducts(baseQuery(24, "desc:trending", "&price=between:0,0"))
val result = StoreShelves(whatsNew = newest, deals = deals, free = free, trending = trending)
if (!result.isEmpty) {
featuredCache = Cached(result, now)
persist(context, result)
Log.i(TAG, "shelves: trending=${trending.size} new=${newest.size} deals=${deals.size} free=${free.size}")
return result
}
Log.w(TAG, "every catalog feed came back empty; using the on-disk mirror")
return loadPersisted(context)
}
fun cachedFeatured(context: Context): StoreShelves? = featuredCache?.value ?: loadPersisted(context)
fun search(query: String): List<CatalogItem> {
val term = query.trim()
if (term.length < 2) return emptyList()
val key = term.lowercase(Locale.ROOT)
val now = System.currentTimeMillis()
searchCache[key]?.let { if (now - it.at < SEARCH_TTL_MS) return it.value }
val results = fetchProducts(baseQuery(40, "desc:score", "&query=like:" + URLEncoder.encode(term, "UTF-8")))
searchCache[key] = Cached(results, now)
return results
}
private fun parseProducts(body: String): List<CatalogItem> = try {
val arr = JSONObject(body).optJSONArray("products") ?: JSONArray()
val out = ArrayList<CatalogItem>(arr.length())
for (i in 0 until arr.length()) arr.optJSONObject(i)?.let { p -> parseProduct(p)?.let { out.add(it) } }
out.distinctBy { it.id }
} catch (e: Exception) {
Log.w(TAG, "catalog parse failed: ${e.message}")
emptyList()
}
fun parseProduct(p: JSONObject): CatalogItem? {
val id = p.optString("id", "")
val title = p.optString("title", "")
if (id.isEmpty() || title.isEmpty()) return null
// Windows only: the app cannot run anything else.
val os = p.optJSONArray("operatingSystems")
if (os != null && os.length() > 0) {
var windows = false
for (i in 0 until os.length()) if (os.optString(i).equals("windows", true)) windows = true
if (!windows) return null
}
val price = p.optJSONObject("price")
val finalAmount = price?.optJSONObject("finalMoney")?.optString("amount")?.toDoubleOrNull()
val finalStr = price?.optString("final", "").orEmpty()
val baseStr = price?.optString("base", "").orEmpty()
val discount = price?.optString("discount", "").orEmpty().filter { it.isDigit() }.toIntOrNull() ?: 0
val isFree = price != null && (finalAmount == 0.0 || finalStr == "$0.00" || finalStr == "0")
val genres = p.optJSONArray("genres")
val tags = buildList {
if (genres != null) for (i in 0 until minOf(3, genres.length())) genres.optJSONObject(i)?.optString("name")?.takeIf { it.isNotBlank() }?.let { add(it) }
}.joinToString(", ")
val slug = p.optString("slug", "")
val storeLink = p.optString("storeLink", "").ifBlank { if (slug.isNotBlank()) "https://www.gog.com/en/game/$slug" else "" }
return CatalogItem(
store = Store.GOG, id = id, title = title,
imageUrl = p.optString("coverHorizontal", "").ifBlank { null },
tallImageUrl = p.optString("coverVertical", "").ifBlank { null },
tags = tags, isFree = isFree, hasPrice = price != null && finalStr.isNotBlank(),
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(),
releaseDate = p.optString("releaseDate", ""),
extra = buildMap { if (slug.isNotBlank()) put("slug", slug) },
)
}
/** GOG returns protocol-relative `//images…` URLs in a few places. */
@JvmStatic
fun absolutize(url: String?): String {
if (url.isNullOrBlank()) return ""
return if (url.startsWith("//")) "https:$url" else url
}
/** "YYYY.MM.DD" later than today: a pre-order, kept out of What's new. */
private fun isFuture(date: String): Boolean {
if (date.length < 10) return false
val parts = date.substring(0, 10).split('.', '-')
if (parts.size != 3) return false
val y = parts[0].toIntOrNull() ?: return false
val m = parts[1].toIntOrNull() ?: return false
val d = parts[2].toIntOrNull() ?: return false
val cal = Calendar.getInstance()
val today = cal.get(Calendar.YEAR) * 10000 + (cal.get(Calendar.MONTH) + 1) * 100 + cal.get(Calendar.DAY_OF_MONTH)
return y * 10000 + m * 100 + d > today
}
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)
}
private fun itemFromJson(o: JSONObject): CatalogItem = CatalogItem(
store = Store.GOG, 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"),
)
private fun listToJson(l: List<CatalogItem>) = JSONArray().apply { l.forEach { put(itemToJson(it)) } }
private fun listFromJson(a: JSONArray?): List<CatalogItem> {
if (a == null) return emptyList()
return List(a.length()) { i -> a.optJSONObject(i) }.filterNotNull().map(::itemFromJson)
}
private fun persist(context: Context, f: StoreShelves) {
runCatching {
val o = JSONObject().put("trending", listToJson(f.trending)).put("new", listToJson(f.whatsNew)).put("deals", listToJson(f.deals)).put("free", listToJson(f.free))
GogPrefs.get(context).edit().putString(KEY_FEATURED, o.toString()).apply()
}
}
private fun loadPersisted(context: Context): StoreShelves? = runCatching {
val s = GogPrefs.get(context).getString(KEY_FEATURED, null) ?: return null
val o = JSONObject(s)
StoreShelves(listFromJson(o.optJSONArray("new")), listFromJson(o.optJSONArray("deals")), listFromJson(o.optJSONArray("free")), listFromJson(o.optJSONArray("trending"))).takeIf { !it.isEmpty }
}.getOrNull()
}
+12
View File
@@ -2085,4 +2085,16 @@
<string name="stores_notification_one">Downloading</string>
<string name="stores_notification_many">%1$d downloads</string>
<string name="stores_notification_waiting">Preparing…</string>
<string name="stores_login_title">Sign in to %1$s</string>
<string name="stores_login_provider_title">Sign in with your provider</string>
<string name="stores_login_reload">Reload</string>
<string name="stores_login_close">Close</string>
<string name="stores_login_retry">Try again</string>
<string name="stores_login_finishing">Finishing sign-in…</string>
<string name="stores_login_error_network">The sign-in page could not be loaded: %1$s</string>
<string name="stores_login_error_idp_blocked">This provider refuses to sign in from an embedded page. Sign in with your store password instead.</string>
<string name="stores_login_error_http">The sign-in page answered with an error (HTTP %1$d).</string>
<string name="stores_login_error_ssl">The connection to the sign-in page is not secure.</string>
<string name="stores_login_error_verification">The sign-in could not be verified. Try again.</string>
<string name="stores_login_error_generic">Sign-in failed. Try again.</string>
</resources>