mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
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:
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)) }
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user