Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,8 @@ dependencies {
// JavaSteam
val localBuild = false // Change to 'true' needed when building JavaSteam manually
if (localBuild) {
implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-21-SNAPSHOT.jar"))
implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-21-SNAPSHOT.jar"))
implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-22-SNAPSHOT.jar"))
implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-22-SNAPSHOT.jar"))
implementation(libs.bundles.javasteam.dev)
} else {
implementation(libs.javasteam) {
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/java/app/gamenative/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,28 @@ object PrefManager {
setPref(LIBRARY_SORT_KEY, value.key)
}

private val LIBRARY_STEAM_COLLECTIONS_CACHE = stringPreferencesKey("library_steam_collections_cache")
var librarySteamCollectionsCache: String
get() = getPref(LIBRARY_STEAM_COLLECTIONS_CACHE, "")
set(value) { setPref(LIBRARY_STEAM_COLLECTIONS_CACHE, value) }

private val LIBRARY_STEAM_COLLECTIONS_SKIPPED_DYNAMIC = booleanPreferencesKey("library_steam_collections_skipped_dynamic")
var librarySteamCollectionsSkippedDynamic: Boolean
get() = getPref(LIBRARY_STEAM_COLLECTIONS_SKIPPED_DYNAMIC, false)
set(value) { setPref(LIBRARY_STEAM_COLLECTIONS_SKIPPED_DYNAMIC, value) }
Comment thread
VinceBT marked this conversation as resolved.

private val LIBRARY_STEAM_COLLECTIONS = stringPreferencesKey("library_steam_collections")
private const val COLLECTION_ID_SEPARATOR = "" // unit separator; cannot appear in a collection id
var librarySteamCollections: Set<String>
get() {
val raw = getPref(LIBRARY_STEAM_COLLECTIONS, "")
if (raw.isEmpty()) return emptySet()
return raw.split(COLLECTION_ID_SEPARATOR).filter { it.isNotEmpty() }.toSet()
}
set(value) {
setPref(LIBRARY_STEAM_COLLECTIONS, value.joinToString(COLLECTION_ID_SEPARATOR))
}

/**
* Get or Set the last known Persona State. See [EPersonaState]
*/
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/app/gamenative/data/SteamCollection.kt
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"
}
}
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) {
Comment thread
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
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")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

fun clear() {
_collections.value = null
_skippedDynamic.value = false
PrefManager.librarySteamCollectionsCache = ""
PrefManager.librarySteamCollectionsSkippedDynamic = false
}
}
67 changes: 67 additions & 0 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ import `in`.dragonbra.javasteam.enums.EPersonaState
import `in`.dragonbra.javasteam.enums.EResult
import `in`.dragonbra.javasteam.networking.steam3.ProtocolTypes
import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientObjects.ECloudPendingRemoteOperation
import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesCloudconfigstoreSteamclient
import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesFamilygroupsSteamclient
import app.gamenative.data.SteamCollectionRepository
import app.gamenative.steam.CloudConfigStoreService
import app.gamenative.steam.SteamCollectionParser
import `in`.dragonbra.javasteam.rpc.service.FamilyGroups
import `in`.dragonbra.javasteam.steam.authentication.AuthPollResult
import `in`.dragonbra.javasteam.steam.authentication.AuthSessionDetails
Expand Down Expand Up @@ -290,6 +294,7 @@ class SteamService : Service(), IChallengeUrlChanged {
private var picsGetProductInfoJob: Job? = null
private var picsChangesCheckerJob: Job? = null
private var friendCheckerJob: Job? = null
private var steamCollectionsJob: Job? = null

private val _isPlayingBlocked = MutableStateFlow(false)
val isPlayingBlocked = _isPlayingBlocked.asStateFlow()
Expand Down Expand Up @@ -2883,6 +2888,7 @@ class SteamService : Service(), IChallengeUrlChanged {
PrefManager.clearSteamSessionPreferences()
instance?.clearPendingSync()
clearDatabase(clearCloudSyncState = clearCloudSyncState)
SteamCollectionRepository.clear()
}

private fun shouldClearUserDataForLoggedOnFailure(result: EResult): Boolean = when (result) {
Expand Down Expand Up @@ -2927,6 +2933,8 @@ class SteamService : Service(), IChallengeUrlChanged {
instance?.picsGetProductInfoJob?.cancel()
instance?.picsChangesCheckerJob?.cancel()
instance?.friendCheckerJob?.cancel()
// Stop an in-flight collections fetch so a slow RPC can't repopulate the repo after logout.
instance?.steamCollectionsJob?.cancel()
}

private fun performLogOffDuties(clearCloudSyncState: Boolean = false) {
Expand Down Expand Up @@ -3749,6 +3757,9 @@ class SteamService : Service(), IChallengeUrlChanged {
// retrieve persona data of logged in user
scope.launch { requestUserPersona() }

// fetch the user's Steam collections for the library filter
steamCollectionsJob = scope.launch { fetchSteamCollections() }

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Request family share info if we have a familyGroupId.
if (callback.familyGroupId != 0L) {
scope.launch {
Expand Down Expand Up @@ -4053,6 +4064,62 @@ class SteamService : Service(), IChallengeUrlChanged {
}
}

/**
* Downloads the user's Steam collections from CloudConfigStore and publishes the
* parsed static collections to [SteamCollectionRepository] for the library filter.
*/
internal suspend fun fetchSteamCollections() {
val client = steamClient
// Remember who we fetched for, so a slow RPC can't write one account's collections into another.
val fetchSteamId = client?.steamID?.convertToUInt64()
val um = client?.getHandler<SteamUnifiedMessages>()
if (um == null) {
Timber.tag("SteamCollections").w("UnifiedMessages handler unavailable; cannot fetch collections")
return
}
try {
val request = SteammessagesCloudconfigstoreSteamclient.CCloudConfigStore_Download_Request.newBuilder()
.addVersions(
SteammessagesCloudconfigstoreSteamclient.CCloudConfigStore_NamespaceVersion.newBuilder()
.setEnamespace(1) // user collections namespace
.setVersion(0L), // 0 = full download
)
.build()

// A registered service is required: JavaSteam routes ServiceMethodResponse packets by
// service name, so the generic sendMessage alone never receives the reply.
val service = um.createService(CloudConfigStoreService::class.java)
val job = service.download(request)
// The fetch fires during the post-login burst (PICS for the whole library), so give the
// response generous headroom beyond the default job timeout.
job.timeout = 60_000L
val response = job.toFuture().await()

val body = response.body.build()
val rawEntries = body.dataList.flatMap { ns ->
ns.entriesList.map { entry ->
SteamCollectionParser.RawEntry(
key = entry.key,
value = entry.value,
isDeleted = entry.isDeleted,
)
}
}

val parsed = SteamCollectionParser.parse(rawEntries)
Timber.tag("SteamCollections").i(
"Fetched ${parsed.collections.size} Steam collections " +
"(${parsed.skippedDynamicCount} dynamic skipped)",
)
// Drop the result if we logged out or switched accounts while the RPC was in flight.
if (isLoggedIn && steamClient?.steamID?.convertToUInt64() == fetchSteamId) {
SteamCollectionRepository.update(parsed)
}
} catch (t: Throwable) {
Timber.tag("SteamCollections").e(t, "Failed to fetch Steam collections; keeping cached snapshot")
}
}

private fun onLicenseList(callback: LicenseListCallback) {
if (callback.result != EResult.OK) {
Timber.w("Failed to get License list")
Expand Down
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 app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt
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 app/src/main/java/app/gamenative/steam/SteamCollectionParser.kt
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" }
Comment thread
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)
}
}
1 change: 1 addition & 0 deletions app/src/main/java/app/gamenative/ui/PluviaMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,7 @@ fun PluviaMain(
)
},
isOffline = isOffline,
isSteamConnected = state.isSteamConnected,
)
}

Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/app/gamenative/ui/component/OptionListItem.kt
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ fun OptionListItem(
icon: ImageVector? = null,
focusRequester: FocusRequester = remember { FocusRequester() },
showCheckmark: Boolean = true,
trailingText: String? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
Expand Down Expand Up @@ -169,6 +170,15 @@ fun OptionListItem(
modifier = Modifier.weight(1f)
)

if (trailingText != null) {
Text(
text = trailingText,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = if (showCheckmark && selected) 8.dp else 0.dp),
)
}

if (showCheckmark && selected) {
Icon(
imageVector = Icons.Default.Check,
Expand Down
Loading
Loading