-
-
Notifications
You must be signed in to change notification settings - Fork 411
feat: Add Steam collections #1633
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
utkarshdalal
merged 1 commit into
utkarshdalal:master
from
VinceBT:feature/steam-collections
Jul 14, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package app.gamenative.data | ||
|
|
||
| import kotlinx.serialization.Serializable | ||
|
|
||
| @Serializable | ||
| data class SteamCollection( | ||
| val id: String, | ||
| val name: String, | ||
| val appIds: Set<Int> = emptySet(), | ||
| ) { | ||
| companion object { | ||
| // Steam's built-in collections. Display names are localized per client language, | ||
| // but these ids are stable across languages. | ||
| const val ID_FAVORITE = "favorite" | ||
| const val ID_HIDDEN = "hidden" | ||
| } | ||
| } |
56 changes: 56 additions & 0 deletions
56
app/src/main/java/app/gamenative/data/SteamCollectionRepository.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| package app.gamenative.data | ||
|
|
||
| import app.gamenative.PrefManager | ||
| import app.gamenative.steam.SteamCollectionParser | ||
| import kotlinx.coroutines.flow.MutableStateFlow | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import kotlinx.coroutines.flow.asStateFlow | ||
| import kotlinx.serialization.encodeToString | ||
| import kotlinx.serialization.json.Json | ||
| import timber.log.Timber | ||
|
|
||
| object SteamCollectionRepository { | ||
| private val json = Json { ignoreUnknownKeys = true } | ||
|
|
||
| // null = not yet loaded (show all); empty = loaded but none; non-empty = loaded | ||
| private val _collections = MutableStateFlow<List<SteamCollection>?>(null) | ||
| val collections: StateFlow<List<SteamCollection>?> = _collections.asStateFlow() | ||
|
|
||
| private val _skippedDynamic = MutableStateFlow(false) | ||
| val skippedDynamic: StateFlow<Boolean> = _skippedDynamic.asStateFlow() | ||
|
|
||
| /** Populate from the persisted JSON snapshot so the filter works offline / before fetch. */ | ||
| fun loadFromCache() { | ||
| val raw = PrefManager.librarySteamCollectionsCache | ||
| if (raw.isEmpty()) return | ||
| try { | ||
| _collections.value = json.decodeFromString<List<SteamCollection>>(raw) | ||
| _skippedDynamic.value = PrefManager.librarySteamCollectionsSkippedDynamic | ||
| } catch (t: Throwable) { | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| Timber.tag("SteamCollectionRepo").w(t, "Failed to load cached collections; clearing corrupt cache") | ||
| _collections.value = null | ||
| PrefManager.librarySteamCollectionsCache = "" | ||
| PrefManager.librarySteamCollectionsSkippedDynamic = false | ||
| _skippedDynamic.value = false | ||
|
coderabbitai[bot] marked this conversation as resolved.
VinceBT marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| /** Set the freshly-fetched collections and persist them. */ | ||
| fun update(result: SteamCollectionParser.ParseResult) { | ||
| _collections.value = result.collections | ||
| _skippedDynamic.value = result.skippedDynamicCount > 0 | ||
| PrefManager.librarySteamCollectionsSkippedDynamic = _skippedDynamic.value | ||
| try { | ||
| PrefManager.librarySteamCollectionsCache = json.encodeToString(result.collections) | ||
| } catch (t: Throwable) { | ||
| Timber.tag("SteamCollectionRepo").w(t, "Failed to persist collections") | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| fun clear() { | ||
| _collections.value = null | ||
| _skippedDynamic.value = false | ||
| PrefManager.librarySteamCollectionsCache = "" | ||
| PrefManager.librarySteamCollectionsSkippedDynamic = false | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
50 changes: 50 additions & 0 deletions
50
app/src/main/java/app/gamenative/steam/CloudConfigStoreService.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package app.gamenative.steam | ||
|
|
||
| import `in`.dragonbra.javasteam.base.PacketClientMsgProtobuf | ||
| import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesCloudconfigstoreSteamclient.CCloudConfigStore_Download_Request | ||
| import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesCloudconfigstoreSteamclient.CCloudConfigStore_Download_Response | ||
| import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages | ||
| import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.UnifiedService | ||
| import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.callback.ServiceMethodResponse | ||
| import `in`.dragonbra.javasteam.types.AsyncJobSingle | ||
|
|
||
| /** | ||
| * Minimal JavaSteam unified-messages stub for the `CloudConfigStore` service. | ||
| * | ||
| * JavaSteam does not ship a generated stub for this service, and its generic | ||
| * [SteamUnifiedMessages.sendMessage] cannot receive replies on its own: incoming | ||
| * `ServiceMethodResponse` packets are routed by service name through the `handlers` map, which is | ||
| * populated exclusively by [SteamUnifiedMessages.createService]. Without a registered service whose | ||
| * [serviceName] is "CloudConfigStore", the download reply is dropped and the job times out. | ||
| * | ||
| * This stub registers that name and forwards the "Download" reply to the correct protobuf type, | ||
| * exactly like the generated services (e.g. Cloud, FamilyGroups). | ||
| */ | ||
| class CloudConfigStoreService( | ||
| unifiedMessages: SteamUnifiedMessages, | ||
| ) : UnifiedService(unifiedMessages) { | ||
|
|
||
| override val serviceName: String = "CloudConfigStore" | ||
|
|
||
| fun download( | ||
| request: CCloudConfigStore_Download_Request, | ||
| ): AsyncJobSingle<ServiceMethodResponse<CCloudConfigStore_Download_Response.Builder>> = | ||
| unifiedMessages!!.sendMessage( | ||
| CCloudConfigStore_Download_Response.Builder::class.java, | ||
| "CloudConfigStore.Download#1", | ||
| request, | ||
| ) | ||
|
|
||
| override fun handleResponseMsg(methodName: String, packetMsg: PacketClientMsgProtobuf) { | ||
| when (methodName) { | ||
| "Download" -> postResponseMsg<CCloudConfigStore_Download_Response.Builder>( | ||
| CCloudConfigStore_Download_Response::class.java, | ||
| packetMsg, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| override fun handleNotificationMsg(methodName: String, packetMsg: PacketClientMsgProtobuf) { | ||
| // CloudConfigStore has no notifications we consume. | ||
| } | ||
| } |
35 changes: 35 additions & 0 deletions
35
app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package app.gamenative.steam | ||
|
|
||
| import app.gamenative.data.SteamCollection | ||
|
|
||
| object SteamCollectionFilter { | ||
| /** True = keep the app. Fail-open: not-loaded or effectively-empty selection shows everything. */ | ||
| fun passes(appId: Int, selectedIds: Set<String>, collections: List<SteamCollection>?): Boolean { | ||
| val allowed = allowedAppIds(selectedIds, collections) ?: return true | ||
| return appId in allowed | ||
| } | ||
|
|
||
| /** | ||
| * The union of app ids across the selected collections, or null to keep everything (fail-open: | ||
| * collections not loaded, no selection, or a selection that matches no known collection). | ||
| * Compute this once per filter pass and test membership per app, rather than rebuilding the | ||
| * selected subset for every app (which is O(apps x collections) with a per-app allocation). | ||
| */ | ||
| fun allowedAppIds(selectedIds: Set<String>, collections: List<SteamCollection>?): Set<Int>? { | ||
| if (collections == null) return null | ||
| if (selectedIds.isEmpty()) return null | ||
| val selected = collections.filter { it.id in selectedIds } | ||
| if (selected.isEmpty()) return null | ||
| return buildSet { selected.forEach { addAll(it.appIds) } } | ||
| } | ||
|
|
||
| data class Reconciliation(val cleaned: Set<String>, val removedAny: Boolean) | ||
|
|
||
| /** Drop selected ids no longer present. No-op while collections are not loaded (null). */ | ||
| fun reconcile(selectedIds: Set<String>, collections: List<SteamCollection>?): Reconciliation { | ||
| if (collections == null) return Reconciliation(selectedIds, removedAny = false) | ||
| val present = collections.mapTo(HashSet()) { it.id } | ||
| val cleaned = selectedIds.filterTo(LinkedHashSet()) { it in present } | ||
| return Reconciliation(cleaned, removedAny = cleaned.size != selectedIds.size) | ||
| } | ||
| } |
39 changes: 39 additions & 0 deletions
39
app/src/main/java/app/gamenative/steam/SteamCollectionParser.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| package app.gamenative.steam | ||
|
|
||
| import app.gamenative.data.SteamCollection | ||
| import org.json.JSONObject | ||
| import timber.log.Timber | ||
|
|
||
| object SteamCollectionParser { | ||
| private const val KEY_PREFIX = "user-collections." | ||
|
|
||
| data class RawEntry(val key: String, val value: String, val isDeleted: Boolean) | ||
| data class ParseResult(val collections: List<SteamCollection>, val skippedDynamicCount: Int) | ||
|
|
||
| fun parse(entries: List<RawEntry>): ParseResult { | ||
| val collections = mutableListOf<SteamCollection>() | ||
| var skippedDynamic = 0 | ||
| for (e in entries) { | ||
| if (!e.key.startsWith(KEY_PREFIX) || e.isDeleted) continue | ||
| try { | ||
| val json = JSONObject(e.value) | ||
| val added = json.optJSONArray("added") | ||
| // Static collections carry an explicit "added" array. Dynamic ones use "filterSpec". | ||
| if (added == null) { | ||
| if (json.has("filterSpec")) skippedDynamic++ | ||
| continue | ||
| } | ||
| val appIds = buildSet { for (i in 0 until added.length()) add(added.getInt(i)) } | ||
| // optString returns the literal "null" for a JSON-null value, so treat that (and blank) as absent. | ||
| val id = json.optString("id").takeUnless { it.isBlank() || it == "null" } | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| ?: e.key.removePrefix(KEY_PREFIX) | ||
| if (id.isBlank()) continue | ||
| val name = json.optString("name").takeUnless { it.isBlank() || it == "null" } ?: id | ||
| collections.add(SteamCollection(id = id, name = name, appIds = appIds)) | ||
| } catch (t: Throwable) { | ||
| Timber.tag("SteamCollectionParser").w(t, "Skipping malformed collection entry ${e.key}") | ||
| } | ||
| } | ||
| return ParseResult(collections, skippedDynamic) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1448,6 +1448,7 @@ fun PluviaMain( | |
| ) | ||
| }, | ||
| isOffline = isOffline, | ||
| isSteamConnected = state.isSteamConnected, | ||
| ) | ||
| } | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.