Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 125 additions & 12 deletions app/src/main/java/app/gamenative/service/SteamWishlistService.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package app.gamenative.service

import app.gamenative.PrefManager
import app.gamenative.utils.Net
import `in`.dragonbra.javasteam.enums.EResult
import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesWishlistSteamclient.CWishlist_AddToWishlist_Request
import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesWishlistSteamclient.CWishlist_RemoveFromWishlist_Request
import `in`.dragonbra.javasteam.rpc.service.Wishlist
import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages
import java.net.URLEncoder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.future.await
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import org.json.JSONObject
Expand All @@ -19,6 +22,8 @@ object SteamWishlistService {
private const val TAG = "SteamWishlist"
private const val JOB_TIMEOUT_MS = 15_000L
private const val GET_URL = "https://api.steampowered.com/IWishlistService/GetWishlist/v1/"
private const val STORE_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"

sealed interface Outcome {
data object Success : Outcome
Expand All @@ -34,6 +39,93 @@ object SteamWishlistService {
}
}

// UTM attribution requires the visit and the wishlist add to happen in the same web session;
// a CM add after a web visit is a different "browser" to Valve and may not attribute.
suspend fun addToWishlistAttributed(appId: Int, campaignId: String): Outcome = withContext(Dispatchers.IO) {
val webOk = try {
webAttributedAdd(appId, campaignId)
} catch (e: Exception) {
Timber.tag(TAG).w(e, "attributed add failed")
false
}
if (webOk) Outcome.Success else addToWishlist(appId)
}

private suspend fun webAttributedAdd(appId: Int, campaignId: String): Boolean {
val steamId = SteamService.userSteamId?.convertToUInt64() ?: return false
var token = PrefManager.accessToken
repeat(2) { attempt ->
if (token.isEmpty()) token = refreshAccessToken() ?: return false
val cookie = "steamLoginSecure=$steamId%7C%7C${URLEncoder.encode(token, "UTF-8")}; " +
"birthtime=0; lastagecheckage=1-January-1970; wantsmatureconctent=1"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Typo in cookie key wantsmatureconctent — should be wantsmaturecontent. The word "content" is misspelled as "conctent". If Steam's storefront checks for this cookie by its correct key, the typo means the cookie will be ignored, which could affect age-gate or maturity-content behavior during the attributed wishlist session.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/SteamWishlistService.kt, line 60:

<comment>Typo in cookie key `wantsmatureconctent` — should be `wantsmaturecontent`. The word "content" is misspelled as "conctent". If Steam's storefront checks for this cookie by its correct key, the typo means the cookie will be ignored, which could affect age-gate or maturity-content behavior during the attributed wishlist session.</comment>

<file context>
@@ -34,6 +39,93 @@ object SteamWishlistService {
+        repeat(2) { attempt ->
+            if (token.isEmpty()) token = refreshAccessToken() ?: return false
+            val cookie = "steamLoginSecure=$steamId%7C%7C${URLEncoder.encode(token, "UTF-8")}; " +
+                "birthtime=0; lastagecheckage=1-January-1970; wantsmatureconctent=1"
+            val utmUrl = "https://store.steampowered.com/app/$appId/" +
+                "?utm_source=gamenative&utm_medium=app&utm_campaign=$campaignId"
</file context>
Suggested change
"birthtime=0; lastagecheckage=1-January-1970; wantsmatureconctent=1"
"birthtime=0; lastagecheckage=1-January-1970; wantsmaturecontent=1"

val utmUrl = "https://store.steampowered.com/app/$appId/" +
"?utm_source=gamenative&utm_medium=app&utm_campaign=$campaignId"
Comment on lines +59 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Encode campaignId in the URL and fix the mature-content cookie name.

Two problems in this block:

  1. campaignId comes from remote featured JSON (FeaturedItem.campaignId). The code interpolates it into the query string without encoding. A value that contains &, #, or a space corrupts the UTM parameters, or makes Request.Builder().url(...) throw. The attributed add then silently falls back to the plain CM add, so attribution is lost.
  2. wantsmatureconctent is misspelled. Steam uses wants_mature_content. With the wrong cookie name, a mature-gated store page can return the age-check interstitial instead of the app page. The data-userinfo parse then fails and loggedIn stays false, which burns both retry attempts.
🛠️ Proposed fix
             val cookie = "steamLoginSecure=$steamId%7C%7C${URLEncoder.encode(token, "UTF-8")}; " +
-                "birthtime=0; lastagecheckage=1-January-1970; wantsmatureconctent=1"
-            val utmUrl = "https://store.steampowered.com/app/$appId/" +
-                "?utm_source=gamenative&utm_medium=app&utm_campaign=$campaignId"
+                "birthtime=0; lastagecheckage=1-January-1970; wants_mature_content=1"
+            val utmUrl = "https://store.steampowered.com/app/$appId/".toHttpUrl().newBuilder()
+                .addQueryParameter("utm_source", "gamenative")
+                .addQueryParameter("utm_medium", "app")
+                .addQueryParameter("utm_campaign", campaignId)
+                .build()
+                .toString()

Note on the static analysis SSRF hints for Line 65 and Line 165: both hosts are hardcoded Steam endpoints, so this is not SSRF. The real risk is query-string corruption from the uncoded campaignId.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val cookie = "steamLoginSecure=$steamId%7C%7C${URLEncoder.encode(token, "UTF-8")}; " +
"birthtime=0; lastagecheckage=1-January-1970; wantsmatureconctent=1"
val utmUrl = "https://store.steampowered.com/app/$appId/" +
"?utm_source=gamenative&utm_medium=app&utm_campaign=$campaignId"
val cookie = "steamLoginSecure=$steamId%7C%7C${URLEncoder.encode(token, "UTF-8")}; " +
"birthtime=0; lastagecheckage=1-January-1970; wants_mature_content=1"
val utmUrl = "https://store.steampowered.com/app/$appId/".toHttpUrl().newBuilder()
.addQueryParameter("utm_source", "gamenative")
.addQueryParameter("utm_medium", "app")
.addQueryParameter("utm_campaign", campaignId)
.build()
.toString()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/service/SteamWishlistService.kt` around
lines 59 - 62, Update the URL construction in the SteamWishlistService flow to
URL-encode campaignId before interpolating it into the utm_campaign query
parameter, preserving the existing attribution parameters. Correct the
mature-content cookie key in the cookie assembled alongside utmUrl to Steam’s
wants_mature_content name.

Source: Linters/SAST tools

Comment on lines +61 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Campaign IDs containing URL-reserved characters produce missing or incorrect UTM attribution; build the UTM URL with query parameters instead of concatenating remote data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/SteamWishlistService.kt, line 61:

<comment>Campaign IDs containing URL-reserved characters produce missing or incorrect UTM attribution; build the UTM URL with query parameters instead of concatenating remote data.</comment>

<file context>
@@ -34,6 +39,93 @@ object SteamWishlistService {
+            if (token.isEmpty()) token = refreshAccessToken() ?: return false
+            val cookie = "steamLoginSecure=$steamId%7C%7C${URLEncoder.encode(token, "UTF-8")}; " +
+                "birthtime=0; lastagecheckage=1-January-1970; wantsmatureconctent=1"
+            val utmUrl = "https://store.steampowered.com/app/$appId/" +
+                "?utm_source=gamenative&utm_medium=app&utm_campaign=$campaignId"
+            var sessionId: String? = null
</file context>
Suggested change
val utmUrl = "https://store.steampowered.com/app/$appId/" +
"?utm_source=gamenative&utm_medium=app&utm_campaign=$campaignId"
val utmUrl = "https://store.steampowered.com/app/$appId/".toHttpUrl().newBuilder()
.addQueryParameter("utm_source", "gamenative")
.addQueryParameter("utm_medium", "app")
.addQueryParameter("utm_campaign", campaignId)
.build()
.toString()

var sessionId: String? = null
var loggedIn = false
Net.http.newCall(
Request.Builder().url(utmUrl)
.header("User-Agent", STORE_UA)
.header("Cookie", cookie)
.build(),
).execute().use { res ->
res.headers("Set-Cookie").forEach { c ->
if (c.startsWith("sessionid=")) sessionId = c.substringAfter("sessionid=").substringBefore(';')
}
val body = res.body?.string().orEmpty()
loggedIn = Regex("data-userinfo=\"([^\"]*)\"").find(body)
?.groupValues?.get(1)?.contains("&quot;logged_in&quot;:true") == true
Timber.tag(TAG).i("utm visit http=${res.code} loggedIn=$loggedIn sessionid=${sessionId != null} campaign=$campaignId")
}
if (loggedIn && sessionId != null) {
Net.http.newCall(
Request.Builder().url("https://store.steampowered.com/api/addtowishlist")
.header("User-Agent", STORE_UA)
.header("Cookie", "$cookie; sessionid=$sessionId")
.header("Referer", utmUrl)
.header("Origin", "https://store.steampowered.com")
.post(FormBody.Builder().add("appid", appId.toString()).add("sessionid", sessionId!!).build())
.build(),
).execute().use { res ->
val body = res.body?.string().orEmpty()
val ok = res.isSuccessful && JSONObject(body).optBoolean("success")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Successful Store adds are treated as failures because numeric success: 1 does not satisfy optBoolean; accept the endpoint’s integer success value so an attributed add is not redundantly retried through CM.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/SteamWishlistService.kt, line 90:

<comment>Successful Store adds are treated as failures because numeric `success: 1` does not satisfy `optBoolean`; accept the endpoint’s integer success value so an attributed add is not redundantly retried through CM.</comment>

<file context>
@@ -34,6 +39,93 @@ object SteamWishlistService {
+                        .build(),
+                ).execute().use { res ->
+                    val body = res.body?.string().orEmpty()
+                    val ok = res.isSuccessful && JSONObject(body).optBoolean("success")
+                    Timber.tag(TAG).i("web addtowishlist http=${res.code} body=$body -> $ok")
+                    return ok
</file context>
Suggested change
val ok = res.isSuccessful && JSONObject(body).optBoolean("success")
val ok = res.isSuccessful && JSONObject(body).let {
it.optInt("success") == 1 || it.optBoolean("success")
}

Timber.tag(TAG).i("web addtowishlist http=${res.code} body=$body -> $ok")
return ok
}
}
Timber.tag(TAG).w("utm visit not logged in (attempt ${attempt + 1}), refreshing token")
token = ""
}
return false
}

private fun steamIdFromToken(token: String): Long? = try {
val payload = token.split(".")[1]
val json = String(java.util.Base64.getUrlDecoder().decode(payload))
JSONObject(json).optString("sub").toLongOrNull()
} catch (e: Exception) {
null
}

private suspend fun refreshAccessToken(): String? {
val client = SteamService.instance?.steamClient ?: return null
val steamId = client.steamID ?: return null
val refresh = PrefManager.refreshToken.ifEmpty { return null }
return try {
val result = client.authentication.generateAccessTokenForApp(steamId, refresh, false).await()
if (result.accessToken.isNotEmpty()) {
PrefManager.accessToken = result.accessToken
if (result.refreshToken.isNotEmpty()) PrefManager.refreshToken = result.refreshToken
Timber.tag(TAG).i("refreshed store access token over CM")
result.accessToken
} else {
null
}
} catch (e: Exception) {
Timber.tag(TAG).w(e, "access token refresh failed")
null
}
}

suspend fun removeFromWishlist(appId: Int): Outcome = withContext(Dispatchers.IO) {
val service = service() ?: return@withContext Outcome.NoSession
val request = CWishlist_RemoveFromWishlist_Request.newBuilder().setAppid(appId).build()
Expand All @@ -42,24 +134,45 @@ object SteamWishlistService {
}
}

// The public steamid read returns nothing for private wishlists, so prefer the
// token-authenticated form, which always sees the caller's own list.
suspend fun isWishlisted(appId: Int): Boolean? = withContext(Dispatchers.IO) {
val steamId = SteamService.userSteamId?.convertToUInt64()
if (steamId == null || steamId == 0L) {
Timber.tag(TAG).w("no live steam session, cannot read wishlist")
return@withContext null
readWishlist(PrefManager.accessToken.ifEmpty { null })?.let {
return@withContext it.contains(appId)
}
val url = GET_URL.toHttpUrl().newBuilder()
.addQueryParameter("steamid", steamId.toString())
.build()
try {
Net.http.newCall(Request.Builder().url(url).build()).execute().use { res ->
val fresh = refreshAccessToken() ?: return@withContext null
readWishlist(fresh)?.contains(appId)
}

private fun readWishlist(token: String?): Set<Int>? {
val builder = GET_URL.toHttpUrl().newBuilder()
if (token != null) {
val steamId = steamIdFromToken(token) ?: SteamService.userSteamId?.convertToUInt64()
if (steamId == null || steamId == 0L) {
Timber.tag(TAG).w("cannot resolve steamid for authed wishlist read")
return null
}
builder.addQueryParameter("access_token", token)
builder.addQueryParameter("steamid", steamId.toString())
} else {
val steamId = SteamService.userSteamId?.convertToUInt64()
if (steamId == null || steamId == 0L) {
Timber.tag(TAG).w("no token or steam session, cannot read wishlist")
return null
}
builder.addQueryParameter("steamid", steamId.toString())
}
return try {
Net.http.newCall(Request.Builder().url(builder.build()).build()).execute().use { res ->
if (!res.isSuccessful) {
Timber.tag(TAG).w("wishlist read failed ${res.code}")
Timber.tag(TAG).w("wishlist read failed ${res.code} (authed=${token != null})")
return@use null
}
val response = JSONObject(res.body?.string().orEmpty()).optJSONObject("response")
val items = response?.optJSONArray("items") ?: return@use null
(0 until items.length()).any { items.optJSONObject(it)?.optInt("appid") == appId }
// Authed: absent items = empty wishlist. Public: absent = private, i.e. unknown.
val items = response?.optJSONArray("items")
?: return@use if (token != null) emptySet() else null
(0 until items.length()).mapNotNull { items.optJSONObject(it)?.optInt("appid") }.toSet()
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "wishlist read failed")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ internal fun FeaturedCtaButton(
val context = LocalContext.current
val scope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() }
val cta = remember(action) { InAppCta.forAction(action) }
val cta = remember(action) { InAppCta.forAction(action, campaignId) }
var done by remember(action) { mutableStateOf<Boolean?>(null) }
var busy by remember(action) { mutableStateOf(false) }

Expand Down Expand Up @@ -141,11 +141,11 @@ private sealed class InAppCta(

abstract suspend fun run(): Boolean

private class Wishlist(appId: Int) : InAppCta(appId, R.string.featured_action_wishlisted) {
private class Wishlist(appId: Int, private val campaignId: String) : InAppCta(appId, R.string.featured_action_wishlisted) {
override suspend fun isDone(): Boolean? = SteamWishlistService.isWishlisted(appId)

override suspend fun run(): Boolean =
SteamWishlistService.addToWishlist(appId) is SteamWishlistService.Outcome.Success
SteamWishlistService.addToWishlistAttributed(appId, campaignId) is SteamWishlistService.Outcome.Success
}

private class GetDemo(appId: Int) : InAppCta(appId, R.string.featured_action_in_library) {
Expand All @@ -155,10 +155,10 @@ private sealed class InAppCta(
}

companion object {
fun forAction(action: FeaturedCta): InAppCta? {
fun forAction(action: FeaturedCta, campaignId: String): InAppCta? {
val appId = action.appId ?: return null
return when (action.type) {
"WISHLIST" -> Wishlist(appId)
"WISHLIST" -> Wishlist(appId, campaignId)
"GET_DEMO" -> GetDemo(appId)
else -> null
}
Expand Down
Loading