From ca5cd4ad66a5ff05b4a9dec14be499368fe6ead6 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 31 Jul 2026 23:49:55 -0400 Subject: [PATCH 01/17] Wishlist the sponsored game in-app instead of deep-linking Adds SteamWishlistService covering IWishlistService add/remove/read. The stored session token comes from a client login and may not carry the audience the web endpoints want, so writes try it first and fall back to a web-audience token minted from the refresh token; both paths log which one worked so we can settle which route Steam actually accepts. A featured WISHLIST action with an appid now wishlists in place and renders as Wishlisted when the game is already on the list. Everything else still opens the store page, and a failed call falls back to that too, so a bad token degrades to the old behaviour rather than a dead button. FeaturedItem gains an appId because a campaign previously had no way to name its Steam app. USE_LOCAL_FEATURED serves a hardcoded Whisk campaign so this can be exercised without a server-side campaign. It needs to go back off before this ships, and the hero endpoint needs appId in its schema for a real one to work. --- .../main/java/app/gamenative/data/Featured.kt | 4 + .../data/RecommendationRepository.kt | 43 +++++ .../app/gamenative/data/RecommendedGame.kt | 2 + .../service/SteamWishlistService.kt | 161 ++++++++++++++++++ .../screen/library/RecommendedGameScreen.kt | 51 +++++- app/src/main/res/values/strings.xml | 2 + 6 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/app/gamenative/service/SteamWishlistService.kt diff --git a/app/src/main/java/app/gamenative/data/Featured.kt b/app/src/main/java/app/gamenative/data/Featured.kt index 49989c1f61..81007b5f6e 100644 --- a/app/src/main/java/app/gamenative/data/Featured.kt +++ b/app/src/main/java/app/gamenative/data/Featured.kt @@ -15,6 +15,8 @@ data class HeroResponse( data class FeaturedItem( val campaignId: String, val title: String, + // Steam appid the campaign points at; required for in-app actions such as WISHLIST. + val appId: Int? = null, val developer: String? = null, val heroImageUrl: String = "", val capsuleImageUrl: String? = null, @@ -91,6 +93,8 @@ fun FeaturedItem.toRecommendedGame(context: Context): RecommendedGame = Recommen label = a.localizedLabel(context), url = a.url, primary = a.style?.equals("primary", ignoreCase = true) ?: (index == 0), + type = a.type.uppercase(), + appId = appId, ) }, ) diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index 9f9f6b9cbd..d6d2925c1f 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -17,6 +17,45 @@ object RecommendationRepository { private const val API_URL = "https://api.gamenative.app/api/games/hero" private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L + // Serves a local campaign instead of the server one, to exercise the in-app wishlist CTA. + private const val USE_LOCAL_FEATURED = true + + private const val WHISK_APP_ID = 3602270 + private const val WHISK_ASSETS = + "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270" + + private val localFeatured = FeaturedItem( + campaignId = "local-whisk", + title = "Whisk", + appId = WHISK_APP_ID, + developer = "Double Dusk Inc.", + heroImageUrl = "$WHISK_ASSETS/04f63f73ec6aefcb4efc26a7c4049aebffb99368/header.jpg", + capsuleImageUrl = "$WHISK_ASSETS/8746d0c28b68abd78cdc7b9c6ad651af33381827/capsule_231x87.jpg", + screenshots = listOf( + "$WHISK_ASSETS/e0a52a09cd85472aecfb430ad086aae040cb100c/ss_e0a52a09cd85472aecfb430ad086aae040cb100c.1920x1080.jpg", + "$WHISK_ASSETS/77719570b08d1b46facf8477df20aa5b97b54573/ss_77719570b08d1b46facf8477df20aa5b97b54573.1920x1080.jpg", + "$WHISK_ASSETS/08fd286888a7efb1e782a5106fdb1b1f237bcdb2/ss_08fd286888a7efb1e782a5106fdb1b1f237bcdb2.1920x1080.jpg", + ), + tags = listOf("Action", "Indie"), + status = "COMING_SOON", + description = mapOf( + "en" to "Whisk is a two-player platformer about shared movement and communication. " + + "Coordinate jumps, climbs and throws with a partner to get every Dreamcat home.", + ), + actions = listOf( + FeaturedAction( + type = "WISHLIST", + url = "https://store.steampowered.com/app/$WHISK_APP_ID/", + store = "Steam", + style = "primary", + ), + FeaturedAction( + type = "VISIT", + url = "https://store.steampowered.com/app/$WHISK_APP_ID/", + ), + ), + ) + private val json = Json { ignoreUnknownKeys = true } // Latest featured from the most recent fetch. Kept in memory (not the disk cache) so the @@ -30,6 +69,10 @@ object RecommendationRepository { */ suspend fun getHero(context: Context): HeroResponse = withContext(Dispatchers.IO) { + if (USE_LOCAL_FEATURED) { + lastFeatured = localFeatured + return@withContext HeroResponse(recommendation = null, featured = localFeatured) + } val fetched = fetchRemote() if (fetched != null) { lastFeatured = fetched.featured diff --git a/app/src/main/java/app/gamenative/data/RecommendedGame.kt b/app/src/main/java/app/gamenative/data/RecommendedGame.kt index d81620f49c..8916597043 100644 --- a/app/src/main/java/app/gamenative/data/RecommendedGame.kt +++ b/app/src/main/java/app/gamenative/data/RecommendedGame.kt @@ -33,4 +33,6 @@ data class FeaturedCta( val label: String, val url: String, val primary: Boolean = false, + val type: String = "", + val appId: Int? = null, ) diff --git a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt new file mode 100644 index 0000000000..0535de9a1d --- /dev/null +++ b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt @@ -0,0 +1,161 @@ +package app.gamenative.service + +import app.gamenative.PrefManager +import app.gamenative.utils.Net +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import org.json.JSONObject +import timber.log.Timber + +/** + * Steam wishlist over the public IWishlistService endpoints. + * + * The stored session token comes from a client login, so it may not carry the audience the web + * endpoints require. Writes therefore try the stored token first and fall back to minting a + * web-audience token from the stored refresh token; [addToWishlist] reports which path succeeded + * so the working route can be confirmed from logcat. + */ +object SteamWishlistService { + + private const val TAG = "SteamWishlist" + + private const val ADD_URL = "https://api.steampowered.com/IWishlistService/AddToWishlist/v1/" + private const val REMOVE_URL = "https://api.steampowered.com/IWishlistService/RemoveFromWishlist/v1/" + private const val GET_URL = "https://api.steampowered.com/IWishlistService/GetWishlist/v1/" + private const val MINT_URL = "https://api.steampowered.com/IAuthenticationService/GenerateAccessTokenForApp/v1/" + + @Volatile private var mintedToken: String? = null + + sealed interface Outcome { + data object Success : Outcome + data object NoSession : Outcome + data class Failed(val code: Int, val body: String) : Outcome + } + + /** True/false when the wishlist could be read, null when it could not be determined. */ + suspend fun isWishlisted(appId: Int): Boolean? = withContext(Dispatchers.IO) { + val steamId = PrefManager.steamUserSteamId64 + if (steamId == 0L) { + Timber.tag(TAG).w("no steamId, cannot read wishlist") + return@withContext null + } + val ids = readWishlist(steamId, PrefManager.accessToken.ifEmpty { null }) + ?: readWishlist(steamId, mintWebToken()) + if (ids == null) { + Timber.tag(TAG).w("wishlist read failed for $steamId") + return@withContext null + } + appId in ids + } + + suspend fun addToWishlist(appId: Int): Outcome = write(ADD_URL, appId) + + suspend fun removeFromWishlist(appId: Int): Outcome = write(REMOVE_URL, appId) + + private suspend fun write(url: String, appId: Int): Outcome = withContext(Dispatchers.IO) { + val stored = PrefManager.accessToken + if (stored.isNotEmpty()) { + when (val first = post(url, appId, stored)) { + is Outcome.Success -> { + Timber.tag(TAG).i("$url ok with stored client token") + return@withContext first + } + is Outcome.Failed -> Timber.tag(TAG) + .w("stored client token rejected (${first.code}): ${first.body.take(200)}") + else -> Unit + } + } else { + Timber.tag(TAG).w("no stored access token") + } + + val minted = mintWebToken() ?: return@withContext Outcome.NoSession + val second = post(url, appId, minted) + Timber.tag(TAG).i("$url with minted web token -> $second") + second + } + + private fun post(url: String, appId: Int, token: String): Outcome { + val body = FormBody.Builder() + .add("access_token", token) + .add("appid", appId.toString()) + .build() + return try { + Net.http.newCall(Request.Builder().url(url).post(body).build()).execute().use { res -> + val text = res.body?.string().orEmpty() + if (res.isSuccessful) Outcome.Success else Outcome.Failed(res.code, text) + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "wishlist write failed") + Outcome.Failed(-1, e.message.orEmpty()) + } + } + + /** + * A public wishlist reads fine unauthenticated; a private one needs the token, and Steam only + * accepts it as a query parameter here, so it lands in Steam's access logs. + */ + private fun readWishlist(steamId: Long, token: String?): Set? { + val url = GET_URL.toHttpUrl().newBuilder() + .addQueryParameter("steamid", steamId.toString()) + .apply { token?.let { addQueryParameter("access_token", it) } } + .build() + val request = Request.Builder().url(url).build() + return try { + Net.http.newCall(request).execute().use { res -> + if (!res.isSuccessful) return null + val response = JSONObject(res.body?.string().orEmpty()).optJSONObject("response") + ?: return null + // Absent (rather than empty) items means private or unreadable, not "nothing wishlisted". + val items = response.optJSONArray("items") ?: return null + buildSet { + for (i in 0 until items.length()) { + items.optJSONObject(i)?.optInt("appid")?.let(::add) + } + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "wishlist read failed") + null + } + } + + /** Exchanges the stored refresh token for a web-audience access token. */ + private fun mintWebToken(): String? { + mintedToken?.let { return it } + val refresh = PrefManager.refreshToken + val steamId = PrefManager.steamUserSteamId64 + if (refresh.isEmpty() || steamId == 0L) { + Timber.tag(TAG).w("cannot mint web token: refresh=${refresh.isNotEmpty()} steamId=$steamId") + return null + } + val body = FormBody.Builder() + .add("steamid", steamId.toString()) + .add("refresh_token", refresh) + .add("renewal_type", "0") + .build() + return try { + Net.http.newCall(Request.Builder().url(MINT_URL).post(body).build()).execute().use { res -> + if (!res.isSuccessful) { + Timber.tag(TAG).w("mint failed ${res.code}") + return null + } + val token = JSONObject(res.body?.string().orEmpty()) + .optJSONObject("response")?.optString("access_token").orEmpty() + if (token.isEmpty()) { + Timber.tag(TAG).w("mint returned no access_token") + null + } else { + mintedToken = token + Timber.tag(TAG).i("minted web-audience token") + token + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "mint request failed") + null + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt index 5dc7c42a0a..341d608e07 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt @@ -33,7 +33,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -51,8 +56,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import app.gamenative.R import app.gamenative.data.RecommendedGame +import app.gamenative.service.SteamWishlistService import app.gamenative.ui.screen.library.components.VideoHero +import app.gamenative.ui.util.SnackbarManager import app.gamenative.PrefManager +import kotlinx.coroutines.launch import com.posthog.PostHog import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage @@ -66,6 +74,7 @@ internal fun RecommendedGameScreen( onBack: () -> Unit, ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val scrollState = rememberScrollState() val media = remember(game) { @@ -346,7 +355,17 @@ internal fun RecommendedGameScreen( // Featured actions (wishlist / pre-order / etc.) game.featuredCtas.forEach { action -> - val onClick = { + val wishlistAppId = action.appId?.takeIf { action.type == "WISHLIST" } + var wishlisted by remember(wishlistAppId) { mutableStateOf(null) } + var busy by remember(wishlistAppId) { mutableStateOf(false) } + + LaunchedEffect(wishlistAppId) { + if (wishlistAppId != null) { + wishlisted = SteamWishlistService.isWishlisted(wishlistAppId) + } + } + + val onClick: () -> Unit = { if (PrefManager.usageAnalyticsEnabled) { PostHog.capture( event = "featured_action_clicked", @@ -358,23 +377,47 @@ internal fun RecommendedGameScreen( ), ) } - context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) + if (wishlistAppId == null) { + context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) + } else { + busy = true + scope.launch { + val outcome = SteamWishlistService.addToWishlist(wishlistAppId) + busy = false + if (outcome is SteamWishlistService.Outcome.Success) { + wishlisted = true + } else { + SnackbarManager.show(context.getString(R.string.featured_wishlist_failed)) + context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) + } + } + } } + + val label = if (wishlistAppId != null && wishlisted == true) { + stringResource(R.string.featured_action_wishlisted) + } else { + action.label + } + val enabled = wishlistAppId == null || (!busy && wishlisted != true) + if (action.primary) { Button( onClick = onClick, + enabled = enabled, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), ) { - Text(text = action.label, fontWeight = FontWeight.SemiBold) + Text(text = label, fontWeight = FontWeight.SemiBold) } } else { OutlinedButton( onClick = onClick, + enabled = enabled, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), ) { - Text(text = action.label, fontWeight = FontWeight.SemiBold) + Text(text = label, fontWeight = FontWeight.SemiBold) } } Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index defb0230ff..bec51e2c6e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1996,6 +1996,8 @@ Sponsored Wishlist Wishlist on %1$s + Wishlisted + Couldn\'t update your wishlist — opening Steam Pre-order Pre-order on %1$s Buy From a1781e5982786151f04cf2ac0b9e0a3a6c8ff2e7 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 00:19:15 -0400 Subject: [PATCH 02/17] Wishlist over the CM connection instead of a web token The web-token approach could not work: the app only persists access/refresh tokens when rememberSession is set, so on this device PrefManager had no token and a steamid of 0, and every call fell straight through to opening the store page. Writes now go out on the already-authenticated JavaSteam connection via a Wishlist UnifiedService stub, modelled on CloudConfigStoreService, so no token is involved at all. The read still uses the public web endpoint since it only needs a steamid, taken from the live session rather than prefs; a private wishlist stays unreadable and reports unknown rather than "not wishlisted". Requires the wishlist protos, so localBuild is on and points at the local JavaSteam -23 jar. That has to go back to a published build before this merges. --- app/build.gradle.kts | 6 +- .../service/SteamWishlistService.kt | 170 ++++++------------ .../app/gamenative/steam/WishlistService.kt | 62 +++++++ 3 files changed, 123 insertions(+), 115 deletions(-) create mode 100644 app/src/main/java/app/gamenative/steam/WishlistService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26550ed229..03bc24f636 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -329,10 +329,10 @@ dependencies { implementation("androidx.browser:browser:1.8.0") // JavaSteam - val localBuild = false // Change to 'true' needed when building JavaSteam manually + val localBuild = true // Change to 'true' needed when building JavaSteam manually if (localBuild) { - implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-22-SNAPSHOT.jar")) - implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-22-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-23-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-23-SNAPSHOT.jar")) implementation(libs.bundles.javasteam.dev) } else { implementation(libs.javasteam) { diff --git a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt index 0535de9a1d..86b82c402a 100644 --- a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt +++ b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt @@ -1,120 +1,74 @@ package app.gamenative.service -import app.gamenative.PrefManager +import app.gamenative.steam.WishlistService 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.steam.handlers.steamunifiedmessages.SteamUnifiedMessages 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 import timber.log.Timber /** - * Steam wishlist over the public IWishlistService endpoints. + * Wishlist add/remove over the logged-in JavaSteam session. * - * The stored session token comes from a client login, so it may not carry the audience the web - * endpoints require. Writes therefore try the stored token first and fall back to minting a - * web-audience token from the stored refresh token; [addToWishlist] reports which path succeeded - * so the working route can be confirmed from logcat. + * Writes go out on the authenticated CM connection, so no web token is involved. The read still + * uses the public web endpoint because it only needs a steamid; a wishlist set to private is + * therefore unreadable and reports null rather than "not wishlisted". */ object SteamWishlistService { private const val TAG = "SteamWishlist" - - private const val ADD_URL = "https://api.steampowered.com/IWishlistService/AddToWishlist/v1/" - private const val REMOVE_URL = "https://api.steampowered.com/IWishlistService/RemoveFromWishlist/v1/" + private const val JOB_TIMEOUT_MS = 15_000L private const val GET_URL = "https://api.steampowered.com/IWishlistService/GetWishlist/v1/" - private const val MINT_URL = "https://api.steampowered.com/IAuthenticationService/GenerateAccessTokenForApp/v1/" - - @Volatile private var mintedToken: String? = null sealed interface Outcome { data object Success : Outcome data object NoSession : Outcome - data class Failed(val code: Int, val body: String) : Outcome + data class Failed(val result: EResult?) : Outcome } - /** True/false when the wishlist could be read, null when it could not be determined. */ - suspend fun isWishlisted(appId: Int): Boolean? = withContext(Dispatchers.IO) { - val steamId = PrefManager.steamUserSteamId64 - if (steamId == 0L) { - Timber.tag(TAG).w("no steamId, cannot read wishlist") - return@withContext null + suspend fun addToWishlist(appId: Int): Outcome = withContext(Dispatchers.IO) { + val service = service() ?: return@withContext Outcome.NoSession + val request = CWishlist_AddToWishlist_Request.newBuilder().setAppid(appId).build() + runJob("AddToWishlist") { + service.addToWishlist(request).also { it.timeout = JOB_TIMEOUT_MS }.toFuture().await().result } - val ids = readWishlist(steamId, PrefManager.accessToken.ifEmpty { null }) - ?: readWishlist(steamId, mintWebToken()) - if (ids == null) { - Timber.tag(TAG).w("wishlist read failed for $steamId") - return@withContext null - } - appId in ids } - suspend fun addToWishlist(appId: Int): Outcome = write(ADD_URL, appId) - - suspend fun removeFromWishlist(appId: Int): Outcome = write(REMOVE_URL, appId) - - private suspend fun write(url: String, appId: Int): Outcome = withContext(Dispatchers.IO) { - val stored = PrefManager.accessToken - if (stored.isNotEmpty()) { - when (val first = post(url, appId, stored)) { - is Outcome.Success -> { - Timber.tag(TAG).i("$url ok with stored client token") - return@withContext first - } - is Outcome.Failed -> Timber.tag(TAG) - .w("stored client token rejected (${first.code}): ${first.body.take(200)}") - else -> Unit - } - } else { - Timber.tag(TAG).w("no stored access token") + 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() + runJob("RemoveFromWishlist") { + service.removeFromWishlist(request).also { it.timeout = JOB_TIMEOUT_MS }.toFuture().await().result } - - val minted = mintWebToken() ?: return@withContext Outcome.NoSession - val second = post(url, appId, minted) - Timber.tag(TAG).i("$url with minted web token -> $second") - second } - private fun post(url: String, appId: Int, token: String): Outcome { - val body = FormBody.Builder() - .add("access_token", token) - .add("appid", appId.toString()) - .build() - return try { - Net.http.newCall(Request.Builder().url(url).post(body).build()).execute().use { res -> - val text = res.body?.string().orEmpty() - if (res.isSuccessful) Outcome.Success else Outcome.Failed(res.code, text) - } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "wishlist write failed") - Outcome.Failed(-1, e.message.orEmpty()) + /** True/false when the wishlist could be read, null when it could not be determined. */ + 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 } - } - - /** - * A public wishlist reads fine unauthenticated; a private one needs the token, and Steam only - * accepts it as a query parameter here, so it lands in Steam's access logs. - */ - private fun readWishlist(steamId: Long, token: String?): Set? { val url = GET_URL.toHttpUrl().newBuilder() .addQueryParameter("steamid", steamId.toString()) - .apply { token?.let { addQueryParameter("access_token", it) } } .build() - val request = Request.Builder().url(url).build() - return try { - Net.http.newCall(request).execute().use { res -> - if (!res.isSuccessful) return null + try { + Net.http.newCall(Request.Builder().url(url).build()).execute().use { res -> + if (!res.isSuccessful) { + Timber.tag(TAG).w("wishlist read failed ${res.code}") + return@use null + } val response = JSONObject(res.body?.string().orEmpty()).optJSONObject("response") - ?: return null // Absent (rather than empty) items means private or unreadable, not "nothing wishlisted". - val items = response.optJSONArray("items") ?: return null - buildSet { - for (i in 0 until items.length()) { - items.optJSONObject(i)?.optInt("appid")?.let(::add) - } - } + val items = response?.optJSONArray("items") ?: return@use null + (0 until items.length()).any { items.optJSONObject(it)?.optInt("appid") == appId } } } catch (e: Exception) { Timber.tag(TAG).e(e, "wishlist read failed") @@ -122,40 +76,32 @@ object SteamWishlistService { } } - /** Exchanges the stored refresh token for a web-audience access token. */ - private fun mintWebToken(): String? { - mintedToken?.let { return it } - val refresh = PrefManager.refreshToken - val steamId = PrefManager.steamUserSteamId64 - if (refresh.isEmpty() || steamId == 0L) { - Timber.tag(TAG).w("cannot mint web token: refresh=${refresh.isNotEmpty()} steamId=$steamId") + private fun service(): WishlistService? { + val client = SteamService.instance?.steamClient + if (client == null) { + Timber.tag(TAG).w("no steam client") return null } - val body = FormBody.Builder() - .add("steamid", steamId.toString()) - .add("refresh_token", refresh) - .add("renewal_type", "0") - .build() + val unifiedMessages = client.getHandler() + if (unifiedMessages == null) { + Timber.tag(TAG).e("SteamUnifiedMessages handler not available") + return null + } + // Replies are routed by service name, so the service must be registered via createService. return try { - Net.http.newCall(Request.Builder().url(MINT_URL).post(body).build()).execute().use { res -> - if (!res.isSuccessful) { - Timber.tag(TAG).w("mint failed ${res.code}") - return null - } - val token = JSONObject(res.body?.string().orEmpty()) - .optJSONObject("response")?.optString("access_token").orEmpty() - if (token.isEmpty()) { - Timber.tag(TAG).w("mint returned no access_token") - null - } else { - mintedToken = token - Timber.tag(TAG).i("minted web-audience token") - token - } - } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "mint request failed") + unifiedMessages.createService(WishlistService::class.java) + } catch (t: Throwable) { + Timber.tag(TAG).e(t, "cannot create Wishlist service") null } } + + private suspend fun runJob(method: String, block: suspend () -> EResult?): Outcome = try { + val result = block() + Timber.tag(TAG).i("$method -> $result") + if (result == EResult.OK) Outcome.Success else Outcome.Failed(result) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "$method failed") + Outcome.Failed(null) + } } diff --git a/app/src/main/java/app/gamenative/steam/WishlistService.kt b/app/src/main/java/app/gamenative/steam/WishlistService.kt new file mode 100644 index 0000000000..95936bf9e4 --- /dev/null +++ b/app/src/main/java/app/gamenative/steam/WishlistService.kt @@ -0,0 +1,62 @@ +package app.gamenative.steam + +import `in`.dragonbra.javasteam.base.PacketClientMsgProtobuf +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesWishlistSteamclient.CWishlist_AddToWishlist_Request +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesWishlistSteamclient.CWishlist_AddToWishlist_Response +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesWishlistSteamclient.CWishlist_RemoveFromWishlist_Request +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesWishlistSteamclient.CWishlist_RemoveFromWishlist_Response +import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages +import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.UnifiedService +import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.callback.ServiceMethodResponse +import `in`.dragonbra.javasteam.types.AsyncJobSingle + +/** + * Minimal JavaSteam unified-messages stub for the `Wishlist` service, following + * [CloudConfigStoreService]: JavaSteam ships no generated stub, and replies are routed by service + * name through a map that only [SteamUnifiedMessages.createService] populates, so the service must + * be registered or the reply is dropped and the job times out. + * + * Upstream defines Wishlist as a WebUI service rather than a `.steamclient` one, so whether the CM + * routes these methods at all is answered by the [ServiceMethodResponse.getResult] of the first call. + */ +class WishlistService( + unifiedMessages: SteamUnifiedMessages, +) : UnifiedService(unifiedMessages) { + + override val serviceName: String = "Wishlist" + + fun addToWishlist( + request: CWishlist_AddToWishlist_Request, + ): AsyncJobSingle> = + unifiedMessages!!.sendMessage( + CWishlist_AddToWishlist_Response.Builder::class.java, + "Wishlist.AddToWishlist#1", + request, + ) + + fun removeFromWishlist( + request: CWishlist_RemoveFromWishlist_Request, + ): AsyncJobSingle> = + unifiedMessages!!.sendMessage( + CWishlist_RemoveFromWishlist_Response.Builder::class.java, + "Wishlist.RemoveFromWishlist#1", + request, + ) + + override fun handleResponseMsg(methodName: String, packetMsg: PacketClientMsgProtobuf) { + when (methodName) { + "AddToWishlist" -> postResponseMsg( + CWishlist_AddToWishlist_Response::class.java, + packetMsg, + ) + "RemoveFromWishlist" -> postResponseMsg( + CWishlist_RemoveFromWishlist_Response::class.java, + packetMsg, + ) + } + } + + override fun handleNotificationMsg(methodName: String, packetMsg: PacketClientMsgProtobuf) { + // Wishlist has no notifications we consume. + } +} From bfe2ff4c48dfb917cd5676c1c0be12d964e5d773 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 10:27:45 -0400 Subject: [PATCH 03/17] Fall back to a minted web token when the CM denies the wishlist write The CM does route Wishlist.AddToWishlist, but a plain client session comes back AccessDenied, and the CM proto header has no access_token field to carry a web identity. So the write now falls back to the web endpoint using a token minted from the session refresh token via Authentication.GenerateAccessTokenForApp over the CM. That token was previously unavailable: PrefManager only stores it when rememberSession is set. SteamService now keeps it in memory for the session, without persisting it. --- .../app/gamenative/service/SteamService.kt | 7 +++ .../service/SteamWishlistService.kt | 57 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index bb419edef2..c407139874 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -333,6 +333,11 @@ class SteamService : Service(), IChallengeUrlChanged { internal var instance: SteamService? = null + // Kept in memory for the session only, never persisted: PrefManager stores tokens just when + // rememberSession is set, but minting a web token needs one regardless. + @Volatile + internal var sessionRefreshToken: String? = null + var cachedAchievements: List? = null private set var cachedAchievementsAppId: Int? = null @@ -2677,6 +2682,8 @@ class SteamService : Service(), IChallengeUrlChanged { PrefManager.username = username + refreshToken?.let { sessionRefreshToken = it } + if ((password != null && rememberSession) || refreshToken != null) { if (accessToken != null) { PrefManager.accessToken = accessToken diff --git a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt index 86b82c402a..a795bc4649 100644 --- a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt +++ b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt @@ -1,14 +1,19 @@ package app.gamenative.service +import app.gamenative.PrefManager import app.gamenative.steam.WishlistService import app.gamenative.utils.Net import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesAuthSteamclient.CAuthentication_AccessToken_GenerateForApp_Request +import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesAuthSteamclient.ETokenRenewalType 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.Authentication import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages 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 @@ -26,6 +31,7 @@ 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 ADD_URL = "https://api.steampowered.com/IWishlistService/AddToWishlist/v1/" sealed interface Outcome { data object Success : Outcome @@ -36,9 +42,58 @@ object SteamWishlistService { suspend fun addToWishlist(appId: Int): Outcome = withContext(Dispatchers.IO) { val service = service() ?: return@withContext Outcome.NoSession val request = CWishlist_AddToWishlist_Request.newBuilder().setAppid(appId).build() - runJob("AddToWishlist") { + val viaCm = runJob("AddToWishlist") { service.addToWishlist(request).also { it.timeout = JOB_TIMEOUT_MS }.toFuture().await().result } + if (viaCm is Outcome.Success) return@withContext viaCm + + // The CM routes Wishlist but denies a plain client session, so retry the web endpoint with a + // token minted from the session's refresh token. + val token = mintWebToken() ?: return@withContext viaCm + val body = FormBody.Builder().add("access_token", token).add("appid", appId.toString()).build() + try { + Net.http.newCall(Request.Builder().url(ADD_URL).post(body).build()).execute().use { res -> + val text = res.body?.string().orEmpty() + Timber.tag(TAG).i("AddToWishlist via minted token -> ${res.code} ${text.take(200)}") + if (res.isSuccessful) Outcome.Success else viaCm + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "wishlist web write failed") + viaCm + } + } + + /** Exchanges the session refresh token for an access token, over the CM connection. */ + private suspend fun mintWebToken(): String? { + val client = SteamService.instance?.steamClient ?: return null + val unifiedMessages = client.getHandler() ?: return null + val steamId = SteamService.userSteamId?.convertToUInt64() ?: return null + val refresh = SteamService.sessionRefreshToken + ?: PrefManager.refreshToken.ifEmpty { null } + if (refresh == null) { + Timber.tag(TAG).w("no refresh token available to mint with") + return null + } + return try { + val auth = unifiedMessages.createService(Authentication::class.java) + val request = CAuthentication_AccessToken_GenerateForApp_Request.newBuilder() + .setRefreshToken(refresh) + .setSteamid(steamId) + .setRenewalType(ETokenRenewalType.k_ETokenRenewalType_None) + .build() + val response = auth.generateAccessTokenForApp(request) + .also { it.timeout = JOB_TIMEOUT_MS } + .toFuture().await() + if (response.result != EResult.OK) { + Timber.tag(TAG).w("mint failed: ${response.result}") + return null + } + response.body.build().accessToken.ifEmpty { null } + .also { Timber.tag(TAG).i("minted token: ${it != null}") } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "mint failed") + null + } } suspend fun removeFromWishlist(appId: Int): Outcome = withContext(Dispatchers.IO) { From 928506f3e6c1c766d7c9e1800f4acc031e3106a9 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 10:38:27 -0400 Subject: [PATCH 04/17] Revert "Fall back to a minted web token when the CM denies the wishlist write" This reverts commit bfe2ff4c48dfb917cd5676c1c0be12d964e5d773. --- .../app/gamenative/service/SteamService.kt | 7 --- .../service/SteamWishlistService.kt | 57 +------------------ 2 files changed, 1 insertion(+), 63 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index c407139874..bb419edef2 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -333,11 +333,6 @@ class SteamService : Service(), IChallengeUrlChanged { internal var instance: SteamService? = null - // Kept in memory for the session only, never persisted: PrefManager stores tokens just when - // rememberSession is set, but minting a web token needs one regardless. - @Volatile - internal var sessionRefreshToken: String? = null - var cachedAchievements: List? = null private set var cachedAchievementsAppId: Int? = null @@ -2682,8 +2677,6 @@ class SteamService : Service(), IChallengeUrlChanged { PrefManager.username = username - refreshToken?.let { sessionRefreshToken = it } - if ((password != null && rememberSession) || refreshToken != null) { if (accessToken != null) { PrefManager.accessToken = accessToken diff --git a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt index a795bc4649..86b82c402a 100644 --- a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt +++ b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt @@ -1,19 +1,14 @@ package app.gamenative.service -import app.gamenative.PrefManager import app.gamenative.steam.WishlistService import app.gamenative.utils.Net import `in`.dragonbra.javasteam.enums.EResult -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesAuthSteamclient.CAuthentication_AccessToken_GenerateForApp_Request -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesAuthSteamclient.ETokenRenewalType 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.Authentication import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages 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 @@ -31,7 +26,6 @@ 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 ADD_URL = "https://api.steampowered.com/IWishlistService/AddToWishlist/v1/" sealed interface Outcome { data object Success : Outcome @@ -42,58 +36,9 @@ object SteamWishlistService { suspend fun addToWishlist(appId: Int): Outcome = withContext(Dispatchers.IO) { val service = service() ?: return@withContext Outcome.NoSession val request = CWishlist_AddToWishlist_Request.newBuilder().setAppid(appId).build() - val viaCm = runJob("AddToWishlist") { + runJob("AddToWishlist") { service.addToWishlist(request).also { it.timeout = JOB_TIMEOUT_MS }.toFuture().await().result } - if (viaCm is Outcome.Success) return@withContext viaCm - - // The CM routes Wishlist but denies a plain client session, so retry the web endpoint with a - // token minted from the session's refresh token. - val token = mintWebToken() ?: return@withContext viaCm - val body = FormBody.Builder().add("access_token", token).add("appid", appId.toString()).build() - try { - Net.http.newCall(Request.Builder().url(ADD_URL).post(body).build()).execute().use { res -> - val text = res.body?.string().orEmpty() - Timber.tag(TAG).i("AddToWishlist via minted token -> ${res.code} ${text.take(200)}") - if (res.isSuccessful) Outcome.Success else viaCm - } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "wishlist web write failed") - viaCm - } - } - - /** Exchanges the session refresh token for an access token, over the CM connection. */ - private suspend fun mintWebToken(): String? { - val client = SteamService.instance?.steamClient ?: return null - val unifiedMessages = client.getHandler() ?: return null - val steamId = SteamService.userSteamId?.convertToUInt64() ?: return null - val refresh = SteamService.sessionRefreshToken - ?: PrefManager.refreshToken.ifEmpty { null } - if (refresh == null) { - Timber.tag(TAG).w("no refresh token available to mint with") - return null - } - return try { - val auth = unifiedMessages.createService(Authentication::class.java) - val request = CAuthentication_AccessToken_GenerateForApp_Request.newBuilder() - .setRefreshToken(refresh) - .setSteamid(steamId) - .setRenewalType(ETokenRenewalType.k_ETokenRenewalType_None) - .build() - val response = auth.generateAccessTokenForApp(request) - .also { it.timeout = JOB_TIMEOUT_MS } - .toFuture().await() - if (response.result != EResult.OK) { - Timber.tag(TAG).w("mint failed: ${response.result}") - return null - } - response.body.build().accessToken.ifEmpty { null } - .also { Timber.tag(TAG).i("minted token: ${it != null}") } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "mint failed") - null - } } suspend fun removeFromWishlist(appId: Int): Outcome = withContext(Dispatchers.IO) { From c6a30ae3a93e2cca93733fe46e5a771ab64b63d9 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 12:50:05 -0400 Subject: [PATCH 05/17] Use JavaSteam's generated Wishlist service The proto now carries its service block, so JavaSteam generates the stub and the hand-written one here is redundant. Points localBuild at the -25 jars, since the proto branch is based on jt/gamenative-latest rather than the older -23 line. --- app/build.gradle.kts | 4 +- .../service/SteamWishlistService.kt | 6 +- .../app/gamenative/steam/WishlistService.kt | 62 ------------------- 3 files changed, 5 insertions(+), 67 deletions(-) delete mode 100644 app/src/main/java/app/gamenative/steam/WishlistService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 03bc24f636..7c1069c427 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -331,8 +331,8 @@ dependencies { // JavaSteam val localBuild = true // Change to 'true' needed when building JavaSteam manually if (localBuild) { - implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-23-SNAPSHOT.jar")) - implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-23-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-25-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-25-SNAPSHOT.jar")) implementation(libs.bundles.javasteam.dev) } else { implementation(libs.javasteam) { diff --git a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt index 86b82c402a..0ea2de669f 100644 --- a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt +++ b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt @@ -1,10 +1,10 @@ package app.gamenative.service -import app.gamenative.steam.WishlistService 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.future.await @@ -76,7 +76,7 @@ object SteamWishlistService { } } - private fun service(): WishlistService? { + private fun service(): Wishlist? { val client = SteamService.instance?.steamClient if (client == null) { Timber.tag(TAG).w("no steam client") @@ -89,7 +89,7 @@ object SteamWishlistService { } // Replies are routed by service name, so the service must be registered via createService. return try { - unifiedMessages.createService(WishlistService::class.java) + unifiedMessages.createService(Wishlist::class.java) } catch (t: Throwable) { Timber.tag(TAG).e(t, "cannot create Wishlist service") null diff --git a/app/src/main/java/app/gamenative/steam/WishlistService.kt b/app/src/main/java/app/gamenative/steam/WishlistService.kt deleted file mode 100644 index 95936bf9e4..0000000000 --- a/app/src/main/java/app/gamenative/steam/WishlistService.kt +++ /dev/null @@ -1,62 +0,0 @@ -package app.gamenative.steam - -import `in`.dragonbra.javasteam.base.PacketClientMsgProtobuf -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesWishlistSteamclient.CWishlist_AddToWishlist_Request -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesWishlistSteamclient.CWishlist_AddToWishlist_Response -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesWishlistSteamclient.CWishlist_RemoveFromWishlist_Request -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesWishlistSteamclient.CWishlist_RemoveFromWishlist_Response -import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages -import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.UnifiedService -import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.callback.ServiceMethodResponse -import `in`.dragonbra.javasteam.types.AsyncJobSingle - -/** - * Minimal JavaSteam unified-messages stub for the `Wishlist` service, following - * [CloudConfigStoreService]: JavaSteam ships no generated stub, and replies are routed by service - * name through a map that only [SteamUnifiedMessages.createService] populates, so the service must - * be registered or the reply is dropped and the job times out. - * - * Upstream defines Wishlist as a WebUI service rather than a `.steamclient` one, so whether the CM - * routes these methods at all is answered by the [ServiceMethodResponse.getResult] of the first call. - */ -class WishlistService( - unifiedMessages: SteamUnifiedMessages, -) : UnifiedService(unifiedMessages) { - - override val serviceName: String = "Wishlist" - - fun addToWishlist( - request: CWishlist_AddToWishlist_Request, - ): AsyncJobSingle> = - unifiedMessages!!.sendMessage( - CWishlist_AddToWishlist_Response.Builder::class.java, - "Wishlist.AddToWishlist#1", - request, - ) - - fun removeFromWishlist( - request: CWishlist_RemoveFromWishlist_Request, - ): AsyncJobSingle> = - unifiedMessages!!.sendMessage( - CWishlist_RemoveFromWishlist_Response.Builder::class.java, - "Wishlist.RemoveFromWishlist#1", - request, - ) - - override fun handleResponseMsg(methodName: String, packetMsg: PacketClientMsgProtobuf) { - when (methodName) { - "AddToWishlist" -> postResponseMsg( - CWishlist_AddToWishlist_Response::class.java, - packetMsg, - ) - "RemoveFromWishlist" -> postResponseMsg( - CWishlist_RemoveFromWishlist_Response::class.java, - packetMsg, - ) - } - } - - override fun handleNotificationMsg(methodName: String, packetMsg: PacketClientMsgProtobuf) { - // Wishlist has no notifications we consume. - } -} From d99dfc72e9a36c3be263255961819d382dca6035 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 13:25:21 -0400 Subject: [PATCH 06/17] Template featured CTAs; add GET_DEMO alongside WISHLIST Pulls the inline wishlist button logic out of RecommendedGameScreen into FeaturedCtaButton, which renders any featured action: types with an in-app handler (WISHLIST, GET_DEMO) run on-device with probe/busy/done states and fall back to the action URL on failure; everything else deep-links as before. A campaign composes any subset of actions, so wishlist-only, demo-only, or both need no client changes, and a new in-app type is one InAppCta entry. GET_DEMO requests a free license over the CM (SteamService.requestFreeLicense) and shows In Library once licenses reflect it. Actions gain an optional per-action appId since a demo is its own app; it falls back to the campaign appId. The local test campaign borrows BZZZT Demo to exercise the path, as Whisk has no demo. --- .../main/java/app/gamenative/data/Featured.kt | 6 +- .../data/RecommendationRepository.kt | 7 + .../app/gamenative/service/SteamService.kt | 21 +++ .../ui/screen/library/FeaturedCtaButton.kt | 156 ++++++++++++++++++ .../screen/library/RecommendedGameScreen.kt | 78 +-------- app/src/main/res/values/strings.xml | 4 + 6 files changed, 195 insertions(+), 77 deletions(-) create mode 100644 app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt diff --git a/app/src/main/java/app/gamenative/data/Featured.kt b/app/src/main/java/app/gamenative/data/Featured.kt index 81007b5f6e..c091d9949d 100644 --- a/app/src/main/java/app/gamenative/data/Featured.kt +++ b/app/src/main/java/app/gamenative/data/Featured.kt @@ -36,6 +36,9 @@ data class FeaturedItem( data class FeaturedAction( val type: String, val url: String, + // Steam appid this action targets when it differs from the campaign's (e.g. GET_DEMO, + // where the demo is its own app). Falls back to the campaign appId. + val appId: Int? = null, val store: String? = null, val style: String? = null, // Only for type CUSTOM: advertiser-supplied locale -> label map. @@ -63,6 +66,7 @@ fun FeaturedAction.localizedLabel(context: Context): String = when (type.upperca "BUY" -> store?.let { context.getString(R.string.featured_action_buy_on, it) } ?: context.getString(R.string.featured_action_buy) "NOTIFY" -> context.getString(R.string.featured_action_notify) + "GET_DEMO" -> context.getString(R.string.featured_action_get_demo) "VISIT" -> context.getString(R.string.featured_action_visit) else -> label.forLocale(context) ?: context.getString(R.string.featured_action_visit) } @@ -94,7 +98,7 @@ fun FeaturedItem.toRecommendedGame(context: Context): RecommendedGame = Recommen url = a.url, primary = a.style?.equals("primary", ignoreCase = true) ?: (index == 0), type = a.type.uppercase(), - appId = appId, + appId = a.appId ?: appId, ) }, ) diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index d6d2925c1f..af945539c2 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -49,6 +49,13 @@ object RecommendationRepository { store = "Steam", style = "primary", ), + // Whisk has no demo; borrows BZZZT Demo to exercise the GET_DEMO path. Swap the + // appid for any other demo if this one is already in the test account's library. + FeaturedAction( + type = "GET_DEMO", + url = "https://store.steampowered.com/app/1293170/", + appId = 1294400, + ), FeaturedAction( type = "VISIT", url = "https://store.steampowered.com/app/$WHISK_APP_ID/", diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index bb419edef2..571d966b06 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -727,6 +727,27 @@ class SteamService : Service(), IChallengeUrlChanged { }.orEmpty() } + /** Whether any owned license grants [appId]. Follows the license list, so a freshly + * granted free license flips this once Steam pushes the updated list. */ + suspend fun isAppInLibrary(appId: Int): Boolean = + instance?.licenseDao?.getAllLicenses()?.any { appId in it.appIds } == true + + /** Requests a free license (demos, F2P) for [appId] over the CM connection. */ + suspend fun requestFreeLicense(appId: Int): Boolean = withContext(Dispatchers.IO) { + val steamApps = instance?._steamApps ?: return@withContext false + try { + val callback = steamApps.requestFreeLicense(appId).toFuture().await() + Timber.i( + "requestFreeLicense($appId) -> ${callback.result}, " + + "apps=${callback.grantedApps}, packages=${callback.grantedPackages}", + ) + callback.result == EResult.OK && appId in callback.grantedApps + } catch (e: Exception) { + Timber.e(e, "requestFreeLicense($appId) failed") + false + } + } + suspend fun getOwnedAppDlc(appId: Int): Map { val client = instance?.steamClient ?: return emptyMap() val accountId = client.steamID?.accountID?.toInt() ?: return emptyMap() diff --git a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt new file mode 100644 index 0000000000..895612e0f4 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt @@ -0,0 +1,156 @@ +package app.gamenative.ui.screen.library + +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import app.gamenative.PrefManager +import app.gamenative.R +import app.gamenative.data.FeaturedCta +import app.gamenative.service.SteamService +import app.gamenative.service.SteamWishlistService +import app.gamenative.ui.util.SnackbarManager +import com.posthog.PostHog +import kotlinx.coroutines.launch + +/** + * One featured call-to-action. Types with an in-app handler ([InAppCta]) run on-device and render + * a done state; every other type deep-links to the action URL, which is also the fallback when an + * in-app handler fails. New in-app types only need a new [InAppCta] entry. + */ +@Composable +internal fun FeaturedCtaButton(action: FeaturedCta, campaignId: String, recSource: String) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val cta = remember(action) { InAppCta.forAction(action) } + // null = unknown (e.g. wishlist private); the button then stays in its idle state. + var done by remember(action) { mutableStateOf(null) } + var busy by remember(action) { mutableStateOf(false) } + + LaunchedEffect(action) { + if (cta != null) { + done = cta.isDone() + } + } + + val openUrl = { context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) } + + val onClick: () -> Unit = { + if (PrefManager.usageAnalyticsEnabled) { + PostHog.capture( + event = "featured_action_clicked", + properties = mapOf( + "campaign_id" to campaignId, + "action_label" to action.label, + "url" to action.url, + "source" to recSource, + ), + ) + } + if (cta == null) { + openUrl() + } else { + busy = true + scope.launch { + val ok = cta.run(context) + busy = false + if (ok) { + done = true + } else { + SnackbarManager.show(context.getString(cta.failedTextRes)) + openUrl() + } + } + } + } + + val label = if (cta != null && done == true) stringResource(cta.doneLabelRes) else action.label + val enabled = cta == null || (!busy && done != true) + + if (action.primary) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Text(text = label, fontWeight = FontWeight.SemiBold) + } + } else { + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Text(text = label, fontWeight = FontWeight.SemiBold) + } + } +} + +/** An action type the app can complete itself instead of deep-linking. */ +private sealed class InAppCta( + val appId: Int, + @StringRes val doneLabelRes: Int, + @StringRes val failedTextRes: Int, +) { + /** True/false when the state is known, null when it cannot be determined. */ + abstract suspend fun isDone(): Boolean? + + /** Runs the action; true on success. */ + abstract suspend fun run(context: Context): Boolean + + private class Wishlist(appId: Int) : InAppCta( + appId, + R.string.featured_action_wishlisted, + R.string.featured_wishlist_failed, + ) { + override suspend fun isDone(): Boolean? = SteamWishlistService.isWishlisted(appId) + + override suspend fun run(context: Context): Boolean = + SteamWishlistService.addToWishlist(appId) is SteamWishlistService.Outcome.Success + } + + private class GetDemo(appId: Int) : InAppCta( + appId, + R.string.featured_action_in_library, + R.string.featured_demo_failed, + ) { + override suspend fun isDone(): Boolean = SteamService.isAppInLibrary(appId) + + override suspend fun run(context: Context): Boolean = + SteamService.requestFreeLicense(appId).also { granted -> + if (granted) { + SnackbarManager.show(context.getString(R.string.featured_demo_added)) + } + } + } + + companion object { + fun forAction(action: FeaturedCta): InAppCta? { + val appId = action.appId ?: return null + return when (action.type) { + "WISHLIST" -> Wishlist(appId) + "GET_DEMO" -> GetDemo(appId) + else -> null + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt index 341d608e07..c3d4b56cf1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt @@ -30,15 +30,9 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -56,11 +50,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import app.gamenative.R import app.gamenative.data.RecommendedGame -import app.gamenative.service.SteamWishlistService import app.gamenative.ui.screen.library.components.VideoHero -import app.gamenative.ui.util.SnackbarManager import app.gamenative.PrefManager -import kotlinx.coroutines.launch import com.posthog.PostHog import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage @@ -74,7 +65,6 @@ internal fun RecommendedGameScreen( onBack: () -> Unit, ) { val context = LocalContext.current - val scope = rememberCoroutineScope() val scrollState = rememberScrollState() val media = remember(game) { @@ -353,73 +343,9 @@ internal fun RecommendedGameScreen( Spacer(modifier = Modifier.height(12.dp)) } - // Featured actions (wishlist / pre-order / etc.) + // Featured actions (wishlist / demo / pre-order / etc.) game.featuredCtas.forEach { action -> - val wishlistAppId = action.appId?.takeIf { action.type == "WISHLIST" } - var wishlisted by remember(wishlistAppId) { mutableStateOf(null) } - var busy by remember(wishlistAppId) { mutableStateOf(false) } - - LaunchedEffect(wishlistAppId) { - if (wishlistAppId != null) { - wishlisted = SteamWishlistService.isWishlisted(wishlistAppId) - } - } - - val onClick: () -> Unit = { - if (PrefManager.usageAnalyticsEnabled) { - PostHog.capture( - event = "featured_action_clicked", - properties = mapOf( - "campaign_id" to game.id, - "action_label" to action.label, - "url" to action.url, - "source" to recSource, - ), - ) - } - if (wishlistAppId == null) { - context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) - } else { - busy = true - scope.launch { - val outcome = SteamWishlistService.addToWishlist(wishlistAppId) - busy = false - if (outcome is SteamWishlistService.Outcome.Success) { - wishlisted = true - } else { - SnackbarManager.show(context.getString(R.string.featured_wishlist_failed)) - context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) - } - } - } - } - - val label = if (wishlistAppId != null && wishlisted == true) { - stringResource(R.string.featured_action_wishlisted) - } else { - action.label - } - val enabled = wishlistAppId == null || (!busy && wishlisted != true) - - if (action.primary) { - Button( - onClick = onClick, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Text(text = label, fontWeight = FontWeight.SemiBold) - } - } else { - OutlinedButton( - onClick = onClick, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Text(text = label, fontWeight = FontWeight.SemiBold) - } - } + FeaturedCtaButton(action = action, campaignId = game.id, recSource = recSource) Spacer(modifier = Modifier.height(8.dp)) } } else { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bec51e2c6e..5b1677547b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1998,6 +1998,10 @@ Wishlist on %1$s Wishlisted Couldn\'t update your wishlist — opening Steam + Get the Demo + In Library + Demo added to your library + Couldn\'t get the demo — opening Steam Pre-order Pre-order on %1$s Buy From 8155cb88d402c03bd9f1fab16fd87762992678ea Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 13:37:41 -0400 Subject: [PATCH 07/17] Use Whisk's own demo in the local campaign Whisk does ship a demo (appid 4320000, per the store's demos field); the BZZZT borrow was based on not having checked that field. --- .../java/app/gamenative/data/RecommendationRepository.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index af945539c2..b69db4de5c 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -21,6 +21,7 @@ object RecommendationRepository { private const val USE_LOCAL_FEATURED = true private const val WHISK_APP_ID = 3602270 + private const val WHISK_DEMO_APP_ID = 4320000 private const val WHISK_ASSETS = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270" @@ -49,12 +50,10 @@ object RecommendationRepository { store = "Steam", style = "primary", ), - // Whisk has no demo; borrows BZZZT Demo to exercise the GET_DEMO path. Swap the - // appid for any other demo if this one is already in the test account's library. FeaturedAction( type = "GET_DEMO", - url = "https://store.steampowered.com/app/1293170/", - appId = 1294400, + url = "https://store.steampowered.com/app/$WHISK_APP_ID/", + appId = WHISK_DEMO_APP_ID, ), FeaturedAction( type = "VISIT", From 8012c706280c308cd12eb4a94e575daa902914b7 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 15:11:09 -0400 Subject: [PATCH 08/17] Count featured conversions; build against published -26 snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit featured_conversion fires on every confirmed in-app CTA success (wishlist added, demo granted) — always, since campaign billing needs a complete count, but consent decides its shape: opted-in users send a normal identified event, opted-out users a personless one under a single-use random id, carrying only campaign_id/action_type/app_id/source. Country breakdowns come from PostHog's server-side GeoIP either way. The gated featured_action_clicked behavioral event is unchanged. JavaSteam wishlist protos are merged upstream and published (1.8.0.1-26-SNAPSHOT), so localBuild goes back off and the version bumps from -24. --- app/build.gradle.kts | 2 +- .../ui/screen/library/FeaturedCtaButton.kt | 7 ++++ .../app/gamenative/utils/ConversionTracker.kt | 37 +++++++++++++++++++ gradle/libs.versions.toml | 2 +- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/app/gamenative/utils/ConversionTracker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c1069c427..d2aaf62d0d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -329,7 +329,7 @@ dependencies { implementation("androidx.browser:browser:1.8.0") // JavaSteam - val localBuild = true // Change to 'true' needed when building JavaSteam manually + val localBuild = false // Change to 'true' needed when building JavaSteam manually if (localBuild) { implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-25-SNAPSHOT.jar")) implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-25-SNAPSHOT.jar")) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt index 895612e0f4..73b2c8a53b 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt @@ -27,6 +27,7 @@ import app.gamenative.data.FeaturedCta import app.gamenative.service.SteamService import app.gamenative.service.SteamWishlistService import app.gamenative.ui.util.SnackbarManager +import app.gamenative.utils.ConversionTracker import com.posthog.PostHog import kotlinx.coroutines.launch @@ -73,6 +74,12 @@ internal fun FeaturedCtaButton(action: FeaturedCta, campaignId: String, recSourc busy = false if (ok) { done = true + ConversionTracker.featuredConversion( + campaignId = campaignId, + actionType = action.type, + appId = action.appId, + source = recSource, + ) } else { SnackbarManager.show(context.getString(cta.failedTextRes)) openUrl() diff --git a/app/src/main/java/app/gamenative/utils/ConversionTracker.kt b/app/src/main/java/app/gamenative/utils/ConversionTracker.kt new file mode 100644 index 0000000000..f975a5a1b1 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/ConversionTracker.kt @@ -0,0 +1,37 @@ +package app.gamenative.utils + +import app.gamenative.PrefManager +import com.posthog.PostHog +import java.util.UUID + +/** + * Billing-grade conversion counting for sponsored campaigns. + * + * A confirmed conversion (wishlist added, demo granted) is always captured, unlike behavioral + * events, because campaign billing needs a complete count. Consent still decides what the event + * carries: opted-in users send a normal identified event; opted-out users send a personless event + * under a single-use random id, so no person profile is created and nothing links it to a device + * or to other events. Region reporting works for both, via PostHog's server-side GeoIP enrichment. + */ +object ConversionTracker { + + fun featuredConversion(campaignId: String, actionType: String, appId: Int?, source: String) { + val properties = mutableMapOf( + "campaign_id" to campaignId, + "action_type" to actionType, + "source" to source, + ) + appId?.let { properties["app_id"] = it } + + if (PrefManager.usageAnalyticsEnabled) { + PostHog.capture(event = "featured_conversion", properties = properties) + } else { + properties["\$process_person_profile"] = false + PostHog.capture( + event = "featured_conversion", + distinctId = UUID.randomUUID().toString(), + properties = properties, + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fc99038cbb..39e75efb83 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espres feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery play-integrity = "1.6.0" # https://mvnrepository.com/artifact/com.google.android.play/integrity hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose -javasteam = "1.8.0.1-24-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest +javasteam = "1.8.0.1-26-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit From 8a0e696d9fc0b68634f77de341db420ca35dfbf7 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 15:43:17 -0400 Subject: [PATCH 09/17] Turn the local featured campaign back off The hardcoded Whisk campaign stays available for testing behind USE_LOCAL_FEATURED; the hero endpoint is live again. --- .../main/java/app/gamenative/data/RecommendationRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index b69db4de5c..9c9f463585 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -18,7 +18,7 @@ object RecommendationRepository { private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L // Serves a local campaign instead of the server one, to exercise the in-app wishlist CTA. - private const val USE_LOCAL_FEATURED = true + private const val USE_LOCAL_FEATURED = false private const val WHISK_APP_ID = 3602270 private const val WHISK_DEMO_APP_ID = 4320000 From 5562894d970f86565510e7035aa4b711e3130dae Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 17:53:13 -0400 Subject: [PATCH 10/17] Mock the hero response as JSON through the production parse path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-built FeaturedItem test campaign skipped deserialization — the layer that actually breaks on a client/server schema mismatch. MOCK_HERO_RESPONSE now feeds a verbatim /api/games/hero JSON payload through parseHero, so the mock exercises everything a real server response would except the socket. Doubles as the payload contract for the backend campaign schema. --- .../data/RecommendationRepository.kt | 83 ++++++++----------- 1 file changed, 34 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index 9c9f463585..e6b98bf601 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -17,50 +17,39 @@ object RecommendationRepository { private const val API_URL = "https://api.gamenative.app/api/games/hero" private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L - // Serves a local campaign instead of the server one, to exercise the in-app wishlist CTA. - private const val USE_LOCAL_FEATURED = false - - private const val WHISK_APP_ID = 3602270 - private const val WHISK_DEMO_APP_ID = 4320000 - private const val WHISK_ASSETS = - "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270" - - private val localFeatured = FeaturedItem( - campaignId = "local-whisk", - title = "Whisk", - appId = WHISK_APP_ID, - developer = "Double Dusk Inc.", - heroImageUrl = "$WHISK_ASSETS/04f63f73ec6aefcb4efc26a7c4049aebffb99368/header.jpg", - capsuleImageUrl = "$WHISK_ASSETS/8746d0c28b68abd78cdc7b9c6ad651af33381827/capsule_231x87.jpg", - screenshots = listOf( - "$WHISK_ASSETS/e0a52a09cd85472aecfb430ad086aae040cb100c/ss_e0a52a09cd85472aecfb430ad086aae040cb100c.1920x1080.jpg", - "$WHISK_ASSETS/77719570b08d1b46facf8477df20aa5b97b54573/ss_77719570b08d1b46facf8477df20aa5b97b54573.1920x1080.jpg", - "$WHISK_ASSETS/08fd286888a7efb1e782a5106fdb1b1f237bcdb2/ss_08fd286888a7efb1e782a5106fdb1b1f237bcdb2.1920x1080.jpg", - ), - tags = listOf("Action", "Indie"), - status = "COMING_SOON", - description = mapOf( - "en" to "Whisk is a two-player platformer about shared movement and communication. " + - "Coordinate jumps, climbs and throws with a partner to get every Dreamcat home.", - ), - actions = listOf( - FeaturedAction( - type = "WISHLIST", - url = "https://store.steampowered.com/app/$WHISK_APP_ID/", - store = "Steam", - style = "primary", - ), - FeaturedAction( - type = "GET_DEMO", - url = "https://store.steampowered.com/app/$WHISK_APP_ID/", - appId = WHISK_DEMO_APP_ID, - ), - FeaturedAction( - type = "VISIT", - url = "https://store.steampowered.com/app/$WHISK_APP_ID/", - ), - ), - ) + // Serves MOCK_HERO_JSON through the production parse path instead of calling the server. + private const val MOCK_HERO_RESPONSE = false + + // Verbatim /api/games/hero payload for a sponsored campaign with in-app CTAs; goes through + // parseHero like a real response, so it exercises deserialization, not just the UI. + private val MOCK_HERO_JSON = """ + { + "recommendation": null, + "featured": { + "campaignId": "mock-whisk", + "title": "Whisk", + "appId": 3602270, + "developer": "Double Dusk Inc.", + "heroImageUrl": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/04f63f73ec6aefcb4efc26a7c4049aebffb99368/header.jpg", + "capsuleImageUrl": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/8746d0c28b68abd78cdc7b9c6ad651af33381827/capsule_231x87.jpg", + "screenshots": [ + "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/e0a52a09cd85472aecfb430ad086aae040cb100c/ss_e0a52a09cd85472aecfb430ad086aae040cb100c.1920x1080.jpg", + "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/77719570b08d1b46facf8477df20aa5b97b54573/ss_77719570b08d1b46facf8477df20aa5b97b54573.1920x1080.jpg", + "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/08fd286888a7efb1e782a5106fdb1b1f237bcdb2/ss_08fd286888a7efb1e782a5106fdb1b1f237bcdb2.1920x1080.jpg" + ], + "tags": ["Action", "Indie"], + "status": "COMING_SOON", + "description": { + "en": "Whisk is a two-player platformer about shared movement and communication. Coordinate jumps, climbs and throws with a partner to get every Dreamcat home." + }, + "actions": [ + { "type": "WISHLIST", "url": "https://store.steampowered.com/app/3602270/", "store": "Steam", "style": "primary" }, + { "type": "GET_DEMO", "url": "https://store.steampowered.com/app/3602270/", "appId": 4320000 }, + { "type": "VISIT", "url": "https://store.steampowered.com/app/3602270/" } + ] + } + } + """.trimIndent() private val json = Json { ignoreUnknownKeys = true } @@ -75,11 +64,7 @@ object RecommendationRepository { */ suspend fun getHero(context: Context): HeroResponse = withContext(Dispatchers.IO) { - if (USE_LOCAL_FEATURED) { - lastFeatured = localFeatured - return@withContext HeroResponse(recommendation = null, featured = localFeatured) - } - val fetched = fetchRemote() + val fetched = if (MOCK_HERO_RESPONSE) parseHero(MOCK_HERO_JSON) else fetchRemote() if (fetched != null) { lastFeatured = fetched.featured return@withContext HeroResponse( From 1c850bb403162b3ddb7b66aa2fade7b01d1b965f Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 18:06:02 -0400 Subject: [PATCH 11/17] Consolidate featured CTA strings and translate them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two per-action failure strings collapse into one generic featured_action_failed (the fallback behavior is identical), and the demo-added snackbar goes away — the button flipping to In Library is the confirmation. The surviving four new strings (wishlisted, get-demo, in-library, failed) are translated into all 14 locales that carry the featured block. --- .../data/RecommendationRepository.kt | 2 +- .../ui/screen/library/FeaturedCtaButton.kt | 29 +++++-------------- app/src/main/res/values-da/strings.xml | 4 +++ app/src/main/res/values-de/strings.xml | 4 +++ app/src/main/res/values-es/strings.xml | 4 +++ app/src/main/res/values-fr/strings.xml | 4 +++ app/src/main/res/values-it/strings.xml | 4 +++ app/src/main/res/values-ja/strings.xml | 4 +++ app/src/main/res/values-ko/strings.xml | 4 +++ app/src/main/res/values-pl/strings.xml | 4 +++ app/src/main/res/values-pt-rBR/strings.xml | 4 +++ app/src/main/res/values-ro/strings.xml | 4 +++ app/src/main/res/values-ru/strings.xml | 4 +++ app/src/main/res/values-uk/strings.xml | 4 +++ app/src/main/res/values-zh-rCN/strings.xml | 4 +++ app/src/main/res/values-zh-rTW/strings.xml | 4 +++ app/src/main/res/values/strings.xml | 4 +-- 17 files changed, 65 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index e6b98bf601..cea26f767a 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -18,7 +18,7 @@ object RecommendationRepository { private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L // Serves MOCK_HERO_JSON through the production parse path instead of calling the server. - private const val MOCK_HERO_RESPONSE = false + private const val MOCK_HERO_RESPONSE = true // Verbatim /api/games/hero payload for a sponsored campaign with in-app CTAs; goes through // parseHero like a real response, so it exercises deserialization, not just the UI. diff --git a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt index 73b2c8a53b..37b5ff81d5 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt @@ -1,6 +1,5 @@ package app.gamenative.ui.screen.library -import android.content.Context import android.content.Intent import androidx.annotation.StringRes import androidx.compose.foundation.layout.fillMaxWidth @@ -70,7 +69,7 @@ internal fun FeaturedCtaButton(action: FeaturedCta, campaignId: String, recSourc } else { busy = true scope.launch { - val ok = cta.run(context) + val ok = cta.run() busy = false if (ok) { done = true @@ -81,7 +80,7 @@ internal fun FeaturedCtaButton(action: FeaturedCta, campaignId: String, recSourc source = recSource, ) } else { - SnackbarManager.show(context.getString(cta.failedTextRes)) + SnackbarManager.show(context.getString(R.string.featured_action_failed)) openUrl() } } @@ -116,38 +115,24 @@ internal fun FeaturedCtaButton(action: FeaturedCta, campaignId: String, recSourc private sealed class InAppCta( val appId: Int, @StringRes val doneLabelRes: Int, - @StringRes val failedTextRes: Int, ) { /** True/false when the state is known, null when it cannot be determined. */ abstract suspend fun isDone(): Boolean? /** Runs the action; true on success. */ - abstract suspend fun run(context: Context): Boolean + abstract suspend fun run(): Boolean - private class Wishlist(appId: Int) : InAppCta( - appId, - R.string.featured_action_wishlisted, - R.string.featured_wishlist_failed, - ) { + private class Wishlist(appId: Int) : InAppCta(appId, R.string.featured_action_wishlisted) { override suspend fun isDone(): Boolean? = SteamWishlistService.isWishlisted(appId) - override suspend fun run(context: Context): Boolean = + override suspend fun run(): Boolean = SteamWishlistService.addToWishlist(appId) is SteamWishlistService.Outcome.Success } - private class GetDemo(appId: Int) : InAppCta( - appId, - R.string.featured_action_in_library, - R.string.featured_demo_failed, - ) { + private class GetDemo(appId: Int) : InAppCta(appId, R.string.featured_action_in_library) { override suspend fun isDone(): Boolean = SteamService.isAppInLibrary(appId) - override suspend fun run(context: Context): Boolean = - SteamService.requestFreeLicense(appId).also { granted -> - if (granted) { - SnackbarManager.show(context.getString(R.string.featured_demo_added)) - } - } + override suspend fun run(): Boolean = SteamService.requestFreeLicense(appId) } companion object { diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index c14c321928..9c39dfe6fb 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1940,6 +1940,10 @@ Sponsoreret Ønskeliste Føj til ønskeliste på %1$s + På ønskelisten + Hent demoen + I biblioteket + Kunne ikke fuldføres i appen – åbner butikssiden Forudbestil Forudbestil på %1$s Køb diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aea4a276b8..dc835ad5f9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2010,6 +2010,10 @@ Gesponsert Wunschliste Auf %1$s auf die Wunschliste + Auf der Wunschliste + Demo holen + In der Bibliothek + In der App nicht möglich – Store-Seite wird geöffnet Vorbestellen Auf %1$s vorbestellen Kaufen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 182c198d43..604c34eb15 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2068,6 +2068,10 @@ Patrocinado Lista de deseos Añadir a la lista en %1$s + En la lista de deseados + Obtener la demo + En la biblioteca + No se pudo completar en la aplicación: abriendo la página de la tienda Reservar Reservar en %1$s Comprar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 670499510e..83a78bb072 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2070,6 +2070,10 @@ Sponsorisé Liste de souhaits Ajouter sur %1$s + Dans la liste de souhaits + Obtenir la démo + Dans la bibliothèque + Impossible dans l\'application – ouverture de la page de la boutique Précommander Précommander sur %1$s Acheter diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f3a09d866c..1157edb215 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2061,6 +2061,10 @@ Sponsorizzato Lista dei desideri Aggiungi ai desideri su %1$s + Nella lista dei desideri + Ottieni la demo + Nella libreria + Impossibile completare nell\'app: apertura della pagina del negozio Preordina Preordina su %1$s Acquista diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d0e920862a..8388984480 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2027,6 +2027,10 @@ スポンサー ウィッシュリスト %1$s でウィッシュリストに追加 + ウィッシュリスト追加済み + デモを入手 + ライブラリにあります + アプリ内で完了できませんでした。ストアページを開きます 予約購入 %1$s で予約購入 購入 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b98b611531..29dfd9ff1c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2068,6 +2068,10 @@ 스폰서 위시리스트 %1$s에서 위시리스트에 추가 + 위시리스트에 추가됨 + 데모 받기 + 라이브러리에 있음 + 앱에서 완료할 수 없어 상점 페이지를 엽니다 예약 구매 %1$s에서 예약 구매 구매 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d3a99bacb2..8868ad14dc 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2068,6 +2068,10 @@ Sponsorowane Lista życzeń Dodaj do listy życzeń na %1$s + Na liście życzeń + Pobierz demo + W bibliotece + Nie udało się w aplikacji – otwieranie strony sklepu Zamów przedpremierowo Zamów przedpremierowo na %1$s Kup diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 66c4e4291f..085770a94e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1940,6 +1940,10 @@ Patrocinado Lista de desejos Adicionar à lista na %1$s + Na lista de desejos + Obter a demo + Na biblioteca + Não foi possível concluir no app – abrindo a página da loja Reservar Reservar na %1$s Comprar diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ec513ec4f0..725d919576 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2071,6 +2071,10 @@ Sponsorizat Listă de dorințe Adaugă în listă pe %1$s + În lista de dorințe + Obține demo-ul + În bibliotecă + Nu s-a putut finaliza în aplicație – se deschide pagina magazinului Precomandă Precomandă pe %1$s Cumpără diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7154645ffb..644d7627cc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1996,6 +1996,10 @@ https://gamenative.app Спонсировано Список желаемого В список желаемого в %1$s + В списке желаемого + Получить демо + В библиотеке + Не удалось выполнить в приложении — открывается страница магазина Предзаказ Предзаказ в %1$s Купить diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 84b2bb645c..94876ecd67 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2064,6 +2064,10 @@ Спонсовано Список бажань До списку бажань у %1$s + У списку бажаного + Отримати демо + У бібліотеці + Не вдалося виконати в застосунку — відкривається сторінка крамниці Передзамовити Передзамовити у %1$s Придбати diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e0c2315787..cf1317451b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2088,6 +2088,10 @@ 赞助 愿望单 在 %1$s 加入愿望单 + 已加入愿望单 + 获取试玩版 + 已在库中 + 无法在应用内完成,正在打开商店页面 预购 在 %1$s 预购 购买 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1fcafe46eb..11042fc0ae 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2079,6 +2079,10 @@ 贊助 願望清單 在 %1$s 加入願望清單 + 已加入願望清單 + 取得試玩版 + 已在收藏庫中 + 無法在應用程式內完成,正在開啟商店頁面 預購 在 %1$s 預購 購買 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b1677547b..6b302503ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1997,11 +1997,9 @@ Wishlist Wishlist on %1$s Wishlisted - Couldn\'t update your wishlist — opening Steam Get the Demo In Library - Demo added to your library - Couldn\'t get the demo — opening Steam + Couldn\'t finish in the app — opening the store page Pre-order Pre-order on %1$s Buy From 854a204cbc39c88c1b966d9593ac365d8120f32d Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 18:33:58 -0400 Subject: [PATCH 12/17] Use Whisk's library art in the mock campaign capsuleImageUrl pointed at the 231x87 store thumbnail rather than the vertical library capsule the grid card expects, so the card rendered a tiny stretched strip. heroImageUrl had the same problem one size up (460x215 header behind a 280dp hero). Both now use the library assets from the store's asset manifest; Whisk is a recent app, so its art lives under hashed paths and the usual unhashed URLs 404. --- .../main/java/app/gamenative/data/RecommendationRepository.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index cea26f767a..597579361a 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -30,8 +30,8 @@ object RecommendationRepository { "title": "Whisk", "appId": 3602270, "developer": "Double Dusk Inc.", - "heroImageUrl": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/04f63f73ec6aefcb4efc26a7c4049aebffb99368/header.jpg", - "capsuleImageUrl": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/8746d0c28b68abd78cdc7b9c6ad651af33381827/capsule_231x87.jpg", + "heroImageUrl": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/92fb97a2832c9c075165c43d14d974c730ca716b/library_hero.jpg", + "capsuleImageUrl": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/435512f90bdf39498f17fcbd103b19fa1223a430/library_capsule.jpg", "screenshots": [ "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/e0a52a09cd85472aecfb430ad086aae040cb100c/ss_e0a52a09cd85472aecfb430ad086aae040cb100c.1920x1080.jpg", "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3602270/77719570b08d1b46facf8477df20aa5b97b54573/ss_77719570b08d1b46facf8477df20aa5b97b54573.1920x1080.jpg", From 674722302a9d6f25841891bb8302c7a936972867 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 18:42:12 -0400 Subject: [PATCH 13/17] Make the recommendation screen controller-navigable D-pad input had no anchor: nothing on the screen ever took focus, so controller navigation was dead on arrival. Focus now lands on the primary action when the screen opens (first featured CTA, or the buy button), the same pattern LibraryAppScreen uses for its play button. CTA buttons, the buy button, and the back arrow get the shared focusRing so the focused element is actually visible. --- .../ui/screen/library/FeaturedCtaButton.kt | 27 ++++++++++--- .../screen/library/RecommendedGameScreen.kt | 38 +++++++++++++++++-- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt index 37b5ff81d5..6053f4bef0 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt @@ -2,6 +2,7 @@ package app.gamenative.ui.screen.library import android.content.Intent import androidx.annotation.StringRes +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button @@ -15,6 +16,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -25,6 +28,7 @@ import app.gamenative.R import app.gamenative.data.FeaturedCta import app.gamenative.service.SteamService import app.gamenative.service.SteamWishlistService +import app.gamenative.ui.component.focusRing import app.gamenative.ui.util.SnackbarManager import app.gamenative.utils.ConversionTracker import com.posthog.PostHog @@ -36,9 +40,15 @@ import kotlinx.coroutines.launch * in-app handler fails. New in-app types only need a new [InAppCta] entry. */ @Composable -internal fun FeaturedCtaButton(action: FeaturedCta, campaignId: String, recSource: String) { +internal fun FeaturedCtaButton( + action: FeaturedCta, + campaignId: String, + recSource: String, + focusRequester: FocusRequester? = null, +) { val context = LocalContext.current val scope = rememberCoroutineScope() + val interactionSource = remember { MutableInteractionSource() } val cta = remember(action) { InAppCta.forAction(action) } // null = unknown (e.g. wishlist private); the button then stays in its idle state. var done by remember(action) { mutableStateOf(null) } @@ -89,13 +99,19 @@ internal fun FeaturedCtaButton(action: FeaturedCta, campaignId: String, recSourc val label = if (cta != null && done == true) stringResource(cta.doneLabelRes) else action.label val enabled = cta == null || (!busy && done != true) + val shape = RoundedCornerShape(12.dp) + val buttonModifier = Modifier + .fillMaxWidth() + .focusRing(interactionSource, shape, width = 2.dp) + .let { if (focusRequester != null) it.focusRequester(focusRequester) else it } if (action.primary) { Button( onClick = onClick, enabled = enabled, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + modifier = buttonModifier, + shape = shape, + interactionSource = interactionSource, ) { Text(text = label, fontWeight = FontWeight.SemiBold) } @@ -103,8 +119,9 @@ internal fun FeaturedCtaButton(action: FeaturedCta, campaignId: String, recSourc OutlinedButton( onClick = onClick, enabled = enabled, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + modifier = buttonModifier, + shape = shape, + interactionSource = interactionSource, ) { Text(text = label, fontWeight = FontWeight.SemiBold) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt index c3d4b56cf1..7910b13902 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt @@ -15,7 +15,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -32,9 +34,12 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -50,6 +55,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import app.gamenative.R import app.gamenative.data.RecommendedGame +import app.gamenative.ui.component.focusRing import app.gamenative.ui.screen.library.components.VideoHero import app.gamenative.PrefManager import com.posthog.PostHog @@ -67,6 +73,17 @@ internal fun RecommendedGameScreen( val context = LocalContext.current val scrollState = rememberScrollState() + // Controller navigation: land focus on the primary action so D-pad input has an anchor, + // matching LibraryAppScreen's play-button behavior. + val firstActionFocusRequester = remember { FocusRequester() } + LaunchedEffect(game) { + try { + firstActionFocusRequester.requestFocus() + } catch (_: IllegalStateException) { + // No focusable action attached (e.g. featured campaign with no CTAs). + } + } + val media = remember(game) { val list = mutableListOf>() game.videos.ifEmpty { listOfNotNull(game.videoUrl) }.forEach { list += true to it } @@ -134,11 +151,14 @@ internal fun RecommendedGameScreen( ) // Back button + val backInteractionSource = remember { MutableInteractionSource() } IconButton( onClick = onBack, + interactionSource = backInteractionSource, modifier = Modifier .align(Alignment.TopStart) - .padding(8.dp), + .padding(8.dp) + .focusRing(backInteractionSource, CircleShape, width = 2.dp), ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, @@ -344,13 +364,20 @@ internal fun RecommendedGameScreen( } // Featured actions (wishlist / demo / pre-order / etc.) - game.featuredCtas.forEach { action -> - FeaturedCtaButton(action = action, campaignId = game.id, recSource = recSource) + game.featuredCtas.forEachIndexed { index, action -> + FeaturedCtaButton( + action = action, + campaignId = game.id, + recSource = recSource, + focusRequester = firstActionFocusRequester.takeIf { index == 0 }, + ) Spacer(modifier = Modifier.height(8.dp)) } } else { // Buy button + val buyInteractionSource = remember { MutableInteractionSource() } Button( + interactionSource = buyInteractionSource, onClick = { if (PrefManager.usageAnalyticsEnabled) { PostHog.capture( @@ -368,7 +395,10 @@ internal fun RecommendedGameScreen( val browserIntent = Intent(Intent.ACTION_VIEW, game.affiliateUrl.toUri()) context.startActivity(browserIntent) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRing(buyInteractionSource, RoundedCornerShape(12.dp), width = 2.dp) + .focusRequester(firstActionFocusRequester), shape = RoundedCornerShape(12.dp), ) { Icon( From cea6b0018e5bd086f043f1241554cd44839dea67 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 18:45:29 -0400 Subject: [PATCH 14/17] Prove the mock hero payload decodes through the production models The mock is also the backend payload contract, so a schema drift between it and the data classes should fail in CI rather than on a device. --- .../data/RecommendationRepository.kt | 2 +- .../gamenative/data/MockHeroResponseTest.kt | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/app/gamenative/data/MockHeroResponseTest.kt diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index 597579361a..109bfe64ce 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -22,7 +22,7 @@ object RecommendationRepository { // Verbatim /api/games/hero payload for a sponsored campaign with in-app CTAs; goes through // parseHero like a real response, so it exercises deserialization, not just the UI. - private val MOCK_HERO_JSON = """ + internal val MOCK_HERO_JSON = """ { "recommendation": null, "featured": { diff --git a/app/src/test/java/app/gamenative/data/MockHeroResponseTest.kt b/app/src/test/java/app/gamenative/data/MockHeroResponseTest.kt new file mode 100644 index 0000000000..d92e56f753 --- /dev/null +++ b/app/src/test/java/app/gamenative/data/MockHeroResponseTest.kt @@ -0,0 +1,48 @@ +package app.gamenative.data + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * MOCK_HERO_JSON stands in for the /api/games/hero response and doubles as the payload contract + * for the backend campaign schema, so it must decode with the same Json settings production uses. + */ +class MockHeroResponseTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `mock hero payload decodes through the production models`() { + val hero = json.decodeFromString(RecommendationRepository.MOCK_HERO_JSON) + + assertNull(hero.recommendation) + val featured = assertNotNull(hero.featured) + + assertEquals("mock-whisk", featured.campaignId) + assertEquals(3602270, featured.appId) + assertEquals("COMING_SOON", featured.status) + assertTrue(featured.description.containsKey("en")) + assertEquals(3, featured.screenshots.size) + + assertEquals(listOf("WISHLIST", "GET_DEMO", "VISIT"), featured.actions.map { it.type }) + + val wishlist = featured.actions[0] + assertEquals("primary", wishlist.style) + assertNull(wishlist.appId) // falls back to the campaign appId + + val demo = featured.actions[1] + assertEquals(4320000, demo.appId) // the demo is its own app + + // Every action must carry a fallback url for clients that don't know its type. + assertTrue(featured.actions.all { it.url.startsWith("https://") }) + } + + private fun assertNotNull(value: T?): T { + org.junit.Assert.assertNotNull(value) + return value!! + } +} From 04b35d10d91a14255ac7b5adf6653963b5e31b88 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 22:25:00 -0400 Subject: [PATCH 15/17] Keep CTA buttons focusable after they complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A disabled button is not focusable, so finishing a wishlist or demo action dropped controller focus with nothing to fall back to and left the screen unnavigable — the same happened for the moment the action was in flight. The buttons now stay enabled and swallow taps while busy or done, dimming instead of greying out. --- .../ui/screen/library/FeaturedCtaButton.kt | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt index 6053f4bef0..45419697fa 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -62,7 +64,12 @@ internal fun FeaturedCtaButton( val openUrl = { context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) } - val onClick: () -> Unit = { + val inert = cta != null && (busy || done == true) + + // Inert taps are swallowed rather than disabling the button, which would make it unfocusable. + val onClick: () -> Unit = onClick@{ + if (inert) return@onClick + if (PrefManager.usageAnalyticsEnabled) { PostHog.capture( event = "featured_action_clicked", @@ -98,30 +105,37 @@ internal fun FeaturedCtaButton( } val label = if (cta != null && done == true) stringResource(cta.doneLabelRes) else action.label - val enabled = cta == null || (!busy && done != true) val shape = RoundedCornerShape(12.dp) val buttonModifier = Modifier .fillMaxWidth() .focusRing(interactionSource, shape, width = 2.dp) .let { if (focusRequester != null) it.focusRequester(focusRequester) else it } + // Kept enabled even when inert: a disabled button is not focusable, so completing an action + // would drop controller focus with nothing to fall back to. Dimmed instead. + val contentAlpha = if (inert) 0.6f else 1f + if (action.primary) { Button( onClick = onClick, - enabled = enabled, modifier = buttonModifier, shape = shape, interactionSource = interactionSource, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha), + ), ) { Text(text = label, fontWeight = FontWeight.SemiBold) } } else { OutlinedButton( onClick = onClick, - enabled = enabled, modifier = buttonModifier, shape = shape, interactionSource = interactionSource, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha), + ), ) { Text(text = label, fontWeight = FontWeight.SemiBold) } From e634b2de926a6be068a750b178bd4ca769200159 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 22:31:03 -0400 Subject: [PATCH 16/17] Turn the mock hero response back off It was flipped on for device testing and got swept into the strings commit. --- .../main/java/app/gamenative/data/RecommendationRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index 109bfe64ce..021d82884c 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -18,7 +18,7 @@ object RecommendationRepository { private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L // Serves MOCK_HERO_JSON through the production parse path instead of calling the server. - private const val MOCK_HERO_RESPONSE = true + private const val MOCK_HERO_RESPONSE = false // Verbatim /api/games/hero payload for a sponsored campaign with in-app CTAs; goes through // parseHero like a real response, so it exercises deserialization, not just the UI. From dff5bc79dd13d65b5a1cbad160b7161bc6b4939b Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 1 Aug 2026 22:43:02 -0400 Subject: [PATCH 17/17] Drop the added comments --- app/src/main/java/app/gamenative/data/Featured.kt | 3 --- .../app/gamenative/data/RecommendationRepository.kt | 3 --- .../main/java/app/gamenative/service/SteamService.kt | 3 --- .../app/gamenative/service/SteamWishlistService.kt | 10 ---------- .../ui/screen/library/FeaturedCtaButton.kt | 12 ------------ .../ui/screen/library/RecommendedGameScreen.kt | 5 +---- .../java/app/gamenative/utils/ConversionTracker.kt | 9 --------- .../java/app/gamenative/data/MockHeroResponseTest.kt | 9 ++------- 8 files changed, 3 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/Featured.kt b/app/src/main/java/app/gamenative/data/Featured.kt index c091d9949d..0575eb4c2f 100644 --- a/app/src/main/java/app/gamenative/data/Featured.kt +++ b/app/src/main/java/app/gamenative/data/Featured.kt @@ -15,7 +15,6 @@ data class HeroResponse( data class FeaturedItem( val campaignId: String, val title: String, - // Steam appid the campaign points at; required for in-app actions such as WISHLIST. val appId: Int? = null, val developer: String? = null, val heroImageUrl: String = "", @@ -36,8 +35,6 @@ data class FeaturedItem( data class FeaturedAction( val type: String, val url: String, - // Steam appid this action targets when it differs from the campaign's (e.g. GET_DEMO, - // where the demo is its own app). Falls back to the campaign appId. val appId: Int? = null, val store: String? = null, val style: String? = null, diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index 021d82884c..665955ae37 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -17,11 +17,8 @@ object RecommendationRepository { private const val API_URL = "https://api.gamenative.app/api/games/hero" private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L - // Serves MOCK_HERO_JSON through the production parse path instead of calling the server. private const val MOCK_HERO_RESPONSE = false - // Verbatim /api/games/hero payload for a sponsored campaign with in-app CTAs; goes through - // parseHero like a real response, so it exercises deserialization, not just the UI. internal val MOCK_HERO_JSON = """ { "recommendation": null, diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 571d966b06..ea2ab69d6d 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -727,12 +727,9 @@ class SteamService : Service(), IChallengeUrlChanged { }.orEmpty() } - /** Whether any owned license grants [appId]. Follows the license list, so a freshly - * granted free license flips this once Steam pushes the updated list. */ suspend fun isAppInLibrary(appId: Int): Boolean = instance?.licenseDao?.getAllLicenses()?.any { appId in it.appIds } == true - /** Requests a free license (demos, F2P) for [appId] over the CM connection. */ suspend fun requestFreeLicense(appId: Int): Boolean = withContext(Dispatchers.IO) { val steamApps = instance?._steamApps ?: return@withContext false try { diff --git a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt index 0ea2de669f..0ec94272a7 100644 --- a/app/src/main/java/app/gamenative/service/SteamWishlistService.kt +++ b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt @@ -14,13 +14,6 @@ import okhttp3.Request import org.json.JSONObject import timber.log.Timber -/** - * Wishlist add/remove over the logged-in JavaSteam session. - * - * Writes go out on the authenticated CM connection, so no web token is involved. The read still - * uses the public web endpoint because it only needs a steamid; a wishlist set to private is - * therefore unreadable and reports null rather than "not wishlisted". - */ object SteamWishlistService { private const val TAG = "SteamWishlist" @@ -49,7 +42,6 @@ object SteamWishlistService { } } - /** True/false when the wishlist could be read, null when it could not be determined. */ suspend fun isWishlisted(appId: Int): Boolean? = withContext(Dispatchers.IO) { val steamId = SteamService.userSteamId?.convertToUInt64() if (steamId == null || steamId == 0L) { @@ -66,7 +58,6 @@ object SteamWishlistService { return@use null } val response = JSONObject(res.body?.string().orEmpty()).optJSONObject("response") - // Absent (rather than empty) items means private or unreadable, not "nothing wishlisted". val items = response?.optJSONArray("items") ?: return@use null (0 until items.length()).any { items.optJSONObject(it)?.optInt("appid") == appId } } @@ -87,7 +78,6 @@ object SteamWishlistService { Timber.tag(TAG).e("SteamUnifiedMessages handler not available") return null } - // Replies are routed by service name, so the service must be registered via createService. return try { unifiedMessages.createService(Wishlist::class.java) } catch (t: Throwable) { diff --git a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt index 45419697fa..5807c9505c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt @@ -36,11 +36,6 @@ import app.gamenative.utils.ConversionTracker import com.posthog.PostHog import kotlinx.coroutines.launch -/** - * One featured call-to-action. Types with an in-app handler ([InAppCta]) run on-device and render - * a done state; every other type deep-links to the action URL, which is also the fallback when an - * in-app handler fails. New in-app types only need a new [InAppCta] entry. - */ @Composable internal fun FeaturedCtaButton( action: FeaturedCta, @@ -52,7 +47,6 @@ internal fun FeaturedCtaButton( val scope = rememberCoroutineScope() val interactionSource = remember { MutableInteractionSource() } val cta = remember(action) { InAppCta.forAction(action) } - // null = unknown (e.g. wishlist private); the button then stays in its idle state. var done by remember(action) { mutableStateOf(null) } var busy by remember(action) { mutableStateOf(false) } @@ -66,7 +60,6 @@ internal fun FeaturedCtaButton( val inert = cta != null && (busy || done == true) - // Inert taps are swallowed rather than disabling the button, which would make it unfocusable. val onClick: () -> Unit = onClick@{ if (inert) return@onClick @@ -111,8 +104,6 @@ internal fun FeaturedCtaButton( .focusRing(interactionSource, shape, width = 2.dp) .let { if (focusRequester != null) it.focusRequester(focusRequester) else it } - // Kept enabled even when inert: a disabled button is not focusable, so completing an action - // would drop controller focus with nothing to fall back to. Dimmed instead. val contentAlpha = if (inert) 0.6f else 1f if (action.primary) { @@ -142,15 +133,12 @@ internal fun FeaturedCtaButton( } } -/** An action type the app can complete itself instead of deep-linking. */ private sealed class InAppCta( val appId: Int, @StringRes val doneLabelRes: Int, ) { - /** True/false when the state is known, null when it cannot be determined. */ abstract suspend fun isDone(): Boolean? - /** Runs the action; true on success. */ abstract suspend fun run(): Boolean private class Wishlist(appId: Int) : InAppCta(appId, R.string.featured_action_wishlisted) { diff --git a/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt index 7910b13902..9197c082df 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/RecommendedGameScreen.kt @@ -73,14 +73,11 @@ internal fun RecommendedGameScreen( val context = LocalContext.current val scrollState = rememberScrollState() - // Controller navigation: land focus on the primary action so D-pad input has an anchor, - // matching LibraryAppScreen's play-button behavior. val firstActionFocusRequester = remember { FocusRequester() } LaunchedEffect(game) { try { firstActionFocusRequester.requestFocus() } catch (_: IllegalStateException) { - // No focusable action attached (e.g. featured campaign with no CTAs). } } @@ -363,7 +360,7 @@ internal fun RecommendedGameScreen( Spacer(modifier = Modifier.height(12.dp)) } - // Featured actions (wishlist / demo / pre-order / etc.) + // Featured actions (wishlist / pre-order / etc.) game.featuredCtas.forEachIndexed { index, action -> FeaturedCtaButton( action = action, diff --git a/app/src/main/java/app/gamenative/utils/ConversionTracker.kt b/app/src/main/java/app/gamenative/utils/ConversionTracker.kt index f975a5a1b1..2cfd64698a 100644 --- a/app/src/main/java/app/gamenative/utils/ConversionTracker.kt +++ b/app/src/main/java/app/gamenative/utils/ConversionTracker.kt @@ -4,15 +4,6 @@ import app.gamenative.PrefManager import com.posthog.PostHog import java.util.UUID -/** - * Billing-grade conversion counting for sponsored campaigns. - * - * A confirmed conversion (wishlist added, demo granted) is always captured, unlike behavioral - * events, because campaign billing needs a complete count. Consent still decides what the event - * carries: opted-in users send a normal identified event; opted-out users send a personless event - * under a single-use random id, so no person profile is created and nothing links it to a device - * or to other events. Region reporting works for both, via PostHog's server-side GeoIP enrichment. - */ object ConversionTracker { fun featuredConversion(campaignId: String, actionType: String, appId: Int?, source: String) { diff --git a/app/src/test/java/app/gamenative/data/MockHeroResponseTest.kt b/app/src/test/java/app/gamenative/data/MockHeroResponseTest.kt index d92e56f753..fc1ca5823f 100644 --- a/app/src/test/java/app/gamenative/data/MockHeroResponseTest.kt +++ b/app/src/test/java/app/gamenative/data/MockHeroResponseTest.kt @@ -7,10 +7,6 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -/** - * MOCK_HERO_JSON stands in for the /api/games/hero response and doubles as the payload contract - * for the backend campaign schema, so it must decode with the same Json settings production uses. - */ class MockHeroResponseTest { private val json = Json { ignoreUnknownKeys = true } @@ -32,12 +28,11 @@ class MockHeroResponseTest { val wishlist = featured.actions[0] assertEquals("primary", wishlist.style) - assertNull(wishlist.appId) // falls back to the campaign appId + assertNull(wishlist.appId) val demo = featured.actions[1] - assertEquals(4320000, demo.appId) // the demo is its own app + assertEquals(4320000, demo.appId) - // Every action must carry a fallback url for clients that don't know its type. assertTrue(featured.actions.all { it.url.startsWith("https://") }) }