From cc0a4f5aa96551422909c2e163a663c239a91807 Mon Sep 17 00:00:00 2001 From: FX192 <120828496+FX192@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:49:05 +0300 Subject: [PATCH 1/5] itch.io: browse the Store tab by sort and price, with paging and a disk cache The Store tab only had four fixed shelves and a title search. A row of sort (Popular, New, Top rated, Top sellers) and price (Any, Free, On sale) chips now sets a BrowseQuery; anything but the defaults swaps the shelves for that query's games, loaded page by page as the grid scrolls. ItchBrowse builds itch's listing-feed URL (platform and tag segments are in the builder for the next change), tries each spelling of a price filter until one answers, spaces requests 1.2 s apart, backs off once on a 429, and keeps pages in the app cache for three hours. Every request is logged with its status and game count so a wrong URL guess can be fixed from a device. Owned games replace their browse copy so Install and the owned markers are right, and the detail page finds browse results. Strings for all 33 locales; URL-builder tests; docs/development/itch-browse.md. --- .../droiddeck/launcher/stores/StoresState.kt | 4 +- .../launcher/stores/itch/ItchBrowse.kt | 197 ++++++++++++++++++ .../launcher/stores/itch/ItchShelves.kt | 2 +- .../droiddeck/launcher/ui/ItchBrowseBar.kt | 107 ++++++++++ .../com/droiddeck/launcher/ui/StoresPage.kt | 25 ++- app/src/main/res/values-ar/strings.xml | 9 + app/src/main/res/values-b+es+419/strings.xml | 9 + app/src/main/res/values-bg/strings.xml | 9 + app/src/main/res/values-cs/strings.xml | 9 + app/src/main/res/values-da/strings.xml | 9 + app/src/main/res/values-de/strings.xml | 9 + app/src/main/res/values-el/strings.xml | 9 + app/src/main/res/values-es/strings.xml | 9 + app/src/main/res/values-fi/strings.xml | 9 + app/src/main/res/values-fr/strings.xml | 9 + app/src/main/res/values-hi/strings.xml | 9 + app/src/main/res/values-hu/strings.xml | 9 + app/src/main/res/values-in/strings.xml | 9 + app/src/main/res/values-it/strings.xml | 9 + app/src/main/res/values-ja/strings.xml | 9 + app/src/main/res/values-ko/strings.xml | 9 + app/src/main/res/values-ms/strings.xml | 9 + app/src/main/res/values-nb/strings.xml | 9 + app/src/main/res/values-nl/strings.xml | 9 + app/src/main/res/values-pl/strings.xml | 9 + app/src/main/res/values-pt-rPT/strings.xml | 9 + app/src/main/res/values-pt/strings.xml | 9 + app/src/main/res/values-ro/strings.xml | 9 + app/src/main/res/values-ru/strings.xml | 9 + app/src/main/res/values-sv/strings.xml | 9 + app/src/main/res/values-th/strings.xml | 9 + app/src/main/res/values-tr/strings.xml | 9 + app/src/main/res/values-uk/strings.xml | 9 + app/src/main/res/values-vi/strings.xml | 9 + app/src/main/res/values-zh-rCN/strings.xml | 9 + app/src/main/res/values-zh-rHK/strings.xml | 9 + app/src/main/res/values-zh-rTW/strings.xml | 9 + app/src/main/res/values/strings.xml | 9 + .../launcher/stores/itch/ItchBrowseTest.kt | 54 +++++ docs/development/itch-browse.md | 30 +++ 40 files changed, 708 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt create mode 100644 docs/development/itch-browse.md diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index 317757a8b..482619e6f 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -126,7 +126,9 @@ object StoresState { fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id } fun detailItem(store: Store, id: String): CatalogItem? = detailCatalogItem( - store, id, library[store].orEmpty(), found[store].orEmpty(), shelves[store], + store, id, library[store].orEmpty(), + found[store].orEmpty() + if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchBrowseState.items else emptyList(), + shelves[store], ) fun open(context: Context, store: Store, force: Boolean = false) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt new file mode 100644 index 000000000..ba0d2e88c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt @@ -0,0 +1,197 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.StoresState +import org.json.JSONArray +import java.io.File +import java.security.MessageDigest + +/** What the Store tab is showing: an order, a price filter and, later, platforms and tags. The default is the front page. */ +internal data class BrowseQuery( + val sort: String = ItchBrowse.SORT_POPULAR, + val price: String = ItchBrowse.PRICE_ANY, + val platforms: Set = emptySet(), + val tags: List = emptyList(), +) { + val isDefault: Boolean get() = this == BrowseQuery() +} + +internal class BrowsePage(val items: List, val code: Int, val cached: Boolean) + +internal object ItchBrowse { + const val SORT_POPULAR = "popular" + const val SORT_NEW = "new" + const val SORT_TOP_RATED = "top-rated" + const val SORT_TOP_SELLERS = "top-sellers" + val sorts = listOf(SORT_POPULAR, SORT_NEW, SORT_TOP_RATED, SORT_TOP_SELLERS) + + const val PRICE_ANY = "any" + const val PRICE_FREE = "free" + const val PRICE_SALE = "sale" + val prices = listOf(PRICE_ANY, PRICE_FREE, PRICE_SALE) + + private const val BASE = "https://itch.io/games" + private const val FRESH_MS = 3 * 60 * 60 * 1000L + private const val GAP_MS = 1_200L + private const val RETRY_MS = 8_000L + private const val FOLDER = "itch-browse" + + @Volatile private var lastRequestAt = 0L + private val lock = Any() + + private fun sortSegment(sort: String): String? = when (sort) { + SORT_NEW -> "newest" + SORT_TOP_RATED -> "top-rated" + SORT_TOP_SELLERS -> "top-sellers" + else -> null + } + + /** The ways itch spells a price filter; the first that answers is used, so one wrong guess does not empty the tab. */ + private fun priceSegments(price: String): List = when (price) { + PRICE_FREE -> listOf("free", "price-free") + PRICE_SALE -> listOf("on-sale") + else -> listOf(null) + } + + /** Every feed URL that could answer [query] for [page], most likely first. */ + fun urls(query: BrowseQuery, page: Int): List = priceSegments(query.price).map { price -> + val segments = listOfNotNull(sortSegment(query.sort), price) + + query.platforms.sorted().map { "platform-$it" } + + query.tags.map { "tag-" + tagSlug(it) } + val path = if (segments.isEmpty()) BASE else BASE + "/" + segments.joinToString("/") + path + ".xml" + if (page > 1) "?page=$page" else "" + } + + fun tagSlug(tag: String): String = tag.trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-') + + /** Page [page] of [query]: the disk copy when it is fresh, otherwise the feed, one request at a time and spaced out. */ + fun page(context: Context, query: BrowseQuery, page: Int, force: Boolean = false): BrowsePage { + val app = context.applicationContext + val candidates = urls(query, page) + if (!force) for (url in candidates) { + val stored = read(app, url) ?: continue + return BrowsePage(stored, 200, true) + } + var code = 0 + for (url in candidates) { + val (status, body) = fetch(url) + code = status + if (status !in 200..299 || body == null) continue + val items = ItchShelves.parse(body) + StoresState.logLine("itch.io browse: $url answered $status with ${items.size} games") + if (items.isEmpty()) continue + write(app, url, items) + return BrowsePage(items, status, false) + } + StoresState.logLine("itch.io browse: ${candidates.first()} gave nothing (last status $code)") + return BrowsePage(emptyList(), code, false) + } + + private fun fetch(url: String): Pair { + synchronized(lock) { + pause() + var answer = ItchShelves.open(url) + if (answer.first == 429) { + StoresState.logLine("itch.io browse: 429, waiting before one more try") + Thread.sleep(RETRY_MS) + pause() + answer = ItchShelves.open(url) + } + lastRequestAt = System.currentTimeMillis() + return answer + } + } + + private fun pause() { + val gap = GAP_MS - (System.currentTimeMillis() - lastRequestAt) + if (gap > 0) Thread.sleep(gap) + } + + private fun file(context: Context, url: String): File { + val digest = MessageDigest.getInstance("SHA-1").digest(url.toByteArray()).joinToString("") { "%02x".format(it) } + return File(File(context.cacheDir, FOLDER), "$digest.json") + } + + private fun read(context: Context, url: String): List? { + val file = file(context, url) + if (!file.isFile || System.currentTimeMillis() - file.lastModified() > FRESH_MS) return null + return runCatching { ItchCatalog.list(JSONArray(file.readText())) }.getOrNull()?.takeIf { it.isNotEmpty() } + } + + private fun write(context: Context, url: String, items: List) { + val file = file(context, url) + runCatching { + file.parentFile?.mkdirs() + file.writeText(ItchCatalog.array(items).toString()) + } + } +} + +/** The Store tab's result list: the query, the games loaded so far, and whether more can be asked for. */ +internal object ItchBrowseState { + var query by mutableStateOf(BrowseQuery()) + private set + var items by mutableStateOf>(emptyList()) + private set + var loading by mutableStateOf(false) + private set + var ended by mutableStateOf(false) + private set + var failed by mutableStateOf(false) + private set + + private var next = 1 + private var generation = 0 + + /** Whether the tab shows these results instead of the front page shelves. */ + val active: Boolean get() = !query.isDefault + + fun choose(context: Context, wanted: BrowseQuery) { + if (wanted == query) return + generation++ + query = wanted + items = emptyList() + ended = false + failed = false + loading = false + next = 1 + if (active) more(context) + } + + fun more(context: Context) { + if (loading || ended || !active) return + loading = true + failed = false + val app = context.applicationContext + val mine = generation + val asked = query + val page = next + Thread({ + val answer = runCatching { ItchBrowse.page(app, asked, page) }.getOrNull() + StoresState.post { + if (mine != generation) return@post + loading = false + if (answer == null) { + failed = items.isEmpty() + ended = items.isNotEmpty() + return@post + } + if (answer.items.isEmpty()) { + failed = items.isEmpty() && answer.code !in 200..299 + ended = true + return@post + } + val known = items.mapTo(HashSet()) { it.id } + val fresh = answer.items.filter { known.add(it.id) } + if (fresh.isEmpty()) ended = true else { + items = items + fresh + next = page + 1 + } + } + }, "itch-browse").start() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt index 0fd783ed6..acf332431 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt @@ -108,7 +108,7 @@ internal object ItchShelves { return shelves.takeUnless { it.isEmpty } } - private fun open(url: String): Pair { + internal fun open(url: String): Pair { var conn: HttpURLConnection? = null return try { conn = (URL(url).openConnection() as HttpURLConnection).apply { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt new file mode 100644 index 000000000..44852c4df --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt @@ -0,0 +1,107 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.itch.ItchBrowse +import com.droiddeck.launcher.stores.itch.ItchBrowseState + +/** Sort and price chips above the itch Store tab; picking anything but the defaults swaps the shelves for results. */ +@Composable +internal fun ItchBrowseBar() { + val ctx = LocalContext.current + val query = ItchBrowseState.query + Column(Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + ChipRow { + for (sort in ItchBrowse.sorts) BrowseChip(sortLabel(sort), query.sort == sort) { ItchBrowseState.choose(ctx, query.copy(sort = sort)) } + } + ChipRow { + for (price in ItchBrowse.prices) BrowseChip(priceLabel(price), query.price == price) { ItchBrowseState.choose(ctx, query.copy(price = price)) } + } + } +} + +/** The end of the result list: it asks for the next page as it scrolls into view. */ +@Composable +internal fun ItchLoadMore() { + val ctx = LocalContext.current + val count = ItchBrowseState.items.size + LaunchedEffect(count) { ItchBrowseState.more(ctx) } + if (ItchBrowseState.loading) LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 12.dp)) + else if (!ItchBrowseState.ended && count > 0) Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + BrowseChip(stringResource(R.string.stores_itch_load_more), false) { ItchBrowseState.more(ctx) } + } +} + +@Composable +private fun ChipRow(content: @Composable () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).selectableGroup(), + ) { content() } +} + +@Composable +private fun BrowseChip(label: String, selected: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = RoundedCornerShape(8.dp) + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Text( + label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.clip(shape).background(if (selected) pal.signal else colors.surfaceVariant) + .glideBorder(hot, shape, pal.signal) + .hoverable(src).selectable(selected = selected, interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 7.dp), + ) +} + +@Composable +private fun sortLabel(sort: String): String = stringResource( + when (sort) { + ItchBrowse.SORT_NEW -> R.string.stores_itch_sort_new + ItchBrowse.SORT_TOP_RATED -> R.string.stores_itch_sort_top_rated + ItchBrowse.SORT_TOP_SELLERS -> R.string.stores_itch_sort_top_sellers + else -> R.string.stores_itch_sort_popular + }, +) + +@Composable +private fun priceLabel(price: String): String = stringResource( + when (price) { + ItchBrowse.PRICE_FREE -> R.string.stores_itch_price_free + ItchBrowse.PRICE_SALE -> R.string.stores_itch_price_sale + else -> R.string.stores_itch_price_any + }, +) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 175cbecd8..3b70318ed 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -145,6 +145,7 @@ import com.droiddeck.launcher.R import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.itch.ItchBrowseState import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreTab import com.droiddeck.launcher.stores.StoresState @@ -954,7 +955,8 @@ private fun Storefront( val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } } LaunchedEffect(store, q) { StoresState.search(ctx, store, q) } val loaded = StoresState.library.containsKey(store) - val showGrid = tab != "store" || q.isNotEmpty() + val browsing = store == Store.ITCH && tab == "store" && q.isEmpty() && ItchBrowseState.active + val showGrid = tab != "store" || q.isNotEmpty() || browsing val sort = s.storesSort val lastPlayedFor = remember(s.steamGames) { val byKey = s.steamGames.associate { "${it.source}:${it.storeId}" to it.lastPlayed } @@ -967,8 +969,13 @@ private fun Storefront( lookup } val isSizeSort = tab == "library" && (sort == "sizeAsc" || sort == "sizeDesc") - val candidates = remember(library, everything, tab, q, s.storesShowMature) { - if (tab == "library") library.filter(::matches) else everything.filter { matches(it) && shown(it) } + val browseItems = if (browsing) ItchBrowseState.items else emptyList() + val candidates = remember(library, everything, tab, q, s.storesShowMature, browsing, browseItems) { + if (tab == "library") library.filter(::matches) + else if (browsing) { + val owned = library.associateBy { it.id } + browseItems.map { owned[it.id] ?: it }.filter(::shown) + } else everything.filter { matches(it) && shown(it) } } val candidateKeys = remember(candidates) { candidates.map { it.key } } var resolvedSizeSortKey by remember(store, tab) { mutableStateOf?>(null) } @@ -1006,6 +1013,8 @@ private fun Storefront( } val empty = stringResource(when { tab == "library" && library.isEmpty() && StoresState.status[store] != null -> R.string.stores_library_loading + browsing && ItchBrowseState.loading -> R.string.stores_shelves_loading + browsing && ItchBrowseState.failed -> R.string.stores_itch_browse_failed else -> R.string.stores_nothing_matches }) var firstPlaced = false @@ -1016,8 +1025,9 @@ private fun Storefront( // A bar only where the tab has nothing else on it: shelves, the shelf of owned games, or a grid still loading. val gridEmpty = if (showGrid) visible.isEmpty() else (shelves == null || shelves.isEmpty) && library.isEmpty() val barLine = StoresState.status[store]?.takeIf { gridEmpty && syncCount(it) != null } + val itchBar = store == Store.ITCH && tab == "store" && q.isEmpty() val headerItems = 1 + (if (sizeSortPending) 1 else 0) + (if (barLine != null) 1 else 0) + - (if (StoresState.problems[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0) + (if (itchBar) 1 else 0) val listMode = s.storesView == "list" && tab == "library" val capsules = s.storesView == "capsules" && tab == "library" val tileWidth = if (capsules) CapsuleWidth else CardWidth @@ -1102,6 +1112,7 @@ private fun Storefront( } barLine?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } } StoresState.problems[store]?.let { line -> item(key = "problem", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(line) } } } } + if (itchBar) item(key = "itch-browse-bar", span = { GridItemSpan(maxLineSpan) }) { ItchBrowseBar() } when (tab) { "library" -> if (!loaded) Unit else if (split == 0) storeGrid(visible, empty, card) @@ -1112,8 +1123,10 @@ private fun Storefront( storeGrid(visible.drop(split), empty, card) } else -> { - if (q.isNotEmpty()) storeGrid(visible, empty, card) - else { + if (q.isNotEmpty() || browsing) { + storeGrid(visible, empty, card) + if (browsing) item(key = "itch-more", span = { GridItemSpan(maxLineSpan) }) { ItchLoadMore() } + } else { item(key = "shelves", span = { GridItemSpan(maxLineSpan) }) { Column { Shelf(stringResource(R.string.stores_shelf_new), shelf(shelves?.whatsNew), card) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 932827b75..349249890 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2088,6 +2088,15 @@ لم يتم العثور على لعبة Windows في التنزيل تعذر استخراج التنزيل (رمز الخروج %1$d) لم يستجب itch.io + الأكثر شعبية + الجديد + الأعلى تقييمًا + الأكثر مبيعًا + أي سعر + مجاني + مخفّض + تحميل المزيد + تعذّر تحميل الألعاب من itch.io أرشيفات %1$s غير مدعومة استجاب itch.io بـ HTTP %1$d مجاني diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 411747242..e2aec713e 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2771,6 +2771,15 @@ No se encontró un juego de Windows en la descarga No se pudo extraer la descarga (salida %1$d) itch.io no respondió + Populares + Nuevos + Mejor valorados + Más vendidos + Cualquier precio + Gratis + En oferta + Cargar más + No se pudieron cargar los juegos de itch.io Los archivos %1$s no son compatibles itch.io respondió HTTP %1$d Cámaras Luma Ultra — Experimental diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 6eab6e3e5..6dd5993d7 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -2004,6 +2004,15 @@ В изтеглянето не е намерена игра за Windows Изтеглянето не можа да бъде разархивирано (код %1$d) itch.io не отговори + Популярни + Нови + Най-високо оценени + Най-продавани + Всякаква цена + Безплатни + С намаление + Зареди още + Игрите от itch.io не можаха да се заредят Архивите %1$s не се поддържат itch.io отговори HTTP %1$d Безплатна diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ae2a28953..44a0d027d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2046,6 +2046,15 @@ Ve stahování nebyla nalezena hra pro Windows Stažený soubor nelze rozbalit (kód %1$d) itch.io neodpovědělo + Populární + Nové + Nejlépe hodnocené + Nejprodávanější + Jakákoli cena + Zdarma + Ve slevě + Načíst další + Hry z itch.io se nepodařilo načíst Archivy %1$s nejsou podporovány itch.io odpovědělo HTTP %1$d Zdarma diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 98d0bca46..a3b601a3d 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2004,6 +2004,15 @@ Der blev ikke fundet et Windows-spil i downloaden Downloaden kunne ikke udpakkes (kode %1$d) itch.io svarede ikke + Populære + Nye + Bedst bedømte + Mest solgte + Alle priser + Gratis + På tilbud + Indlæs flere + Kunne ikke hente spil fra itch.io %1$s-arkiver understøttes ikke itch.io svarede HTTP %1$d Gratis diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1b0ae75cd..3b8c8162d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1996,6 +1996,15 @@ Im Download wurde kein Windows-Spiel gefunden Download konnte nicht entpackt werden (Code %1$d) itch.io hat nicht geantwortet + Beliebt + Neu + Bestbewertet + Meistverkauft + Beliebiger Preis + Kostenlos + Im Angebot + Mehr laden + Spiele von itch.io konnten nicht geladen werden %1$s-Archive werden nicht unterstützt itch.io antwortete mit HTTP %1$d Kostenlos diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 709eb2244..ae4e0063d 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -2004,6 +2004,15 @@ Δεν βρέθηκε παιχνίδι Windows στη λήψη Δεν ήταν δυνατή η αποσυμπίεση της λήψης (κωδικός %1$d) Το itch.io δεν απάντησε + Δημοφιλή + Νέα + Κορυφαία βαθμολογία + Μπεστ σέλερ + Οποιαδήποτε τιμή + Δωρεάν + Σε προσφορά + Φόρτωση περισσότερων + Δεν ήταν δυνατή η φόρτωση παιχνιδιών από το itch.io Τα αρχεία %1$s δεν υποστηρίζονται Το itch.io απάντησε HTTP %1$d Δωρεάν diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 38019dade..bc25285ea 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2027,6 +2027,15 @@ No se ha encontrado un juego de Windows en la descarga No se ha podido extraer la descarga (código %1$d) itch.io no respondió + Populares + Novedades + Mejor valorados + Más vendidos + Cualquier precio + Gratis + En oferta + Cargar más + No se pudieron cargar los juegos de itch.io Los archivos %1$s no son compatibles itch.io respondió HTTP %1$d Gratis diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 6aafc85b6..155521246 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -2004,6 +2004,15 @@ Latauksesta ei löytynyt Windows-peliä Latausta ei voitu purkaa (koodi %1$d) itch.io ei vastannut + Suositut + Uudet + Parhaiten arvioidut + Myydyimmät + Mikä tahansa hinta + Ilmaiset + Alennuksessa + Lataa lisää + Pelien lataaminen itch.io:sta epäonnistui %1$s-arkistot eivät ole tuettuja itch.io vastasi HTTP %1$d Ilmainen diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f345150b6..5f4354c2e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2027,6 +2027,15 @@ Aucun jeu Windows trouvé dans le téléchargement Impossible d\'extraire le téléchargement (code %1$d) itch.io n\'a pas répondu + Populaires + Nouveautés + Les mieux notés + Meilleures ventes + Tous les prix + Gratuits + En promotion + Charger plus + Impossible de charger les jeux d\'itch.io Les archives %1$s ne sont pas prises en charge itch.io a répondu HTTP %1$d Gratuit diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index d74e5c0d6..e0dc7e02c 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -2004,6 +2004,15 @@ डाउनलोड में कोई Windows गेम नहीं मिला डाउनलोड एक्सट्रैक्ट नहीं हो सका (कोड %1$d) itch.io ने जवाब नहीं दिया + लोकप्रिय + नए + सबसे ऊँची रेटिंग + सबसे ज़्यादा बिकने वाले + कोई भी कीमत + मुफ़्त + सेल में + और लोड करें + itch.io से गेम लोड नहीं हो सके %1$s आर्काइव समर्थित नहीं हैं itch.io ने HTTP %1$d लौटाया फ़्री diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index e8571e492..59c1587ff 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -2004,6 +2004,15 @@ A letöltésben nem található Windows-játék A letöltés nem bontható ki (kód: %1$d) Az itch.io nem válaszolt + Népszerű + Új + Legjobbra értékelt + Legkelendőbb + Bármilyen ár + Ingyenes + Akciós + Továbbiak betöltése + Nem sikerült betölteni a játékokat az itch.io-ról A(z) %1$s archívumok nem támogatottak Az itch.io HTTP %1$d választ adott Ingyenes diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 80927811f..73e4d0758 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1983,6 +1983,15 @@ Tidak ada game Windows di unduhan Tidak dapat mengekstrak unduhan (kode %1$d) itch.io tidak menjawab + Populer + Baru + Rating tertinggi + Terlaris + Harga apa saja + Gratis + Diskon + Muat lebih banyak + Tidak dapat memuat game dari itch.io Arsip %1$s tidak didukung itch.io menjawab HTTP %1$d Gratis diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e5aa836ad..d7d25d20e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2017,6 +2017,15 @@ Nessun gioco Windows trovato nel download Impossibile estrarre il download (codice %1$d) itch.io non ha risposto + Popolari + Novità + I più votati + I più venduti + Qualsiasi prezzo + Gratis + In offerta + Carica altri + Impossibile caricare i giochi da itch.io Gli archivi %1$s non sono supportati itch.io ha risposto HTTP %1$d Gratis diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 03e8ae200..4c71bac1b 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1985,6 +1985,15 @@ ダウンロードに Windows 版のゲームが見つかりません ダウンロードを展開できませんでした (終了コード %1$d) itch.io から応答がありません + 人気 + 新着 + 高評価 + 売れ筋 + すべての価格 + 無料 + セール中 + さらに読み込む + itch.io のゲームを読み込めませんでした %1$s 形式のアーカイブには対応していません itch.io が HTTP %1$d を返しました 無料 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 5096684e2..ef455c263 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1985,6 +1985,15 @@ 다운로드에서 Windows 게임을 찾을 수 없습니다 다운로드한 파일의 압축을 풀 수 없습니다(코드 %1$d) itch.io가 응답하지 않았습니다 + 인기 + 신규 + 평점 높은 순 + 베스트셀러 + 모든 가격 + 무료 + 할인 중 + 더 불러오기 + itch.io에서 게임을 불러오지 못했습니다 %1$s 압축 파일은 지원하지 않습니다 itch.io가 HTTP %1$d을(를) 반환했습니다 무료 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 82f9e7b2c..f8ce63780 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1983,6 +1983,15 @@ Tiada permainan Windows ditemui dalam muat turun Tidak dapat mengekstrak muat turun (kod %1$d) itch.io tidak menjawab + Popular + Baharu + Penilaian tertinggi + Terlaris + Sebarang harga + Percuma + Diskaun + Muat lagi + Tidak dapat memuatkan permainan daripada itch.io Arkib %1$s tidak disokong itch.io menjawab HTTP %1$d Percuma diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index af48f21a9..302f38673 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -2004,6 +2004,15 @@ Fant ikke noe Windows-spill i nedlastingen Kunne ikke pakke ut nedlastingen (kode %1$d) itch.io svarte ikke + Populære + Nye + Best vurdert + Mest solgt + Alle priser + Gratis + På salg + Last inn flere + Kunne ikke laste spill fra itch.io %1$s-arkiver støttes ikke itch.io svarte HTTP %1$d Gratis diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d591de6dd..1b294f91a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -2004,6 +2004,15 @@ Geen Windows-game gevonden in de download Download kan niet worden uitgepakt (code %1$d) itch.io gaf geen antwoord + Populair + Nieuw + Hoogst beoordeeld + Bestverkocht + Elke prijs + Gratis + In de aanbieding + Meer laden + Games van itch.io konden niet worden geladen %1$s-archieven worden niet ondersteund itch.io antwoordde HTTP %1$d Gratis diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index b1cdccf7b..8b1057f1d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2046,6 +2046,15 @@ W pobranych plikach nie znaleziono gry na Windows Nie można rozpakować pobranego pliku (kod %1$d) itch.io nie odpowiedziało + Popularne + Nowe + Najwyżej oceniane + Najlepiej sprzedające się + Dowolna cena + Za darmo + Na wyprzedaży + Wczytaj więcej + Nie udało się wczytać gier z itch.io Archiwa %1$s nie są obsługiwane itch.io odpowiedziało HTTP %1$d Bezpłatna diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 0d5d739f3..d6cacfcb6 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -2017,6 +2017,15 @@ Não foi encontrado nenhum jogo para Windows na transferência Não foi possível extrair a transferência (código %1$d) O itch.io não respondeu + Populares + Novos + Mais bem avaliados + Mais vendidos + Qualquer preço + Grátis + Em promoção + Carregar mais + Não foi possível carregar os jogos do itch.io Ficheiros %1$s não são suportados O itch.io respondeu HTTP %1$d Grátis diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3e5cd06e7..6db4712e6 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2017,6 +2017,15 @@ Nenhum jogo para Windows encontrado no download Não foi possível extrair o download (código %1$d) O itch.io não respondeu + Populares + Novos + Mais bem avaliados + Mais vendidos + Qualquer preço + Grátis + Em promoção + Carregar mais + Não foi possível carregar os jogos do itch.io Arquivos %1$s não são suportados O itch.io respondeu HTTP %1$d Grátis diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 60f025f87..6a818b906 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2025,6 +2025,15 @@ Nu s-a găsit niciun joc Windows în descărcare Descărcarea nu a putut fi extrasă (cod %1$d) itch.io nu a răspuns + Populare + Noi + Cele mai bine cotate + Cele mai vândute + Orice preț + Gratuite + La reducere + Încarcă mai multe + Nu s-au putut încărca jocurile de pe itch.io Arhivele %1$s nu sunt acceptate itch.io a răspuns HTTP %1$d Gratuit diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 39582774c..baedc0add 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2048,6 +2048,15 @@ В загрузке не найдена игра для Windows Не удалось распаковать загрузку (код %1$d) itch.io не ответил + Популярные + Новые + С лучшими оценками + Бестселлеры + Любая цена + Бесплатные + Со скидкой + Загрузить ещё + Не удалось загрузить игры с itch.io Архивы %1$s не поддерживаются itch.io ответил HTTP %1$d Бесплатно diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index d245c20a0..40e8f3001 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -2004,6 +2004,15 @@ Inget Windows-spel hittades i nedladdningen Det gick inte att packa upp nedladdningen (kod %1$d) itch.io svarade inte + Populära + Nya + Högst betyg + Mest sålda + Alla priser + Gratis + På rea + Ladda fler + Det gick inte att läsa in spel från itch.io %1$s-arkiv stöds inte itch.io svarade HTTP %1$d Gratis diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index ae74b6e9f..0bbbc29f9 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1983,6 +1983,15 @@ ไม่พบเกม Windows ในไฟล์ที่ดาวน์โหลด แตกไฟล์ที่ดาวน์โหลดไม่ได้ (รหัส %1$d) itch.io ไม่ตอบสนอง + ยอดนิยม + ใหม่ + คะแนนสูงสุด + ขายดี + ทุกราคา + ฟรี + ลดราคา + โหลดเพิ่มเติม + โหลดเกมจาก itch.io ไม่ได้ ไม่รองรับไฟล์ %1$s itch.io ตอบกลับ HTTP %1$d ฟรี diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 146f57d80..1cee39be2 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1998,6 +1998,15 @@ İndirmede Windows oyunu bulunamadı İndirme ayıklanamadı (kod %1$d) itch.io yanıt vermedi + Popüler + Yeni + En yüksek puanlı + En çok satanlar + Herhangi bir fiyat + Ücretsiz + İndirimde + Daha fazla yükle + itch.io oyunları yüklenemedi %1$s arşivleri desteklenmiyor itch.io HTTP %1$d yanıtı verdi Ücretsiz diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 5b8bfd4c4..2bb20ce20 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2046,6 +2046,15 @@ У завантаженні не знайдено гри для Windows Не вдалося розпакувати завантаження (код %1$d) itch.io не відповів + Популярні + Нові + З найвищими оцінками + Бестселери + Будь-яка ціна + Безкоштовні + Зі знижкою + Завантажити ще + Не вдалося завантажити ігри з itch.io Архіви %1$s не підтримуються itch.io відповів HTTP %1$d Безкоштовно diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 3effbb2a0..44cec9e4d 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1983,6 +1983,15 @@ Không tìm thấy trò chơi Windows trong bản tải xuống Không thể giải nén bản tải xuống (mã %1$d) itch.io không phản hồi + Phổ biến + Mới + Đánh giá cao nhất + Bán chạy nhất + Mọi mức giá + Miễn phí + Đang giảm giá + Tải thêm + Không tải được game từ itch.io Không hỗ trợ tệp %1$s itch.io trả về HTTP %1$d Miễn phí diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d7c20634c..3e526c9bd 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2062,6 +2062,15 @@ 下载内容中未找到 Windows 游戏 无法解压下载内容(代码 %1$d) itch.io 没有响应 + 热门 + 最新 + 评分最高 + 热销 + 任意价格 + 免费 + 特价 + 加载更多 + 无法从 itch.io 加载游戏 不支持 %1$s 压缩包 itch.io 返回 HTTP %1$d 免费 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index e021df01e..9762b520f 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1986,6 +1986,15 @@ 下載內容中找不到 Windows 遊戲 無法解壓下載內容(代碼 %1$d) itch.io 沒有回應 + 熱門 + 最新 + 評分最高 + 熱賣 + 任何價格 + 免費 + 特價 + 載入更多 + 無法從 itch.io 載入遊戲 不支援 %1$s 壓縮檔 itch.io 回應 HTTP %1$d 免費 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 44e41ea6a..c5535bea7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1986,6 +1986,15 @@ 下載內容中找不到 Windows 遊戲 無法解壓縮下載內容(代碼 %1$d) itch.io 沒有回應 + 熱門 + 最新 + 評分最高 + 熱銷 + 任何價格 + 免費 + 特價 + 載入更多 + 無法從 itch.io 載入遊戲 不支援 %1$s 壓縮檔 itch.io 回應 HTTP %1$d 免費 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bbe2d6fc3..3c6a8f85d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2081,6 +2081,15 @@ No Windows game found in the download Could not extract the download (exit %1$d) itch.io did not answer + Popular + New + Top rated + Top sellers + Any price + Free + On sale + Load more + Could not load games from itch.io %1$s archives are not supported itch.io answered HTTP %1$d Free diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt new file mode 100644 index 000000000..c8df0bbbe --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt @@ -0,0 +1,54 @@ +package com.droiddeck.launcher.stores.itch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ItchBrowseTest { + @Test fun theDefaultQueryIsTheFrontPage() { + assertTrue(BrowseQuery().isDefault) + assertFalse(BrowseQuery(sort = ItchBrowse.SORT_NEW).isDefault) + assertFalse(BrowseQuery(price = ItchBrowse.PRICE_FREE).isDefault) + } + + @Test fun sortsMapToTheirFeedPaths() { + assertEquals(listOf("https://itch.io/games.xml"), ItchBrowse.urls(BrowseQuery(), 1)) + assertEquals(listOf("https://itch.io/games/newest.xml"), ItchBrowse.urls(BrowseQuery(sort = ItchBrowse.SORT_NEW), 1)) + assertEquals(listOf("https://itch.io/games/top-rated.xml"), ItchBrowse.urls(BrowseQuery(sort = ItchBrowse.SORT_TOP_RATED), 1)) + assertEquals(listOf("https://itch.io/games/top-sellers.xml"), ItchBrowse.urls(BrowseQuery(sort = ItchBrowse.SORT_TOP_SELLERS), 1)) + } + + @Test fun pagesAfterTheFirstCarryThePageNumber() { + assertEquals(listOf("https://itch.io/games/newest.xml?page=3"), ItchBrowse.urls(BrowseQuery(sort = ItchBrowse.SORT_NEW), 3)) + } + + @Test fun aPriceFilterOffersEverySpellingInOrder() { + val free = ItchBrowse.urls(BrowseQuery(sort = ItchBrowse.SORT_TOP_RATED, price = ItchBrowse.PRICE_FREE), 1) + assertEquals(listOf("https://itch.io/games/top-rated/free.xml", "https://itch.io/games/top-rated/price-free.xml"), free) + assertEquals(listOf("https://itch.io/games/on-sale.xml"), ItchBrowse.urls(BrowseQuery(price = ItchBrowse.PRICE_SALE), 1)) + } + + @Test fun platformsAndTagsFollowTheSortAndPriceInAFixedOrder() { + val query = BrowseQuery( + sort = ItchBrowse.SORT_NEW, price = ItchBrowse.PRICE_SALE, + platforms = setOf("web", "linux"), tags = listOf("Pixel Art", "Horror"), + ) + assertEquals( + listOf("https://itch.io/games/newest/on-sale/platform-linux/platform-web/tag-pixel-art/tag-horror.xml"), + ItchBrowse.urls(query, 1), + ) + } + + @Test fun tagsBecomeSlugs() { + assertEquals("pixel-art", ItchBrowse.tagSlug(" Pixel Art ")) + assertEquals("2d", ItchBrowse.tagSlug("2D")) + assertEquals("sci-fi", ItchBrowse.tagSlug("Sci-Fi!")) + } + + @Test fun theSamePlatformsInAnyOrderGiveTheSameUrl() { + val a = ItchBrowse.urls(BrowseQuery(platforms = setOf("web", "linux")), 1) + val b = ItchBrowse.urls(BrowseQuery(platforms = setOf("linux", "web")), 1) + assertEquals(a, b) + } +} diff --git a/docs/development/itch-browse.md b/docs/development/itch-browse.md new file mode 100644 index 000000000..de5929afc --- /dev/null +++ b/docs/development/itch-browse.md @@ -0,0 +1,30 @@ +# itch.io browsing + +The itch Store tab opens on the front page shelves. A row of chips above them (`ui/ItchBrowseBar.kt`) +sets an order (Popular, New, Top rated, Top sellers) and a price (Any, Free, On sale). Leave both at +their defaults and the shelves stay; pick anything else and the tab shows that query's games as a grid +that loads the next page as it scrolls into view. + +## Data + +`ItchBrowse` (`stores/itch/ItchBrowse.kt`) turns a `BrowseQuery` into itch's listing feed: + +``` +https://itch.io/games[/][/][/platform-...][/tag-...].xml[?page=N] +``` + +`BrowseQuery` already carries platforms and tags, so the URL builder is complete; the controls for them +come in a later change. A price filter has more than one spelling on itch, so `urls()` returns every +candidate and `page()` takes the first that answers with games. Every request is logged +(`itch.io browse: answered with games`), which is how a wrong guess is found and fixed +from a device. + +Requests go out one at a time, at least 1.2 s apart. A 429 waits 8 s and tries once more; if that fails +the games already on screen stay. Each page is kept under the app cache for three hours, so going back to +a query does not ask itch again. Results are parsed by `ItchShelves.parse`, the same reader the shelves +use, so a game looks the same wherever it appears. + +`ItchBrowseState` holds the query, the games loaded so far and whether more can be asked for. A newer +query bumps a generation counter, so a slow answer for an older one is dropped. A game already in the +library replaces its browse copy on the grid, so Install and the owned markers are right; the detail page +looks browse results up through `StoresState.detailItem`. From dbcbf0b6fa17ed2042dc8c32902a59edcc1ed971 Mon Sep 17 00:00:00 2001 From: FX192 <120828496+FX192@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:27:49 +0300 Subject: [PATCH 2/5] itch.io browse: use itch's own sort, price and platform paths The sort and price chips now cover everything itch's browse page offers, with the paths copied from its links: new-and-popular, top-sellers, top-rated and newest for sorts; free, on-sale, store, 5-dollars-or-less and 15-dollars-or-less for prices; platform-windows, -osx, -linux, -android, -ios, -web and -mobile-web. The guessed alternative spelling of the free filter is gone, so a query is one address. The tests assert the addresses itch itself uses. New and reworded strings in all 33 locales. --- .../launcher/stores/itch/ItchBrowse.kt | 69 ++++++++----------- .../droiddeck/launcher/ui/ItchBrowseBar.kt | 8 ++- app/src/main/res/values-ar/strings.xml | 6 +- app/src/main/res/values-b+es+419/strings.xml | 6 +- app/src/main/res/values-bg/strings.xml | 6 +- app/src/main/res/values-cs/strings.xml | 6 +- app/src/main/res/values-da/strings.xml | 6 +- app/src/main/res/values-de/strings.xml | 6 +- app/src/main/res/values-el/strings.xml | 6 +- app/src/main/res/values-es/strings.xml | 6 +- app/src/main/res/values-fi/strings.xml | 6 +- app/src/main/res/values-fr/strings.xml | 6 +- app/src/main/res/values-hi/strings.xml | 6 +- app/src/main/res/values-hu/strings.xml | 6 +- app/src/main/res/values-in/strings.xml | 6 +- app/src/main/res/values-it/strings.xml | 6 +- app/src/main/res/values-ja/strings.xml | 6 +- app/src/main/res/values-ko/strings.xml | 6 +- app/src/main/res/values-ms/strings.xml | 6 +- app/src/main/res/values-nb/strings.xml | 6 +- app/src/main/res/values-nl/strings.xml | 6 +- app/src/main/res/values-pl/strings.xml | 6 +- app/src/main/res/values-pt-rPT/strings.xml | 6 +- app/src/main/res/values-pt/strings.xml | 6 +- app/src/main/res/values-ro/strings.xml | 6 +- app/src/main/res/values-ru/strings.xml | 6 +- app/src/main/res/values-sv/strings.xml | 6 +- app/src/main/res/values-th/strings.xml | 6 +- app/src/main/res/values-tr/strings.xml | 6 +- app/src/main/res/values-uk/strings.xml | 6 +- app/src/main/res/values-vi/strings.xml | 6 +- app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values-zh-rHK/strings.xml | 6 +- app/src/main/res/values-zh-rTW/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- .../launcher/stores/itch/ItchBrowseTest.kt | 65 +++++++++-------- docs/development/itch-browse.md | 10 +-- 37 files changed, 244 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt index ba0d2e88c..d64855f50 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt @@ -24,15 +24,22 @@ internal class BrowsePage(val items: List, val code: Int, val cache internal object ItchBrowse { const val SORT_POPULAR = "popular" - const val SORT_NEW = "new" - const val SORT_TOP_RATED = "top-rated" + const val SORT_NEW_POPULAR = "new-and-popular" const val SORT_TOP_SELLERS = "top-sellers" - val sorts = listOf(SORT_POPULAR, SORT_NEW, SORT_TOP_RATED, SORT_TOP_SELLERS) + const val SORT_TOP_RATED = "top-rated" + const val SORT_NEWEST = "newest" + val sorts = listOf(SORT_POPULAR, SORT_NEW_POPULAR, SORT_TOP_SELLERS, SORT_TOP_RATED, SORT_NEWEST) const val PRICE_ANY = "any" const val PRICE_FREE = "free" - const val PRICE_SALE = "sale" - val prices = listOf(PRICE_ANY, PRICE_FREE, PRICE_SALE) + const val PRICE_SALE = "on-sale" + const val PRICE_PAID = "store" + const val PRICE_UNDER_5 = "5-dollars-or-less" + const val PRICE_UNDER_15 = "15-dollars-or-less" + val prices = listOf(PRICE_ANY, PRICE_FREE, PRICE_SALE, PRICE_PAID, PRICE_UNDER_5, PRICE_UNDER_15) + + /** Platform names as itch spells them in `platform-`. */ + val platformNames = listOf("windows", "osx", "linux", "android", "ios", "web", "mobile-web") private const val BASE = "https://itch.io/games" private const val FRESH_MS = 3 * 60 * 60 * 1000L @@ -43,52 +50,34 @@ internal object ItchBrowse { @Volatile private var lastRequestAt = 0L private val lock = Any() - private fun sortSegment(sort: String): String? = when (sort) { - SORT_NEW -> "newest" - SORT_TOP_RATED -> "top-rated" - SORT_TOP_SELLERS -> "top-sellers" - else -> null - } + /** The path piece itch uses for each choice; the default sort and price have none. */ + private fun sortSegment(sort: String): String? = sort.takeIf { it != SORT_POPULAR && it in sorts } - /** The ways itch spells a price filter; the first that answers is used, so one wrong guess does not empty the tab. */ - private fun priceSegments(price: String): List = when (price) { - PRICE_FREE -> listOf("free", "price-free") - PRICE_SALE -> listOf("on-sale") - else -> listOf(null) - } + private fun priceSegment(price: String): String? = price.takeIf { it != PRICE_ANY && it in prices } - /** Every feed URL that could answer [query] for [page], most likely first. */ - fun urls(query: BrowseQuery, page: Int): List = priceSegments(query.price).map { price -> - val segments = listOfNotNull(sortSegment(query.sort), price) + + /** The address of [query]'s page on itch: sort, price, platforms, then tags, as itch's own links spell them. */ + fun path(query: BrowseQuery): String { + val segments = listOfNotNull(sortSegment(query.sort), priceSegment(query.price)) + query.platforms.sorted().map { "platform-$it" } + query.tags.map { "tag-" + tagSlug(it) } - val path = if (segments.isEmpty()) BASE else BASE + "/" + segments.joinToString("/") - path + ".xml" + if (page > 1) "?page=$page" else "" + return if (segments.isEmpty()) BASE else BASE + "/" + segments.joinToString("/") } + /** The listing feed for [query]'s [page]. */ + fun url(query: BrowseQuery, page: Int): String = path(query) + ".xml" + if (page > 1) "?page=$page" else "" + fun tagSlug(tag: String): String = tag.trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-') /** Page [page] of [query]: the disk copy when it is fresh, otherwise the feed, one request at a time and spaced out. */ fun page(context: Context, query: BrowseQuery, page: Int, force: Boolean = false): BrowsePage { val app = context.applicationContext - val candidates = urls(query, page) - if (!force) for (url in candidates) { - val stored = read(app, url) ?: continue - return BrowsePage(stored, 200, true) - } - var code = 0 - for (url in candidates) { - val (status, body) = fetch(url) - code = status - if (status !in 200..299 || body == null) continue - val items = ItchShelves.parse(body) - StoresState.logLine("itch.io browse: $url answered $status with ${items.size} games") - if (items.isEmpty()) continue - write(app, url, items) - return BrowsePage(items, status, false) - } - StoresState.logLine("itch.io browse: ${candidates.first()} gave nothing (last status $code)") - return BrowsePage(emptyList(), code, false) + val url = url(query, page) + if (!force) read(app, url)?.let { return BrowsePage(it, 200, true) } + val (status, body) = fetch(url) + val items = if (status in 200..299 && body != null) ItchShelves.parse(body) else emptyList() + StoresState.logLine("itch.io browse: $url answered $status with ${items.size} games") + if (items.isNotEmpty()) write(app, url, items) + return BrowsePage(items, status, false) } private fun fetch(url: String): Pair { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt index 44852c4df..896d1b37f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt @@ -90,9 +90,10 @@ private fun BrowseChip(label: String, selected: Boolean, onClick: () -> Unit) { @Composable private fun sortLabel(sort: String): String = stringResource( when (sort) { - ItchBrowse.SORT_NEW -> R.string.stores_itch_sort_new - ItchBrowse.SORT_TOP_RATED -> R.string.stores_itch_sort_top_rated + ItchBrowse.SORT_NEW_POPULAR -> R.string.stores_itch_sort_new_popular ItchBrowse.SORT_TOP_SELLERS -> R.string.stores_itch_sort_top_sellers + ItchBrowse.SORT_TOP_RATED -> R.string.stores_itch_sort_top_rated + ItchBrowse.SORT_NEWEST -> R.string.stores_itch_sort_new else -> R.string.stores_itch_sort_popular }, ) @@ -102,6 +103,9 @@ private fun priceLabel(price: String): String = stringResource( when (price) { ItchBrowse.PRICE_FREE -> R.string.stores_itch_price_free ItchBrowse.PRICE_SALE -> R.string.stores_itch_price_sale + ItchBrowse.PRICE_PAID -> R.string.stores_itch_price_paid + ItchBrowse.PRICE_UNDER_5 -> R.string.stores_itch_price_under_5 + ItchBrowse.PRICE_UNDER_15 -> R.string.stores_itch_price_under_15 else -> R.string.stores_itch_price_any }, ) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 349249890..e183c155c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2089,7 +2089,11 @@ تعذر استخراج التنزيل (رمز الخروج %1$d) لم يستجب itch.io الأكثر شعبية - الجديد + الأحدث + جديد ورائج + مدفوع + 5 دولارات أو أقل + 15 دولارًا أو أقل الأعلى تقييمًا الأكثر مبيعًا أي سعر diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index e2aec713e..6ecfbabd8 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2772,7 +2772,11 @@ No se pudo extraer la descarga (salida %1$d) itch.io no respondió Populares - Nuevos + Más recientes + Nuevos y populares + De pago + $5 o menos + $15 o menos Mejor valorados Más vendidos Cualquier precio diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 6dd5993d7..f2f5d740d 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -2005,7 +2005,11 @@ Изтеглянето не можа да бъде разархивирано (код %1$d) itch.io не отговори Популярни - Нови + Най-нови + Нови и популярни + Платени + До $5 + До $15 Най-високо оценени Най-продавани Всякаква цена diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 44a0d027d..595045bfe 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2047,7 +2047,11 @@ Stažený soubor nelze rozbalit (kód %1$d) itch.io neodpovědělo Populární - Nové + Nejnovější + Nové a populární + Placené + Do $5 + Do $15 Nejlépe hodnocené Nejprodávanější Jakákoli cena diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index a3b601a3d..6ae7e6ef0 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2005,7 +2005,11 @@ Downloaden kunne ikke udpakkes (kode %1$d) itch.io svarede ikke Populære - Nye + Seneste + Nye og populære + Betalt + $5 eller mindre + $15 eller mindre Bedst bedømte Mest solgte Alle priser diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3b8c8162d..37bbd2e36 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1997,7 +1997,11 @@ Download konnte nicht entpackt werden (Code %1$d) itch.io hat nicht geantwortet Beliebt - Neu + Neueste + Neu und beliebt + Kostenpflichtig + 5 $ oder weniger + 15 $ oder weniger Bestbewertet Meistverkauft Beliebiger Preis diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index ae4e0063d..cfdd79e02 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -2005,7 +2005,11 @@ Δεν ήταν δυνατή η αποσυμπίεση της λήψης (κωδικός %1$d) Το itch.io δεν απάντησε Δημοφιλή - Νέα + Πιο πρόσφατα + Νέα και δημοφιλή + Επί πληρωμή + $5 ή λιγότερο + $15 ή λιγότερο Κορυφαία βαθμολογία Μπεστ σέλερ Οποιαδήποτε τιμή diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index bc25285ea..b1d492c4b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2028,7 +2028,11 @@ No se ha podido extraer la descarga (código %1$d) itch.io no respondió Populares - Novedades + Más recientes + Nuevos y populares + De pago + 5 $ o menos + 15 $ o menos Mejor valorados Más vendidos Cualquier precio diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 155521246..3bcb194b9 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -2005,7 +2005,11 @@ Latausta ei voitu purkaa (koodi %1$d) itch.io ei vastannut Suositut - Uudet + Uusimmat + Uudet ja suositut + Maksulliset + Enintään 5 $ + Enintään 15 $ Parhaiten arvioidut Myydyimmät Mikä tahansa hinta diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5f4354c2e..3d2e42173 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2028,7 +2028,11 @@ Impossible d\'extraire le téléchargement (code %1$d) itch.io n\'a pas répondu Populaires - Nouveautés + Plus récents + Nouveautés populaires + Payants + 5 $ ou moins + 15 $ ou moins Les mieux notés Meilleures ventes Tous les prix diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index e0dc7e02c..3635c47b5 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -2005,7 +2005,11 @@ डाउनलोड एक्सट्रैक्ट नहीं हो सका (कोड %1$d) itch.io ने जवाब नहीं दिया लोकप्रिय - नए + सबसे हाल के + नए और लोकप्रिय + सशुल्क + $5 या कम + $15 या कम सबसे ऊँची रेटिंग सबसे ज़्यादा बिकने वाले कोई भी कीमत diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 59c1587ff..26681d702 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -2005,7 +2005,11 @@ A letöltés nem bontható ki (kód: %1$d) Az itch.io nem válaszolt Népszerű - Új + Legfrissebb + Új és népszerű + Fizetős + Legfeljebb 5 $ + Legfeljebb 15 $ Legjobbra értékelt Legkelendőbb Bármilyen ár diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 73e4d0758..30d3ffddb 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1984,7 +1984,11 @@ Tidak dapat mengekstrak unduhan (kode %1$d) itch.io tidak menjawab Populer - Baru + Terbaru + Baru dan populer + Berbayar + $5 atau kurang + $15 atau kurang Rating tertinggi Terlaris Harga apa saja diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d7d25d20e..e0f8dcff0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2018,7 +2018,11 @@ Impossibile estrarre il download (codice %1$d) itch.io non ha risposto Popolari - Novità + Più recenti + Nuovi e popolari + A pagamento + 5 $ o meno + 15 $ o meno I più votati I più venduti Qualsiasi prezzo diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 4c71bac1b..13b533017 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1986,7 +1986,11 @@ ダウンロードを展開できませんでした (終了コード %1$d) itch.io から応答がありません 人気 - 新着 + 新着順 + 新着&人気 + 有料 + 5ドル以下 + 15ドル以下 高評価 売れ筋 すべての価格 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ef455c263..082d66e92 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1986,7 +1986,11 @@ 다운로드한 파일의 압축을 풀 수 없습니다(코드 %1$d) itch.io가 응답하지 않았습니다 인기 - 신규 + 최신순 + 신규 및 인기 + 유료 + $5 이하 + $15 이하 평점 높은 순 베스트셀러 모든 가격 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index f8ce63780..542c2dd34 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1984,7 +1984,11 @@ Tidak dapat mengekstrak muat turun (kod %1$d) itch.io tidak menjawab Popular - Baharu + Terkini + Baharu dan popular + Berbayar + $5 atau kurang + $15 atau kurang Penilaian tertinggi Terlaris Sebarang harga diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 302f38673..475da964d 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -2005,7 +2005,11 @@ Kunne ikke pakke ut nedlastingen (kode %1$d) itch.io svarte ikke Populære - Nye + Nyeste + Nye og populære + Betalt + $5 eller mindre + $15 eller mindre Best vurdert Mest solgt Alle priser diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1b294f91a..abef2e0e7 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -2005,7 +2005,11 @@ Download kan niet worden uitgepakt (code %1$d) itch.io gaf geen antwoord Populair - Nieuw + Meest recent + Nieuw en populair + Betaald + $5 of minder + $15 of minder Hoogst beoordeeld Bestverkocht Elke prijs diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8b1057f1d..13c4442d1 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2047,7 +2047,11 @@ Nie można rozpakować pobranego pliku (kod %1$d) itch.io nie odpowiedziało Popularne - Nowe + Najnowsze + Nowe i popularne + Płatne + Do 5 $ + Do 15 $ Najwyżej oceniane Najlepiej sprzedające się Dowolna cena diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index d6cacfcb6..7cb4db950 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -2018,7 +2018,11 @@ Não foi possível extrair a transferência (código %1$d) O itch.io não respondeu Populares - Novos + Mais recentes + Novos e populares + Pagos + 5 $ ou menos + 15 $ ou menos Mais bem avaliados Mais vendidos Qualquer preço diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 6db4712e6..69ff257a7 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2018,7 +2018,11 @@ Não foi possível extrair o download (código %1$d) O itch.io não respondeu Populares - Novos + Mais recentes + Novos e populares + Pagos + US$ 5 ou menos + US$ 15 ou menos Mais bem avaliados Mais vendidos Qualquer preço diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 6a818b906..0e3af22a0 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2026,7 +2026,11 @@ Descărcarea nu a putut fi extrasă (cod %1$d) itch.io nu a răspuns Populare - Noi + Cele mai recente + Noi și populare + Cu plată + 5 $ sau mai puțin + 15 $ sau mai puțin Cele mai bine cotate Cele mai vândute Orice preț diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index baedc0add..45bd91d49 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2049,7 +2049,11 @@ Не удалось распаковать загрузку (код %1$d) itch.io не ответил Популярные - Новые + Самые новые + Новые и популярные + Платные + До $5 + До $15 С лучшими оценками Бестселлеры Любая цена diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 40e8f3001..845f2e115 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -2005,7 +2005,11 @@ Det gick inte att packa upp nedladdningen (kod %1$d) itch.io svarade inte Populära - Nya + Senaste + Nya och populära + Betalda + $5 eller mindre + $15 eller mindre Högst betyg Mest sålda Alla priser diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 0bbbc29f9..abef80667 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1984,7 +1984,11 @@ แตกไฟล์ที่ดาวน์โหลดไม่ได้ (รหัส %1$d) itch.io ไม่ตอบสนอง ยอดนิยม - ใหม่ + ล่าสุด + ใหม่และยอดนิยม + เสียเงิน + ไม่เกิน $5 + ไม่เกิน $15 คะแนนสูงสุด ขายดี ทุกราคา diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1cee39be2..a6b779155 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1999,7 +1999,11 @@ İndirme ayıklanamadı (kod %1$d) itch.io yanıt vermedi Popüler - Yeni + En yeniler + Yeni ve popüler + Ücretli + $5 veya altı + $15 veya altı En yüksek puanlı En çok satanlar Herhangi bir fiyat diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 2bb20ce20..b2afcec56 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2047,7 +2047,11 @@ Не вдалося розпакувати завантаження (код %1$d) itch.io не відповів Популярні - Нові + Найновіші + Нові й популярні + Платні + До $5 + До $15 З найвищими оцінками Бестселери Будь-яка ціна diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 44cec9e4d..d0f0f0f83 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1984,7 +1984,11 @@ Không thể giải nén bản tải xuống (mã %1$d) itch.io không phản hồi Phổ biến - Mới + Mới nhất + Mới và phổ biến + Trả phí + Từ $5 trở xuống + Từ $15 trở xuống Đánh giá cao nhất Bán chạy nhất Mọi mức giá diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3e526c9bd..b3f20f0a3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2063,7 +2063,11 @@ 无法解压下载内容(代码 %1$d) itch.io 没有响应 热门 - 最新 + 最新发布 + 新品与热门 + 付费 + 5 美元以下 + 15 美元以下 评分最高 热销 任意价格 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 9762b520f..dd8fce4d8 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1987,7 +1987,11 @@ 無法解壓下載內容(代碼 %1$d) itch.io 沒有回應 熱門 - 最新 + 最新發布 + 新品與熱門 + 付費 + 5 美元以下 + 15 美元以下 評分最高 熱賣 任何價格 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c5535bea7..186da38ef 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1987,7 +1987,11 @@ 無法解壓縮下載內容(代碼 %1$d) itch.io 沒有回應 熱門 - 最新 + 最新發布 + 新品與熱門 + 付費 + 5 美元以下 + 15 美元以下 評分最高 熱銷 任何價格 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c6a8f85d..67e247971 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2082,7 +2082,11 @@ Could not extract the download (exit %1$d) itch.io did not answer Popular - New + Most recent + New & popular + Paid + $5 or less + $15 or less Top rated Top sellers Any price diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt index c8df0bbbe..8ee4f8334 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt @@ -5,50 +5,61 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +/** Every address here was copied from itch.io's own links, not worked out. */ class ItchBrowseTest { + private fun path(sort: String = ItchBrowse.SORT_POPULAR, price: String = ItchBrowse.PRICE_ANY, platforms: Set = emptySet(), tags: List = emptyList()) = + ItchBrowse.path(BrowseQuery(sort, price, platforms, tags)) + @Test fun theDefaultQueryIsTheFrontPage() { assertTrue(BrowseQuery().isDefault) - assertFalse(BrowseQuery(sort = ItchBrowse.SORT_NEW).isDefault) + assertFalse(BrowseQuery(sort = ItchBrowse.SORT_NEWEST).isDefault) assertFalse(BrowseQuery(price = ItchBrowse.PRICE_FREE).isDefault) } - @Test fun sortsMapToTheirFeedPaths() { - assertEquals(listOf("https://itch.io/games.xml"), ItchBrowse.urls(BrowseQuery(), 1)) - assertEquals(listOf("https://itch.io/games/newest.xml"), ItchBrowse.urls(BrowseQuery(sort = ItchBrowse.SORT_NEW), 1)) - assertEquals(listOf("https://itch.io/games/top-rated.xml"), ItchBrowse.urls(BrowseQuery(sort = ItchBrowse.SORT_TOP_RATED), 1)) - assertEquals(listOf("https://itch.io/games/top-sellers.xml"), ItchBrowse.urls(BrowseQuery(sort = ItchBrowse.SORT_TOP_SELLERS), 1)) + @Test fun sortsUseItchsOwnPaths() { + assertEquals("https://itch.io/games", path()) + assertEquals("https://itch.io/games/new-and-popular", path(sort = ItchBrowse.SORT_NEW_POPULAR)) + assertEquals("https://itch.io/games/top-sellers", path(sort = ItchBrowse.SORT_TOP_SELLERS)) + assertEquals("https://itch.io/games/top-rated", path(sort = ItchBrowse.SORT_TOP_RATED)) + assertEquals("https://itch.io/games/newest", path(sort = ItchBrowse.SORT_NEWEST)) } - @Test fun pagesAfterTheFirstCarryThePageNumber() { - assertEquals(listOf("https://itch.io/games/newest.xml?page=3"), ItchBrowse.urls(BrowseQuery(sort = ItchBrowse.SORT_NEW), 3)) + @Test fun pricesUseItchsOwnPaths() { + assertEquals("https://itch.io/games/free", path(price = ItchBrowse.PRICE_FREE)) + assertEquals("https://itch.io/games/on-sale", path(price = ItchBrowse.PRICE_SALE)) + assertEquals("https://itch.io/games/store", path(price = ItchBrowse.PRICE_PAID)) + assertEquals("https://itch.io/games/5-dollars-or-less", path(price = ItchBrowse.PRICE_UNDER_5)) + assertEquals("https://itch.io/games/15-dollars-or-less", path(price = ItchBrowse.PRICE_UNDER_15)) } - @Test fun aPriceFilterOffersEverySpellingInOrder() { - val free = ItchBrowse.urls(BrowseQuery(sort = ItchBrowse.SORT_TOP_RATED, price = ItchBrowse.PRICE_FREE), 1) - assertEquals(listOf("https://itch.io/games/top-rated/free.xml", "https://itch.io/games/top-rated/price-free.xml"), free) - assertEquals(listOf("https://itch.io/games/on-sale.xml"), ItchBrowse.urls(BrowseQuery(price = ItchBrowse.PRICE_SALE), 1)) - } - - @Test fun platformsAndTagsFollowTheSortAndPriceInAFixedOrder() { - val query = BrowseQuery( - sort = ItchBrowse.SORT_NEW, price = ItchBrowse.PRICE_SALE, - platforms = setOf("web", "linux"), tags = listOf("Pixel Art", "Horror"), + @Test fun platformsUseItchsOwnNames() { + val expected = mapOf( + "windows" to "platform-windows", "osx" to "platform-osx", "linux" to "platform-linux", "android" to "platform-android", + "ios" to "platform-ios", "web" to "platform-web", "mobile-web" to "platform-mobile-web", ) + assertEquals(expected.keys.toList(), ItchBrowse.platformNames) + for ((name, segment) in expected) assertEquals("https://itch.io/games/$segment", path(platforms = setOf(name))) + } + + @Test fun aSortAPriceAndAPlatformCombineInThatOrder() { assertEquals( - listOf("https://itch.io/games/newest/on-sale/platform-linux/platform-web/tag-pixel-art/tag-horror.xml"), - ItchBrowse.urls(query, 1), + "https://itch.io/games/top-rated/free/platform-windows", + path(ItchBrowse.SORT_TOP_RATED, ItchBrowse.PRICE_FREE, setOf("windows")), ) } - @Test fun tagsBecomeSlugs() { + @Test fun aTagIsItsLowercaseNameWithHyphens() { + assertEquals("https://itch.io/games/tag-pixel-art", path(tags = listOf("Pixel Art"))) assertEquals("pixel-art", ItchBrowse.tagSlug(" Pixel Art ")) - assertEquals("2d", ItchBrowse.tagSlug("2D")) - assertEquals("sci-fi", ItchBrowse.tagSlug("Sci-Fi!")) } - @Test fun theSamePlatformsInAnyOrderGiveTheSameUrl() { - val a = ItchBrowse.urls(BrowseQuery(platforms = setOf("web", "linux")), 1) - val b = ItchBrowse.urls(BrowseQuery(platforms = setOf("linux", "web")), 1) - assertEquals(a, b) + @Test fun theSamePlatformsInAnyOrderGiveTheSameAddress() { + assertEquals(path(platforms = setOf("web", "linux")), path(platforms = setOf("linux", "web"))) + } + + @Test fun laterPagesAskForThePageNumberAndTheFirstDoesNot() { + val query = BrowseQuery(sort = ItchBrowse.SORT_TOP_RATED) + assertEquals("https://itch.io/games/top-rated.xml", ItchBrowse.url(query, 1)) + assertEquals("https://itch.io/games/top-rated.xml?page=2", ItchBrowse.url(query, 2)) } } diff --git a/docs/development/itch-browse.md b/docs/development/itch-browse.md index de5929afc..78c056300 100644 --- a/docs/development/itch-browse.md +++ b/docs/development/itch-browse.md @@ -14,10 +14,12 @@ https://itch.io/games[/][/][/platform-...][/tag-...].xm ``` `BrowseQuery` already carries platforms and tags, so the URL builder is complete; the controls for them -come in a later change. A price filter has more than one spelling on itch, so `urls()` returns every -candidate and `page()` takes the first that answers with games. Every request is logged -(`itch.io browse: answered with games`), which is how a wrong guess is found and fixed -from a device. +come in a later change. The path pieces are itch's own, copied from its links: sorts `new-and-popular`, `top-sellers`, `top-rated`, +`newest` (Popular has none); prices `free`, `on-sale`, `store` (Paid), `5-dollars-or-less`, +`15-dollars-or-less`; platforms `platform-windows`, `-osx`, `-linux`, `-android`, `-ios`, `-web`, +`-mobile-web`; a tag is `tag-` and its lowercase name with hyphens. `ItchBrowse.path()` builds the page +address, `url()` adds `.xml` and `?page=N`. Every request is logged +(`itch.io browse: answered with games`). Requests go out one at a time, at least 1.2 s apart. A 429 waits 8 s and tries once more; if that fails the games already on screen stay. Each page is kept under the app cache for three hours, so going back to From 583858763992f1fd99e2f4a9265561b4e4240f0c Mon Sep 17 00:00:00 2001 From: FX192 <120828496+FX192@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:02:17 +0300 Subject: [PATCH 3/5] itch.io browse: let a failed page be retried instead of ending the list --- .../com/droiddeck/launcher/stores/itch/ItchBrowse.kt | 12 ++++++------ .../java/com/droiddeck/launcher/ui/ItchBrowseBar.kt | 3 +++ docs/development/itch-browse.md | 4 ++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt index d64855f50..781f918ec 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt @@ -151,8 +151,9 @@ internal object ItchBrowseState { if (active) more(context) } - fun more(context: Context) { - if (loading || ended || !active) return + /** Asks for the next page. A page that failed is only asked for again when [retry] is set, so scrolling can't loop on it. */ + fun more(context: Context, retry: Boolean = false) { + if (loading || ended || !active || (failed && !retry)) return loading = true failed = false val app = context.applicationContext @@ -164,13 +165,12 @@ internal object ItchBrowseState { StoresState.post { if (mine != generation) return@post loading = false - if (answer == null) { - failed = items.isEmpty() - ended = items.isNotEmpty() + // A thrown or non-2xx fetch leaves the list open so the same page can be asked for again. + if (answer == null || answer.code !in 200..299) { + failed = true return@post } if (answer.items.isEmpty()) { - failed = items.isEmpty() && answer.code !in 200..299 ended = true return@post } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt index 896d1b37f..da53f7ebf 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt @@ -56,6 +56,9 @@ internal fun ItchLoadMore() { val count = ItchBrowseState.items.size LaunchedEffect(count) { ItchBrowseState.more(ctx) } if (ItchBrowseState.loading) LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 12.dp)) + else if (ItchBrowseState.failed) Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + BrowseChip(stringResource(R.string.stores_dl_retry), false) { ItchBrowseState.more(ctx, retry = true) } + } else if (!ItchBrowseState.ended && count > 0) Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { BrowseChip(stringResource(R.string.stores_itch_load_more), false) { ItchBrowseState.more(ctx) } } diff --git a/docs/development/itch-browse.md b/docs/development/itch-browse.md index 78c056300..241b50853 100644 --- a/docs/development/itch-browse.md +++ b/docs/development/itch-browse.md @@ -30,3 +30,7 @@ use, so a game looks the same wherever it appears. query bumps a generation counter, so a slow answer for an older one is dropped. A game already in the library replaces its browse copy on the grid, so Install and the owned markers are right; the detail page looks browse results up through `StoresState.detailItem`. + +## When a page fails + +A fetch that throws or answers with a non-2xx status marks the list as failed but leaves it open. The end of the list then shows a Retry button that asks for the same page again. Scrolling does not retry on its own, so a failing page cannot loop. Only a successful page with no new games ends the list. From dc57f367e1609c9c21e6a4d424def4cd7588a1e6 Mon Sep 17 00:00:00 2001 From: FX192 <120828496+FX192@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:07:16 +0300 Subject: [PATCH 4/5] itch.io browse: don't re-fire the load-more trigger when a page is appended --- app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt index da53f7ebf..fd7334451 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt @@ -54,7 +54,8 @@ internal fun ItchBrowseBar() { internal fun ItchLoadMore() { val ctx = LocalContext.current val count = ItchBrowseState.items.size - LaunchedEffect(count) { ItchBrowseState.more(ctx) } + // Keyed on nothing that changes with a page, so appending games never re-fires it while this end item stays composed; scrolling it back into view does. + LaunchedEffect(Unit) { ItchBrowseState.more(ctx) } if (ItchBrowseState.loading) LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 12.dp)) else if (ItchBrowseState.failed) Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { BrowseChip(stringResource(R.string.stores_dl_retry), false) { ItchBrowseState.more(ctx, retry = true) } From 1883a8c7cfd5ed013e169cfdedb63c49cb2616ad Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Sat, 24 Oct 2026 11:15:35 -0400 Subject: [PATCH 5/5] itch.io browse: drop code comments --- .../com/droiddeck/launcher/stores/itch/ItchBrowse.kt | 10 ---------- .../java/com/droiddeck/launcher/ui/ItchBrowseBar.kt | 3 --- .../droiddeck/launcher/stores/itch/ItchBrowseTest.kt | 1 - 3 files changed, 14 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt index 781f918ec..f820be6d2 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBrowse.kt @@ -10,7 +10,6 @@ import org.json.JSONArray import java.io.File import java.security.MessageDigest -/** What the Store tab is showing: an order, a price filter and, later, platforms and tags. The default is the front page. */ internal data class BrowseQuery( val sort: String = ItchBrowse.SORT_POPULAR, val price: String = ItchBrowse.PRICE_ANY, @@ -38,7 +37,6 @@ internal object ItchBrowse { const val PRICE_UNDER_15 = "15-dollars-or-less" val prices = listOf(PRICE_ANY, PRICE_FREE, PRICE_SALE, PRICE_PAID, PRICE_UNDER_5, PRICE_UNDER_15) - /** Platform names as itch spells them in `platform-`. */ val platformNames = listOf("windows", "osx", "linux", "android", "ios", "web", "mobile-web") private const val BASE = "https://itch.io/games" @@ -50,12 +48,10 @@ internal object ItchBrowse { @Volatile private var lastRequestAt = 0L private val lock = Any() - /** The path piece itch uses for each choice; the default sort and price have none. */ private fun sortSegment(sort: String): String? = sort.takeIf { it != SORT_POPULAR && it in sorts } private fun priceSegment(price: String): String? = price.takeIf { it != PRICE_ANY && it in prices } - /** The address of [query]'s page on itch: sort, price, platforms, then tags, as itch's own links spell them. */ fun path(query: BrowseQuery): String { val segments = listOfNotNull(sortSegment(query.sort), priceSegment(query.price)) + query.platforms.sorted().map { "platform-$it" } + @@ -63,12 +59,10 @@ internal object ItchBrowse { return if (segments.isEmpty()) BASE else BASE + "/" + segments.joinToString("/") } - /** The listing feed for [query]'s [page]. */ fun url(query: BrowseQuery, page: Int): String = path(query) + ".xml" + if (page > 1) "?page=$page" else "" fun tagSlug(tag: String): String = tag.trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-') - /** Page [page] of [query]: the disk copy when it is fresh, otherwise the feed, one request at a time and spaced out. */ fun page(context: Context, query: BrowseQuery, page: Int, force: Boolean = false): BrowsePage { val app = context.applicationContext val url = url(query, page) @@ -120,7 +114,6 @@ internal object ItchBrowse { } } -/** The Store tab's result list: the query, the games loaded so far, and whether more can be asked for. */ internal object ItchBrowseState { var query by mutableStateOf(BrowseQuery()) private set @@ -136,7 +129,6 @@ internal object ItchBrowseState { private var next = 1 private var generation = 0 - /** Whether the tab shows these results instead of the front page shelves. */ val active: Boolean get() = !query.isDefault fun choose(context: Context, wanted: BrowseQuery) { @@ -151,7 +143,6 @@ internal object ItchBrowseState { if (active) more(context) } - /** Asks for the next page. A page that failed is only asked for again when [retry] is set, so scrolling can't loop on it. */ fun more(context: Context, retry: Boolean = false) { if (loading || ended || !active || (failed && !retry)) return loading = true @@ -165,7 +156,6 @@ internal object ItchBrowseState { StoresState.post { if (mine != generation) return@post loading = false - // A thrown or non-2xx fetch leaves the list open so the same page can be asked for again. if (answer == null || answer.code !in 200..299) { failed = true return@post diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt index fd7334451..e1e170fde 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchBrowseBar.kt @@ -34,7 +34,6 @@ import com.droiddeck.launcher.R import com.droiddeck.launcher.stores.itch.ItchBrowse import com.droiddeck.launcher.stores.itch.ItchBrowseState -/** Sort and price chips above the itch Store tab; picking anything but the defaults swaps the shelves for results. */ @Composable internal fun ItchBrowseBar() { val ctx = LocalContext.current @@ -49,12 +48,10 @@ internal fun ItchBrowseBar() { } } -/** The end of the result list: it asks for the next page as it scrolls into view. */ @Composable internal fun ItchLoadMore() { val ctx = LocalContext.current val count = ItchBrowseState.items.size - // Keyed on nothing that changes with a page, so appending games never re-fires it while this end item stays composed; scrolling it back into view does. LaunchedEffect(Unit) { ItchBrowseState.more(ctx) } if (ItchBrowseState.loading) LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 12.dp)) else if (ItchBrowseState.failed) Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt index 8ee4f8334..6b7315334 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchBrowseTest.kt @@ -5,7 +5,6 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -/** Every address here was copied from itch.io's own links, not worked out. */ class ItchBrowseTest { private fun path(sort: String = ItchBrowse.SORT_POPULAR, price: String = ItchBrowse.PRICE_ANY, platforms: Set = emptySet(), tags: List = emptyList()) = ItchBrowse.path(BrowseQuery(sort, price, platforms, tags))