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