diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62369136aa..eff1ae7158 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) { diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 69de228556..306f18936c 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -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) } + + 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 + 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] */ diff --git a/app/src/main/java/app/gamenative/data/SteamCollection.kt b/app/src/main/java/app/gamenative/data/SteamCollection.kt new file mode 100644 index 0000000000..9c1bfeeff4 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/SteamCollection.kt @@ -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 = 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" + } +} diff --git a/app/src/main/java/app/gamenative/data/SteamCollectionRepository.kt b/app/src/main/java/app/gamenative/data/SteamCollectionRepository.kt new file mode 100644 index 0000000000..01d7a74abc --- /dev/null +++ b/app/src/main/java/app/gamenative/data/SteamCollectionRepository.kt @@ -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?>(null) + val collections: StateFlow?> = _collections.asStateFlow() + + private val _skippedDynamic = MutableStateFlow(false) + val skippedDynamic: StateFlow = _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>(raw) + _skippedDynamic.value = PrefManager.librarySteamCollectionsSkippedDynamic + } catch (t: Throwable) { + Timber.tag("SteamCollectionRepo").w(t, "Failed to load cached collections; clearing corrupt cache") + _collections.value = null + PrefManager.librarySteamCollectionsCache = "" + PrefManager.librarySteamCollectionsSkippedDynamic = false + _skippedDynamic.value = false + } + } + + /** 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") + } + } + + fun clear() { + _collections.value = null + _skippedDynamic.value = false + PrefManager.librarySteamCollectionsCache = "" + PrefManager.librarySteamCollectionsSkippedDynamic = false + } +} diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 9d30ac58de..7852b5b90a 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -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 @@ -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() @@ -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) { @@ -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) { @@ -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() } + // Request family share info if we have a familyGroupId. if (callback.familyGroupId != 0L) { scope.launch { @@ -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() + 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") diff --git a/app/src/main/java/app/gamenative/steam/CloudConfigStoreService.kt b/app/src/main/java/app/gamenative/steam/CloudConfigStoreService.kt new file mode 100644 index 0000000000..8c9a0708ab --- /dev/null +++ b/app/src/main/java/app/gamenative/steam/CloudConfigStoreService.kt @@ -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> = + 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::class.java, + packetMsg, + ) + } + } + + override fun handleNotificationMsg(methodName: String, packetMsg: PacketClientMsgProtobuf) { + // CloudConfigStore has no notifications we consume. + } +} diff --git a/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt b/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt new file mode 100644 index 0000000000..1272552b8a --- /dev/null +++ b/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt @@ -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, collections: List?): 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, collections: List?): Set? { + 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, val removedAny: Boolean) + + /** Drop selected ids no longer present. No-op while collections are not loaded (null). */ + fun reconcile(selectedIds: Set, collections: List?): 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) + } +} diff --git a/app/src/main/java/app/gamenative/steam/SteamCollectionParser.kt b/app/src/main/java/app/gamenative/steam/SteamCollectionParser.kt new file mode 100644 index 0000000000..06a44eb34c --- /dev/null +++ b/app/src/main/java/app/gamenative/steam/SteamCollectionParser.kt @@ -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, val skippedDynamicCount: Int) + + fun parse(entries: List): ParseResult { + val collections = mutableListOf() + 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" } + ?: 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) + } +} diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 763acde625..658a1fd9d3 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1448,6 +1448,7 @@ fun PluviaMain( ) }, isOffline = isOffline, + isSteamConnected = state.isSteamConnected, ) } diff --git a/app/src/main/java/app/gamenative/ui/component/OptionListItem.kt b/app/src/main/java/app/gamenative/ui/component/OptionListItem.kt index 0b580f3a0b..6858a7467e 100644 --- a/app/src/main/java/app/gamenative/ui/component/OptionListItem.kt +++ b/app/src/main/java/app/gamenative/ui/component/OptionListItem.kt @@ -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() @@ -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, diff --git a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt index 682d61a12b..ec17d918a5 100644 --- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt +++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt @@ -4,6 +4,7 @@ import app.gamenative.PrefManager import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem +import app.gamenative.data.SteamCollection import app.gamenative.ui.enums.AppFilter import app.gamenative.utils.DeviceGameStatsService.DeviceGameStats import app.gamenative.ui.enums.LibraryTab @@ -32,6 +33,12 @@ data class LibraryState( val showEpicInLibrary: Boolean = PrefManager.showEpicInLibrary, val showAmazonInLibrary: Boolean = PrefManager.showAmazonInLibrary, + // Steam collections filter + val selectedSteamCollectionIds: Set = PrefManager.librarySteamCollections, + val steamCollections: List? = null, // null = not loaded + val skippedDynamicCollections: Boolean = false, + val steamCollectionCounts: Map = emptyMap(), + // Loading state for skeleton loaders val isLoading: Boolean = false, diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index e75bdc9887..884332a57d 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope import app.gamenative.BuildConfig import app.gamenative.PluviaApp import app.gamenative.PrefManager +import app.gamenative.R import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem @@ -18,6 +19,8 @@ import app.gamenative.data.gog.GogSeedCollector import app.gamenative.service.gog.GOGAuthManager import app.gamenative.data.LibraryPlayHistory import app.gamenative.data.SteamApp +import app.gamenative.data.SteamCollection +import app.gamenative.data.SteamCollectionRepository import app.gamenative.events.AndroidEvent import app.gamenative.data.GOGGame import app.gamenative.data.EpicGame @@ -33,6 +36,7 @@ import app.gamenative.service.amazon.AmazonArtwork import app.gamenative.service.amazon.AmazonService import app.gamenative.service.epic.EpicService import app.gamenative.service.gog.GOGService +import app.gamenative.steam.SteamCollectionFilter import app.gamenative.ui.data.LibraryState import app.gamenative.ui.data.statsFor import app.gamenative.ui.enums.AppFilter @@ -40,6 +44,7 @@ import app.gamenative.ui.enums.LibraryTab import app.gamenative.ui.enums.LibraryTab.Companion.next import app.gamenative.ui.enums.LibraryTab.Companion.previous import app.gamenative.ui.enums.SortOption +import app.gamenative.ui.util.SnackbarManager import app.gamenative.utils.CustomGameScanner import app.gamenative.data.RecommendationRepository import app.gamenative.data.RecommendedGame @@ -115,6 +120,8 @@ class LibraryViewModel @Inject constructor( private var amazonGameList: List = emptyList() private var playHistoryByAppId: Map = emptyMap() + @Volatile private var steamCollections: List? = null + // Track if this is the first load to apply minimum load time private var isFirstLoad = true @@ -236,6 +243,35 @@ class LibraryViewModel @Inject constructor( } } + // Load any cached collections immediately, then observe live updates. + SteamCollectionRepository.loadFromCache() + viewModelScope.launch(Dispatchers.IO) { + SteamCollectionRepository.collections.collect { collections -> + steamCollections = collections + // Reconcile the persisted selection against freshly-loaded collections. + val current = _state.value.selectedSteamCollectionIds + val recon = SteamCollectionFilter.reconcile(current, collections) + if (recon.removedAny) { + PrefManager.librarySteamCollections = recon.cleaned + } + _state.update { + it.copy( + steamCollections = collections, + selectedSteamCollectionIds = recon.cleaned, + ) + } + if (recon.removedAny) { + SnackbarManager.show(context.getString(R.string.steam_collections_removed)) + } + onFilterApps(paginationCurrentPage) + } + } + viewModelScope.launch(Dispatchers.IO) { + SteamCollectionRepository.skippedDynamic.collect { skipped -> + _state.update { it.copy(skippedDynamicCollections = skipped) } + } + } + PluviaApp.events.on(onInstallStatusChanged) PluviaApp.events.on(onCustomGameImagesFetched) PluviaApp.events.on(onRecommendationToggleChanged) @@ -388,6 +424,24 @@ class LibraryViewModel @Inject constructor( onFilterApps() } + fun onSteamCollectionToggle(id: String) { + _state.update { currentState -> + val updated = currentState.selectedSteamCollectionIds.toMutableSet() + if (!updated.add(id)) updated.remove(id) + PrefManager.librarySteamCollections = updated + currentState.copy(selectedSteamCollectionIds = updated) + } + onFilterApps() + } + + fun onClearSteamCollections() { + _state.update { currentState -> + PrefManager.librarySteamCollections = emptySet() + currentState.copy(selectedSteamCollectionIds = emptySet()) + } + onFilterApps() + } + fun onPageChange(pageIncrement: Int) { // Amount to change by var toPage = max(0, paginationCurrentPage + pageIncrement) @@ -530,7 +584,7 @@ class LibraryViewModel @Inject constructor( return status == GameCompatibilityStatus.COMPATIBLE || status == GameCompatibilityStatus.GPU_COMPATIBLE } - val steamFilteredBeforeCompatibility: List = appList + val steamOwnerTypeFiltered: List = appList .asSequence() .filter { item -> SteamService.familyMembers.ifEmpty { @@ -574,6 +628,27 @@ class LibraryViewModel @Inject constructor( } .toList() + // Per-collection counts: computed from the owner/type/search-filtered set (independent of the + // current collection selection) so each collection shows how many games it would contribute. + val steamCollectionCounts: Map = steamCollections?.associate { collection -> + collection.id to steamOwnerTypeFiltered.count { it.id in collection.appIds } + } ?: emptyMap() + + // Apply the Steam collection filter — union/OR, fail-open (see SteamCollectionFilter). + // Resolve the allowed app-id set once for the whole pass instead of per app. + val allowedSteamAppIds = SteamCollectionFilter.allowedAppIds( + selectedIds = currentState.selectedSteamCollectionIds, + collections = steamCollections, + ) + val steamFilteredBeforeCompatibility: List = + ( + if (allowedSteamAppIds == null) { + steamOwnerTypeFiltered + } else { + steamOwnerTypeFiltered.filter { it.id in allowedSteamAppIds } + } + ) + // Filter Steam apps first (no pagination yet) // Note: Don't sort individual lists - we'll sort the combined list for consistent ordering val filteredSteamApps: List = steamFilteredBeforeCompatibility @@ -867,12 +942,16 @@ class LibraryViewModel @Inject constructor( }.thenBy { it.item.name.lowercase() } } + // A Steam collection can only contain Steam apps, so when one is selected the non-Steam + // sources can't match it — keep them out of the combined list (and their tab counts). + val steamCollectionSelected = allowedSteamAppIds != null + val combined = buildList { if (includeSteam) addAll(steamEntries) - if (includeOpen) addAll(customEntries) - if (includeGOG) addAll(gogEntries) - if (includeEpic) addAll(epicEntries) - if (includeAmazon) addAll(amazonEntries) + if (includeOpen && !steamCollectionSelected) addAll(customEntries) + if (includeGOG && !steamCollectionSelected) addAll(gogEntries) + if (includeEpic && !steamCollectionSelected) addAll(epicEntries) + if (includeAmazon && !steamCollectionSelected) addAll(amazonEntries) }.sortedWith(sortComparator).mapIndexed { idx, entry -> entry.item.copy(index = idx, isInstalled = entry.isInstalled) } @@ -959,6 +1038,7 @@ class LibraryViewModel @Inject constructor( epicCount = if (currentState.showEpicInLibrary && EpicService.hasStoredCredentials(context)) epicEntries.size else 0, amazonCount = if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) amazonEntries.size else 0, localCount = if (currentState.showCustomGamesInLibrary) customEntries.size else 0, + steamCollectionCounts = steamCollectionCounts, ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt b/app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt index 78fe945020..ae93fb01a2 100644 --- a/app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt @@ -27,7 +27,8 @@ fun HomeScreen( onLogout: () -> Unit, onNavigateRoute: (String) -> Unit, onGoOnline: () -> Unit, - isOffline: Boolean = false + isOffline: Boolean = false, + isSteamConnected: Boolean = false, ) { val homeState by viewModel.homeState.collectAsStateWithLifecycle() @@ -50,6 +51,7 @@ fun HomeScreen( onGoOnline = onGoOnline, onDownloadsClick = { viewModel.onDestination(HomeDestination.Downloads) }, isOffline = isOffline, + isSteamConnected = isSteamConnected, ) HomeDestination.Downloads -> HomeDownloadsScreen( onBack = { viewModel.onDestination(HomeDestination.Library) }, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index c0f06215aa..cf18be43b2 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -127,6 +127,7 @@ fun HomeLibraryScreen( onGoOnline: () -> Unit, onDownloadsClick: () -> Unit = {}, isOffline: Boolean = false, + isSteamConnected: Boolean = false, ) { val state by viewModel.state.collectAsStateWithLifecycle() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -151,11 +152,14 @@ fun HomeLibraryScreen( onSourceToggle = viewModel::onSourceToggle, onAddCustomGameFolder = viewModel::addCustomGameFolder, onSortOptionChanged = viewModel::onSortOptionChanged, + onSteamCollectionToggle = viewModel::onSteamCollectionToggle, + onClearSteamCollections = viewModel::onClearSteamCollections, onOptionsPanelToggle = viewModel::onOptionsPanelToggle, onTabChanged = viewModel::onTabChanged, onPreviousTab = viewModel::onPreviousTab, onNextTab = viewModel::onNextTab, isOffline = isOffline, + isSteamConnected = isSteamConnected, ) } @@ -189,11 +193,14 @@ private fun LibraryScreenContent( onSourceToggle: (GameSource) -> Unit, onAddCustomGameFolder: (String) -> Unit, onSortOptionChanged: (SortOption) -> Unit, + onSteamCollectionToggle: (String) -> Unit, + onClearSteamCollections: () -> Unit, onOptionsPanelToggle: (Boolean) -> Unit, onTabChanged: (LibraryTab) -> Unit, onPreviousTab: () -> Unit, onNextTab: () -> Unit, isOffline: Boolean = false, + isSteamConnected: Boolean = false, ) { val context = LocalContext.current val lifecycleScope = LocalLifecycleOwner.current.lifecycleScope @@ -1152,6 +1159,14 @@ private fun LibraryScreenContent( PrefManager.libraryLayout = newPaneType currentPaneType = newPaneType }, + steamCollections = state.steamCollections, + selectedSteamCollectionIds = state.selectedSteamCollectionIds, + steamCollectionCounts = state.steamCollectionCounts, + skippedDynamicCollections = state.skippedDynamicCollections, + isSteamConnected = isSteamConnected, + isOffline = isOffline, + onSteamCollectionToggle = onSteamCollectionToggle, + onClearSteamCollections = onClearSteamCollections, ) // System menu (START) - renders on top of everything @@ -1321,6 +1336,8 @@ private fun Preview_LibraryScreenContent() { onSourceToggle = {}, onAddCustomGameFolder = {}, onSortOptionChanged = {}, + onSteamCollectionToggle = {}, + onClearSteamCollections = {}, onOptionsPanelToggle = { isOpen -> state = state.copy(isOptionsPanelOpen = isOpen) }, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt index e3ea0ca090..34c49efec9 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -35,6 +36,8 @@ import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Compress import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.PhotoAlbum import androidx.compose.material.icons.filled.PhotoSizeSelectActual import androidx.compose.material.icons.filled.Schedule @@ -46,15 +49,21 @@ import androidx.compose.material.icons.rounded.SportsEsports import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Stars import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -66,6 +75,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.gamenative.PrefManager import app.gamenative.R +import app.gamenative.data.SteamCollection import app.gamenative.ui.component.GameStatsKey import app.gamenative.ui.component.OptionListItem import app.gamenative.ui.component.OptionRadioItem @@ -87,6 +97,14 @@ fun LibraryOptionsPanel( onSortOptionChanged: (SortOption) -> Unit, currentView: PaneType, onViewChanged: (PaneType) -> Unit, + steamCollections: List?, + selectedSteamCollectionIds: Set, + steamCollectionCounts: Map, + skippedDynamicCollections: Boolean, + isSteamConnected: Boolean, + isOffline: Boolean, + onSteamCollectionToggle: (String) -> Unit, + onClearSteamCollections: () -> Unit, modifier: Modifier = Modifier, ) { val firstItemFocusRequester = remember { FocusRequester() } @@ -304,6 +322,116 @@ fun LibraryOptionsPanel( ) } + // Steam collections — local view filter, shown only when Steam is connected. + if (isSteamConnected) { + Spacer(modifier = Modifier.height(20.dp)) + var collectionsExpanded by rememberSaveable { + mutableStateOf(selectedSteamCollectionIds.isNotEmpty()) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { collectionsExpanded = !collectionsExpanded } + .padding(end = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OptionSectionHeader(text = stringResource(R.string.steam_collections_title)) + Row(verticalAlignment = Alignment.CenterVertically) { + if (selectedSteamCollectionIds.isNotEmpty()) { + TextButton(onClick = onClearSteamCollections) { + Text(stringResource(R.string.steam_collections_clear)) + } + } + Icon( + imageVector = if (collectionsExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + AnimatedVisibility(visible = collectionsExpanded) { + Column { + when { + steamCollections == null -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.steam_collections_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + steamCollections.isEmpty() -> { + // No static collections to list, but still explain why (offline / + // only smart collections) so the section doesn't look broken. + if (isOffline) { + Text( + text = stringResource(R.string.steam_collections_offline), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + if (skippedDynamicCollections) { + Text( + text = stringResource(R.string.steam_collections_smart_unsupported), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + } + else -> { + Column( + modifier = Modifier + .fillMaxWidth() + .focusGroup() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + steamCollections.sortedBy { it.name.lowercase() }.forEach { collection -> + OptionListItem( + text = collection.name, + selected = selectedSteamCollectionIds.contains(collection.id), + onClick = { onSteamCollectionToggle(collection.id) }, + trailingText = steamCollectionCounts[collection.id]?.toString(), + modifier = Modifier.fillMaxWidth(), + ) + } + } + if (isOffline) { + Text( + text = stringResource(R.string.steam_collections_offline), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + if (skippedDynamicCollections) { + Text( + text = stringResource(R.string.steam_collections_smart_unsupported), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + } + } + } + } + } + Spacer(modifier = Modifier.height(24.dp)) } } @@ -357,6 +485,17 @@ private fun Preview_LibraryOptionsPanel() { onSortOptionChanged = { }, currentView = PaneType.GRID_HERO, onViewChanged = { }, + steamCollections = listOf( + SteamCollection(id = "fav", name = "Favorites", appIds = setOf(440, 570)), + SteamCollection(id = "rpg", name = "RPGs", appIds = setOf(292030)), + ), + selectedSteamCollectionIds = setOf("fav"), + steamCollectionCounts = mapOf("fav" to 2, "rpg" to 1), + skippedDynamicCollections = true, + isSteamConnected = true, + isOffline = false, + onSteamCollectionToggle = { }, + onClearSteamCollections = { }, ) } } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 96be80556e..354ea76091 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1948,4 +1948,10 @@ Tilgængelig nu Anbefalet Tilføj eller spil nogle spil for at få GOG-anbefalinger baseret på dit bibliotek. + En gemt samling blev fjernet i Steam og filtreres ikke længere. + Steam-samlinger + Indlæser samlinger… + Viser senest synkroniserede samlinger. Opret forbindelse for at opdatere. + Ryd + Kun standardsamlinger vises. Smarte samlinger understøttes ikke endnu. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 72a3e42b3d..23a25c3b18 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2018,4 +2018,10 @@ Jetzt verfügbar Empfohlen Füge Spiele hinzu oder spiele welche, um GOG-Empfehlungen basierend auf deiner Bibliothek zu erhalten. + Eine gespeicherte Sammlung wurde in Steam entfernt und wird nicht mehr gefiltert. + Steam-Sammlungen + Sammlungen werden geladen… + Es werden die zuletzt synchronisierten Sammlungen angezeigt. Zum Aktualisieren verbinden. + Löschen + Es werden nur Standardsammlungen angezeigt. Intelligente Sammlungen werden noch nicht unterstützt. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b065cd0964..0015733e4a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2076,4 +2076,10 @@ Disponible ahora Recomendados Añade o juega a algunos juegos para recibir recomendaciones de GOG basadas en tu biblioteca. + Se eliminó una colección guardada en Steam y ya no se filtra. + Colecciones de Steam + Cargando colecciones… + Mostrando las últimas colecciones sincronizadas. Conéctate para actualizar. + Borrar + Solo se muestran las colecciones estándar. Las colecciones inteligentes aún no son compatibles. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ab04687bef..6c712c86e2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2078,4 +2078,10 @@ Disponible maintenant Recommandés Ajoutez ou lancez des jeux pour obtenir des recommandations GOG basées sur votre bibliothèque. + Une collection enregistrée a été supprimée dans Steam et n\'est plus filtrée. + Collections Steam + Chargement des collections… + Affichage des dernières collections synchronisées. Connectez-vous pour mettre à jour. + Effacer + Seules les collections standard sont affichées. Les collections dynamiques ne sont pas encore prises en charge. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a58a0a2d13..cdf69199b0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2069,4 +2069,10 @@ Ora disponibile Consigliati Aggiungi o gioca ad alcuni giochi per ricevere consigli GOG basati sulla tua libreria. + Una raccolta salvata è stata rimossa da Steam e non viene più filtrata. + Collezioni Steam + Caricamento delle raccolte… + Visualizzazione delle ultime raccolte sincronizzate. Connettiti per aggiornare. + Cancella + Vengono mostrate solo le raccolte standard. Le raccolte intelligenti non sono ancora supportate. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 013dfae7d8..9171c654da 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2035,4 +2035,10 @@ 現在利用可能 おすすめ ゲームを追加またはプレイすると、ライブラリに基づいたGOGのおすすめが表示されます。 + 保存されたコレクションが Steam で削除されたため、フィルターから除外されました。 + Steam コレクション + コレクションを読み込み中… + 最後に同期されたコレクションを表示しています。更新するには接続してください。 + クリア + 標準コレクションのみ表示されます。スマートコレクションはまだ対応していません。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e87772950e..9ab0d844dd 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2076,4 +2076,10 @@ 지금 이용 가능 추천 게임을 추가하거나 플레이하면 라이브러리를 기반으로 한 GOG 추천을 받을 수 있습니다. + 저장된 컬렉션이 Steam에서 제거되어 더 이상 필터링되지 않습니다. + Steam 컬렉션 + 컬렉션 불러오는 중… + 마지막으로 동기화된 컬렉션을 표시하고 있습니다. 업데이트하려면 연결하세요. + 지우기 + 표준 컬렉션만 표시됩니다. 스마트 컬렉션은 아직 지원되지 않습니다. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 54164cb478..e484424ae1 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2076,4 +2076,10 @@ Dostępne teraz Polecane Dodaj lub zagraj w kilka gier, aby otrzymać rekomendacje GOG na podstawie Twojej biblioteki. + Zapisana kolekcja została usunięta w Steam i nie jest już filtrowana. + Kolekcje Steam + Wczytywanie kolekcji… + Wyświetlanie ostatnio zsynchronizowanych kolekcji. Połącz się, aby zaktualizować. + Wyczyść + Wyświetlane są tylko standardowe kolekcje. Kolekcje inteligentne nie są jeszcze obsługiwane. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 71c7e5ea61..35e0e26812 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1948,4 +1948,10 @@ Disponível agora Recomendados Adicione ou jogue alguns jogos para receber recomendações da GOG com base na sua biblioteca. + Uma coleção salva foi removida no Steam e não é mais filtrada. + Coleções da Steam + Carregando coleções… + Exibindo as últimas coleções sincronizadas. Conecte-se para atualizar. + Limpar + Apenas coleções padrão são exibidas. Coleções inteligentes ainda não são compatíveis. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index c05d059440..94eabc7df3 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2079,4 +2079,10 @@ Acum disponibil Recomandate Adaugă sau joacă câteva jocuri pentru a primi recomandări GOG pe baza bibliotecii tale. + O colecție salvată a fost eliminată în Steam și nu mai este filtrată. + Colecții Steam + Se încarcă colecțiile… + Se afișează ultimele colecții sincronizate. Conectează-te pentru a actualiza. + Șterge + Sunt afișate doar colecțiile standard. Colecțiile inteligente nu sunt încă acceptate. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d711734ed7..002b3939ad 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2004,4 +2004,10 @@ https://gamenative.app Доступно сейчас Рекомендации Добавьте или поиграйте в игры, чтобы получить рекомендации GOG на основе вашей библиотеки. + Сохранённая коллекция была удалена в Steam и больше не используется для фильтрации. + Коллекции Steam + Загрузка коллекций… + Показаны последние синхронизированные коллекции. Подключитесь для обновления. + Очистить + Показаны только обычные коллекции. Умные коллекции пока не поддерживаются. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a54443c0ff..1d374c47e6 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2072,4 +2072,10 @@ Доступно зараз Рекомендовані Додайте або пограйте в ігри, щоб отримати рекомендації GOG на основі вашої бібліотеки. + Збережену колекцію було видалено в Steam, і вона більше не фільтрується. + Колекції Steam + Завантаження колекцій… + Показано останні синхронізовані колекції. Підключіться, щоб оновити. + Очистити + Показано лише звичайні колекції. Розумні колекції ще не підтримуються. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c68407fe10..9a17da00ec 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2096,4 +2096,10 @@ 现在可用 推荐 添加或试玩一些游戏,即可根据你的游戏库获得 GOG 推荐。 + 已保存的收藏集已在 Steam 中移除,不再用于筛选。 + Steam 收藏集 + 正在加载收藏集… + 正在显示上次同步的收藏集。请连接以更新。 + 清除 + 仅显示标准收藏集。智能收藏集尚不受支持。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c9d7cd17d8..178509cd2c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2087,4 +2087,10 @@ 現在可用 推薦 新增或試玩一些遊戲,即可根據你的遊戲庫獲得 GOG 推薦。 + 已儲存的收藏已在 Steam 中移除,不再用於篩選。 + Steam 收藏 + 正在載入收藏… + 正在顯示上次同步的收藏。請連線以更新。 + 清除 + 僅顯示標準收藏。智慧型收藏尚不支援。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d416a16540..debe07e400 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,6 +117,12 @@ Custom %1$s (%2$d) Close search + A saved collection was removed in Steam and is no longer filtered. + Steam collections + Loading collections… + Showing last synced collections. Connect to update. + Clear + Only standard collections are shown. Smart collections aren\'t supported yet. User diff --git a/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt b/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt new file mode 100644 index 0000000000..c2a9d1d783 --- /dev/null +++ b/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt @@ -0,0 +1,62 @@ +package app.gamenative.steam + +import app.gamenative.data.SteamCollection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SteamCollectionFilterTest { + private val favorites = SteamCollection("fav", "Favorites", setOf(440, 570)) + private val shooters = SteamCollection("sht", "Shooters", setOf(730)) + private val all = listOf(favorites, shooters) + + @Test fun notLoadedShowsAll() = + assertTrue(SteamCollectionFilter.passes(999, setOf("fav"), collections = null)) + + @Test fun emptySelectionShowsAll() = + assertTrue(SteamCollectionFilter.passes(999, emptySet(), all)) + + @Test fun unionMatchAcrossSelected() { + val sel = setOf("fav", "sht") + assertTrue(SteamCollectionFilter.passes(440, sel, all)) + assertTrue(SteamCollectionFilter.passes(730, sel, all)) + assertFalse(SteamCollectionFilter.passes(999, sel, all)) + } + + @Test fun selectionAllDeletedShowsAll() { + // selected id no longer exists in collections -> effective selection empty -> show all + assertTrue(SteamCollectionFilter.passes(999, setOf("gone"), all)) + } + + @Test fun reconcileDropsMissingIds() { + val r = SteamCollectionFilter.reconcile(setOf("fav", "gone"), all) + assertEquals(setOf("fav"), r.cleaned) + assertTrue(r.removedAny) + } + + @Test fun reconcileNoChangeWhenAllPresent() { + val r = SteamCollectionFilter.reconcile(setOf("fav"), all) + assertEquals(setOf("fav"), r.cleaned) + assertFalse(r.removedAny) + } + + @Test fun reconcileSkippedWhenNotLoaded() { + // not loaded -> don't drop anything (avoid wiping a valid selection before data arrives) + val r = SteamCollectionFilter.reconcile(setOf("fav"), collections = null) + assertEquals(setOf("fav"), r.cleaned) + assertFalse(r.removedAny) + } + + @Test fun allowedAppIdsNullForFailOpenCases() { + // null collections, empty selection, and selection matching no known collection all show all. + assertEquals(null, SteamCollectionFilter.allowedAppIds(setOf("fav"), collections = null)) + assertEquals(null, SteamCollectionFilter.allowedAppIds(emptySet(), all)) + assertEquals(null, SteamCollectionFilter.allowedAppIds(setOf("gone"), all)) + } + + @Test fun allowedAppIdsUnionsSelectedCollections() { + assertEquals(setOf(440, 570), SteamCollectionFilter.allowedAppIds(setOf("fav"), all)) + assertEquals(setOf(440, 570, 730), SteamCollectionFilter.allowedAppIds(setOf("fav", "sht"), all)) + } +} diff --git a/app/src/test/java/app/gamenative/steam/SteamCollectionParserTest.kt b/app/src/test/java/app/gamenative/steam/SteamCollectionParserTest.kt new file mode 100644 index 0000000000..e134daadfc --- /dev/null +++ b/app/src/test/java/app/gamenative/steam/SteamCollectionParserTest.kt @@ -0,0 +1,97 @@ +package app.gamenative.steam + +import app.gamenative.steam.SteamCollectionParser.RawEntry +import org.junit.Assert.assertEquals +import org.junit.Test + +class SteamCollectionParserTest { + private fun entry(id: String, json: String, deleted: Boolean = false) = + RawEntry(key = "user-collections.$id", value = json, isDeleted = deleted) + + @Test + fun parsesStaticCollectionWithAddedApps() { + val result = SteamCollectionParser.parse( + listOf(entry("abc", """{"id":"abc","name":"Favorites","added":[440,570]}""")) + ) + assertEquals(1, result.collections.size) + val c = result.collections.first() + assertEquals("abc", c.id) + assertEquals("Favorites", c.name) + assertEquals(setOf(440, 570), c.appIds) + assertEquals(0, result.skippedDynamicCount) + } + + @Test + fun skipsDeletedEntries() { + val result = SteamCollectionParser.parse( + listOf(entry("abc", """{"id":"abc","name":"X","added":[1]}""", deleted = true)) + ) + assertEquals(0, result.collections.size) + } + + @Test + fun skipsDynamicCollectionsAndCountsThem() { + val result = SteamCollectionParser.parse( + listOf(entry("dyn", """{"id":"dyn","name":"Smart","filterSpec":{"x":1}}""")) + ) + assertEquals(0, result.collections.size) + assertEquals(1, result.skippedDynamicCount) + } + + @Test + fun ignoresNonCollectionKeys() { + val result = SteamCollectionParser.parse( + listOf(RawEntry(key = "other.key", value = "{}", isDeleted = false)) + ) + assertEquals(0, result.collections.size) + assertEquals(0, result.skippedDynamicCount) + } + + @Test + fun treatsStaticWithEmptyAddedAsStatic() { + val result = SteamCollectionParser.parse( + listOf(entry("e", """{"id":"e","name":"Empty","added":[]}""")) + ) + assertEquals(1, result.collections.size) + assertEquals(emptySet(), result.collections.first().appIds) + } + + @Test + fun toleratesMalformedJson() { + val result = SteamCollectionParser.parse( + listOf(entry("bad", "not json")) + ) + assertEquals(0, result.collections.size) + } + + @Test + fun fallsBackToKeyAndIdWhenIdNameAreJsonNull() { + // optString returns the literal "null" for a JSON-null value; it must be treated as absent. + val result = SteamCollectionParser.parse( + listOf(entry("xyz", """{"id":null,"name":null,"added":[1]}""")) + ) + assertEquals(1, result.collections.size) + val c = result.collections.first() + assertEquals("xyz", c.id) // from the entry key, not the literal "null" + assertEquals("xyz", c.name) // falls back to id + } + + @Test + fun nonArrayAddedIsDroppedAndNotCountedDynamic() { + // "added" present but not an array (optJSONArray -> null) and no filterSpec: drop, don't count. + val result = SteamCollectionParser.parse( + listOf(entry("x", """{"id":"x","name":"X","added":"440"}""")) + ) + assertEquals(0, result.collections.size) + assertEquals(0, result.skippedDynamicCount) + } + + @Test + fun nonIntegerAddedElementDropsWholeCollection() { + // A non-coercible element makes getInt throw; the whole entry is skipped (all-or-nothing). + val result = SteamCollectionParser.parse( + listOf(entry("x", """{"id":"x","name":"X","added":[440,null]}""")) + ) + assertEquals(0, result.collections.size) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb7b47e97b..ca8f42a004 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espres feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery play-integrity = "1.6.0" # https://mvnrepository.com/artifact/com.google.android.play/integrity hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose -javasteam = "1.8.0.1-21-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest +javasteam = "1.8.0.1-22-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit