diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ec5cf1f7d..1f80bb372 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -127,6 +127,13 @@
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
+
+
+
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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt
new file mode 100644
index 000000000..a6b0253bd
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt
new file mode 100644
index 000000000..109e4f6a4
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt
@@ -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 = 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]"
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresNative.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresNative.kt
index ffbf364f6..6d626353f 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoresNative.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresNative.kt
@@ -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
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt
index 33dcced0c..93266dbcf 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt
@@ -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)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt
new file mode 100644
index 000000000..d889674d1
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt
@@ -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")
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt
new file mode 100644
index 000000000..ff5456839
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt
@@ -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) = 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()
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java
new file mode 100644
index 000000000..9eac5a2cd
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java
@@ -0,0 +1,1006 @@
+package com.droiddeck.launcher.stores.gog;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.droiddeck.launcher.stores.StoreLog;
+import com.droiddeck.launcher.stores.download.StoreDownloadTier;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.Inflater;
+
+/**
+ * The GOG install pipeline, ported from Bannerlator's GogDownloadManager and made blocking: the
+ * download queue runs {@link #install} on its own thread and reads progress through {@link Callback}.
+ *
+ * Gen 2: builds → build manifest → depot manifests (base product, matching language) → secure
+ * link → per file: chunks fetched, inflated, MD5-checked, assembled (the native engine when it is
+ * there, else the Java pool here). Gen 1: build manifest → per-file Range GET. Neither available:
+ * the product's Windows installer is downloaded as a file to run once.
+ *
+ * Resume and repair come from the per-file size + MD5 check: a complete file is skipped, a damaged
+ * one re-fetched. Nothing here writes outside the install folder; URLs are only logged redacted.
+ */
+public final class GogDownloadManager {
+
+ private static final String TAG = "GogDownload";
+ private static final int TIMEOUT = 30_000;
+ private static final String MARKER = "_gog_manifest.json";
+
+ /** Progress out of a running install. Every method is called on the install's thread. */
+ public interface Callback {
+ /** A new phase or file, as text, with the install's overall percentage. */
+ void onProgress(String message, int pct);
+ /** Bytes landed against the planned total, and the current speed (0 = unknown). */
+ default void onBytes(long done, long total, long speedBps) {}
+ /** A diagnostic line for the engine log. */
+ default void onLog(String line) {}
+ }
+
+ /** What an install leaves behind for the sidecar. */
+ public static final class Result {
+ /** The chosen exe, relative to the install folder (forward slashes), or "" when none was found. */
+ public final String exeRelative;
+ public final String buildId;
+ public final long bytes;
+ Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; }
+ }
+
+ /** Thrown for every way an install can fail; the message is what the Downloads page shows. */
+ public static final class InstallException extends Exception {
+ InstallException(String message) { super(message); }
+ }
+
+ private GogDownloadManager() {}
+
+ /**
+ * Installs {@code game} into {@code installPath}. Blocks until done; {@code cancelled} set from
+ * another thread stops it at the next file boundary (what was written stays for a resume).
+ * Returns null when cancelled.
+ */
+ public static Result install(Context ctx, GogGame game, File installPath, Callback cb, AtomicBoolean cancelled) throws InstallException {
+ cb.onProgress("Checking sign-in…", 0);
+ String token = GogAuth.validToken(ctx);
+ if (token == null) throw new InstallException("Not signed in to GOG");
+ if (cancelled.get()) return null;
+ cb.onProgress("Fetching builds…", 2);
+
+ String buildsUrl = "https://content-system.gog.com/products/" + game.gameId + "/os/windows/builds?generation=2";
+ String buildsJson = httpGet(buildsUrl, null);
+ if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
+ if (buildsJson != null) {
+ Gen2Outcome out = runGen2(ctx, game, token, buildsJson, installPath, cb, cancelled);
+ if (out.result != null || cancelled.get()) return out.result;
+ if (out.hardStop) throw new InstallException(out.error);
+ cb.onLog("gen2 unavailable: " + out.error);
+ }
+ if (cancelled.get()) return null;
+ cb.onProgress("Trying the older build format…", 10);
+ String builds1Url = "https://content-system.gog.com/products/" + game.gameId + "/os/windows/builds?generation=1";
+ String builds1Json = httpGet(builds1Url, null);
+ if (builds1Json == null) builds1Json = httpGet(builds1Url, token);
+ if (builds1Json == null) throw new InstallException("No builds available for this game");
+ Gen1Outcome one = runGen1(ctx, game, token, builds1Json, installPath, cb, cancelled);
+ if (one.result != null || cancelled.get()) return one.result;
+ if (!"NO_CS_BUILDS".equals(one.error)) throw new InstallException("Download failed: " + one.error);
+ cb.onProgress("No Galaxy builds; fetching the installer…", 12);
+ return runInstaller(ctx, game, token, installPath, cb, cancelled);
+ }
+
+ // ---- gen2 -----------------------------------------------------------------------------------
+
+ private static final class Gen2Outcome {
+ Result result; String error; boolean hardStop;
+ }
+
+ private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled) {
+ Gen2Outcome out = new Gen2Outcome();
+ try {
+ cb.onProgress("Fetching manifest…", 5);
+ Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, cb);
+ if (gm == null) { out.error = "gen2 manifest unavailable"; return out; }
+ JSONObject manifest = gm.manifest;
+ JSONArray depots = manifest.optJSONArray("depots");
+ if (depots == null) { out.error = "no depots in manifest"; return out; }
+ JSONArray products = manifest.optJSONArray("products");
+ String tempExe = null;
+ if (products != null && products.length() > 0) {
+ tempExe = products.getJSONObject(0).optString("temp_executable", null);
+ if (tempExe != null && tempExe.isEmpty()) tempExe = null;
+ }
+ // The build manifest lists every product's depots (base + DLC), each tagged by productId;
+ // the base install takes the base product's only, under a secure link scoped to it.
+ String baseProductId = manifest.optString("baseProductId", null);
+ if ((baseProductId == null || baseProductId.isEmpty()) && products != null && products.length() > 0)
+ baseProductId = products.getJSONObject(0).optString("productId", null);
+ if (baseProductId == null || baseProductId.isEmpty()) baseProductId = game.gameId;
+ // The build's required redistributables (MSVC, DirectX, ...) are recorded for the engine
+ // log; Steam's shared redistributables run in the prefix on first launch as for any shortcut.
+ JSONArray deps = manifest.optJSONArray("dependencies");
+ if (deps != null && deps.length() > 0) cb.onLog("gog: build lists dependencies " + deps);
+
+ cb.onProgress("Reading depot manifests…", 10);
+ List files = new ArrayList<>();
+ List depotJsons = new ArrayList<>();
+ for (int i = 0; i < depots.length(); i++) {
+ if (cancelled.get()) return out;
+ JSONObject depot = depots.getJSONObject(i);
+ String depotPid = depot.optString("productId", "");
+ if (!depotPid.isEmpty() && !depotPid.equals(baseProductId)) continue;
+ if (!languageCompatible(depot)) continue;
+ String manifestHash = depot.optString("manifest");
+ if (manifestHash == null || manifestHash.isEmpty()) continue;
+ byte[] dmRaw = fetchBytes("https://gog-cdn-fastly.gog.com/content-system/v2/meta/" + buildCdnPath(manifestHash), null);
+ if (dmRaw == null) { cb.onLog("gog: depot " + i + " meta fetch failed"); continue; }
+ String dmStr = decompressBytes(dmRaw);
+ if (dmStr == null) { cb.onLog("gog: depot " + i + " decompress failed"); continue; }
+ parseDepotManifest(dmStr, files);
+ depotJsons.add(dmStr);
+ }
+ if (files.isEmpty()) { out.error = "no depot files collected"; return out; }
+
+ cb.onProgress("Fetching CDN link…", 15);
+ String secureLinkUrl = "https://content-system.gog.com/products/" + baseProductId + "/secure_link?_version=2&generation=2&path=/";
+ String cdnBase = parseCdnUrl(httpGet(secureLinkUrl, token));
+ if (cdnBase == null) { out.error = "secure link refused"; return out; }
+
+ installPath.mkdirs();
+ long plannedBytes = 0;
+ for (DepotFile df : files) plannedBytes += df.totalSize;
+ if (plannedBytes > 0) {
+ long usable = installPath.getUsableSpace();
+ if (usable > 0 && plannedBytes > usable) {
+ out.hardStop = true;
+ out.error = "Not enough free space: need " + formatBytes(plannedBytes) + ", only " + formatBytes(usable) + " free";
+ return out;
+ }
+ }
+ // Largest first: the biggest archives start first and small files fill the tail.
+ Collections.sort(files, (a, b) -> Long.compare(b.totalSize, a.totalSize));
+
+ final int total = files.size();
+ final long planned = plannedBytes;
+ final AtomicInteger doneCount = new AtomicInteger(0);
+ final AtomicLong totalBytes = new AtomicLong(0);
+ final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
+ final AtomicLong lastSpeedB = new AtomicLong(0);
+ final AtomicLong speedBps = new AtomicLong(0);
+ final AtomicBoolean anyFailed = new AtomicBoolean(false);
+ final AtomicReference cdnBaseRef = new AtomicReference<>(cdnBase);
+ final AtomicInteger cdnRefreshCount = new AtomicInteger(0);
+ final int MAX_CDN_REFRESH = 5;
+ cb.onBytes(0, planned, 0);
+ cb.onLog("gog: " + total + " files, " + formatBytes(planned) + ", base=" + baseProductId);
+
+ int threads = downloadThreads();
+ if (GogNative.isAvailable()) {
+ runNativeEngine(ctx, GogNative.KIND_GEN2_CHUNKS, "Verified…", depotJsons, installPath, cdnBaseRef, secureLinkUrl, token,
+ cdnRefreshCount, MAX_CDN_REFRESH, threads, threads, true, "gog base=" + baseProductId,
+ cancelled, anyFailed, doneCount, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
+ } else {
+ cb.onLog("gog: engine=built-in (" + threads + " threads)");
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ List> futures = new ArrayList<>();
+ for (DepotFile df : files) {
+ futures.add(pool.submit((Callable) () -> {
+ if (cancelled.get() || anyFailed.get()) return null;
+ File outFile = new File(installPath, df.relativePath);
+ outFile.getParentFile().mkdirs();
+ if (fileVerified(outFile, df.totalSize, df.md5)) {
+ int done = doneCount.incrementAndGet();
+ long tb = totalBytes.addAndGet(df.totalSize);
+ cb.onProgress("Verified…", 15 + (int) ((done / (float) total) * 80));
+ cb.onBytes(tb, planned, speedBps.get());
+ return null;
+ }
+ boolean ok = assembleDepotFile(df, outFile, cdnBaseRef, secureLinkUrl, token, cdnRefreshCount, MAX_CDN_REFRESH, cancelled, cb);
+ if (cancelled.get()) return null;
+ if (!ok) { cb.onLog("FAIL file=" + df.relativePath); anyFailed.set(true); return null; }
+ int done = doneCount.incrementAndGet();
+ long tb = totalBytes.addAndGet(df.totalSize);
+ sampleSpeed(tb, lastSpeedMs, lastSpeedB, speedBps);
+ cb.onProgress("Downloading: " + baseName(df.relativePath) + speedSuffix(speedBps.get()), 15 + (int) ((done / (float) total) * 80));
+ cb.onBytes(tb, planned, speedBps.get());
+ return null;
+ }));
+ }
+ pool.shutdown();
+ try {
+ for (Future f : futures) f.get();
+ } catch (Exception e) {
+ pool.shutdownNow();
+ out.error = "download pool error: " + e.getClass().getSimpleName();
+ return out;
+ }
+ }
+ if (cancelled.get()) return out;
+ if (anyFailed.get()) { out.error = "one or more chunks failed to download"; return out; }
+
+ cb.onProgress("Finishing…", 96);
+ writeFile(new File(installPath, MARKER), ("{\"gameId\":\"" + game.gameId + "\",\"buildId\":\"" + gm.buildId + "\"}").getBytes("UTF-8"));
+ deleteDir(new File(installPath, ".gog_chunks"));
+ String clientId = manifest.optString("clientId", null);
+ if (clientId != null && !clientId.isEmpty()) GogPrefs.get(ctx).edit().putString("client_id_" + game.gameId, clientId).apply();
+ out.result = new Result(pickExe(installPath, tempExe, game.title), gm.buildId, planned);
+ return out;
+ } catch (Exception e) {
+ out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
+ return out;
+ }
+ }
+
+ private static final class Gen2Manifest {
+ final JSONObject manifest; final String buildId;
+ Gen2Manifest(JSONObject manifest, String buildId) { this.manifest = manifest; this.buildId = buildId; }
+ }
+
+ /** The first Windows build's manifest, inflated and parsed; null on any failure. */
+ private static Gen2Manifest resolveGen2Manifest(String buildsJson, String token, Callback cb) {
+ try {
+ JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
+ if (items == null || items.length() == 0) { cb.onLog("gog: no gen2 builds"); return null; }
+ String buildId = null, manifestUrl = null;
+ for (int i = 0; i < items.length(); i++) {
+ JSONObject item = items.getJSONObject(i);
+ if ("windows".equals(item.optString("os"))) {
+ buildId = item.optString("build_id");
+ manifestUrl = item.optString("link");
+ if (manifestUrl == null || manifestUrl.isEmpty()) manifestUrl = item.optString("meta_url");
+ break;
+ }
+ }
+ if (buildId == null || manifestUrl == null || manifestUrl.isEmpty()) { cb.onLog("gog: no windows build"); return null; }
+ byte[] raw = fetchBytes(manifestUrl, token);
+ if (raw == null) { cb.onLog("gog: manifest fetch failed"); return null; }
+ String str = decompressBytes(raw);
+ if (str == null) { cb.onLog("gog: manifest decompress failed"); return null; }
+ return new Gen2Manifest(new JSONObject(str), buildId);
+ } catch (Exception e) {
+ cb.onLog("gog: manifest " + e.getClass().getSimpleName());
+ return null;
+ }
+ }
+
+ /** English or every-language depots; GOG tags the rest by language code. */
+ private static boolean languageCompatible(JSONObject depot) {
+ JSONArray languages = depot.optJSONArray("languages");
+ if (languages == null || languages.length() == 0) return true;
+ String s = languages.toString();
+ return s.contains("*") || s.contains("en-US") || s.contains("\"en\"") || s.contains("english");
+ }
+
+ // ---- gen1 -----------------------------------------------------------------------------------
+
+ private static final class Gen1Outcome { Result result; String error; }
+
+ private static Gen1Outcome runGen1(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled) {
+ Gen1Outcome out = new Gen1Outcome();
+ try {
+ JSONObject builds = new JSONObject(buildsJson);
+ JSONArray items = builds.optJSONArray("items");
+ if (items == null || items.length() == 0) { out.error = builds.optInt("total_count", -1) == 0 ? "NO_CS_BUILDS" : "no items"; return out; }
+ String manifestUrl = null;
+ for (int i = 0; i < items.length(); i++) {
+ JSONObject item = items.getJSONObject(i);
+ if ("windows".equals(item.optString("os"))) { manifestUrl = item.optString("link"); break; }
+ }
+ if (manifestUrl == null || manifestUrl.isEmpty()) { out.error = "no windows manifest"; return out; }
+ cb.onProgress("Fetching manifest…", 12);
+ byte[] raw = fetchBytes(manifestUrl, token);
+ if (raw == null) { out.error = "manifest fetch failed"; return out; }
+ String manifestStr = decompressBytes(raw);
+ if (manifestStr == null) { out.error = "manifest decompress failed"; return out; }
+ JSONObject manifest = new JSONObject(manifestStr);
+ JSONArray depots = manifest.optJSONArray("depot");
+ if (depots == null) { out.error = "no depot array"; return out; }
+ List files = new ArrayList<>();
+ for (int i = 0; i < depots.length(); i++) {
+ JSONObject depot = depots.getJSONObject(i);
+ if (depot.optBoolean("support", false)) continue;
+ JSONArray jFiles = depot.optJSONArray("files");
+ if (jFiles == null) continue;
+ for (int j = 0; j < jFiles.length(); j++) {
+ JSONObject f = jFiles.getJSONObject(j);
+ String path = f.optString("path"), url = f.optString("url");
+ long size = f.optLong("size", 0);
+ if (path == null || url == null || size == 0) continue;
+ files.add(new Gen1File(path, url, f.optLong("offset", 0), size));
+ }
+ }
+ if (files.isEmpty()) { out.error = "no files in manifest"; return out; }
+ installPath.mkdirs();
+ long planned = 0;
+ for (Gen1File gf : files) planned += gf.size;
+ final int total = files.size();
+ final long plannedBytes = planned;
+ final AtomicInteger done = new AtomicInteger(0);
+ final AtomicLong totalBytes = new AtomicLong(0);
+ final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
+ final AtomicLong lastSpeedB = new AtomicLong(0);
+ final AtomicLong speedBps = new AtomicLong(0);
+ final AtomicBoolean anyFailed = new AtomicBoolean(false);
+ cb.onBytes(0, planned, 0);
+ int threads = downloadThreads();
+ if (GogNative.isAvailable()) {
+ runNativeEngine(ctx, GogNative.KIND_GEN1_RANGES, "Resuming…", Collections.singletonList(manifestStr), installPath,
+ new AtomicReference<>(""), null, null, new AtomicInteger(0), 0, threads, threads, false, "gog gen1=" + game.gameId,
+ cancelled, anyFailed, done, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
+ } else {
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ List> futures = new ArrayList<>();
+ for (Gen1File gf : files) {
+ futures.add(pool.submit((Callable) () -> {
+ if (cancelled.get() || anyFailed.get()) return null;
+ File outFile = new File(installPath, gf.path);
+ outFile.getParentFile().mkdirs();
+ if (outFile.exists() && outFile.length() == gf.size) {
+ cb.onProgress("Resuming…", 15 + (int) ((done.incrementAndGet() / (float) total) * 80));
+ cb.onBytes(totalBytes.addAndGet(gf.size), plannedBytes, speedBps.get());
+ return null;
+ }
+ for (int attempt = 1; attempt <= 3; attempt++) {
+ if (cancelled.get()) return null;
+ outFile.delete();
+ if (downloadRange(gf.url, gf.offset, gf.size, outFile)) {
+ int d = done.incrementAndGet();
+ long tb = totalBytes.addAndGet(gf.size);
+ sampleSpeed(tb, lastSpeedMs, lastSpeedB, speedBps);
+ cb.onProgress("Downloading: " + baseName(gf.path) + speedSuffix(speedBps.get()), 15 + (int) ((d / (float) total) * 80));
+ cb.onBytes(tb, plannedBytes, speedBps.get());
+ return null;
+ }
+ if (attempt < 3) Thread.sleep(1000L << (attempt - 1));
+ }
+ cb.onLog("FAIL file=" + gf.path);
+ anyFailed.set(true);
+ return null;
+ }));
+ }
+ pool.shutdown();
+ try { for (Future f : futures) f.get(); } catch (Exception e) { pool.shutdownNow(); out.error = "gen1 pool error"; return out; }
+ }
+ if (cancelled.get()) return out;
+ if (anyFailed.get()) { out.error = "one or more files failed to download"; return out; }
+ cb.onProgress("Finishing…", 96);
+ writeFile(new File(installPath, MARKER), ("{\"gameId\":\"" + game.gameId + "\",\"generation\":1}").getBytes("UTF-8"));
+ out.result = new Result(pickExe(installPath, null, game.title), "", planned);
+ return out;
+ } catch (Exception e) {
+ out.error = "exception: " + e.getClass().getSimpleName();
+ return out;
+ }
+ }
+
+ // ---- installer fallback ---------------------------------------------------------------------
+
+ /** Games with no content-system builds: the Windows installer, as one file, to run once in Proton. */
+ private static Result runInstaller(Context ctx, GogGame game, String token, File installPath, Callback cb, AtomicBoolean cancelled) throws InstallException {
+ try {
+ String productJson = httpGet("https://api.gog.com/products/" + game.gameId + "?expand=downloads", token);
+ if (productJson == null) throw new InstallException("No downloadable builds for this game");
+ JSONObject downloads = new JSONObject(productJson).optJSONObject("downloads");
+ JSONArray installers = downloads == null ? null : downloads.optJSONArray("installers");
+ if (installers == null || installers.length() == 0) throw new InstallException("No downloadable builds for this game");
+ String manualUrl = null, fileName = null;
+ for (int i = 0; i < installers.length(); i++) {
+ JSONObject inst = installers.getJSONObject(i);
+ if (!"windows".equals(inst.optString("os"))) continue;
+ JSONArray files = inst.optJSONArray("files");
+ if (files != null && files.length() > 0) {
+ JSONObject f = files.getJSONObject(0);
+ manualUrl = f.optString("downlink", null);
+ if (manualUrl == null) manualUrl = f.optString("manualUrl", null);
+ fileName = f.optString("filename", null);
+ }
+ if (manualUrl == null) manualUrl = inst.optString("manualUrl", null);
+ if (fileName == null || fileName.isEmpty()) fileName = "setup_" + game.gameId + ".exe";
+ break;
+ }
+ if (manualUrl == null) throw new InstallException("No Windows installer for this game");
+ String downloadUrl = resolveRedirect(manualUrl, token);
+ if (downloadUrl == null) throw new InstallException("The installer link could not be resolved");
+ installPath.mkdirs();
+ File outFile = new File(installPath, fileName);
+ cb.onProgress("Downloading installer: " + fileName, 15);
+ long size = downloadWithProgress(downloadUrl, outFile, cb, cancelled);
+ if (cancelled.get()) return null;
+ if (size <= 0) throw new InstallException("The installer download failed");
+ cb.onLog("gog: installer downloaded; the game installs itself on first launch");
+ return new Result(fileName, "", size);
+ } catch (InstallException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new InstallException("Installer download error: " + e.getClass().getSimpleName());
+ }
+ }
+
+ private static String resolveRedirect(String url, String token) {
+ try {
+ if (url.startsWith("/")) url = "https://www.gog.com" + url;
+ for (int hop = 0; hop < 5; hop++) {
+ HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+ conn.setConnectTimeout(TIMEOUT); conn.setReadTimeout(TIMEOUT); conn.setInstanceFollowRedirects(false);
+ if (token != null) conn.setRequestProperty("Authorization", "Bearer " + token);
+ int code = conn.getResponseCode();
+ String location = conn.getHeaderField("Location");
+ if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
+ conn.disconnect();
+ if (location == null) return null;
+ url = location.startsWith("/") ? "https://www.gog.com" + location : location;
+ continue;
+ }
+ if (code == 200) {
+ String ct = conn.getContentType();
+ if (ct != null && ct.contains("application/json")) {
+ String body = readAll(conn.getInputStream());
+ conn.disconnect();
+ String inner = new JSONObject(body).optString("downlink", null);
+ if (inner != null && !inner.isEmpty()) { url = inner; continue; }
+ } else conn.disconnect();
+ return url;
+ }
+ conn.disconnect();
+ return null;
+ }
+ } catch (Exception ignored) {}
+ return null;
+ }
+
+ private static long downloadWithProgress(String url, File out, Callback cb, AtomicBoolean cancelled) {
+ try {
+ HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+ conn.setConnectTimeout(TIMEOUT); conn.setReadTimeout(60_000);
+ long total = conn.getContentLengthLong();
+ long downloaded = 0;
+ try (InputStream is = conn.getInputStream(); FileOutputStream fos = new FileOutputStream(out)) {
+ byte[] buf = new byte[131072];
+ int n;
+ long windowStart = System.currentTimeMillis(), windowBytes = 0, speed = 0;
+ while ((n = is.read(buf)) != -1) {
+ if (cancelled.get()) return -1;
+ fos.write(buf, 0, n);
+ downloaded += n; windowBytes += n;
+ long elapsed = System.currentTimeMillis() - windowStart;
+ if (elapsed >= 500) { speed = windowBytes * 1000L / elapsed; windowStart = System.currentTimeMillis(); windowBytes = 0; }
+ if (total > 0) {
+ cb.onProgress("Downloading: " + out.getName() + speedSuffix(speed), 15 + (int) ((downloaded / (float) total) * 80));
+ cb.onBytes(downloaded, total, speed);
+ }
+ }
+ } finally {
+ conn.disconnect();
+ }
+ return downloaded;
+ } catch (Exception e) {
+ Log.w(TAG, "installer download failed: " + e.getClass().getSimpleName());
+ return -1;
+ }
+ }
+
+ // ---- native engine --------------------------------------------------------------------------
+
+ /**
+ * Drives the native engine for one fetch loop, producing what the Java pool produces: the same
+ * counters, progress strings and percentages. A secure-link expiry (the run dying on
+ * 401/403/404/500) refreshes the link and re-runs for the files not yet done.
+ */
+ private static void runNativeEngine(Context ctx, int kind, String verifiedMsg, List depotJsons, File installPath,
+ AtomicReference cdnBaseRef, String secureLinkUrl, String token,
+ AtomicInteger cdnRefreshCount, int maxCdnRefresh, int maxWorkers, int processWorkers,
+ boolean sortLargestFirst, String label, AtomicBoolean cancelled, AtomicBoolean anyFailed,
+ AtomicInteger doneCount, int total, long planned, AtomicLong totalBytes,
+ AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps, Callback cb) {
+ StoreDownloadTier tier = StoreDownloadTier.Companion.current(ctx);
+ int workers = Math.max(1, Math.min(128, tier.getNetworkWindow()));
+ int process = Math.max(16, Math.max(processWorkers, tier.getProcessWorkers()));
+ cb.onLog("gog: engine=native workers=" + workers + " process_workers=" + process + " tier=" + tier.getId());
+ final String[] manifests = depotJsons.toArray(new String[0]);
+ final java.util.Set donePaths = java.util.concurrent.ConcurrentHashMap.newKeySet();
+ int run = 0;
+ while (!cancelled.get()) {
+ run++;
+ final String base = cdnBaseRef.get();
+ final CountDownLatch latch = new CountDownLatch(1);
+ final AtomicBoolean okRef = new AtomicBoolean(false);
+ final AtomicBoolean linkExpiryRef = new AtomicBoolean(false);
+ final AtomicReference errRef = new AtomicReference<>("");
+ GogNative.Listener listener = new GogNative.Listener() {
+ @Override public void onProgress(long bytesDone, long bytesTotal, int filesDone, int filesTotal, String file, long fileBytes, boolean verified) {
+ donePaths.add(file);
+ int done = doneCount.incrementAndGet();
+ int pct = 15 + (int) ((done / (float) total) * 80);
+ long tb = totalBytes.addAndGet(fileBytes);
+ if (verified) { cb.onProgress(verifiedMsg, pct); cb.onBytes(tb, planned, speedBps.get()); return; }
+ sampleSpeed(tb, lastSpeedMs, lastSpeedB, speedBps);
+ cb.onProgress("Downloading: " + baseName(file) + speedSuffix(speedBps.get()), pct);
+ cb.onBytes(tb, planned, speedBps.get());
+ }
+ @Override public void onLog(String line) { cb.onLog(line); }
+ @Override public void onComplete(boolean success, boolean wasCancelled, boolean linkExpiry, String error, long bytesWritten, int filesDone) {
+ okRef.set(success); linkExpiryRef.set(linkExpiry); errRef.set(error == null ? "" : error); latch.countDown();
+ }
+ };
+ long handle = GogNative.start(kind, manifests, base, installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
+ workers, process, sortLargestFirst, label + " run=" + run, listener);
+ if (handle == 0L) { cb.onLog("gog: native start failed"); anyFailed.set(true); return; }
+ boolean interrupted = false;
+ try {
+ while (true) {
+ try { if (latch.await(250, TimeUnit.MILLISECONDS)) break; } catch (InterruptedException ie) { interrupted = true; }
+ if (cancelled.get() || interrupted) GogNative.cancel(handle);
+ }
+ } finally {
+ GogNative.release(handle);
+ }
+ if (interrupted) { Thread.currentThread().interrupt(); anyFailed.set(true); return; }
+ if (cancelled.get() || okRef.get()) return;
+ String err = errRef.get();
+ if (linkExpiryRef.get() && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) {
+ cb.onLog("gog: secure link expired (" + err + "); refreshed, running again for the remaining files");
+ continue;
+ }
+ cb.onLog("gog: engine failed: " + err);
+ anyFailed.set(true);
+ return;
+ }
+ }
+
+ // ---- gen2 helpers (shared with the Java pool) -----------------------------------------------
+
+ private static void parseDepotManifest(String json, List out) {
+ try {
+ JSONObject depotObj = new JSONObject(json).optJSONObject("depot");
+ if (depotObj == null) return;
+ JSONArray depot = depotObj.optJSONArray("items");
+ if (depot == null) return;
+ for (int i = 0; i < depot.length(); i++) {
+ JSONObject entry = depot.getJSONObject(i);
+ String path = entry.optString("path", "").replace("\\", "/");
+ if (path.startsWith("/")) path = path.substring(1);
+ JSONArray chunks = entry.optJSONArray("chunks");
+ if (path.isEmpty() || chunks == null || chunks.length() == 0) continue;
+ DepotFile df = new DepotFile(path);
+ String fileMd5 = entry.optString("md5", null);
+ if (fileMd5 != null && !fileMd5.isEmpty()) df.md5 = fileMd5;
+ long fileTotal = 0;
+ for (int c = 0; c < chunks.length(); c++) {
+ JSONObject chunk = chunks.getJSONObject(c);
+ String compressedMd5 = chunk.optString("compressedMd5"), decMd5 = chunk.optString("md5");
+ String hash = (compressedMd5 != null && !compressedMd5.isEmpty()) ? compressedMd5 : decMd5;
+ if (hash == null || hash.isEmpty()) continue;
+ long decSize = chunk.optLong("size", 0);
+ df.chunks.add(new DepotFile.ChunkRef(hash, compressedMd5, decMd5, chunk.optLong("compressedSize", 0), decSize));
+ fileTotal += decSize;
+ }
+ df.totalSize = fileTotal;
+ if (!df.chunks.isEmpty()) out.add(df);
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "depot manifest parse: " + e.getClass().getSimpleName());
+ }
+ }
+
+ static String buildCdnPath(String hash) { return hash.substring(0, 2) + "/" + hash.substring(2, 4) + "/" + hash; }
+
+ private static boolean assembleDepotFile(DepotFile df, File outFile, AtomicReference cdnBaseRef, String secureLinkUrl, String token,
+ AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
+ File tmpFile = new File(outFile.getAbsolutePath() + ".bhtmp");
+ File parent = outFile.getParentFile();
+ if (parent != null) parent.mkdirs();
+ tmpFile.delete();
+ boolean ok = true;
+ try (FileOutputStream fos = new FileOutputStream(tmpFile)) {
+ for (DepotFile.ChunkRef chunk : df.chunks) {
+ if (cancelled.get()) { ok = false; break; }
+ byte[] inflated = fetchChunkVerified(cdnBaseRef, buildCdnPath(chunk.hash), chunk, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cancelled, cb);
+ if (inflated == null) { ok = false; break; }
+ fos.write(inflated);
+ }
+ } catch (Exception e) {
+ ok = false;
+ }
+ if (!ok || cancelled.get()) { tmpFile.delete(); return false; }
+ if (df.totalSize > 0 && tmpFile.length() != df.totalSize) { cb.onLog("size mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
+ if (df.md5 != null && !df.md5.isEmpty()) {
+ String actual = md5HexFile(tmpFile);
+ if (actual == null || !actual.equalsIgnoreCase(df.md5)) { cb.onLog("md5 mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
+ }
+ if (outFile.exists()) outFile.delete();
+ return tmpFile.renameTo(outFile);
+ }
+
+ private static String parseCdnUrl(String json) {
+ if (json == null) return null;
+ try {
+ JSONArray urls = new JSONObject(json).optJSONArray("urls");
+ if (urls == null || urls.length() == 0) return null;
+ JSONObject first = urls.getJSONObject(0);
+ String urlFormat = first.optString("url_format");
+ JSONObject params = first.optJSONObject("parameters");
+ if (urlFormat == null || params == null) return null;
+ java.util.Iterator keys = params.keys();
+ while (keys.hasNext()) { String k = keys.next(); urlFormat = urlFormat.replace("{" + k + "}", params.optString(k)); }
+ urlFormat = urlFormat.replace("\\/", "/");
+ int idx = urlFormat.indexOf("/{path}");
+ if (idx >= 0) urlFormat = urlFormat.substring(0, idx);
+ return urlFormat;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static boolean downloadRange(String url, long offset, long size, File out) {
+ try {
+ HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+ conn.setConnectTimeout(TIMEOUT); conn.setReadTimeout(TIMEOUT);
+ conn.setRequestProperty("Range", "bytes=" + offset + "-" + (offset + size - 1));
+ try (InputStream is = conn.getInputStream(); FileOutputStream fos = new FileOutputStream(out)) {
+ byte[] buf = new byte[131072];
+ int n;
+ while ((n = is.read(buf)) != -1) fos.write(buf, 0, n);
+ }
+ conn.disconnect();
+ return true;
+ } catch (Exception e) {
+ Log.w(TAG, "range download failed: " + out.getName() + " (" + e.getClass().getSimpleName() + ")");
+ return false;
+ }
+ }
+
+ static String httpGet(String url, String token) {
+ try {
+ HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+ conn.setConnectTimeout(TIMEOUT); conn.setReadTimeout(TIMEOUT);
+ conn.setRequestProperty("User-Agent", "GOG Galaxy");
+ if (token != null) conn.setRequestProperty("Authorization", "Bearer " + token);
+ if (conn.getResponseCode() != 200) { conn.disconnect(); return null; }
+ String body = readAll(conn.getInputStream());
+ conn.disconnect();
+ return body;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static String readAll(InputStream is) throws IOException {
+ StringBuilder sb = new StringBuilder();
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"))) {
+ String line;
+ while ((line = br.readLine()) != null) sb.append(line);
+ }
+ return sb.toString();
+ }
+
+ static byte[] fetchBytes(String url, String token) { return fetchBytesEx(url, token).body; }
+
+ /** gzip or zlib detected by magic, else the bytes as UTF-8. */
+ static String decompressBytes(byte[] data) {
+ if (data == null || data.length < 2) return null;
+ try {
+ int b0 = data[0] & 0xFF, b1 = data[1] & 0xFF;
+ if (b0 == 0x1F && b1 == 0x8B) {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(data))) {
+ byte[] buf = new byte[4096];
+ int n;
+ while ((n = gzip.read(buf)) != -1) bos.write(buf, 0, n);
+ }
+ return bos.toString("UTF-8");
+ }
+ if (b0 == 0x78) {
+ byte[] out = inflateZlib(data);
+ return out == null ? null : new String(out, "UTF-8");
+ }
+ return new String(data, "UTF-8");
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static byte[] inflateZlib(byte[] data) {
+ try {
+ if (data == null || data.length < 2 || (data[0] & 0xFF) != 0x78) return null;
+ Inflater inf = new Inflater();
+ inf.setInput(data);
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ while (!inf.finished()) {
+ int n = inf.inflate(buf);
+ if (n == 0) { if (inf.needsInput() || inf.needsDictionary()) break; }
+ bos.write(buf, 0, n);
+ }
+ inf.end();
+ return bos.toByteArray();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static final class HttpResult {
+ final byte[] body; final int status;
+ HttpResult(byte[] body, int status) { this.body = body; this.status = status; }
+ }
+
+ private static HttpResult fetchBytesEx(String url, String token) {
+ HttpURLConnection conn = null;
+ try {
+ conn = (HttpURLConnection) new URL(url).openConnection();
+ conn.setConnectTimeout(TIMEOUT); conn.setReadTimeout(TIMEOUT);
+ conn.setRequestProperty("User-Agent", "GOG Galaxy");
+ if (token != null) conn.setRequestProperty("Authorization", "Bearer " + token);
+ int code = conn.getResponseCode();
+ if (code != 200) { conn.disconnect(); return new HttpResult(null, code); }
+ int len = conn.getContentLength();
+ ByteArrayOutputStream bos = len > 0 ? new ByteArrayOutputStream(len) : new ByteArrayOutputStream();
+ byte[] buf = new byte[131072];
+ try (InputStream is = conn.getInputStream()) {
+ int n;
+ while ((n = is.read(buf)) != -1) bos.write(buf, 0, n);
+ }
+ conn.disconnect();
+ return new HttpResult(bos.toByteArray(), code);
+ } catch (Exception e) {
+ if (conn != null) try { conn.disconnect(); } catch (Exception ignored) {}
+ return new HttpResult(null, -1);
+ }
+ }
+
+ /** The chunk path goes before the query string so the secure link's token survives. */
+ private static String buildChunkUrl(String base, String chunkPath) {
+ int q = base.indexOf('?');
+ return q >= 0 ? base.substring(0, q) + "/" + chunkPath + base.substring(q) : base + "/" + chunkPath;
+ }
+
+ private static String md5Hex(byte[] data) {
+ try { return toHex(java.security.MessageDigest.getInstance("MD5").digest(data)); } catch (Exception e) { return null; }
+ }
+
+ private static String md5HexFile(File f) {
+ try (FileInputStream fis = new FileInputStream(f)) {
+ java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
+ byte[] buf = new byte[131072];
+ int n;
+ while ((n = fis.read(buf)) != -1) md.update(buf, 0, n);
+ return toHex(md.digest());
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static String toHex(byte[] d) {
+ StringBuilder sb = new StringBuilder(d.length * 2);
+ for (byte b : d) { int v = b & 0xFF; if (v < 16) sb.append('0'); sb.append(Integer.toHexString(v)); }
+ return sb.toString();
+ }
+
+ private static boolean fileVerified(File f, long expectedSize, String expectedMd5) {
+ if (f == null || !f.exists() || f.length() == 0) return false;
+ if (expectedSize > 0 && f.length() != expectedSize) return false;
+ if (expectedMd5 != null && !expectedMd5.isEmpty()) {
+ String actual = md5HexFile(f);
+ return actual != null && actual.equalsIgnoreCase(expectedMd5);
+ }
+ return true;
+ }
+
+ private static void sleepBackoff(int attempt) {
+ try { Thread.sleep(Math.min(1000L << Math.max(0, attempt - 1), 8000L)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
+ }
+
+ private static boolean tryRefreshCdn(AtomicReference cdnBaseRef, String staleBase, String secureLinkUrl, String token,
+ AtomicInteger refreshCount, int cap, Callback cb) {
+ if (secureLinkUrl == null) return false;
+ synchronized (cdnBaseRef) {
+ String current = cdnBaseRef.get();
+ if (staleBase == null || !staleBase.equals(current)) return true;
+ if (refreshCount.get() >= cap) { cb.onLog("gog: CDN refresh cap reached (" + cap + ")"); return false; }
+ String neu = parseCdnUrl(httpGet(secureLinkUrl, token));
+ if (neu == null || neu.isEmpty()) { cb.onLog("gog: CDN refresh failed"); return false; }
+ cdnBaseRef.set(neu);
+ cb.onLog("gog: secure link refreshed #" + refreshCount.incrementAndGet());
+ return true;
+ }
+ }
+
+ private static byte[] fetchChunkVerified(AtomicReference cdnBaseRef, String chunkPath, DepotFile.ChunkRef chunk, String secureLinkUrl, String token,
+ AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
+ int hardFail = 0, guard = 0;
+ while (guard++ < 8) {
+ if (cancelled.get()) return null;
+ String base = cdnBaseRef.get();
+ HttpResult res = fetchBytesEx(buildChunkUrl(base, chunkPath), null);
+ if (res.body == null) {
+ int code = res.status;
+ if ((code == 401 || code == 403 || code == 404 || code == 500) && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) continue;
+ cb.onLog("chunk http fail code=" + code);
+ if (++hardFail >= 3) return null;
+ sleepBackoff(hardFail);
+ continue;
+ }
+ byte[] raw = res.body;
+ if (chunk.compressedSize > 0 && raw.length != chunk.compressedSize) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
+ if (chunk.compressedMd5 != null && !chunk.compressedMd5.isEmpty()) {
+ String actual = md5Hex(raw);
+ if (actual == null || !actual.equalsIgnoreCase(chunk.compressedMd5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
+ }
+ byte[] inflated = inflateZlib(raw);
+ if (inflated == null) inflated = raw;
+ if (chunk.size > 0 && inflated.length != chunk.size) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
+ if (chunk.md5 != null && !chunk.md5.isEmpty()) {
+ String actual = md5Hex(inflated);
+ if (actual == null || !actual.equalsIgnoreCase(chunk.md5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
+ }
+ return inflated;
+ }
+ return null;
+ }
+
+ private static void sampleSpeed(long totalBytes, AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps) {
+ long nowMs = System.currentTimeMillis(), prevMs = lastSpeedMs.get();
+ if (nowMs - prevMs >= 500 && lastSpeedMs.compareAndSet(prevMs, nowMs)) {
+ long prevB = lastSpeedB.getAndSet(totalBytes);
+ long dt = nowMs - prevMs;
+ if (dt > 0) speedBps.set((totalBytes - prevB) * 1000L / dt);
+ }
+ }
+
+ private static String speedSuffix(long bps) {
+ if (bps <= 0) return "";
+ return " " + (bps >= 1048576 ? String.format("%.1f MB/s", bps / 1048576.0) : (bps / 1024) + " KB/s");
+ }
+
+ private static String baseName(String path) { return path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path; }
+
+ /** Download-pool threads for the built-in loop: cores times two, between 6 and 16. */
+ private static int downloadThreads() {
+ int threads = Runtime.getRuntime().availableProcessors() * 2;
+ return Math.max(6, Math.min(16, threads));
+ }
+
+ private static void writeFile(File f, byte[] data) throws IOException {
+ try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(data); }
+ }
+
+ static void deleteDir(File dir) {
+ if (dir == null || !dir.exists()) return;
+ File[] children = dir.listFiles();
+ if (children != null) for (File c : children) deleteDir(c);
+ dir.delete();
+ }
+
+ /**
+ * The exe to run, relative to the install folder: the manifest's hint when present, else the
+ * candidate named most like the title, else the shallowest. "" when the folder has none.
+ */
+ static String pickExe(File installPath, String tempExe, String title) {
+ if (tempExe != null) {
+ File hinted = new File(installPath, tempExe);
+ if (hinted.isFile()) return relative(installPath, hinted);
+ }
+ List candidates = new ArrayList<>();
+ collectExeRecursive(installPath, candidates);
+ if (candidates.isEmpty()) return "";
+ String key = title.toLowerCase().replaceAll("[^a-z0-9]", "");
+ File best = candidates.get(0);
+ for (File f : candidates) {
+ String n = f.getName().toLowerCase().replaceAll("\\.exe$", "").replaceAll("[^a-z0-9]", "");
+ if (!n.isEmpty() && (n.equals(key) || key.startsWith(n) || n.startsWith(key))) { best = f; break; }
+ }
+ return relative(installPath, best);
+ }
+
+ private static String relative(File root, File f) {
+ String rel = f.getAbsolutePath().substring(root.getAbsolutePath().length());
+ if (rel.startsWith("/")) rel = rel.substring(1);
+ return rel.replace('\\', '/');
+ }
+
+ private static void collectExeRecursive(File dir, List out) {
+ if (!dir.isDirectory()) return;
+ File[] files = dir.listFiles();
+ if (files == null) return;
+ for (File f : files) {
+ if (f.isFile() && f.getName().toLowerCase().endsWith(".exe")) {
+ String path = f.getAbsolutePath().toLowerCase();
+ if (!path.contains("redist") && !path.contains("unins") && !path.contains("setup") && !path.contains("crash")
+ && !path.contains("report") && !path.contains("helper") && !path.contains("dotnet") && !path.contains("vcredist")
+ && !path.contains("directx")) out.add(f);
+ }
+ }
+ for (File f : files) if (f.isDirectory()) collectExeRecursive(f, out);
+ }
+
+ private static final class DepotFile {
+ final String relativePath;
+ final List chunks = new ArrayList<>();
+ String md5;
+ long totalSize;
+ DepotFile(String relativePath) { this.relativePath = relativePath; }
+ static final class ChunkRef {
+ final String hash, compressedMd5, md5;
+ final long compressedSize, size;
+ ChunkRef(String hash, String compressedMd5, String md5, long compressedSize, long size) {
+ this.hash = hash; this.compressedMd5 = compressedMd5; this.md5 = md5; this.compressedSize = compressedSize; this.size = size;
+ }
+ }
+ }
+
+ private static final class Gen1File {
+ final String path, url; final long offset, size;
+ Gen1File(String path, String url, long offset, long size) { this.path = path; this.url = url; this.offset = offset; this.size = size; }
+ }
+
+ /** The install size of the Windows build (English / all-language depots), or -1. Blocking. */
+ public static long fetchInstallSizeBytes(String gameId, String token) {
+ try {
+ String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
+ String buildsJson = httpGet(buildsUrl, null);
+ if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
+ if (buildsJson == null) return -1;
+ JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
+ if (items == null || items.length() == 0) return -1;
+ String manifestUrl = null;
+ for (int i = 0; i < items.length(); i++) {
+ JSONObject item = items.getJSONObject(i);
+ if ("windows".equals(item.optString("os"))) {
+ manifestUrl = item.optString("link");
+ if (manifestUrl == null || manifestUrl.isEmpty()) manifestUrl = item.optString("meta_url");
+ break;
+ }
+ }
+ if (manifestUrl == null || manifestUrl.isEmpty()) return -1;
+ byte[] raw = fetchBytes(manifestUrl, token);
+ if (raw == null) return -1;
+ String manifestStr = decompressBytes(raw);
+ if (manifestStr == null) return -1;
+ JSONArray depots = new JSONObject(manifestStr).optJSONArray("depots");
+ if (depots == null) return -1;
+ long total = 0;
+ for (int i = 0; i < depots.length(); i++) {
+ JSONObject depot = depots.getJSONObject(i);
+ if (languageCompatible(depot)) total += depot.optLong("size", 0);
+ }
+ return total > 0 ? total : -1;
+ } catch (Exception e) {
+ Log.w(TAG, "install size " + gameId + ": " + e.getClass().getSimpleName());
+ return -1;
+ }
+ }
+
+ public static String formatBytes(long bytes) {
+ if (bytes <= 0) return "Unknown";
+ if (bytes < 1024L * 1024) return String.format("%.1f KB", bytes / 1024.0);
+ if (bytes < 1024L * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
+ return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024));
+ }
+
+ /** Keeps the import used: every URL a log line names goes through here. */
+ static String safe(String url) { return StoreLog.redactUrl(url); }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogGame.java b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogGame.java
new file mode 100644
index 000000000..63dbab9c7
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogGame.java
@@ -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;
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt
new file mode 100644
index 000000000..38a0bbe43
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt
@@ -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, 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 {
+ 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()
+ 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()
+ 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 { 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) {
+ 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}",
+ )
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt
new file mode 100644
index 000000000..173ad8059
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt
@@ -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, cdnBase: String, installDir: String, skipPaths: Array,
+ 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, cdnBase: String, installDir: String, skipPaths: Array,
+ 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)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogPrefs.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogPrefs.kt
new file mode 100644
index 000000000..b5a3af6cb
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogPrefs.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt
new file mode 100644
index 000000000..c0103e23b
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt
@@ -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(val value: T, val at: Long)
+ private var featuredCache: Cached? = null
+ private val searchCache = ConcurrentHashMap>>()
+
+ 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¤cyCode=${currency()}$extra"
+
+ /** GET the catalog; on a refusal of the locale's currency, retry as US / USD. */
+ private fun fetchProducts(url: String): List {
+ var body = StoreNet.get(url)
+ if (body == null && !url.contains("countryCode=US&locale=en-US¤cyCode=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 {
+ 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 = try {
+ val arr = JSONObject(body).optJSONArray("products") ?: JSONArray()
+ val out = ArrayList(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) = JSONArray().apply { l.forEach { put(itemToJson(it)) } }
+ private fun listFromJson(a: JSONArray?): List {
+ 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()
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c8c82732b..a80396053 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2085,4 +2085,16 @@
Downloading
%1$d downloads
Preparing…
+ Sign in to %1$s
+ Sign in with your provider
+ Reload
+ Close
+ Try again
+ Finishing sign-in…
+ The sign-in page could not be loaded: %1$s
+ This provider refuses to sign in from an embedded page. Sign in with your store password instead.
+ The sign-in page answered with an error (HTTP %1$d).
+ The connection to the sign-in page is not secure.
+ The sign-in could not be verified. Try again.
+ Sign-in failed. Try again.