diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26550ed229..d2aaf62d0d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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")) + 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/data/Featured.kt b/app/src/main/java/app/gamenative/data/Featured.kt index 49989c1f61..0575eb4c2f 100644 --- a/app/src/main/java/app/gamenative/data/Featured.kt +++ b/app/src/main/java/app/gamenative/data/Featured.kt @@ -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, @@ -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. @@ -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) } @@ -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, ) }, ) diff --git a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt index 9f9f6b9cbd..665955ae37 100644 --- a/app/src/main/java/app/gamenative/data/RecommendationRepository.kt +++ b/app/src/main/java/app/gamenative/data/RecommendationRepository.kt @@ -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 = """ + { + "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 @@ -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( 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/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index bb419edef2..ea2ab69d6d 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -727,6 +727,24 @@ class SteamService : Service(), IChallengeUrlChanged { }.orEmpty() } + suspend fun isAppInLibrary(appId: Int): Boolean = + instance?.licenseDao?.getAllLicenses()?.any { appId in it.appIds } == true + + 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/service/SteamWishlistService.kt b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt new file mode 100644 index 0000000000..0ec94272a7 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/SteamWishlistService.kt @@ -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() + 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) + } +} 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..5807c9505c --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/FeaturedCtaButton.kt @@ -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(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 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 + } + } + } +} 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..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 @@ -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 @@ -30,12 +32,14 @@ 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.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 @@ -51,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 @@ -68,6 +73,14 @@ internal fun RecommendedGameScreen( val context = LocalContext.current val scrollState = rememberScrollState() + val firstActionFocusRequester = remember { FocusRequester() } + LaunchedEffect(game) { + try { + firstActionFocusRequester.requestFocus() + } catch (_: IllegalStateException) { + } + } + val media = remember(game) { val list = mutableListOf>() game.videos.ifEmpty { listOfNotNull(game.videoUrl) }.forEach { list += true to it } @@ -135,11 +148,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, @@ -345,43 +361,20 @@ internal fun RecommendedGameScreen( } // Featured actions (wishlist / pre-order / etc.) - game.featuredCtas.forEach { action -> - val onClick = { - 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, - ), - ) - } - context.startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) - } - if (action.primary) { - Button( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Text(text = action.label, fontWeight = FontWeight.SemiBold) - } - } else { - OutlinedButton( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Text(text = action.label, fontWeight = FontWeight.SemiBold) - } - } + 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( @@ -399,7 +392,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( 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..2cfd64698a --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/ConversionTracker.kt @@ -0,0 +1,28 @@ +package app.gamenative.utils + +import app.gamenative.PrefManager +import com.posthog.PostHog +import java.util.UUID + +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/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 defb0230ff..6b302503ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1996,6 +1996,10 @@ Sponsored Wishlist Wishlist on %1$s + Wishlisted + Get the Demo + In Library + Couldn\'t finish in the app — opening the store page Pre-order Pre-order on %1$s Buy 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..fc1ca5823f --- /dev/null +++ b/app/src/test/java/app/gamenative/data/MockHeroResponseTest.kt @@ -0,0 +1,43 @@ +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 + +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) + + val demo = featured.actions[1] + assertEquals(4320000, demo.appId) + + assertTrue(featured.actions.all { it.url.startsWith("https://") }) + } + + private fun assertNotNull(value: T?): T { + org.junit.Assert.assertNotNull(value) + return value!! + } +} 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