feat(itch): collections filter in the Library (#1597)

* Add itch.io collections to the Library

A Collections button on the Library's itch tab filters the library to one collection of the signed-in account. Its games that are not installed show in the Not installed section.

* Keep itch collections complete across failures, sign-out and empty libraries

A failed later page no longer publishes a partial collection, selecting a collection refreshes it, pagination runs until an empty page, signing out clears collection state, and the itch chip shows for a signed-in account with no owned games.

* Tie itch collection requests to the account that started them

Requests capture the account generation before reading the key and dedupe per generation, sign-out clears collections after the token is removed, and the collections load follows the itch sign-in state.

* Keep paging itch collections past a page of already seen games

* Run the itch collections tests under Robolectric like the other itch tests

* Let the Installed filter apply inside a collection and keep the grid for an empty itch account

* fix(itch): stop paging a collection when a page adds no new games

* Keep the collections icon on the itch collections tab when it opens

---------

Co-authored-by: maxjivi05 <maxjividen@gmail.com>
This commit is contained in:
FX1922andmaxjivi05 authored and GitHub committed 2026-10-26 18:15:24 -04:00
1 parent f13916ee25
commit d905f03bf7
41 files changed
+313 -12

No files matched your search

@@ -130,7 +130,7 @@ object StoresState {
fun detailItem(store: Store, id: String): CatalogItem? = detailCatalogItem(
store, id, library[store].orEmpty(),
found[store].orEmpty() + if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchBrowseState.items else emptyList(),
found[store].orEmpty() + if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchBrowseState.items + com.droiddeck.launcher.stores.itch.ItchCollections.items() else emptyList(),
shelves[store],
)
@@ -191,6 +191,7 @@ object StoresState {
backend?.signOut(app)
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store)
if (store == Store.ITCH) com.droiddeck.launcher.stores.itch.ItchCollections.clear()
main.post {
if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store); libraryAsked.remove(store)
@@ -19,6 +19,11 @@ internal object ItchApi {
fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key)
fun collections(key: String): JSONArray? = get("/profile/collections", key)?.optJSONArray("collections")
fun collectionGames(key: String, collectionId: String, page: Int): JSONObject? =
get("/collections/$collectionId/collection-games?page=$page", key)
fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? =
get("/games/$gameId/uploads" + keyQuery(downloadKeyId), key)?.optJSONArray("uploads")
@@ -0,0 +1,116 @@
package com.droiddeck.launcher.stores.itch
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
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 org.json.JSONObject
import java.util.concurrent.ConcurrentHashMap
internal class ItchCollection(val id: String, val title: String, val count: Int)
internal object ItchCollections {
var list by mutableStateOf<List<ItchCollection>>(emptyList())
private set
var selected by mutableStateOf<String?>(null)
private set
private val games = mutableStateMapOf<String, List<CatalogItem>>()
private val loading = ConcurrentHashMap.newKeySet<String>()
@Volatile private var generation = 0
fun items(): List<CatalogItem> = games.values.flatten()
fun gamesOf(id: String): List<CatalogItem>? = games[id]
fun chosen(): ItchCollection? = list.firstOrNull { it.id == selected }
fun clear() {
generation++
StoresState.post {
list = emptyList()
selected = null
games.clear()
}
}
fun load(context: Context) {
val app = context.applicationContext
val started = generation
val guard = "$started:"
if (!loading.add(guard)) return
Thread({
try {
val key = ItchAuth.key(app)
if (key.isBlank()) return@Thread
val parsed = parseCollections(ItchApi.collections(key) ?: return@Thread)
if (started != generation) return@Thread
StoresState.post {
if (started != generation) return@post
list = parsed
if (selected != null && parsed.none { it.id == selected }) selected = null
}
StoresState.logLine("itch.io: ${parsed.size} collections")
selected?.let { fetchGames(key, it, started) }
} finally {
loading.remove(guard)
}
}, "itch-collections").start()
}
fun select(context: Context, id: String?) {
selected = id
if (id == null) return
val app = context.applicationContext
val started = generation
Thread({
val key = ItchAuth.key(app)
if (key.isNotBlank()) fetchGames(key, id, started)
}, "itch-collection-games").start()
}
private fun fetchGames(key: String, id: String, started: Int) {
val guard = "$started:$id"
if (!loading.add(guard)) return
try {
val out = ArrayList<CatalogItem>()
val seen = HashSet<String>()
var page = 1
while (true) {
val body = ItchApi.collectionGames(key, id, page)
if (body == null) {
StoresState.logLine("itch.io: collection $id page $page failed")
return
}
val batch = parseGames(body)
if (batch.isEmpty()) break
val before = out.size
out.addAll(batch.filter { seen.add(it.id) })
if (out.size == before) break
page++
}
val distinct = out.toList()
StoresState.post { if (started == generation) games[id] = distinct }
StoresState.logLine("itch.io: collection $id has ${distinct.size} games")
} finally {
loading.remove(guard)
}
}
internal fun parseCollections(array: JSONArray): List<ItchCollection> = (0 until array.length()).mapNotNull { i ->
val c = array.optJSONObject(i) ?: return@mapNotNull null
val id = c.opt("id")?.toString().orEmpty().takeIf { it.isNotBlank() && it != "null" } ?: return@mapNotNull null
val title = c.optString("title").ifBlank { return@mapNotNull null }
ItchCollection(id, title, c.optInt("games_count", c.optInt("gamesCount")))
}
internal fun parseGames(body: JSONObject): List<CatalogItem> {
val rows = body.optJSONArray("collection_games") ?: body.optJSONArray("collectionGames") ?: return emptyList()
return (0 until rows.length()).mapNotNull { i ->
rows.optJSONObject(i)?.optJSONObject("game")?.let { ItchLibrary.item(it, "") }
}
}
}
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.unit.dp
internal val CollectionsIcon: ImageVector = ImageVector.Builder("Collections", 24.dp, 24.dp, 24f, 24f).apply {
for (d in listOf(
"M2.65,4.6H7.33V22H2.65Z",
"M8.32,2H13V22H8.32Z",
"M13.9,5.4H18.5L21.4,22H16.8Z",
)) addPath(addPathNodes(d), fill = SolidColor(Color.Black))
}.build()
@@ -89,6 +89,8 @@ import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.arrangeStoreTabs
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.formatBytes
import com.droiddeck.launcher.stores.itch.ItchCollection
import com.droiddeck.launcher.stores.itch.ItchCollections
import com.droiddeck.launcher.stores.retainOrder
import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter
import com.droiddeck.launcher.stores.sortGames
@@ -127,6 +129,14 @@ internal fun libraryEntries(
return out.distinctBy { it.key }
}
internal fun collectionEntries(all: List<LibraryEntry>, items: List<CatalogItem>): List<LibraryEntry> {
val keys = items.map { it.key }.toSet()
val itch = all.filter { it.source == Store.ITCH.id }
val have = itch.mapNotNull { it.storeKey }.toSet()
return itch.filter { it.storeKey in keys } +
items.filter { it.key !in have }.map { LibraryEntry(GameRef.key(it), Store.ITCH.id, it.title, null, it, false) }
}
internal fun installedFirst(entries: List<LibraryEntry>): List<LibraryEntry> = entries.partition { it.installed }.let { (on, off) -> on + off }
internal const val LIBRARY_ALL = "all"
@@ -169,19 +179,30 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
val all = remember(s.steamGames, cloudRows, owned, installedStore) { libraryEntries(s.steamGames + cloudRows, owned, installedStore) }
LaunchedEffect(Unit) { XboxLibraryFilter.load(ctx) }
var showUninstalled by remember(LibraryView.ownedRequest) { mutableStateOf(SessionPrefs.libraryShowUninstalled(ctx)) }
var collectionShows by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
val setShowUninstalled = { show: Boolean ->
showUninstalled = show
SessionPrefs.setLibraryShowUninstalled(ctx, show)
val picked = ItchCollections.selected.takeIf { LibraryView.source == Store.ITCH.id }
if (picked != null) collectionShows = picked to show
else {
showUninstalled = show
SessionPrefs.setLibraryShowUninstalled(ctx, show)
}
}
val sources = librarySources(all, SessionPrefs.storesTabOrder(ctx))
val itchSignedIn = owned.containsKey(Store.ITCH)
val listed = librarySources(all, SessionPrefs.storesTabOrder(ctx))
val sources = if (itchSignedIn && Store.ITCH.id !in listed) listed + Store.ITCH.id else listed
val source = LibraryView.source.takeIf { it == LIBRARY_ALL || it in sources } ?: LIBRARY_ALL
val q = LibraryView.query.trim().lowercase()
val xboxFilter = if (source == Store.XBOX.id) XboxLibraryFilter.current else XboxLibraryFilter.ALL
val matching = all.filter {
LaunchedEffect(source, itchSignedIn) { if (source == Store.ITCH.id && itchSignedIn) ItchCollections.load(ctx) }
val collection = if (source == Store.ITCH.id) ItchCollections.chosen() else null
val pool = if (collection == null) all else collectionEntries(all, ItchCollections.gamesOf(collection.id).orEmpty())
val matching = pool.filter {
(source == LIBRARY_ALL || it.source == source) && (q.isEmpty() || it.title.lowercase().contains(q)) && keepsXbox(xboxFilter, it)
}
val candidates = if (showUninstalled) matching else matching.filter { it.installed }
val hiddenUninstalled = if (showUninstalled) 0 else matching.count { !it.installed }
val listUninstalled = if (collection == null) showUninstalled else collectionShows?.takeIf { it.first == collection.id }?.second ?: true
val candidates = if (listUninstalled) matching else matching.filter { it.installed }
val hiddenUninstalled = if (listUninstalled) 0 else matching.count { !it.installed }
val sort = s.storesSort
val sizeSort = sort == "sizeAsc" || sort == "sizeDesc"
val installedAt = StoresState.installed.associate { it.key to it.sidecar.installedAt }
@@ -264,7 +285,7 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
kotlinx.coroutines.delay(Motion.ms(240).toLong())
focusWithinFrames({ ff.last?.startsWith("card:") == true }) { if (ff.firstTileAttached > 0) ff.firstTile else FocusRequester.Default }
}
if (all.isEmpty() && open == null) {
if (all.isEmpty() && open == null && !itchSignedIn) {
LibraryEmpty(s, a, modifier)
return
}
@@ -272,7 +293,7 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
val detailScroll = rememberScrollState()
var cycled by remember { mutableIntStateOf(0) }
var gridFocused by remember { mutableStateOf(false) }
LaunchedEffect(source, sort, showUninstalled) { grid.scrollToItem(0) }
LaunchedEffect(source, sort, listUninstalled) { grid.scrollToItem(0) }
LaunchedEffect(cycled) {
if (cycled == 0 || ff == null || inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect
focusWithinFrames({ gridFocused }) {
@@ -288,7 +309,7 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
grid = {
LibraryGrid(
s, a, grid, visible, split, visible.size, sources, source, sizesPending, syncing,
showUninstalled = showUninstalled.takeIf { stores }, onShowUninstalled = setShowUninstalled,
showUninstalled = listUninstalled.takeIf { stores }, onShowUninstalled = setShowUninstalled,
hiddenUninstalled = hiddenUninstalled,
onSource = { LibraryView.source = it },
onCycle = { step ->
@@ -383,7 +404,7 @@ private fun LibraryGrid(
contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp),
) {
item(key = "header", span = { GridItemSpan(maxLineSpan) }) {
Rise(0) { LibraryHeader(s, a, total, searchCleared, source == Store.XBOX.id, showUninstalled, onShowUninstalled) }
Rise(0) { LibraryHeader(s, a, total, searchCleared, source == Store.XBOX.id, showUninstalled, onShowUninstalled, collections = source == Store.ITCH.id) }
}
item(key = "filters", span = { GridItemSpan(maxLineSpan) }) {
Rise(1) { LibraryFilters(sources, source, onSource) }
@@ -430,7 +451,7 @@ private fun LazyGridScope.libraryGrid(entries: List<LibraryEntry>, empty: Int, t
@Composable
private fun LibraryHeader(
s: FrontEndState, a: FrontEndActions, total: Int, searchCleared: Boolean, xbox: Boolean,
showUninstalled: Boolean?, onShowUninstalled: (Boolean) -> Unit,
showUninstalled: Boolean?, onShowUninstalled: (Boolean) -> Unit, collections: Boolean,
) {
val colors = MaterialTheme.colorScheme
val narrow = LocalNarrowPane.current
@@ -447,6 +468,7 @@ private fun LibraryHeader(
},
) {
if (showUninstalled != null || xbox) LibraryFilterButton(showUninstalled, onShowUninstalled, xbox)
if (collections) CollectionsButton()
SortButton(s, a)
ViewButton(s, a)
GameFileFolderActions(s, a)
@@ -700,6 +722,39 @@ private fun LibraryFilterButton(showUninstalled: Boolean?, onShowUninstalled: (B
}
}
@Composable
private fun CollectionsButton() {
val pal = LocalPalette.current
val ctx = LocalContext.current
val title = stringResource(R.string.itch_collections)
val all = stringResource(R.string.stores_tab_all)
var at by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
HeaderIconButton(title + " · " + (ItchCollections.chosen()?.title ?: all), CollectionsIcon, "itch-collections") {
val from = at ?: return@HeaderIconButton
val choices = listOf<ItchCollection?>(null) + ItchCollections.list
Steps.ask(StepAsk(
anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = choices.size,
handle = { Icon(CollectionsIcon, null, tint = pal.signal, modifier = Modifier.size(18.dp)) },
) {
StepTitle(title)
StepList {
choices.forEachIndexed { i, c ->
val picked = c?.id == ItchCollections.selected
StepChoice(
c?.let { it.title + " (" + it.count + ")" } ?: all, selected = picked, enabled = open,
modifier = Modifier.fillMaxWidth().stepItem(i).then(if (picked) Modifier.focusRequester(first) else Modifier),
) {
ItchCollections.select(ctx, c?.id)
Steps.fold()
}
}
}
})
}
}
}
@Composable
private fun SortButton(s: FrontEndState, a: FrontEndActions) {
val pal = LocalPalette.current
+1
View File
@@ -3219,6 +3219,7 @@
<string name="library_remove">إزالة من المكتبة</string>
<string name="library_in">في المكتبة</string>
<string name="xbox_filter_show">عرض</string>
<string name="itch_collections">المجموعات</string>
<string name="xbox_filter_cloud">سحابية</string>
<string name="stores_xbox_cloud_settings">إعدادات السحابة</string>
</resources>
@@ -3069,6 +3069,7 @@
<string name="library_remove">Quitar de Biblioteca</string>
<string name="library_in">En Biblioteca</string>
<string name="xbox_filter_show">Mostrar</string>
<string name="itch_collections">Colecciones</string>
<string name="xbox_filter_cloud">Nube</string>
<string name="stores_xbox_cloud_settings">Configuración de nube</string>
</resources>
+1
View File
@@ -3083,6 +3083,7 @@
<string name="library_remove">Премахни от Библиотека</string>
<string name="library_in">В Библиотека</string>
<string name="xbox_filter_show">Показване</string>
<string name="itch_collections">Колекции</string>
<string name="xbox_filter_cloud">Облак</string>
<string name="stores_xbox_cloud_settings">Настройки на облака</string>
</resources>
+1
View File
@@ -3151,6 +3151,7 @@
<string name="library_remove">Odebrat z knihovny</string>
<string name="library_in">V knihovně</string>
<string name="xbox_filter_show">Zobrazit</string>
<string name="itch_collections">Kolekce</string>
<string name="xbox_filter_cloud">Cloud</string>
<string name="stores_xbox_cloud_settings">Nastavení cloudu</string>
</resources>
+1
View File
@@ -3083,6 +3083,7 @@
<string name="library_remove">Fjern fra Bibliotek</string>
<string name="library_in">I Bibliotek</string>
<string name="xbox_filter_show">Vis</string>
<string name="itch_collections">Samlinger</string>
<string name="xbox_filter_cloud">Sky</string>
<string name="stores_xbox_cloud_settings">Skyindstillinger</string>
</resources>
+1
View File
@@ -3083,6 +3083,7 @@
<string name="library_remove">Aus der Bibliothek entfernen</string>
<string name="library_in">In der Bibliothek</string>
<string name="xbox_filter_show">Anzeigen</string>
<string name="itch_collections">Sammlungen</string>
<string name="xbox_filter_cloud">Cloud</string>
<string name="stores_xbox_cloud_settings">Cloud-Einstellungen</string>
</resources>
+1
View File
@@ -3083,6 +3083,7 @@
<string name="library_remove">Αφαίρεση από τη Βιβλιοθήκη</string>
<string name="library_in">Στη Βιβλιοθήκη</string>
<string name="xbox_filter_show">Εμφάνιση</string>
<string name="itch_collections">Συλλογές</string>
<string name="xbox_filter_cloud">Cloud</string>
<string name="stores_xbox_cloud_settings">Ρυθμίσεις cloud</string>
</resources>
+1
View File
@@ -3119,6 +3119,7 @@
<string name="library_remove">Quitar de la Biblioteca</string>
<string name="library_in">En la Biblioteca</string>
<string name="xbox_filter_show">Mostrar</string>
<string name="itch_collections">Colecciones</string>
<string name="xbox_filter_cloud">Nube</string>
<string name="stores_xbox_cloud_settings">Ajustes de nube</string>
</resources>
+1
View File
@@ -3083,6 +3083,7 @@
<string name="library_remove">Poista kirjastosta</string>
<string name="library_in">Kirjastossa</string>
<string name="xbox_filter_show">Näytä</string>
<string name="itch_collections">Kokoelmat</string>
<string name="xbox_filter_cloud">Pilvi</string>
<string name="stores_xbox_cloud_settings">Pilviasetukset</string>
</resources>
+1
View File
@@ -3119,6 +3119,7 @@
<string name="library_remove">Retirer de la Bibliothèque</string>
<string name="library_in">Dans la Bibliothèque</string>
<string name="xbox_filter_show">Afficher</string>
<string name="itch_collections">Collections</string>
<string name="xbox_filter_cloud">Cloud</string>
<string name="stores_xbox_cloud_settings">Paramètres cloud</string>
</resources>
+1
View File
@@ -3083,6 +3083,7 @@
<string name="library_remove">लाइब्रेरी से हटाएँ</string>
<string name="library_in">लाइब्रेरी में</string>
<string name="xbox_filter_show">दिखाएँ</string>
<string name="itch_collections">संग्रह</string>
<string name="xbox_filter_cloud">क्लाउड</string>
<string name="stores_xbox_cloud_settings">क्लाउड सेटिंग्स</string>
</resources>
+1
View File
@@ -3083,6 +3083,7 @@
<string name="library_remove">Eltávolítás a könyvtárból</string>
<string name="library_in">A könyvtárban</string>
<string name="xbox_filter_show">Megjelenítés</string>
<string name="itch_collections">Gyűjtemények</string>
<string name="xbox_filter_cloud">Felhő</string>
<string name="stores_xbox_cloud_settings">Felhőbeállítások</string>
</resources>
+1
View File
@@ -3073,6 +3073,7 @@
<string name="library_remove">Hapus dari Pustaka</string>
<string name="library_in">Di Pustaka</string>
<string name="xbox_filter_show">Tampilkan</string>
<string name="itch_collections">Koleksi</string>
<string name="xbox_filter_cloud">Cloud</string>
<string name="stores_xbox_cloud_settings">Pengaturan cloud</string>
</resources>
+1
View File
@@ -3117,6 +3117,7 @@
<string name="library_remove">Rimuovi dalla Libreria</string>
<string name="library_in">Nella Libreria</string>
<string name="xbox_filter_show">Mostra</string>
<string name="itch_collections">Raccolte</string>
<string name="xbox_filter_cloud">Cloud</string>
<string name="stores_xbox_cloud_settings">Impostazioni cloud</string>
</resources>
+1
View File
@@ -3060,6 +3060,7 @@
<string name="library_remove">ライブラリから削除</string>
<string name="library_in">ライブラリにあります</string>
<string name="xbox_filter_show">表示</string>
<string name="itch_collections">コレクション</string>
<string name="xbox_filter_cloud">クラウド</string>
<string name="stores_xbox_cloud_settings">クラウド設定</string>
</resources>
+1
View File
@@ -3060,6 +3060,7 @@
<string name="library_remove">라이브러리에서 제거</string>
<string name="library_in">라이브러리에 있음</string>
<string name="xbox_filter_show">표시</string>
<string name="itch_collections">컬렉션</string>
<string name="xbox_filter_cloud">클라우드</string>
<string name="stores_xbox_cloud_settings">클라우드 설정</string>
</resources>
+1
View File
@@ -3058,6 +3058,7 @@
<string name="library_remove">Alih keluar dari Pustaka</string>
<string name="library_in">Dalam Pustaka</string>
<string name="xbox_filter_show">Tunjukkan</string>
<string name="itch_collections">Koleksi</string>
<string name="xbox_filter_cloud">Awan</string>
<string name="stores_xbox_cloud_settings">Tetapan awan</string>
</resources>
+1
View File
@@ -3083,6 +3083,7 @@
<string name="library_remove">Fjern fra Bibliotek</string>
<string name="library_in">I Bibliotek</string>
<string name="xbox_filter_show">Vis</string>
<string name="itch_collections">Samlinger</string>
<string name="xbox_filter_cloud">Sky</string>
<string name="stores_xbox_cloud_settings">Skyinnstillinger</string>
</resources>
+1
View File
@@ -3083,6 +3083,7 @@
<string name="library_remove">Verwijderen uit Bibliotheek</string>
<string name="library_in">In Bibliotheek</string>
<string name="xbox_filter_show">Weergeven</string>
<string name="itch_collections">Collecties</string>
<string name="xbox_filter_cloud">Cloud</string>
<string name="stores_xbox_cloud_settings">Cloudinstellingen</string>
</resources>
+1
View File
@@ -3154,6 +3154,7 @@
<string name="library_remove">Usuń z biblioteki</string>
<string name="library_in">W bibliotece</string>
<string name="xbox_filter_show">Pokaż</string>
<string name="itch_collections">Kolekcje</string>
<string name="xbox_filter_cloud">Chmura</string>
<string name="stores_xbox_cloud_settings">Ustawienia chmury</string>
</resources>
@@ -3117,6 +3117,7 @@
<string name="library_remove">Remover da Biblioteca</string>
<string name="library_in">Na Biblioteca</string>
<string name="xbox_filter_show">Mostrar</string>
<string name="itch_collections">Coleções</string>
<string name="xbox_filter_cloud">Nuvem</string>
<string name="stores_xbox_cloud_settings">Definições da nuvem</string>
</resources>
+1
View File
@@ -3117,6 +3117,7 @@
<string name="library_remove">Remover da Biblioteca</string>
<string name="library_in">Na Biblioteca</string>
<string name="xbox_filter_show">Mostrar</string>
<string name="itch_collections">Coleções</string>
<string name="xbox_filter_cloud">Nuvem</string>
<string name="stores_xbox_cloud_settings">Configurações da nuvem</string>
</resources>
+1
View File
@@ -3117,6 +3117,7 @@
<string name="library_remove">Elimină din Bibliotecă</string>
<string name="library_in">În Bibliotecă</string>
<string name="xbox_filter_show">Afișează</string>
<string name="itch_collections">Colecții</string>
<string name="xbox_filter_cloud">Cloud</string>
<string name="stores_xbox_cloud_settings">Setări cloud</string>
</resources>
+1
View File
@@ -3153,6 +3153,7 @@
<string name="library_remove">Убрать из библиотеки</string>
<string name="library_in">В библиотеке</string>
<string name="xbox_filter_show">Показать</string>
<string name="itch_collections">Коллекции</string>
<string name="xbox_filter_cloud">Облако</string>
<string name="stores_xbox_cloud_settings">Настройки облака</string>
</resources>
+1
View File
@@ -3083,6 +3083,7 @@
<string name="library_remove">Ta bort från Bibliotek</string>
<string name="library_in">I Bibliotek</string>
<string name="xbox_filter_show">Visa</string>
<string name="itch_collections">Samlingar</string>
<string name="xbox_filter_cloud">Moln</string>
<string name="stores_xbox_cloud_settings">Molninställningar</string>
</resources>
+1
View File
@@ -3058,6 +3058,7 @@
<string name="library_remove">นำออกจากไลบรารี</string>
<string name="library_in">อยู่ในไลบรารี</string>
<string name="xbox_filter_show">แสดง</string>
<string name="itch_collections">คอลเลกชัน</string>
<string name="xbox_filter_cloud">คลาวด์</string>
<string name="stores_xbox_cloud_settings">การตั้งค่าคลาวด์</string>
</resources>
+1
View File
@@ -3085,6 +3085,7 @@
<string name="library_remove">Kütüphane içinden kaldır</string>
<string name="library_in">Kütüphane içinde</string>
<string name="xbox_filter_show">Göster</string>
<string name="itch_collections">Koleksiyonlar</string>
<string name="xbox_filter_cloud">Bulut</string>
<string name="stores_xbox_cloud_settings">Bulut ayarları</string>
</resources>
+1
View File
@@ -3151,6 +3151,7 @@
<string name="library_remove">Прибрати з бібліотеки</string>
<string name="library_in">У бібліотеці</string>
<string name="xbox_filter_show">Показати</string>
<string name="itch_collections">Колекції</string>
<string name="xbox_filter_cloud">Хмара</string>
<string name="stores_xbox_cloud_settings">Налаштування хмари</string>
</resources>
+1
View File
@@ -3058,6 +3058,7 @@
<string name="library_remove">Xóa khỏi Thư viện</string>
<string name="library_in">Trong Thư viện</string>
<string name="xbox_filter_show">Hiển thị</string>
<string name="itch_collections">Bộ sưu tập</string>
<string name="xbox_filter_cloud">Đám mây</string>
<string name="stores_xbox_cloud_settings">Cài đặt đám mây</string>
</resources>
@@ -3062,6 +3062,7 @@
<string name="library_remove">从游戏库移除</string>
<string name="library_in">已在游戏库</string>
<string name="xbox_filter_show">显示</string>
<string name="itch_collections">收藏集</string>
<string name="xbox_filter_cloud">云</string>
<string name="stores_xbox_cloud_settings">云设置</string>
</resources>
@@ -3061,6 +3061,7 @@
<string name="library_remove">從收藏庫移除</string>
<string name="library_in">已在收藏庫</string>
<string name="xbox_filter_show">顯示</string>
<string name="itch_collections">收藏集</string>
<string name="xbox_filter_cloud">雲端</string>
<string name="stores_xbox_cloud_settings">雲端設定</string>
</resources>
@@ -3061,6 +3061,7 @@
<string name="library_remove">從收藏庫移除</string>
<string name="library_in">已在收藏庫</string>
<string name="xbox_filter_show">顯示</string>
<string name="itch_collections">收藏集</string>
<string name="xbox_filter_cloud">雲端</string>
<string name="stores_xbox_cloud_settings">雲端設定</string>
</resources>
+1
View File
@@ -3236,6 +3236,7 @@
<string name="library_remove">Remove from Library</string>
<string name="library_in">In Library</string>
<string name="xbox_filter_show">Show</string>
<string name="itch_collections">Collections</string>
<string name="xbox_filter_cloud">Cloud</string>
<string name="stores_xbox_cloud_settings">Cloud settings</string>
<string name="xbox_cloud_gaming" translatable="false">Xbox Cloud Gaming</string>
@@ -0,0 +1,31 @@
package com.droiddeck.launcher.stores.itch
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Test
@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class)
@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class)
class ItchCollectionsTest {
@Test fun collectionsKeepTheirIdTitleAndCount() {
val body = JSONArray()
.put(JSONObject().put("id", 8356509).put("title", "library").put("games_count", 12))
.put(JSONObject().put("id", 7).put("title", "").put("games_count", 3))
.put(JSONObject().put("title", "no id"))
val list = ItchCollections.parseCollections(body)
assertEquals(listOf("8356509"), list.map { it.id })
assertEquals("library", list.single().title)
assertEquals(12, list.single().count)
}
@Test fun collectionGamesReadBothKeyStylesAndSkipRowsWithoutAGame() {
val game = JSONObject().put("id", 99).put("title", "Celeste 64").put("url", "https://noel.itch.io/celeste64")
val snake = JSONObject().put("collection_games", JSONArray().put(JSONObject().put("game", game)).put(JSONObject().put("position", 1)))
val camel = JSONObject().put("collectionGames", JSONArray().put(JSONObject().put("game", game)))
assertEquals(listOf("noel.itch.io/celeste64"), ItchCollections.parseGames(snake).map { it.id })
assertEquals(1, ItchCollections.parseGames(camel).size)
assertEquals(false, ItchCollections.parseGames(snake).single().owned)
assertEquals(0, ItchCollections.parseGames(JSONObject()).size)
}
}
@@ -94,4 +94,23 @@ class LibraryEntriesTest {
GameRef.parse("xbox:9NBLGGH4R315").let { assertEquals(Store.XBOX, it.store); assertEquals("9NBLGGH4R315", it.storeId) }
GameRef.parse("app:nope:1").let { assertNull(it.store); assertNull(it.appId) }
}
@Test fun aCollectionKeepsItsInstalledGamesAndListsTheRestAsNotInstalled() {
val games = listOf(
Library.SteamGame(440, "Team Fortress 2", null, "Steam library"),
Library.SteamGame(-5, "Celeste", null, Library.ADDED, source = "itch", storeId = "a/celeste"),
Library.SteamGame(-6, "Other", null, Library.ADDED, source = "itch", storeId = "a/other"),
)
val library = mapOf(Store.ITCH to listOf(owned(Store.ITCH, "a/celeste"), owned(Store.ITCH, "a/other"), owned(Store.ITCH, "b/owned")))
val all = libraryEntries(games, library, setOf("itch:a/celeste", "itch:a/other"))
val collection = listOf(owned(Store.ITCH, "a/celeste"), owned(Store.ITCH, "b/owned"), owned(Store.ITCH, "c/new"))
val out = collectionEntries(all, collection)
assertEquals(listOf("app:-5", "app:itch:b/owned", "app:itch:c/new"), out.map { it.key })
assertEquals(listOf(true, false, false), out.map { it.installed })
}
@Test fun anEmptyCollectionHidesEveryGame() {
val all = libraryEntries(listOf(Library.SteamGame(440, "Team Fortress 2", null, "Steam library")), emptyMap(), emptySet())
assertEquals(emptyList<String>(), collectionEntries(all, emptyList()).map { it.key })
}
}
+26
View File
@@ -0,0 +1,26 @@
# itch.io collections
On the Library's itch tab a Collections button in the header lists the collections of the signed-in
itch account. Picking one hides every game that is not in it; the games of the collection that are not
installed show in the existing "Not installed" section, and All brings the full library back. The button
exists only while the itch chip is selected.
## Data
`ItchCollections` (`stores/itch/ItchCollections.kt`) asks itch with the account's API key:
```
GET /profile/collections -> collections[{id, title, games_count}]
GET /collections/<id>/collection-games?page=N -> collection_games[{game{...}}]
```
Pages are read until one comes back empty. Each game goes through `ItchLibrary.item`, the reader the
library and shelves share, so it looks the same everywhere. The selection and the loaded games live in
memory; the list is asked again whenever the itch tab opens. Counts are logged
(`itch.io: <n> collections`, `itch.io: collection <id> has <n> games`).
## Filtering
`collectionEntries` keeps the itch entries whose store key is in the collection, then adds the
collection games the library has no entry for as not installed ones. Their game page resolves through
`StoresState.detailItem`, which also looks at the collection games.