Stores: Show mature content, off by default

The storefront's shelves and search leave out titles the store itself
rates or tags as adult unless Show mature content is on (Setup > Stores
and the cog). Library, Installed and owned games always show, and a title
without rating data stays. GOG's catalog gives ratings[].ageRating per
system and content tags; 17+ in any system or a mature / nsfw / sexual
content / nudity tag marks a title. Epic's store GraphQL carries no age
rating on an offer, so only an adult content tag would mark one.
This commit is contained in:
The412Banner committed 2026-10-08 22:45:22 -04:00
1 parent 37b6f336a7
commit ff9807ad29
12 files changed
+103 -5

No files matched your search

@@ -167,6 +167,7 @@ class MainActivity : ComponentActivity() {
private var gameStoresEnabled by mutableStateOf(false)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
private var storesShowMature by mutableStateOf(false)
private var mic by mutableStateOf(false)
private var wifiDiscovery by mutableStateOf(false)
private var wifiDiscoveryPermission by mutableStateOf(false)
@@ -529,6 +530,7 @@ class MainActivity : ComponentActivity() {
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
storesShowMature = SessionPrefs.storesShowMature(this)
applyLauncherFullscreen()
updates.start()
setContent {
@@ -585,6 +587,7 @@ class MainActivity : ComponentActivity() {
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
storesShowMature = storesShowMature,
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
),
FrontEndActions(
@@ -716,6 +719,7 @@ class MainActivity : ComponentActivity() {
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) },
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show },
onLibraryChanged = { refreshAddedGames(); refresh() },
onAnimationsEnabled = { on ->
SessionPrefs.setAnimationsEnabled(this, on)
@@ -75,6 +75,13 @@ object SessionPrefs {
prefs(context).edit().putString("storesOpenTab", if (tab == STORES_OPEN_STORE) STORES_OPEN_STORE else STORES_OPEN_LIBRARY).apply()
}
/** Mature titles (the stores' own 17+/18 ratings or adult tags) in the storefront's shelves and search; off by default. Owned games always show. */
fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false)
fun setStoresShowMature(context: Context, show: Boolean) {
prefs(context).edit().putBoolean("storesShowMature", show).apply()
}
const val STORES_OPEN_LIBRARY = "library"
const val STORES_OPEN_STORE = "store"
@@ -34,6 +34,8 @@ data class CatalogItem(
val sizeBytes: Long = 0L,
/** Per-store identifiers the install needs (Epic namespace / catalog id / app name, Amazon entitlement / sku). */
val extra: Map<String, String> = emptyMap(),
/** Rated or tagged for adults by the store's own data; hidden from the storefront unless Show mature content is on. */
val mature: Boolean = false,
) {
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
/** Stable key across stores ("gog:1207658924"). */
@@ -52,6 +54,18 @@ class StoreShelves(
}
/** A game a store installed, as found on disk through its sidecar. */
/** Store tags that mark adult content, by slug or name, lowercased. */
private val MATURE_TAGS = setOf("mature", "nsfw", "adult", "adult only", "adults only", "nudity", "sexual content", "sexual-content")
/** Whether a store's own tags mark a title as adult. */
fun matureByTags(tags: Collection<String>): Boolean = tags.any { it.trim().lowercase() in MATURE_TAGS }
/**
* Whether a store's own age ratings mark a title as adult: ESRB M (17) or AO (18), PEGI / USK / the
* store's own rating 18, or any rating system's 17+. Ratings are ages, as GOG gives them.
*/
fun matureByAge(ages: Collection<Int>): Boolean = ages.any { it >= 17 }
/**
* The Installed tab's cards for [store], from the installs on disk - the same source as its count:
* each joined to its library item by store id (by title when the ids differ), else a card made
@@ -105,6 +105,9 @@ object EpicStoreCatalog {
val tagLine = buildList {
e.optJSONArray("tags")?.let { tags -> for (t in 0 until tags.length()) { val n = tags.optJSONObject(t)?.optString("name").orEmpty(); if (n.isNotBlank() && n != "Windows" && size < 3) add(n) } }
}.joinToString(", ")
// Epic's public store GraphQL has no age rating on an offer (no ageGatings/ratings field on
// CatalogOffer or StoreConfig); only a content tag, if Epic sets one, can mark it.
val allTags = buildList { e.optJSONArray("tags")?.let { t -> for (k in 0 until t.length()) t.optJSONObject(k)?.optString("name")?.let { add(it) } } }
val itemIds = buildList {
e.optJSONArray("items")?.let { items -> for (j in 0 until items.length()) items.optJSONObject(j)?.optString("id")?.takeIf { it.isNotBlank() }?.let { add(it) } }
}
@@ -116,6 +119,7 @@ object EpicStoreCatalog {
storeUrl = storeUrl, developer = e.optJSONObject("seller")?.optString("name", "").orEmpty(),
releaseDate = e.optString("releaseDate", e.optString("effectiveDate", "")), description = com.droiddeck.launcher.stores.cleanStoreText(e.optString("description", "")),
extra = buildMap { put("namespace", ns); if (itemIds.isNotEmpty()) put("items", itemIds.joinToString(",")); if (slug.isNotBlank()) put("slug", slug) },
mature = com.droiddeck.launcher.stores.matureByTags(allTags),
)
}
@@ -188,6 +192,7 @@ object EpicStoreCatalog {
put("id", i.id); put("title", i.title); put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: ""); put("tags", i.tags)
put("free", i.isFree); put("hasPrice", i.hasPrice); put("final", i.finalPrice); put("orig", i.originalPrice); put("disc", i.discountPercent)
put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate); put("desc", i.description); put("extra", JSONObject(i.extra as Map<*, *>))
if (i.mature) put("mature", true)
}
private fun itemFromJson(o: JSONObject): CatalogItem {
@@ -197,6 +202,7 @@ object EpicStoreCatalog {
store = Store.EPIC, id = o.optString("id"), title = o.optString("title"), imageUrl = o.optString("image").ifBlank { null }, tallImageUrl = o.optString("tall").ifBlank { null },
tags = o.optString("tags"), isFree = o.optBoolean("free"), hasPrice = o.optBoolean("hasPrice"), finalPrice = o.optString("final"), originalPrice = o.optString("orig"),
discountPercent = o.optInt("disc"), storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"), description = o.optString("desc"), extra = extra,
mature = o.optBoolean("mature"),
)
}
@@ -121,6 +121,7 @@ object GogStoreCatalog {
}.joinToString(", ")
val slug = p.optString("slug", "")
val storeLink = p.optString("storeLink", "").ifBlank { if (slug.isNotBlank()) "https://www.gog.com/en/game/$slug" else "" }
val mature = isMature(p)
return CatalogItem(
store = Store.GOG, id = id, title = title,
imageUrl = p.optString("coverHorizontal", "").ifBlank { null },
@@ -131,9 +132,23 @@ object GogStoreCatalog {
storeUrl = storeLink, developer = p.optJSONArray("developers")?.optString(0, "").orEmpty(),
releaseDate = p.optString("releaseDate", ""),
extra = buildMap { if (slug.isNotBlank()) put("slug", slug) },
mature = mature,
)
}
/**
* GOG's own data: `ratings[]` carries each system's age (`esrbRating` 17, `pegiRating` 18,
* `uskRating` 18, `gogRating` 18, ...) and `tags[]` its content tags (`mature`, `nsfw`,
* `sexual-content`, `nudity`). The catalog has no filter for either, so this is client-side.
*/
fun isMature(p: JSONObject): Boolean {
val ages = ArrayList<Int>()
p.optJSONArray("ratings")?.let { r -> for (i in 0 until r.length()) r.optJSONObject(i)?.optString("ageRating")?.toIntOrNull()?.let { ages.add(it) } }
val tags = ArrayList<String>()
p.optJSONArray("tags")?.let { t -> for (i in 0 until t.length()) t.optJSONObject(i)?.let { o -> tags.add(o.optString("slug")); tags.add(o.optString("name")) } }
return com.droiddeck.launcher.stores.matureByAge(ages) || com.droiddeck.launcher.stores.matureByTags(tags)
}
/** GOG returns protocol-relative `//images…` URLs in a few places. */
@JvmStatic
fun absolutize(url: String?): String {
@@ -158,6 +173,7 @@ object GogStoreCatalog {
put("id", i.id); put("title", i.title); put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
put("tags", i.tags); put("free", i.isFree); put("hasPrice", i.hasPrice); put("final", i.finalPrice); put("orig", i.originalPrice)
put("disc", i.discountPercent); put("url", i.storeUrl); put("dev", i.developer); put("rel", i.releaseDate)
if (i.mature) put("mature", true)
}
private fun itemFromJson(o: JSONObject): CatalogItem = CatalogItem(
@@ -166,6 +182,7 @@ object GogStoreCatalog {
tags = o.optString("tags"), isFree = o.optBoolean("free"), hasPrice = o.optBoolean("hasPrice"),
finalPrice = o.optString("final"), originalPrice = o.optString("orig"), discountPercent = o.optInt("disc"),
storeUrl = o.optString("url"), developer = o.optString("dev"), releaseDate = o.optString("rel"),
mature = o.optBoolean("mature"),
)
private fun listToJson(l: List<CatalogItem>) = JSONArray().apply { l.forEach { put(itemToJson(it)) } }
@@ -135,6 +135,7 @@ class FrontEndState(
val gameStoresSpeedTier: String = "fast",
/** Setup › Stores: the tab a signed-in store opens on (SessionPrefs.STORES_OPEN_LIBRARY / STORE). */
val storesOpenTab: String = SessionPrefs.STORES_OPEN_LIBRARY,
val storesShowMature: Boolean = false,
/** Downloads queued, running or paused across the three stores; the rail item's badge. */
val storeDownloadsActive: Int = 0,
/** The Updates page: DroidDeck's own builds and the channel followed. */
@@ -190,6 +191,7 @@ class FrontEndActions(
val onGameStoresEnabled: (Boolean) -> Unit = {},
val onGameStoresSpeedTier: (String) -> Unit = {},
val onStoresOpenTab: (String) -> Unit = {},
val onStoresShowMature: (Boolean) -> Unit = {},
/** The Games list rebuilt now: a store install or removal changed what is on disk. */
val onLibraryChanged: () -> Unit = {},
val onHomeApp: () -> Unit = {},
@@ -380,6 +380,7 @@ internal fun SetupPanel(
s.storesOpenTab,
) { a.onStoresOpenTab(it) }
}
ToggleRow(host, "stores-show-mature", stringResource(R.string.stores_show_mature), null, s.storesShowMature) { a.onStoresShowMature(it) }
SettingsRow(stringResource(R.string.setup_stores_speed), null) {
SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) }
}
@@ -245,6 +245,9 @@ internal fun StoresSettingsDialog(s: FrontEndState, a: FrontEndActions, onDismis
s.storesOpenTab,
) { a.onStoresOpenTab(it) }
}
SettingsRow(stringResource(R.string.stores_show_mature), null) {
ToggleSwitch(s.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) }
}
// The queue's two knobs as one compact row, the same control the Downloads page has.
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(s, a) }
}
@@ -285,6 +285,9 @@ private fun Storefront(
val installedKeys = remember(installedItems) { installedItems.map { it.id }.toSet() + StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id } }
val q = query.trim().lowercase()
fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q)
// The storefront leaves out what the store rates or tags as adult unless the user shows it; owned games always show.
fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.owned
fun shelf(l: List<CatalogItem>?) = l?.filter(::shown)
// Everything this store knows about: the library plus whatever the shelves brought, each title once.
val everything = remember(library, shelves) { (library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
Rise(1) {
@@ -327,12 +330,12 @@ private fun Storefront(
"library" -> Grid(library.filter(::matches), if (library.isEmpty() && StoresState.status[store] != null) stringResource(R.string.stores_library_loading) else stringResource(R.string.stores_nothing_matches), card)
"all" -> Grid(everything.filter(::matches), stringResource(R.string.stores_nothing_matches), card)
else -> {
if (q.isNotEmpty()) Grid(everything.filter(::matches), stringResource(R.string.stores_nothing_matches), card)
if (q.isNotEmpty()) Grid(everything.filter { matches(it) && shown(it) }, stringResource(R.string.stores_nothing_matches), card)
else {
Shelf(stringResource(R.string.stores_shelf_new), stringResource(R.string.stores_shelf_new_hint), shelves?.whatsNew, card)
Shelf(stringResource(R.string.stores_shelf_deals), stringResource(R.string.stores_shelf_deals_hint), shelves?.deals, card)
Shelf(stringResource(R.string.stores_shelf_free), stringResource(R.string.stores_shelf_free_hint), shelves?.free, card)
Shelf(stringResource(R.string.stores_shelf_trending), stringResource(R.string.stores_shelf_trending_hint), shelves?.trending, card)
Shelf(stringResource(R.string.stores_shelf_new), stringResource(R.string.stores_shelf_new_hint), shelf(shelves?.whatsNew), card)
Shelf(stringResource(R.string.stores_shelf_deals), stringResource(R.string.stores_shelf_deals_hint), shelf(shelves?.deals), card)
Shelf(stringResource(R.string.stores_shelf_free), stringResource(R.string.stores_shelf_free_hint), shelf(shelves?.free), card)
Shelf(stringResource(R.string.stores_shelf_trending), stringResource(R.string.stores_shelf_trending_hint), shelf(shelves?.trending), card)
Shelf(stringResource(R.string.stores_shelf_library), stringResource(R.string.stores_shelf_library_hint, store.label), library, card)
if (shelves == null && library.isEmpty()) Rise(4) { Note(if (StoresState.status[store] != null) stringResource(R.string.stores_library_loading) else stringResource(R.string.stores_shelves_loading)) }
else if (shelves != null && shelves.isEmpty && store == Store.AMAZON) Rise(4) { Note(stringResource(R.string.stores_amazon_no_catalog)) }
+1
View File
@@ -2016,6 +2016,7 @@
<string name="stores_in_library">In library</string>
<string name="stores_free">Free</string>
<string name="stores_resume">Resume</string>
<string name="stores_show_mature">Show mature content</string>
<string name="stores_eyebrow_installed">installed</string>
<string name="stores_eyebrow_library">in your library</string>
<string name="stores_eyebrow_store">store</string>
@@ -0,0 +1,31 @@
package com.droiddeck.launcher.stores
import com.droiddeck.launcher.stores.gog.GogStoreCatalog
import org.json.JSONObject
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Mature means what the store's own ratings or tags say; a title without them stays visible. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class MatureContentTest {
private fun gog(ratings: String, tags: String) = JSONObject("""{"id":"1","title":"T","ratings":[$ratings],"tags":[$tags]}""")
@Test fun gogRatingsAndTagsDecide() {
assertTrue(GogStoreCatalog.isMature(gog("""{"name":"esrbRating","ageRating":"17"}""", "")))
assertTrue(GogStoreCatalog.isMature(gog("""{"name":"gogRating","ageRating":"18"}""", "")))
assertTrue(GogStoreCatalog.isMature(gog("""{"name":"pegiRating","ageRating":"12"}""", """{"name":"NSFW","slug":"nsfw"}""")))
assertFalse(GogStoreCatalog.isMature(gog("""{"name":"esrbRating","ageRating":"13"},{"name":"uskRating","ageRating":"16"}""", """{"name":"Puzzle","slug":"puzzle"}""")))
// No rating data at all: visible.
assertFalse(GogStoreCatalog.isMature(JSONObject("""{"id":"2","title":"U"}""")))
}
@Test fun tagNamesFromAnyStore() {
assertTrue(matureByTags(listOf("Action", "Sexual Content")))
assertFalse(matureByTags(listOf("Action", "FPS", "RELAXING")))
}
}
+9
View File
@@ -144,6 +144,15 @@ without it and Setup says "Stores engine not built").
(`token`, `__token__`, `f_token`, `hdnts`, access/refresh/id tokens, `code=`) and Authorization /
Cookie headers are blanked outside URLs too. `StoreLogTest` covers a GOG secure link and an Epic
Akamai URL. The engine's own logcat lines (tag `EpicNative` etc.) are written on the native side.
- **Show mature content** (Setup › Stores and the cog; off by default): the storefront's shelves
and search leave out titles the store itself rates or tags as adult; Library, Installed and owned
games always show, and a title without rating data stays. GOG: `ratings[].ageRating` 17+ (ESRB M
17, PEGI / USK / GOG 18) or the tags `mature`, `nsfw`, `sexual-content`, `nudity` - the catalog
API has no server-side filter for either, so it is client-side. Epic: its public store GraphQL
carries no age rating on an offer (no age-gating or rating field on CatalogOffer or StoreConfig;
introspection is off), so only an adult content tag would mark one, and none of the probed
titles have one - Epic's storefront is effectively unfiltered. Amazon's Store tab is the user's
own games.
- **UI slow while downloading (device):** engines report per chunk or file, and each report
published a new queue that the whole front end read (the rail badge was computed from it in the
root composition). Now progress-only changes reach the UI at most four times a second (stage and