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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,8 @@ dependencies {
// JavaSteam
val localBuild = false // 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-25-SNAPSHOT.jar"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: This edit bumps only the inactive localBuild branch (localBuild=false), so it has no effect on the actual dependency resolution — the active else branch uses libs.javasteam from the version catalog, which is already at 1.8.0.1-26-SNAPSHOT. The local-build paths are now one version behind the catalog, creating a confusing mismatch for anyone who flips localBuild on. Consider instead bumping the version in gradle/libs.versions.toml (the path that actually affects the build), or dropping this dead-branch edit to keep the diff scoped to functional changes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/build.gradle.kts, line 334:

<comment>This edit bumps only the inactive `localBuild` branch (localBuild=false), so it has no effect on the actual dependency resolution — the active `else` branch uses `libs.javasteam` from the version catalog, which is already at 1.8.0.1-26-SNAPSHOT. The local-build paths are now one version behind the catalog, creating a confusing mismatch for anyone who flips localBuild on. Consider instead bumping the version in gradle/libs.versions.toml (the path that actually affects the build), or dropping this dead-branch edit to keep the diff scoped to functional changes.</comment>

<file context>
@@ -331,8 +331,8 @@ dependencies {
     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-25-SNAPSHOT.jar"))
+        implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-25-SNAPSHOT.jar"))
         implementation(libs.bundles.javasteam.dev)
</file context>

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) {
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/app/gamenative/data/Featured.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ data class HeroResponse(
data class FeaturedItem(
val campaignId: String,
val title: String,
val appId: Int? = null,
val developer: String? = null,
val heroImageUrl: String = "",
val capsuleImageUrl: String? = null,
Expand All @@ -34,6 +35,7 @@ data class FeaturedItem(
data class FeaturedAction(
val type: String,
val url: String,
val appId: Int? = null,
val store: String? = null,
val style: String? = null,
// Only for type CUSTOM: advertiser-supplied locale -> label map.
Expand Down Expand Up @@ -61,6 +63,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)
}
Expand Down Expand Up @@ -91,6 +94,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 = a.appId ?: appId,
)
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,37 @@ object RecommendationRepository {
private const val API_URL = "https://api.gamenative.app/api/games/hero"
private const val CACHE_TTL_MS = 24L * 60L * 60L * 1000L

private const val MOCK_HERO_RESPONSE = false

internal val MOCK_HERO_JSON = """

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: This PR ships the demo scaffold inside production code: MOCK_HERO_RESPONSE is a hardcoded false, making the parseHero(MOCK_HERO_JSON) branch unreachable at runtime, so the large embedded payload (a real 'Whisk' campaign with its actual Steam appid, asset URLs, and a separate demo appid) is effectively a test fixture compiled into the app. It's only used by MockHeroResponseTest. This adds noise and embeds a real third-party/Steam campaign in the shipped binary just to power the demo. Consider moving the mock payload and a demo/debug toggle out of the production RecommendationRepository (e.g. into the test source set or behind a debug-only/flag-gated layer), so the demo doesn't ship with the app.

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

<comment>This PR ships the demo scaffold inside production code: `MOCK_HERO_RESPONSE` is a hardcoded `false`, making the `parseHero(MOCK_HERO_JSON)` branch unreachable at runtime, so the large embedded payload (a real 'Whisk' campaign with its actual Steam appid, asset URLs, and a separate demo appid) is effectively a test fixture compiled into the app. It's only used by `MockHeroResponseTest`. This adds noise and embeds a real third-party/Steam campaign in the shipped binary just to power the demo. Consider moving the mock payload and a `demo`/debug toggle out of the production `RecommendationRepository` (e.g. into the test source set or behind a debug-only/flag-gated layer), so the demo doesn't ship with the app.</comment>

<file context>
@@ -17,6 +17,40 @@ 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.
+    internal val MOCK_HERO_JSON = """
+        {
+          "recommendation": null,
</file context>

{
"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/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",
"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 }

// Latest featured from the most recent fetch. Kept in memory (not the disk cache) so the
Expand All @@ -30,7 +61,7 @@ object RecommendationRepository {
*/
suspend fun getHero(context: Context): HeroResponse =
withContext(Dispatchers.IO) {
val fetched = fetchRemote()
val fetched = if (MOCK_HERO_RESPONSE) parseHero(MOCK_HERO_JSON) else fetchRemote()
if (fetched != null) {
lastFeatured = fetched.featured
return@withContext HeroResponse(
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/app/gamenative/data/RecommendedGame.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,6 @@ data class FeaturedCta(
val label: String,
val url: String,
val primary: Boolean = false,
val type: String = "",
val appId: Int? = null,
)
18 changes: 18 additions & 0 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,24 @@ class SteamService : Service(), IChallengeUrlChanged {
}.orEmpty()
}

suspend fun isAppInLibrary(appId: Int): Boolean =
instance?.licenseDao?.getAllLicenses()?.any { appId in it.appIds } == true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Rendering multiple demo CTAs repeatedly loads the entire license table per app, which scales with the user's full library rather than the featured items. Use a cached/indexed entitlement lookup or batch these app IDs instead.

(Based on your team's feedback about scaling library lookups.)

View Feedback

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

<comment>Rendering multiple demo CTAs repeatedly loads the entire license table per app, which scales with the user's full library rather than the featured items. Use a cached/indexed entitlement lookup or batch these app IDs instead.

(Based on your team's feedback about scaling library lookups.) </comment>

<file context>
@@ -727,6 +727,27 @@ class SteamService : Service(), IChallengeUrlChanged {
+        /** 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. */
</file context>


suspend fun requestFreeLicense(appId: Int): Boolean = withContext(Dispatchers.IO) {
val steamApps = instance?._steamApps ?: return@withContext false
try {
val callback = steamApps.requestFreeLicense(appId).toFuture().await()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: requestFreeLicense awaits the Steam callback without a timeout, so if Steam never responds the coroutine hangs indefinitely. This call is triggered directly from a user tapping the featured CTA button, so consider wrapping the await in withTimeout(...) similar to getEncryptedAppTicket elsewhere in this file.

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

<comment>requestFreeLicense awaits the Steam callback without a timeout, so if Steam never responds the coroutine hangs indefinitely. This call is triggered directly from a user tapping the featured CTA button, so consider wrapping the await in withTimeout(...) similar to getEncryptedAppTicket elsewhere in this file.</comment>

<file context>
@@ -727,6 +727,27 @@ class SteamService : Service(), IChallengeUrlChanged {
+        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}, " +
</file context>
Suggested change
val callback = steamApps.requestFreeLicense(appId).toFuture().await()
val callback = withTimeout(15_000) {
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Leaving the screen while the license request is pending is treated as a failed request and can open the fallback URL from a cancelled UI coroutine. Rethrow CancellationException before handling ordinary failures.

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

<comment>Leaving the screen while the license request is pending is treated as a failed request and can open the fallback URL from a cancelled UI coroutine. Rethrow `CancellationException` before handling ordinary failures.</comment>

<file context>
@@ -727,6 +727,27 @@ class SteamService : Service(), IChallengeUrlChanged {
+                        "apps=${callback.grantedApps}, packages=${callback.grantedPackages}",
+                )
+                callback.result == EResult.OK && appId in callback.grantedApps
+            } catch (e: Exception) {
+                Timber.e(e, "requestFreeLicense($appId) failed")
+                false
</file context>
Suggested change
} catch (e: Exception) {
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {

Timber.e(e, "requestFreeLicense($appId) failed")
false
}
}
Comment on lines +733 to +746

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add a timeout to requestFreeLicense.

requestFreeLicense awaits the Steam callback without a timeout. If Steam never responds, the coroutine hangs indefinitely. getEncryptedAppTicket in this same file wraps its one-shot RPC in withTimeout(5_000) for the same class of risk. Apply the same guard here, since this call is triggered directly by a user tapping a featured CTA button.

🕐 Proposed fix to add a timeout
         suspend fun requestFreeLicense(appId: Int): Boolean = withContext(Dispatchers.IO) {
             val steamApps = instance?._steamApps ?: return@withContext false
             try {
-                val callback = steamApps.requestFreeLicense(appId).toFuture().await()
+                val callback = withTimeout(15_000) {
+                    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
             }
         }
📝 Committable suggestion

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

Suggested change
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 requestFreeLicense(appId: Int): Boolean = withContext(Dispatchers.IO) {
val steamApps = instance?._steamApps ?: return@withContext false
try {
val callback = withTimeout(15_000) {
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
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/service/SteamService.kt` around lines 736 -
749, Wrap the awaited Steam callback in requestFreeLicense with the same 5,000
ms withTimeout guard used by getEncryptedAppTicket, preserving the existing
success logging, result validation, and exception fallback to false.


suspend fun getOwnedAppDlc(appId: Int): Map<Int, DepotInfo> {
val client = instance?.steamClient ?: return emptyMap()
val accountId = client.steamID?.accountID?.toInt() ?: return emptyMap()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package app.gamenative.service

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
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import org.json.JSONObject
import timber.log.Timber

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/"

sealed interface Outcome {
data object Success : Outcome
data object NoSession : Outcome
data class Failed(val result: EResult?) : Outcome
}

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
}
}

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
}
}

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
}
val url = GET_URL.toHttpUrl().newBuilder()
.addQueryParameter("steamid", steamId.toString())
.build()
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")
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")
null
}
}

private fun service(): Wishlist? {
val client = SteamService.instance?.steamClient
if (client == null) {
Timber.tag(TAG).w("no steam client")
return null
}
val unifiedMessages = client.getHandler<SteamUnifiedMessages>()
if (unifiedMessages == null) {
Timber.tag(TAG).e("SteamUnifiedMessages handler not available")
return null
}
return try {
unifiedMessages.createService(Wishlist::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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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
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
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.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
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.component.focusRing
import app.gamenative.ui.util.SnackbarManager
import app.gamenative.utils.ConversionTracker
import com.posthog.PostHog
import kotlinx.coroutines.launch

@Composable
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) }
var done by remember(action) { mutableStateOf<Boolean?>(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())) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard startActivity against ActivityNotFoundException.

openUrl starts an ACTION_VIEW intent without a guard. If no activity resolves the URL, or if action.url is empty or malformed, the app crashes. This path is also the failure fallback at Line 101, so a failed in-app action can turn into a crash.

🛡️ Proposed fix
-    val openUrl = { context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) }
+    val openUrl = {
+        try {
+            context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri()))
+        } catch (e: ActivityNotFoundException) {
+            Timber.e(e, "no activity to open featured action url")
+            SnackbarManager.show(context.getString(R.string.featured_action_failed))
+        }
+    }

Add the imports:

import android.content.ActivityNotFoundException
import timber.log.Timber
📝 Committable suggestion

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

Suggested change
val openUrl = { context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) }
val openUrl = {
try {
context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri()))
} catch (e: ActivityNotFoundException) {
Timber.e(e, "no activity to open featured action url")
SnackbarManager.show(context.getString(R.string.featured_action_failed))
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt` at
line 65, Update the openUrl lambda in FeaturedCtaButton to catch
ActivityNotFoundException around context.startActivity, including malformed or
unresolvable URLs, and log the failure with Timber instead of allowing the app
to crash. Preserve its use as the fallback for failed in-app actions.


val inert = cta != null && (busy || done == true)

val onClick: () -> Unit = onClick@{
if (inert) return@onClick

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()
busy = false
if (ok) {
done = true
ConversionTracker.featuredConversion(
campaignId = campaignId,
actionType = action.type,
appId = action.appId,
source = recSource,
)
} else {
SnackbarManager.show(context.getString(R.string.featured_action_failed))
openUrl()
}
}
}
}

val label = if (cta != null && done == true) stringResource(cta.doneLabelRes) else action.label
val shape = RoundedCornerShape(12.dp)
val buttonModifier = Modifier
.fillMaxWidth()
.focusRing(interactionSource, shape, width = 2.dp)
.let { if (focusRequester != null) it.focusRequester(focusRequester) else it }

val contentAlpha = if (inert) 0.6f else 1f

if (action.primary) {
Button(
onClick = onClick,
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,
modifier = buttonModifier,
shape = shape,
interactionSource = interactionSource,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha),
),
) {
Text(text = label, fontWeight = FontWeight.SemiBold)
}
}
}

private sealed class InAppCta(
val appId: Int,
@StringRes val doneLabelRes: Int,
) {
abstract suspend fun isDone(): Boolean?

abstract suspend fun run(): Boolean

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

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

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(): Boolean = SteamService.requestFreeLicense(appId)
}

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
}
}
}
}
Loading
Loading