From f467bb22e89adf985b67c05ebb5c7e9216b45fd5 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 8 Oct 2026 16:51:47 -0400 Subject: [PATCH] Stores: GOG end to end - sign-in, library, public shelves, installs through the queue GOG's OAuth page in the new StoreLoginActivity (the Galaxy client identity, a browser identity for a social sign-in hop), the token kept in the app's private credentials file and refreshed through auth.gog.com. The library syncs the owned ids against a cache and fetches each product's details; the Store tab reads catalog.gog.com's public feeds (new, deals, free, trending). An install runs Bannerlator's GOG pipeline made blocking under the download queue: gen2 chunks through the native engine when the library is there (secure-link refresh on expiry), the built-in pool otherwise, gen1 ranges, and the plain installer as the last resort; the sidecar is written and the game registered with Steam when it is done. --- app/src/main/AndroidManifest.xml | 7 + .../main/java/com/droiddeck/launcher/App.kt | 2 + .../droiddeck/launcher/stores/LoginFlows.kt | 55 + .../launcher/stores/StoreLoginActivity.kt | 308 +++++ .../com/droiddeck/launcher/stores/StoreNet.kt | 125 ++ .../droiddeck/launcher/stores/StoresNative.kt | 3 +- .../launcher/stores/download/DownloadQueue.kt | 7 +- .../droiddeck/launcher/stores/gog/GogAuth.kt | 101 ++ .../launcher/stores/gog/GogBackend.kt | 115 ++ .../stores/gog/GogDownloadManager.java | 1006 +++++++++++++++++ .../launcher/stores/gog/GogGame.java | 27 + .../launcher/stores/gog/GogLibrary.kt | 194 ++++ .../launcher/stores/gog/GogNative.kt | 103 ++ .../droiddeck/launcher/stores/gog/GogPrefs.kt | 15 + .../launcher/stores/gog/GogStoreCatalog.kt | 189 ++++ app/src/main/res/values/strings.xml | 12 + 16 files changed, 2266 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gog/GogAuth.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gog/GogGame.java create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gog/GogPrefs.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt 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.