From 399df455a96795dfa84f14d62df409e6ee6249f1 Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Fri, 7 Aug 2026 03:00:43 -0700 Subject: [PATCH 01/15] Hide platform-hidden Steam and GOG games from the library --- .../main/java/app/gamenative/PrefManager.kt | 27 +++++ .../gamenative/data/GogHiddenRepository.kt | 37 ++++++ .../app/gamenative/data/HiddenGameFilter.kt | 41 +++++++ .../app/gamenative/events/AndroidEvent.kt | 1 + .../gamenative/service/gog/GOGApiClient.kt | 114 ++++++++++++++++++ .../app/gamenative/service/gog/GOGManager.kt | 24 ++++ .../app/gamenative/service/gog/GOGService.kt | 4 + .../service/gog/GogFilteredProductsParser.kt | 57 +++++++++ .../gamenative/steam/SteamCollectionFilter.kt | 10 ++ .../app/gamenative/ui/data/LibraryCounts.kt | 29 +++++ .../gamenative/ui/model/LibraryViewModel.kt | 81 +++++++++++-- .../screen/settings/SettingsGroupInterface.kt | 14 ++- app/src/main/res/values/strings.xml | 2 + .../PrefManagerHiddenGamesDefaultsTest.kt | 22 ++++ .../data/GogHiddenRepositoryTest.kt | 69 +++++++++++ .../gamenative/data/HiddenGameFilterTest.kt | 85 +++++++++++++ .../gog/GogFilteredProductsParserTest.kt | 84 +++++++++++++ .../steam/SteamCollectionFilterTest.kt | 19 +++ .../gamenative/ui/data/LibraryCountsTest.kt | 36 ++++++ .../app/gamenative/utils/TestPrefManager.kt | 60 +++++++++ 20 files changed, 804 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/app/gamenative/data/GogHiddenRepository.kt create mode 100644 app/src/main/java/app/gamenative/data/HiddenGameFilter.kt create mode 100644 app/src/main/java/app/gamenative/service/gog/GogFilteredProductsParser.kt create mode 100644 app/src/main/java/app/gamenative/ui/data/LibraryCounts.kt create mode 100644 app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt create mode 100644 app/src/test/java/app/gamenative/data/GogHiddenRepositoryTest.kt create mode 100644 app/src/test/java/app/gamenative/data/HiddenGameFilterTest.kt create mode 100644 app/src/test/java/app/gamenative/service/gog/GogFilteredProductsParserTest.kt create mode 100644 app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt create mode 100644 app/src/test/java/app/gamenative/utils/TestPrefManager.kt diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 8d77acdecb..96dcade6ae 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -897,6 +897,21 @@ object PrefManager { setPref(LIBRARY_STEAM_COLLECTIONS, value.joinToString(COLLECTION_ID_SEPARATOR)) } + /** + * IDs of GOG games the user has hidden on GOG, cached so hidden filtering works offline. + * Encoded the same way as [librarySteamCollections]; empty by default. + */ + private val LIBRARY_GOG_HIDDEN_IDS = stringPreferencesKey("library_gog_hidden_ids") + var libraryGogHiddenIds: Set + get() { + val raw = getPref(LIBRARY_GOG_HIDDEN_IDS, "") + if (raw.isEmpty()) return emptySet() + return raw.split(COLLECTION_ID_SEPARATOR).filter { it.isNotEmpty() }.toSet() + } + set(value) { + setPref(LIBRARY_GOG_HIDDEN_IDS, value.joinToString(COLLECTION_ID_SEPARATOR)) + } + /** * Get or Set the last known Persona State. See [EPersonaState] */ @@ -1163,6 +1178,18 @@ object PrefManager { setPref(SHOW_RECOMMENDATIONS, value) } + /** + * Whether games marked hidden on Steam/GOG are shown in the library by default. + * Defaults to false so hidden games stay out of the library unless the user explicitly + * selects the Steam Hidden collection or turns this setting on. + */ + private val SHOW_HIDDEN_GAMES_BY_DEFAULT = booleanPreferencesKey("show_hidden_games_by_default") + var showHiddenGamesByDefault: Boolean + get() = getPref(SHOW_HIDDEN_GAMES_BY_DEFAULT, false) + set(value) { + setPref(SHOW_HIDDEN_GAMES_BY_DEFAULT, value) + } + private val REC_DISCLOSURE_SHOWN = booleanPreferencesKey("rec_disclosure_shown") var recDisclosureShown: Boolean get() = getPref(REC_DISCLOSURE_SHOWN, false) diff --git a/app/src/main/java/app/gamenative/data/GogHiddenRepository.kt b/app/src/main/java/app/gamenative/data/GogHiddenRepository.kt new file mode 100644 index 0000000000..515315c3e3 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/GogHiddenRepository.kt @@ -0,0 +1,37 @@ +package app.gamenative.data + +import app.gamenative.PrefManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Cache of GOG product IDs the user has hidden on GOG. + * + * Semantics of [hiddenIds]: + * - `null` means hidden metadata has not been loaded yet, so filtering fails open. + * - `emptySet` means a successful sync found no hidden games. + * - otherwise the set holds the hidden product IDs from the last successful sync. + */ +object GogHiddenRepository { + private val _hiddenIds = MutableStateFlow?>(null) + val hiddenIds: StateFlow?> = _hiddenIds.asStateFlow() + + /** Loads persisted hidden IDs. With no persisted cache the flow stays null (fail open). */ + fun loadFromCache() { + val cached = PrefManager.libraryGogHiddenIds + _hiddenIds.value = if (cached.isEmpty()) null else cached + } + + /** Publishes a successful sync result and persists it. */ + fun update(ids: Set) { + PrefManager.libraryGogHiddenIds = ids + _hiddenIds.value = ids + } + + /** Clears both in-memory and persisted state (e.g. on GOG logout). */ + fun clear() { + PrefManager.libraryGogHiddenIds = emptySet() + _hiddenIds.value = null + } +} diff --git a/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt b/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt new file mode 100644 index 0000000000..9dadc98670 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt @@ -0,0 +1,41 @@ +package app.gamenative.data + +import app.gamenative.PrefManager + +/** + * Visibility rules for games the user has hidden on a platform. + * + * Hidden games are excluded from the library by default. Steam's built-in Hidden collection can + * explicitly reveal hidden Steam games, and the "show hidden games by default" setting reveals + * hidden games everywhere. Missing hidden metadata fails open so a game is never hidden just + * because the metadata has not loaded yet. + */ +object HiddenGameFilter { + /** + * Whether a Steam app should appear in the library. + * + * @param appId Steam app ID to test. + * @param hiddenAppIds IDs from the Steam Hidden collection; empty when collections are unloaded. + * @param showHiddenByDefault Value of [PrefManager.showHiddenGamesByDefault]. + * @param hiddenCollectionSelected Whether the Hidden collection is among the selected collection IDs. + */ + fun passesSteam( + appId: Int, + hiddenAppIds: Set, + showHiddenByDefault: Boolean, + hiddenCollectionSelected: Boolean, + ): Boolean = showHiddenByDefault || hiddenCollectionSelected || appId !in hiddenAppIds + + /** + * Whether a GOG game should appear in the library. + * + * @param gameId GOG product ID to test. + * @param hiddenIds Hidden GOG product IDs; null means not loaded yet and fails open. + * @param showHiddenByDefault Value of [PrefManager.showHiddenGamesByDefault]. + */ + fun passesGog( + gameId: String, + hiddenIds: Set?, + showHiddenByDefault: Boolean, + ): Boolean = hiddenIds == null || showHiddenByDefault || gameId !in hiddenIds +} diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt index a2ec39df10..48ddd6d33e 100644 --- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt +++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt @@ -26,6 +26,7 @@ interface AndroidEvent : Event { data class LibraryInstallStatusChanged(val appId: Int, val source: GameSource) : AndroidEvent data class CustomGameImagesFetched(val appId: String) : AndroidEvent data object RecommendationToggleChanged : AndroidEvent + data object HiddenGamesSettingChanged : AndroidEvent data class GOGAuthCodeReceived(val authCode: String) : AndroidEvent data class EpicAuthCodeReceived(val authCode: String) : AndroidEvent data object ServiceReady : AndroidEvent diff --git a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt index 975f3ae20b..6d2897cd3c 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt @@ -2,6 +2,7 @@ package app.gamenative.service.gog import android.content.Context import app.gamenative.data.GOGGame +import app.gamenative.data.GOGCredentials import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -163,6 +164,119 @@ object GOGApiClient { } } + /** + * Fetch IDs of games the user has hidden in their GOG library. + * + * Queries `account/getFilteredProducts?hiddenFlag=1` (all pages), where every returned product + * is hidden. Tries the embed host first, then www as a fallback when embed returns nothing, + * so a host that excludes hidden products cannot silently produce an empty set. Pagination is + * all-or-nothing per host: failures return failure and the caller retains its previous cache. + * + * @param context Application context for auth access + * @return Result containing the set of hidden game IDs or error + */ + suspend fun getHiddenGameIds(context: Context): Result> = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG").d("Fetching hidden GOG game IDs...") + + // Get credentials from AuthManager + val credentialsResult = GOGAuthManager.getStoredCredentials(context) + if (credentialsResult.isFailure) { + val error = credentialsResult.exceptionOrNull() + Timber.tag("GOG").e(error, "Cannot list hidden games: not authenticated") + return@withContext Result.failure(Exception("Not authenticated. Please log in first.")) + } + + val credentials = credentialsResult.getOrNull() + if (credentials == null || credentials.accessToken.isEmpty()) { + Timber.tag("GOG").e("No valid access token found") + return@withContext Result.failure(Exception("No valid credentials found")) + } + + val primaryResult = fetchHiddenGameIdsFrom(credentials, GOGConstants.GOG_EMBED_URL) + val primaryIds = primaryResult.getOrNull() + if (primaryIds != null && primaryIds.isNotEmpty()) { + Timber.tag("GOG").i("Successfully fetched ${primaryIds.size} hidden GOG game IDs") + return@withContext Result.success(primaryIds) + } + + // The embed host returned no hidden products (or failed). Retry on www before + // concluding the account has none, because hiddenFlag handling can differ between hosts. + val fallbackResult = fetchHiddenGameIdsFrom(credentials, "https://www.gog.com") + val mergedIds = buildSet { + primaryIds?.let { addAll(it) } + fallbackResult.getOrNull()?.let { addAll(it) } + } + if (primaryResult.isFailure && fallbackResult.isFailure) { + val error = primaryResult.exceptionOrNull() + ?: Exception("Failed to fetch hidden GOG game IDs") + Timber.tag("GOG").e(error, "Failed to fetch hidden GOG game IDs from both hosts") + return@withContext Result.failure(error) + } + if (fallbackResult.isFailure) { + Timber.tag("GOG").w(fallbackResult.exceptionOrNull(), "www fallback failed; using embed result") + } + + Timber.tag("GOG").i("Successfully fetched ${mergedIds.size} hidden GOG game IDs") + return@withContext Result.success(mergedIds) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Exception fetching hidden GOG game IDs: ${e.message}") + return@withContext Result.failure(e) + } + } + + /** + * Paginates `account/getFilteredProducts?hiddenFlag=1` on [baseUrl] and returns every product + * ID (all products on those pages are hidden). Pagination is all-or-nothing: any page failure + * fails the whole attempt so callers can retain their previous cache. + */ + private suspend fun fetchHiddenGameIdsFrom( + credentials: GOGCredentials, + baseUrl: String, + ): Result> { + return try { + var page = 1 + var totalPages = 1 + val hiddenIds = mutableSetOf() + while (page <= totalPages) { + // hiddenFlag=1 makes the account library endpoint return only hidden products; + // without it the response excludes hidden games entirely. + val url = "$baseUrl/account/getFilteredProducts?hiddenFlag=1&mediaType=1&page=$page" + Timber.tag("GOG").d("Requesting hidden game IDs from: $url") + val request = Request.Builder() + .url(url) + .addHeader("Authorization", "Bearer ${credentials.accessToken}") + .addHeader("User-Agent", "GameNative/1.0") + // GOG's embed host expects an AJAX header to return JSON for this endpoint. + .addHeader("X-Requested-With", "XMLHttpRequest") + .get() + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Timber.tag("GOG").e("Failed to fetch hidden game IDs: HTTP ${response.code} - $errorBody") + return Result.failure( + Exception("Failed to fetch hidden game IDs: HTTP ${response.code}") + ) + } + + val responseBody = response.body?.string() + ?: return Result.failure(Exception("Empty response from GOG")) + val parsed = GogFilteredProductsParser.parseHiddenPage(responseBody) + hiddenIds.addAll(parsed.hiddenProductIds) + totalPages = parsed.totalPages + } + page++ + } + + Timber.tag("GOG").d("Fetched ${hiddenIds.size} hidden GOG game IDs from $baseUrl") + Result.success(hiddenIds) + } catch (e: Exception) { + Result.failure(e) + } + } + /** * Fetch detailed information for a specific game by ID * diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index c2fd406868..4f28703d30 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -2,6 +2,7 @@ package app.gamenative.service.gog import android.content.Context import app.gamenative.PluviaApp +import app.gamenative.data.GogHiddenRepository import app.gamenative.data.GOGCloudSavesLocation import app.gamenative.data.GOGCloudSavesLocationTemplate import app.gamenative.data.GOGGame @@ -152,6 +153,25 @@ class GOGManager @Inject constructor( } } + /** + * Fetches hidden-product IDs and publishes them via [GogHiddenRepository]. + * + * Failures keep the previous cache (or the unloaded fail-open state) and are logged; this + * never throws and never fails the caller. + */ + suspend fun refreshHiddenIds() { + if (!GOGAuthManager.hasStoredCredentials(context)) return + val hiddenIdsResult = GOGApiClient.getHiddenGameIds(context) + if (hiddenIdsResult.isSuccess) { + GogHiddenRepository.update(hiddenIdsResult.getOrNull() ?: emptySet()) + } else { + Timber.tag("GOG").w( + hiddenIdsResult.exceptionOrNull(), + "Failed to fetch hidden GOG game IDs; keeping cached hidden list", + ) + } + } + /** * Refresh the entire library (called manually by user) * Fetches all games from GOG API and updates the database @@ -180,6 +200,10 @@ class GOGManager @Inject constructor( val gameIds = gameIdList.getOrNull() ?: emptyList() Timber.tag("GOG").i("Successfully fetched ${gameIds.size} game IDs from GOG") + // Refresh hidden-game metadata even when the owned library itself is unchanged. + // A failure here keeps the previous cache and must not fail the library refresh. + refreshHiddenIds() + if (gameIds.isEmpty()) { Timber.w("No games found in GOG library") return@withContext Result.success(0) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 003d8389f4..4a28b565b4 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -8,6 +8,7 @@ import android.os.IBinder import app.gamenative.data.DownloadInfo import app.gamenative.data.GOGCredentials import app.gamenative.data.GOGGame +import app.gamenative.data.GogHiddenRepository import app.gamenative.data.LaunchInfo import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent @@ -151,6 +152,9 @@ class GOGService : Service() { instance.gogManager.deleteAllNonInstalledGames() Timber.i("[GOGService] All non-installed GOG games removed from database") + // Hidden-game metadata belongs to the logged-out account. + GogHiddenRepository.clear() + // Stop the service stop() diff --git a/app/src/main/java/app/gamenative/service/gog/GogFilteredProductsParser.kt b/app/src/main/java/app/gamenative/service/gog/GogFilteredProductsParser.kt new file mode 100644 index 0000000000..6abaaf712a --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GogFilteredProductsParser.kt @@ -0,0 +1,57 @@ +package app.gamenative.service.gog + +import org.json.JSONException +import org.json.JSONObject + +/** + * Parses a single `account/getFilteredProducts` page fetched with `hiddenFlag=1`, where every + * returned product is hidden on GOG. + * + * Malformed JSON or structurally invalid pages throw instead of silently producing an empty hidden + * set, so pagination in [GOGApiClient.getHiddenGameIds] can stay all-or-nothing. + */ +object GogFilteredProductsParser { + + /** One response page: hidden product IDs plus the total page count (0 = no hidden games). */ + data class Page( + val hiddenProductIds: Set, + val totalPages: Int, + ) + + /** + * Parses one `hiddenFlag=1` page. Every product ID is a hidden product ID. A product ID must be + * present and non-blank (number or string), otherwise the page is rejected. `totalPages` may be + * 0, which is a valid empty hidden set. + */ + fun parseHiddenPage(rawJson: String): Page { + val root = try { + JSONObject(rawJson) + } catch (e: JSONException) { + throw IllegalArgumentException("Malformed getFilteredProducts response", e) + } + + val products = root.optJSONArray("products") + ?: throw IllegalArgumentException("getFilteredProducts response is missing products") + val totalPages = root.optInt("totalPages", -1) + if (totalPages < 0) { + throw IllegalArgumentException("getFilteredProducts response has invalid totalPages: $totalPages") + } + + val hiddenProductIds = buildSet { + for (i in 0 until products.length()) { + val product = products.optJSONObject(i) + ?: throw IllegalArgumentException("getFilteredProducts product $i is not an object") + val id = when (val rawId = product.opt("id")) { + null -> throw IllegalArgumentException("getFilteredProducts product $i is missing id") + is Number -> rawId.toString() + is String -> rawId.takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("getFilteredProducts product $i has a blank id") + else -> throw IllegalArgumentException("getFilteredProducts product $i has an invalid id") + } + add(id) + } + } + + return Page(hiddenProductIds = hiddenProductIds, totalPages = totalPages) + } +} diff --git a/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt b/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt index 1272552b8a..1b963fcd0c 100644 --- a/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt +++ b/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt @@ -23,6 +23,16 @@ object SteamCollectionFilter { return buildSet { selected.forEach { addAll(it.appIds) } } } + /** + * Per-collection counts computed from a pre-hidden app-id set (e.g. the owner/type/search + * filtered list before default hidden filtering). This keeps the Hidden collection's count + * visible even when hidden games are excluded from the main library list. + */ + fun collectionCounts(collections: List?, appIds: Collection): Map = + collections?.associate { collection -> + collection.id to appIds.count { it in collection.appIds } + } ?: emptyMap() + data class Reconciliation(val cleaned: Set, val removedAny: Boolean) /** Drop selected ids no longer present. No-op while collections are not loaded (null). */ diff --git a/app/src/main/java/app/gamenative/ui/data/LibraryCounts.kt b/app/src/main/java/app/gamenative/ui/data/LibraryCounts.kt new file mode 100644 index 0000000000..bb6cca8564 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/data/LibraryCounts.kt @@ -0,0 +1,29 @@ +package app.gamenative.ui.data + +import app.gamenative.PrefManager + +/** + * Persists the library sizes that drive skeleton loaders. + * + * Callers must pass sizes of already-filtered lists (after default hidden-game filtering) so the + * persisted Steam/GOG counts never include games that are hidden by default. + */ +object LibraryCounts { + fun persist( + customGames: Int, + steamGames: Int, + gogGames: Int, + gogInstalledGames: Int, + epicGames: Int, + epicInstalledGames: Int, + amazonInstalledGames: Int, + ) { + PrefManager.customGamesCount = customGames + PrefManager.steamGamesCount = steamGames + PrefManager.gogGamesCount = gogGames + PrefManager.gogInstalledGamesCount = gogInstalledGames + PrefManager.epicGamesCount = epicGames + PrefManager.epicInstalledGamesCount = epicInstalledGames + PrefManager.amazonInstalledGamesCount = amazonInstalledGames + } +} 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 884332a57d..8c95f04224 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -13,6 +13,8 @@ import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.GameSource +import app.gamenative.data.GogHiddenRepository +import app.gamenative.data.HiddenGameFilter import app.gamenative.data.LibraryItem import app.gamenative.data.gog.GogRecommendationsRepository import app.gamenative.data.gog.GogSeedCollector @@ -35,9 +37,11 @@ import app.gamenative.service.SteamService import app.gamenative.service.amazon.AmazonArtwork import app.gamenative.service.amazon.AmazonService import app.gamenative.service.epic.EpicService +import app.gamenative.service.gog.GOGManager import app.gamenative.service.gog.GOGService import app.gamenative.steam.SteamCollectionFilter import app.gamenative.ui.data.LibraryState +import app.gamenative.ui.data.LibraryCounts import app.gamenative.ui.data.statsFor import app.gamenative.ui.enums.AppFilter import app.gamenative.ui.enums.LibraryTab @@ -86,6 +90,7 @@ class LibraryViewModel @Inject constructor( private val gogGameDao: GOGGameDao, private val epicGameDao: EpicGameDao, private val amazonGameDao: AmazonGameDao, + private val gogManager: GOGManager, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -109,6 +114,10 @@ class LibraryViewModel @Inject constructor( refreshRecommendationHero() } + private val onHiddenGamesSettingChanged: (AndroidEvent.HiddenGamesSettingChanged) -> Unit = { + onFilterApps(paginationCurrentPage) + } + // How many items loaded on one page of results @Volatile private var paginationCurrentPage: Int = 0 @Volatile private var lastPageInCurrentFilter: Int = 0 @@ -122,6 +131,9 @@ class LibraryViewModel @Inject constructor( @Volatile private var steamCollections: List? = null + // null = not loaded yet (fail open); empty = loaded with no hidden games + @Volatile private var gogHiddenIds: Set? = null + // Track if this is the first load to apply minimum load time private var isFirstLoad = true @@ -272,9 +284,24 @@ class LibraryViewModel @Inject constructor( } } + // Load cached hidden GOG IDs immediately, then observe live updates. + GogHiddenRepository.loadFromCache() + viewModelScope.launch(Dispatchers.IO) { + GogHiddenRepository.hiddenIds.collect { ids -> + gogHiddenIds = ids + onFilterApps(paginationCurrentPage) + } + } + // Keep hidden metadata fresh even if the GOG background sync is throttled or has not run + // since this feature was added; failures keep the previous cache (fail open) and are logged. + viewModelScope.launch(Dispatchers.IO) { + gogManager.refreshHiddenIds() + } + PluviaApp.events.on(onInstallStatusChanged) PluviaApp.events.on(onCustomGameImagesFetched) PluviaApp.events.on(onRecommendationToggleChanged) + PluviaApp.events.on(onHiddenGamesSettingChanged) refreshRecommendationHero() } @@ -310,6 +337,7 @@ class LibraryViewModel @Inject constructor( PluviaApp.events.off(onInstallStatusChanged) PluviaApp.events.off(onCustomGameImagesFetched) PluviaApp.events.off(onRecommendationToggleChanged) + PluviaApp.events.off(onHiddenGamesSettingChanged) super.onCleared() } @@ -630,9 +658,12 @@ class LibraryViewModel @Inject constructor( // 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() + // Kept pre-hidden so the Hidden collection keeps its full count while hidden games are + // excluded from the visible list. + val steamCollectionCounts: Map = SteamCollectionFilter.collectionCounts( + collections = steamCollections, + appIds = steamOwnerTypeFiltered.map { it.id }, + ) // 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. @@ -640,6 +671,14 @@ class LibraryViewModel @Inject constructor( selectedIds = currentState.selectedSteamCollectionIds, collections = steamCollections, ) + // Default hidden filtering: hidden Steam games stay out unless the setting is on or the + // Hidden collection is explicitly selected. Unloaded collections fail open (empty set). + val hiddenSteamAppIds = steamCollections + ?.firstOrNull { it.id == SteamCollection.ID_HIDDEN } + ?.appIds + ?: emptySet() + val showHiddenGamesByDefault = PrefManager.showHiddenGamesByDefault + val hiddenCollectionSelected = currentState.selectedSteamCollectionIds.contains(SteamCollection.ID_HIDDEN) val steamFilteredBeforeCompatibility: List = ( if (allowedSteamAppIds == null) { @@ -647,7 +686,14 @@ class LibraryViewModel @Inject constructor( } else { steamOwnerTypeFiltered.filter { it.id in allowedSteamAppIds } } - ) + ).filter { item -> + HiddenGameFilter.passesSteam( + appId = item.id, + hiddenAppIds = hiddenSteamAppIds, + showHiddenByDefault = showHiddenGamesByDefault, + hiddenCollectionSelected = hiddenCollectionSelected, + ) + } // Filter Steam apps first (no pagination yet) // Note: Don't sort individual lists - we'll sort the combined list for consistent ordering @@ -740,6 +786,15 @@ class LibraryViewModel @Inject constructor( true } } + .filter { game -> + // Hidden GOG games stay out of every library view unless the user opted to + // show them by default. Null/unloaded hidden metadata fails open. + HiddenGameFilter.passesGog( + gameId = game.id, + hiddenIds = gogHiddenIds, + showHiddenByDefault = PrefManager.showHiddenGamesByDefault, + ) + } .toList() val gogEntries = filteredGOGGames @@ -860,13 +915,17 @@ class LibraryViewModel @Inject constructor( // Save game counts for skeleton loaders (only when not searching, to get accurate counts) // This needs to happen before filtering by source, so we save the total counts if (currentState.searchQuery.isEmpty()) { - PrefManager.customGamesCount = customGameItems.size - PrefManager.steamGamesCount = steamFilteredBeforeCompatibility.size - PrefManager.gogGamesCount = filteredGOGGames.size - PrefManager.gogInstalledGamesCount = gogInstalledCount - PrefManager.epicGamesCount = filteredEpicGames.size - PrefManager.epicInstalledGamesCount = epicInstalledCount - PrefManager.amazonInstalledGamesCount = amazonInstalledCount + // The lists passed here are already post-hidden, so persisted counts never include + // games hidden by default. + LibraryCounts.persist( + customGames = customGameItems.size, + steamGames = steamFilteredBeforeCompatibility.size, + gogGames = filteredGOGGames.size, + gogInstalledGames = gogInstalledCount, + epicGames = filteredEpicGames.size, + epicInstalledGames = epicInstalledCount, + amazonInstalledGames = amazonInstalledCount, + ) Timber.tag("LibraryViewModel").d("Saved counts - Custom: ${customGameItems.size}, Steam: ${steamFilteredBeforeCompatibility.size}, GOG: ${filteredGOGGames.size}, GOG installed: $gogInstalledCount, Epic: ${filteredEpicGames.size}, Epic installed: $epicInstalledCount, Amazon installed: $amazonInstalledCount") } diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt index f288a6a618..daf71d79d1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt @@ -371,6 +371,19 @@ fun SettingsGroupInterface( }, ) + var showHiddenGamesByDefault by rememberSaveable { mutableStateOf(PrefManager.showHiddenGamesByDefault) } + SettingsSwitch( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.settings_interface_show_hidden_games_title)) }, + subtitle = { Text(text = stringResource(R.string.settings_interface_show_hidden_games_subtitle)) }, + state = showHiddenGamesByDefault, + onCheckedChange = { + showHiddenGamesByDefault = it + PrefManager.showHiddenGamesByDefault = it + PluviaApp.events.emit(AndroidEvent.HiddenGamesSettingChanged) + }, + ) + if (!BuildConfig.MODERN_ANDROID) { val anyFrontendSyncConfigured by FrontendSyncManager.anyConfigured.collectAsState() SettingsMenuLink( @@ -804,4 +817,3 @@ private fun Preview_SettingsScreen() { ) } } - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b302503ed..e69106d45f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2062,6 +2062,8 @@ Not now Show game recommendations Show personalized recommendations. Keeping this on helps support GameNative. + Show hidden games by default + Hidden Steam and GOG games appear in library tabs. Selecting the Hidden Steam collection always shows hidden Steam games. Main Story diff --git a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt new file mode 100644 index 0000000000..8a6435c99d --- /dev/null +++ b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt @@ -0,0 +1,22 @@ +package app.gamenative + +import app.gamenative.utils.FakeDataStore +import app.gamenative.utils.installFakePrefManager +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrefManagerHiddenGamesDefaultsTest { + + @Test + fun showHiddenGamesByDefaultDefaultsToFalse() { + installFakePrefManager(FakeDataStore()) + assertFalse(PrefManager.showHiddenGamesByDefault) + } + + @Test + fun libraryGogHiddenIdsDefaultsToEmpty() { + installFakePrefManager(FakeDataStore()) + assertTrue(PrefManager.libraryGogHiddenIds.isEmpty()) + } +} diff --git a/app/src/test/java/app/gamenative/data/GogHiddenRepositoryTest.kt b/app/src/test/java/app/gamenative/data/GogHiddenRepositoryTest.kt new file mode 100644 index 0000000000..320b6e58ee --- /dev/null +++ b/app/src/test/java/app/gamenative/data/GogHiddenRepositoryTest.kt @@ -0,0 +1,69 @@ +package app.gamenative.data + +import androidx.datastore.preferences.core.mutablePreferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey +import app.gamenative.PrefManager +import app.gamenative.utils.FakeDataStore +import app.gamenative.utils.awaitUpdateCount +import app.gamenative.utils.installFakePrefManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class GogHiddenRepositoryTest { + + @Before + fun setUp() { + installFakePrefManager(FakeDataStore()) + GogHiddenRepository.clear() + } + + @Test + fun noCacheStartsAsNull() { + assertNull(GogHiddenRepository.hiddenIds.value) + } + + @Test + fun loadFromCacheWithNoCacheStaysNull() { + GogHiddenRepository.loadFromCache() + assertNull(GogHiddenRepository.hiddenIds.value) + } + + @Test + fun validCacheRestoresIds() { + val initial = mutablePreferencesOf( + stringPreferencesKey("library_gog_hidden_ids") to "1\u001F2", + ) + installFakePrefManager(FakeDataStore(initial)) + + GogHiddenRepository.loadFromCache() + + assertEquals(setOf("1", "2"), GogHiddenRepository.hiddenIds.value) + } + + @Test + fun updateChangesFlowAndPersistence() { + val fake = FakeDataStore() + installFakePrefManager(fake) + + GogHiddenRepository.update(setOf("3", "4")) + + assertEquals(setOf("3", "4"), GogHiddenRepository.hiddenIds.value) + fake.awaitUpdateCount() + assertEquals(setOf("3", "4"), PrefManager.libraryGogHiddenIds) + } + + @Test + fun clearResetsFlowAndPersistence() { + val fake = FakeDataStore() + installFakePrefManager(fake) + GogHiddenRepository.update(setOf("3", "4")) + + GogHiddenRepository.clear() + + assertNull(GogHiddenRepository.hiddenIds.value) + fake.awaitUpdateCount(2) + assertEquals(emptySet(), PrefManager.libraryGogHiddenIds) + } +} diff --git a/app/src/test/java/app/gamenative/data/HiddenGameFilterTest.kt b/app/src/test/java/app/gamenative/data/HiddenGameFilterTest.kt new file mode 100644 index 0000000000..bb1ce70abc --- /dev/null +++ b/app/src/test/java/app/gamenative/data/HiddenGameFilterTest.kt @@ -0,0 +1,85 @@ +package app.gamenative.data + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HiddenGameFilterTest { + private val hiddenSteamIds = setOf(440, 570) + private val hiddenGogIds = setOf("123", "456") + + @Test + fun steamHiddenGameIsHiddenByDefault() { + assertFalse( + HiddenGameFilter.passesSteam(440, hiddenSteamIds, showHiddenByDefault = false, hiddenCollectionSelected = false), + ) + } + + @Test + fun steamHiddenGameIsShownWhenSettingOn() { + assertTrue( + HiddenGameFilter.passesSteam(440, hiddenSteamIds, showHiddenByDefault = true, hiddenCollectionSelected = false), + ) + } + + @Test + fun steamHiddenGameIsShownWhenHiddenCollectionSelected() { + assertTrue( + HiddenGameFilter.passesSteam(440, hiddenSteamIds, showHiddenByDefault = false, hiddenCollectionSelected = true), + ) + } + + @Test + fun steamHiddenGameIsShownWhenHiddenPlusAnotherCollectionSelected() { + // Only the Hidden-collection flag matters; other selected collections do not override it. + assertTrue( + HiddenGameFilter.passesSteam(440, hiddenSteamIds, showHiddenByDefault = false, hiddenCollectionSelected = true), + ) + } + + @Test + fun steamHiddenGameIsHiddenWhenOnlyAnotherCollectionSelected() { + assertFalse( + HiddenGameFilter.passesSteam(440, hiddenSteamIds, showHiddenByDefault = false, hiddenCollectionSelected = false), + ) + } + + @Test + fun steamEmptyHiddenSetShowsAll() { + assertTrue( + HiddenGameFilter.passesSteam(440, emptySet(), showHiddenByDefault = false, hiddenCollectionSelected = false), + ) + } + + @Test + fun steamNonHiddenGameIsAlwaysShown() { + assertTrue( + HiddenGameFilter.passesSteam(999, hiddenSteamIds, showHiddenByDefault = false, hiddenCollectionSelected = false), + ) + } + + @Test + fun gogHiddenGameIsHiddenByDefault() { + assertFalse(HiddenGameFilter.passesGog("123", hiddenGogIds, showHiddenByDefault = false)) + } + + @Test + fun gogHiddenGameIsShownWhenSettingOn() { + assertTrue(HiddenGameFilter.passesGog("123", hiddenGogIds, showHiddenByDefault = true)) + } + + @Test + fun gogNonHiddenGameIsAlwaysShown() { + assertTrue(HiddenGameFilter.passesGog("789", hiddenGogIds, showHiddenByDefault = false)) + } + + @Test + fun gogNullHiddenStateFailsOpen() { + assertTrue(HiddenGameFilter.passesGog("123", hiddenIds = null, showHiddenByDefault = false)) + } + + @Test + fun gogLoadedEmptySetShowsAll() { + assertTrue(HiddenGameFilter.passesGog("123", emptySet(), showHiddenByDefault = false)) + } +} diff --git a/app/src/test/java/app/gamenative/service/gog/GogFilteredProductsParserTest.kt b/app/src/test/java/app/gamenative/service/gog/GogFilteredProductsParserTest.kt new file mode 100644 index 0000000000..708942782f --- /dev/null +++ b/app/src/test/java/app/gamenative/service/gog/GogFilteredProductsParserTest.kt @@ -0,0 +1,84 @@ +package app.gamenative.service.gog + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class GogFilteredProductsParserTest { + + @Test + fun parsesHiddenPageCollectingAllProductIds() { + val page = GogFilteredProductsParser.parseHiddenPage( + """{"totalPages":3,"products":[{"id":1,"title":"a"},{"id":2},{"id":3,"isHidden":true}]}""", + ) + assertEquals(setOf("1", "2", "3"), page.hiddenProductIds) + assertEquals(3, page.totalPages) + } + + @Test + fun emptyHiddenPageWithZeroTotalPagesIsValid() { + val page = GogFilteredProductsParser.parseHiddenPage( + """{"totalPages":0,"products":[]}""", + ) + assertEquals(emptySet(), page.hiddenProductIds) + assertEquals(0, page.totalPages) + } + + @Test + fun emptyHiddenPagePreservesTotalPages() { + val page = GogFilteredProductsParser.parseHiddenPage( + """{"totalPages":7,"products":[]}""", + ) + assertEquals(emptySet(), page.hiddenProductIds) + assertEquals(7, page.totalPages) + } + + @Test + fun malformedJsonFails() { + assertThrows(IllegalArgumentException::class.java) { + GogFilteredProductsParser.parseHiddenPage("not json") + } + } + + @Test + fun missingProductsFails() { + assertThrows(IllegalArgumentException::class.java) { + GogFilteredProductsParser.parseHiddenPage("""{"totalPages":1}""") + } + } + + @Test + fun missingTotalPagesFails() { + assertThrows(IllegalArgumentException::class.java) { + GogFilteredProductsParser.parseHiddenPage("""{"products":[]}""") + } + } + + @Test + fun negativeTotalPagesFails() { + assertThrows(IllegalArgumentException::class.java) { + GogFilteredProductsParser.parseHiddenPage("""{"totalPages":-1,"products":[]}""") + } + } + + @Test + fun nonObjectProductFails() { + assertThrows(IllegalArgumentException::class.java) { + GogFilteredProductsParser.parseHiddenPage("""{"totalPages":1,"products":[42]}""") + } + } + + @Test + fun missingProductIdFails() { + assertThrows(IllegalArgumentException::class.java) { + GogFilteredProductsParser.parseHiddenPage("""{"totalPages":1,"products":[{"title":"x"}]}""") + } + } + + @Test + fun blankProductIdFails() { + assertThrows(IllegalArgumentException::class.java) { + GogFilteredProductsParser.parseHiddenPage("""{"totalPages":1,"products":[{"id":""}]}""") + } + } +} diff --git a/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt b/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt index c2a9d1d783..cc5ac5264e 100644 --- a/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt +++ b/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt @@ -59,4 +59,23 @@ class SteamCollectionFilterTest { assertEquals(setOf(440, 570), SteamCollectionFilter.allowedAppIds(setOf("fav"), all)) assertEquals(setOf(440, 570, 730), SteamCollectionFilter.allowedAppIds(setOf("fav", "sht"), all)) } + + @Test fun collectionCountsIncludeHiddenGamesForHiddenCollection() { + val hidden = SteamCollection(SteamCollection.ID_HIDDEN, "Hidden", setOf(440, 570)) + val favorites = SteamCollection("fav", "Favorites", setOf(440, 730)) + + val counts = SteamCollectionFilter.collectionCounts( + collections = listOf(hidden, favorites), + appIds = listOf(440, 570, 730), + ) + + // Counts come from the pre-hidden set, so the Hidden collection keeps its full count even + // when hidden games are excluded from the visible library list. + assertEquals(2, counts[SteamCollection.ID_HIDDEN]) + assertEquals(2, counts["fav"]) + } + + @Test fun collectionCountsAreEmptyWhenCollectionsNotLoaded() { + assertEquals(emptyMap(), SteamCollectionFilter.collectionCounts(null, listOf(440))) + } } diff --git a/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt b/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt new file mode 100644 index 0000000000..72d24c7d41 --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt @@ -0,0 +1,36 @@ +package app.gamenative.ui.data + +import app.gamenative.PrefManager +import app.gamenative.utils.FakeDataStore +import app.gamenative.utils.awaitUpdateCount +import app.gamenative.utils.installFakePrefManager +import org.junit.Assert.assertEquals +import org.junit.Test + +class LibraryCountsTest { + + @Test + fun persistsPostHiddenVisibilityCounts() { + val fake = FakeDataStore() + installFakePrefManager(fake) + + LibraryCounts.persist( + customGames = 1, + steamGames = 2, + gogGames = 3, + gogInstalledGames = 4, + epicGames = 5, + epicInstalledGames = 6, + amazonInstalledGames = 7, + ) + + fake.awaitUpdateCount(7) + assertEquals(1, PrefManager.customGamesCount) + assertEquals(2, PrefManager.steamGamesCount) + assertEquals(3, PrefManager.gogGamesCount) + assertEquals(4, PrefManager.gogInstalledGamesCount) + assertEquals(5, PrefManager.epicGamesCount) + assertEquals(6, PrefManager.epicInstalledGamesCount) + assertEquals(7, PrefManager.amazonInstalledGamesCount) + } +} diff --git a/app/src/test/java/app/gamenative/utils/TestPrefManager.kt b/app/src/test/java/app/gamenative/utils/TestPrefManager.kt new file mode 100644 index 0000000000..170063150e --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/TestPrefManager.kt @@ -0,0 +1,60 @@ +package app.gamenative.utils + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import app.gamenative.PrefManager +import java.io.File +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mockito.Mockito + +/** In-memory [DataStore] so unit tests can observe [PrefManager] writes. */ +class FakeDataStore(initial: Preferences = emptyPreferences()) : DataStore { + private val state = MutableStateFlow(initial.toMutablePreferences()) + private val mutex = Mutex() + + /** Number of completed `updateData` calls; used to await asynchronous [PrefManager] writes. */ + val updates = MutableStateFlow(0) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + // Serialize like the real DataStore so concurrent edit() calls cannot lose keys. + return mutex.withLock { + val updated = transform(state.value).toMutablePreferences() + state.value = updated + updates.value += 1 + updated + } + } +} + +/** Waits until at least [expectedUpdates] writes have landed in [FakeDataStore]. */ +fun FakeDataStore.awaitUpdateCount(expectedUpdates: Int = 1) { + runBlocking { updates.first { it >= expectedUpdates } } +} + +/** + * Replaces [PrefManager]'s backing store with [fake] so preference defaults, reads, and writes are + * deterministic in unit tests. + */ +fun installFakePrefManager(fake: FakeDataStore) { + val context = Mockito.mock(Context::class.java) + val filesDir = File(System.getProperty("java.io.tmpdir"), "gamenative-pref-test-${System.nanoTime()}") + filesDir.mkdirs() + Mockito.`when`(context.filesDir).thenReturn(filesDir) + Mockito.`when`(context.dataDir).thenReturn(filesDir) + Mockito.`when`(context.applicationContext).thenReturn(context) + + PrefManager.init(context) + + val dataStoreField = PrefManager::class.java.getDeclaredField("dataStore") + dataStoreField.isAccessible = true + dataStoreField.set(PrefManager, fake) +} From ec9790990454e893bbc7e57d2dda7f69b32fdc6c Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Fri, 7 Aug 2026 03:10:26 -0700 Subject: [PATCH 02/15] Refresh library immediately when hidden-games setting changes --- .../main/java/app/gamenative/events/AndroidEvent.kt | 2 +- .../java/app/gamenative/ui/model/LibraryViewModel.kt | 11 ++++++++--- .../ui/screen/settings/SettingsGroupInterface.kt | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt index 48ddd6d33e..6dd0f3309a 100644 --- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt +++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt @@ -26,7 +26,7 @@ interface AndroidEvent : Event { data class LibraryInstallStatusChanged(val appId: Int, val source: GameSource) : AndroidEvent data class CustomGameImagesFetched(val appId: String) : AndroidEvent data object RecommendationToggleChanged : AndroidEvent - data object HiddenGamesSettingChanged : AndroidEvent + data class HiddenGamesSettingChanged(val showHiddenGamesByDefault: Boolean) : AndroidEvent data class GOGAuthCodeReceived(val authCode: String) : AndroidEvent data class EpicAuthCodeReceived(val authCode: String) : AndroidEvent data object ServiceReady : AndroidEvent 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 8c95f04224..01cc1c8152 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -114,7 +114,10 @@ class LibraryViewModel @Inject constructor( refreshRecommendationHero() } - private val onHiddenGamesSettingChanged: (AndroidEvent.HiddenGamesSettingChanged) -> Unit = { + private val onHiddenGamesSettingChanged: (AndroidEvent.HiddenGamesSettingChanged) -> Unit = { event -> + // Use the value from the event rather than re-reading PrefManager: its DataStore write is + // asynchronous, so reading it here can race and re-filter with the old value. + showHiddenGamesByDefault = event.showHiddenGamesByDefault onFilterApps(paginationCurrentPage) } @@ -134,6 +137,9 @@ class LibraryViewModel @Inject constructor( // null = not loaded yet (fail open); empty = loaded with no hidden games @Volatile private var gogHiddenIds: Set? = null + // Mirrors PrefManager.showHiddenGamesByDefault without the async DataStore write race. + @Volatile private var showHiddenGamesByDefault: Boolean = PrefManager.showHiddenGamesByDefault + // Track if this is the first load to apply minimum load time private var isFirstLoad = true @@ -677,7 +683,6 @@ class LibraryViewModel @Inject constructor( ?.firstOrNull { it.id == SteamCollection.ID_HIDDEN } ?.appIds ?: emptySet() - val showHiddenGamesByDefault = PrefManager.showHiddenGamesByDefault val hiddenCollectionSelected = currentState.selectedSteamCollectionIds.contains(SteamCollection.ID_HIDDEN) val steamFilteredBeforeCompatibility: List = ( @@ -792,7 +797,7 @@ class LibraryViewModel @Inject constructor( HiddenGameFilter.passesGog( gameId = game.id, hiddenIds = gogHiddenIds, - showHiddenByDefault = PrefManager.showHiddenGamesByDefault, + showHiddenByDefault = showHiddenGamesByDefault, ) } .toList() diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt index daf71d79d1..4f0ab02bb8 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt @@ -380,7 +380,7 @@ fun SettingsGroupInterface( onCheckedChange = { showHiddenGamesByDefault = it PrefManager.showHiddenGamesByDefault = it - PluviaApp.events.emit(AndroidEvent.HiddenGamesSettingChanged) + PluviaApp.events.emit(AndroidEvent.HiddenGamesSettingChanged(showHiddenGamesByDefault = it)) }, ) From 80093166807c1f273732fa7887f298cd03a4875a Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Sat, 8 Aug 2026 17:52:17 -0700 Subject: [PATCH 03/15] Store GOG hidden state in gog_games.hidden column --- .../app.gamenative.db.PluviaDatabase/26.json | 1881 +++++++++++++++++ .../main/java/app/gamenative/PrefManager.kt | 15 - .../main/java/app/gamenative/data/GOGGame.kt | 3 + .../gamenative/data/GogHiddenRepository.kt | 37 - .../app/gamenative/data/HiddenGameFilter.kt | 9 +- .../java/app/gamenative/db/PluviaDatabase.kt | 3 +- .../java/app/gamenative/db/dao/GOGGameDao.kt | 19 + .../app/gamenative/service/gog/GOGManager.kt | 38 +- .../app/gamenative/service/gog/GOGService.kt | 3 +- .../gamenative/ui/model/LibraryViewModel.kt | 20 +- .../PrefManagerHiddenGamesDefaultsTest.kt | 7 - .../data/GogHiddenRepositoryTest.kt | 69 - .../gamenative/data/HiddenGameFilterTest.kt | 17 +- .../app/gamenative/db/dao/GOGGameDaoTest.kt | 89 + 14 files changed, 2035 insertions(+), 175 deletions(-) create mode 100644 app/schemas/app.gamenative.db.PluviaDatabase/26.json delete mode 100644 app/src/main/java/app/gamenative/data/GogHiddenRepository.kt delete mode 100644 app/src/test/java/app/gamenative/data/GogHiddenRepositoryTest.kt create mode 100644 app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt diff --git a/app/schemas/app.gamenative.db.PluviaDatabase/26.json b/app/schemas/app.gamenative.db.PluviaDatabase/26.json new file mode 100644 index 0000000000..8db33ed69f --- /dev/null +++ b/app/schemas/app.gamenative.db.PluviaDatabase/26.json @@ -0,0 +1,1881 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "342c7a6e39ea112d85cb3d00d8989b3f", + "entities": [ + { + "tableName": "app_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_downloaded` INTEGER NOT NULL, `downloaded_depots` TEXT NOT NULL, `dlc_depots` TEXT NOT NULL, `branch` TEXT NOT NULL DEFAULT 'public', `recovered_install_size_bytes` INTEGER NOT NULL DEFAULT 0, `custom_install_path` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "is_downloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedDepots", + "columnName": "downloaded_depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dlcDepots", + "columnName": "dlc_depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branch", + "columnName": "branch", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'public'" + }, + { + "fieldPath": "recoveredInstallSizeBytes", + "columnName": "recovered_install_size_bytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "customInstallPath", + "columnName": "custom_install_path", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `license_json` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseJson", + "columnName": "license_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_change_numbers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `changeNumber` INTEGER, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "changeNumber", + "columnName": "changeNumber", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "encrypted_app_ticket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` INTEGER NOT NULL, `result` INTEGER NOT NULL, `ticket_version_no` INTEGER NOT NULL, `crc_encrypted_ticket` INTEGER NOT NULL, `cb_encrypted_user_data` INTEGER NOT NULL, `cb_encrypted_app_ownership_ticket` INTEGER NOT NULL, `encrypted_ticket` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`app_id`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ticketVersionNo", + "columnName": "ticket_version_no", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "crcEncryptedTicket", + "columnName": "crc_encrypted_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedUserData", + "columnName": "cb_encrypted_user_data", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedAppOwnershipTicket", + "columnName": "cb_encrypted_app_ownership_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedTicket", + "columnName": "encrypted_ticket", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "app_id" + ] + } + }, + { + "tableName": "app_file_change_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `userFileInfo` TEXT NOT NULL, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "userFileInfo", + "columnName": "userFileInfo", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "library_play_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` TEXT NOT NULL, `last_played` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`app_id`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "app_id" + ] + } + }, + { + "tableName": "steam_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `package_id` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `license_flags` INTEGER NOT NULL, `received_pics` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `ufs_parse_version` INTEGER NOT NULL DEFAULT 0, `depots` TEXT NOT NULL, `branches` TEXT NOT NULL, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `os_list` INTEGER NOT NULL, `release_state` INTEGER NOT NULL, `release_date` INTEGER NOT NULL, `metacritic_score` INTEGER NOT NULL, `metacritic_full_url` TEXT NOT NULL, `logo_hash` TEXT NOT NULL, `logo_small_hash` TEXT NOT NULL, `icon_hash` TEXT NOT NULL, `client_icon_hash` TEXT NOT NULL, `client_tga_hash` TEXT NOT NULL, `small_capsule` TEXT NOT NULL, `header_image` TEXT NOT NULL, `library_assets` TEXT NOT NULL, `primary_genre` INTEGER NOT NULL, `review_score` INTEGER NOT NULL, `review_percentage` INTEGER NOT NULL, `controller_support` INTEGER NOT NULL, `demo_of_app_id` INTEGER NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `homepage_url` TEXT NOT NULL, `game_manual_url` TEXT NOT NULL, `load_all_before_launch` INTEGER NOT NULL, `dlc_app_ids` TEXT NOT NULL, `is_free_app` INTEGER NOT NULL, `dlc_for_app_id` INTEGER NOT NULL, `must_own_app_to_purchase` INTEGER NOT NULL, `dlc_available_on_store` INTEGER NOT NULL, `optional_dlc` INTEGER NOT NULL, `game_dir` TEXT NOT NULL, `install_script` TEXT NOT NULL, `no_servers` INTEGER NOT NULL, `order` INTEGER NOT NULL, `primary_cache` INTEGER NOT NULL, `valid_os_list` INTEGER NOT NULL, `third_party_cd_key` INTEGER NOT NULL, `visible_only_when_installed` INTEGER NOT NULL, `visible_only_when_subscribed` INTEGER NOT NULL, `launch_eula_url` TEXT NOT NULL, `require_default_install_folder` INTEGER NOT NULL, `content_type` INTEGER NOT NULL, `install_dir` TEXT NOT NULL, `use_launch_cmd_line` INTEGER NOT NULL, `launch_without_workshop_updates` INTEGER NOT NULL, `use_mms` INTEGER NOT NULL, `install_script_signature` TEXT NOT NULL, `install_script_override` INTEGER NOT NULL, `config` TEXT NOT NULL, `ufs` TEXT NOT NULL, `workshop_mods` INTEGER NOT NULL DEFAULT 0, `enabled_workshop_item_ids` TEXT NOT NULL DEFAULT '', `workshop_download_pending` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receivedPICS", + "columnName": "received_pics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ufsParseVersion", + "columnName": "ufs_parse_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "depots", + "columnName": "depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branches", + "columnName": "branches", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "osList", + "columnName": "os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseState", + "columnName": "release_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticScore", + "columnName": "metacritic_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticFullUrl", + "columnName": "metacritic_full_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoHash", + "columnName": "logo_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoSmallHash", + "columnName": "logo_small_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconHash", + "columnName": "icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIconHash", + "columnName": "client_icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientTgaHash", + "columnName": "client_tga_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallCapsule", + "columnName": "small_capsule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerImage", + "columnName": "header_image", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryAssets", + "columnName": "library_assets", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryGenre", + "columnName": "primary_genre", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewScore", + "columnName": "review_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewPercentage", + "columnName": "review_percentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "controllerSupport", + "columnName": "controller_support", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "demoOfAppId", + "columnName": "demo_of_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homepageUrl", + "columnName": "homepage_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gameManualUrl", + "columnName": "game_manual_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loadAllBeforeLaunch", + "columnName": "load_all_before_launch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAppIds", + "columnName": "dlc_app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFreeApp", + "columnName": "is_free_app", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcForAppId", + "columnName": "dlc_for_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mustOwnAppToPurchase", + "columnName": "must_own_app_to_purchase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAvailableOnStore", + "columnName": "dlc_available_on_store", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "optionalDlc", + "columnName": "optional_dlc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gameDir", + "columnName": "game_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScript", + "columnName": "install_script", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noServers", + "columnName": "no_servers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryCache", + "columnName": "primary_cache", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "validOSList", + "columnName": "valid_os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thirdPartyCdKey", + "columnName": "third_party_cd_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenInstalled", + "columnName": "visible_only_when_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenSubscribed", + "columnName": "visible_only_when_subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchEulaUrl", + "columnName": "launch_eula_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requireDefaultInstallFolder", + "columnName": "require_default_install_folder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installDir", + "columnName": "install_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useLaunchCmdLine", + "columnName": "use_launch_cmd_line", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchWithoutWorkshopUpdates", + "columnName": "launch_without_workshop_updates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useMms", + "columnName": "use_mms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installScriptSignature", + "columnName": "install_script_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScriptOverride", + "columnName": "install_script_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ufs", + "columnName": "ufs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workshopMods", + "columnName": "workshop_mods", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabledWorkshopItemIds", + "columnName": "enabled_workshop_item_ids", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "workshopDownloadPending", + "columnName": "workshop_download_pending", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "steam_file_hash_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER NOT NULL, `absPath` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `mtimeMillis` INTEGER NOT NULL, `sha` BLOB NOT NULL, PRIMARY KEY(`appId`, `absPath`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "absPath", + "columnName": "absPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mtimeMillis", + "columnName": "mtimeMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sha", + "columnName": "sha", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId", + "absPath" + ] + } + }, + { + "tableName": "steam_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageId` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `time_created` INTEGER NOT NULL, `time_next_process` INTEGER NOT NULL, `minute_limit` INTEGER NOT NULL, `minutes_used` INTEGER NOT NULL, `payment_method` INTEGER NOT NULL, `license_flags` INTEGER NOT NULL, `purchase_code` TEXT NOT NULL, `license_type` INTEGER NOT NULL, `territory_code` INTEGER NOT NULL, `access_token` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `master_package_id` INTEGER NOT NULL, `app_ids` TEXT NOT NULL, `depot_ids` TEXT NOT NULL, PRIMARY KEY(`packageId`))", + "fields": [ + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeCreated", + "columnName": "time_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeNextProcess", + "columnName": "time_next_process", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minuteLimit", + "columnName": "minute_limit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesUsed", + "columnName": "minutes_used", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentMethod", + "columnName": "payment_method", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseCode", + "columnName": "purchase_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseType", + "columnName": "license_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "territoryCode", + "columnName": "territory_code", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "access_token", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "masterPackageID", + "columnName": "master_package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appIds", + "columnName": "app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "depotIds", + "columnName": "depot_ids", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageId" + ] + } + }, + { + "tableName": "gog_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `slug` TEXT NOT NULL, `download_size` INTEGER NOT NULL, `install_size` INTEGER NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `image_url` TEXT NOT NULL, `icon_url` TEXT NOT NULL, `background_url` TEXT NOT NULL DEFAULT '', `vertical_cover_url` TEXT NOT NULL DEFAULT '', `description` TEXT NOT NULL, `release_date` TEXT NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `genres` TEXT NOT NULL, `languages` TEXT NOT NULL, `last_played` INTEGER NOT NULL, `play_time` INTEGER NOT NULL, `type` INTEGER NOT NULL, `exclude` INTEGER NOT NULL DEFAULT 0, `hidden` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "slug", + "columnName": "slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadSize", + "columnName": "download_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installSize", + "columnName": "install_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "is_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installPath", + "columnName": "install_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "verticalCoverUrl", + "columnName": "vertical_cover_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "languages", + "columnName": "languages", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "play_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exclude", + "columnName": "exclude", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "epic_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `catalog_id` TEXT NOT NULL, `app_name` TEXT NOT NULL, `title` TEXT NOT NULL, `namespace` TEXT NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `platform` TEXT NOT NULL, `version` TEXT NOT NULL, `executable` TEXT NOT NULL, `install_size` INTEGER NOT NULL, `download_size` INTEGER NOT NULL, `art_cover` TEXT NOT NULL, `art_square` TEXT NOT NULL, `art_logo` TEXT NOT NULL, `art_portrait` TEXT NOT NULL, `can_run_offline` INTEGER NOT NULL, `requires_ot` INTEGER NOT NULL, `cloud_save_enabled` INTEGER NOT NULL, `save_folder` TEXT NOT NULL, `third_party_managed_app` TEXT NOT NULL, `is_ea_managed` INTEGER NOT NULL, `is_dlc` INTEGER NOT NULL, `base_game_app_name` TEXT NOT NULL, `description` TEXT NOT NULL, `release_date` TEXT NOT NULL, `genres` TEXT NOT NULL, `tags` TEXT NOT NULL, `last_played` INTEGER NOT NULL, `play_time` INTEGER NOT NULL, `type` INTEGER NOT NULL, `eos_catalog_item_id` TEXT NOT NULL, `eos_app_id` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "catalogId", + "columnName": "catalog_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appName", + "columnName": "app_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "is_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installPath", + "columnName": "install_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "executable", + "columnName": "executable", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installSize", + "columnName": "install_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadSize", + "columnName": "download_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artCover", + "columnName": "art_cover", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artSquare", + "columnName": "art_square", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artLogo", + "columnName": "art_logo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artPortrait", + "columnName": "art_portrait", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canRunOffline", + "columnName": "can_run_offline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresOT", + "columnName": "requires_ot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cloudSaveEnabled", + "columnName": "cloud_save_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saveFolder", + "columnName": "save_folder", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thirdPartyManagedApp", + "columnName": "third_party_managed_app", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEAManaged", + "columnName": "is_ea_managed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDLC", + "columnName": "is_dlc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseGameAppName", + "columnName": "base_game_app_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "play_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eosCatalogItemId", + "columnName": "eos_catalog_item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eosAppId", + "columnName": "eos_app_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "amazon_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `product_id` TEXT NOT NULL, `entitlement_id` TEXT NOT NULL DEFAULT '', `title` TEXT NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `art_url` TEXT NOT NULL, `hero_url` TEXT NOT NULL DEFAULT '', `purchased_date` TEXT NOT NULL, `developer` TEXT NOT NULL DEFAULT '', `publisher` TEXT NOT NULL DEFAULT '', `release_date` TEXT NOT NULL DEFAULT '', `download_size` INTEGER NOT NULL DEFAULT 0, `install_size` INTEGER NOT NULL DEFAULT 0, `version_id` TEXT NOT NULL DEFAULT '', `product_sku` TEXT NOT NULL DEFAULT '', `last_played` INTEGER NOT NULL DEFAULT 0, `play_time_minutes` INTEGER NOT NULL DEFAULT 0, `product_json` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "product_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entitlementId", + "columnName": "entitlement_id", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "is_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installPath", + "columnName": "install_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artUrl", + "columnName": "art_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "heroUrl", + "columnName": "hero_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "purchasedDate", + "columnName": "purchased_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "downloadSize", + "columnName": "download_size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "installSize", + "columnName": "install_size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "versionId", + "columnName": "version_id", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "productSku", + "columnName": "product_sku", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "playTimeMinutes", + "columnName": "play_time_minutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "productJson", + "columnName": "product_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "app_id" + ] + }, + "indices": [ + { + "name": "index_amazon_games_product_id", + "unique": false, + "columnNames": [ + "product_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_amazon_games_product_id` ON `${TABLE_NAME}` (`product_id`)" + } + ] + }, + { + "tableName": "downloading_app_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER NOT NULL, `dlcAppIds` TEXT NOT NULL, `branch` TEXT NOT NULL DEFAULT 'public', PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAppIds", + "columnName": "dlcAppIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branch", + "columnName": "branch", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'public'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "steam_unlocked_branch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER NOT NULL, `branchName` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`appId`, `branchName`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "branchName", + "columnName": "branchName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId", + "branchName" + ] + } + }, + { + "tableName": "mod_install", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`install_id` TEXT NOT NULL, `app_id` TEXT NOT NULL, `source` TEXT NOT NULL, `nexus_game_domain` TEXT, `nexus_mod_id` INTEGER, `nexus_file_id` INTEGER, `mod_name` TEXT NOT NULL, `file_name` TEXT NOT NULL, `version` TEXT NOT NULL, `size_bytes` INTEGER NOT NULL, `archive_path` TEXT NOT NULL, `extracted_path` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `status` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `downloaded_at` INTEGER NOT NULL, `metadata_json` TEXT NOT NULL, `archive_sha256` TEXT NOT NULL, PRIMARY KEY(`install_id`))", + "fields": [ + { + "fieldPath": "installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nexusGameDomain", + "columnName": "nexus_game_domain", + "affinity": "TEXT" + }, + { + "fieldPath": "nexusModId", + "columnName": "nexus_mod_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "nexusFileId", + "columnName": "nexus_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "modName", + "columnName": "mod_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archivePath", + "columnName": "archive_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extractedPath", + "columnName": "extracted_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloaded_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadataJson", + "columnName": "metadata_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archiveSha256", + "columnName": "archive_sha256", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "install_id" + ] + }, + "indices": [ + { + "name": "index_mod_install_app_id", + "unique": false, + "columnNames": [ + "app_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_install_app_id` ON `${TABLE_NAME}` (`app_id`)" + }, + { + "name": "index_mod_install_app_id_source_archive_sha256", + "unique": false, + "columnNames": [ + "app_id", + "source", + "archive_sha256" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_install_app_id_source_archive_sha256` ON `${TABLE_NAME}` (`app_id`, `source`, `archive_sha256`)" + }, + { + "name": "index_mod_install_app_id_source_nexus_game_domain_nexus_mod_id_nexus_file_id", + "unique": true, + "columnNames": [ + "app_id", + "source", + "nexus_game_domain", + "nexus_mod_id", + "nexus_file_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_mod_install_app_id_source_nexus_game_domain_nexus_mod_id_nexus_file_id` ON `${TABLE_NAME}` (`app_id`, `source`, `nexus_game_domain`, `nexus_mod_id`, `nexus_file_id`)" + } + ] + }, + { + "tableName": "mod_profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` TEXT NOT NULL, `app_id` TEXT NOT NULL, `name` TEXT NOT NULL, `active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`profile_id`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profile_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profile_id" + ] + }, + "indices": [ + { + "name": "index_mod_profile_app_id", + "unique": false, + "columnNames": [ + "app_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_profile_app_id` ON `${TABLE_NAME}` (`app_id`)" + }, + { + "name": "index_mod_profile_app_id_name", + "unique": true, + "columnNames": [ + "app_id", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_mod_profile_app_id_name` ON `${TABLE_NAME}` (`app_id`, `name`)" + } + ] + }, + { + "tableName": "mod_profile_install_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` TEXT NOT NULL, `install_id` TEXT NOT NULL, `app_id` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`profile_id`, `install_id`), FOREIGN KEY(`profile_id`) REFERENCES `mod_profile`(`profile_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`install_id`) REFERENCES `mod_install`(`install_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profile_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profile_id", + "install_id" + ] + }, + "indices": [ + { + "name": "index_mod_profile_install_state_app_id", + "unique": false, + "columnNames": [ + "app_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_profile_install_state_app_id` ON `${TABLE_NAME}` (`app_id`)" + }, + { + "name": "index_mod_profile_install_state_install_id", + "unique": false, + "columnNames": [ + "install_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_profile_install_state_install_id` ON `${TABLE_NAME}` (`install_id`)" + }, + { + "name": "index_mod_profile_install_state_app_id_profile_id_priority", + "unique": false, + "columnNames": [ + "app_id", + "profile_id", + "priority" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_profile_install_state_app_id_profile_id_priority` ON `${TABLE_NAME}` (`app_id`, `profile_id`, `priority`)" + } + ], + "foreignKeys": [ + { + "table": "mod_profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profile_id" + ], + "referencedColumns": [ + "profile_id" + ] + }, + { + "table": "mod_install", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "install_id" + ], + "referencedColumns": [ + "install_id" + ] + } + ] + }, + { + "tableName": "mod_placement_recipe", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recipe_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `install_id` TEXT NOT NULL, `source_subpath` TEXT NOT NULL, `target_root` TEXT NOT NULL, `target_relative_path` TEXT NOT NULL, `mode` TEXT NOT NULL, `strip_prefix_segments` INTEGER NOT NULL, `include_source_directory` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, FOREIGN KEY(`install_id`) REFERENCES `mod_install`(`install_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recipeId", + "columnName": "recipe_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceSubpath", + "columnName": "source_subpath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetRoot", + "columnName": "target_root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetRelativePath", + "columnName": "target_relative_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stripPrefixSegments", + "columnName": "strip_prefix_segments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeSourceDirectory", + "columnName": "include_source_directory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "recipe_id" + ] + }, + "indices": [ + { + "name": "index_mod_placement_recipe_install_id", + "unique": false, + "columnNames": [ + "install_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_placement_recipe_install_id` ON `${TABLE_NAME}` (`install_id`)" + } + ], + "foreignKeys": [ + { + "table": "mod_install", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "install_id" + ], + "referencedColumns": [ + "install_id" + ] + } + ] + }, + { + "tableName": "mod_overwrite_manifest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manifest_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `install_id` TEXT NOT NULL, `target_path` TEXT NOT NULL, `backup_path` TEXT NOT NULL, `original_hash` TEXT NOT NULL, `original_size` INTEGER NOT NULL, `original_mtime` INTEGER NOT NULL, `installed_hash` TEXT NOT NULL, `installed_size` INTEGER NOT NULL, `installed_mtime` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`install_id`) REFERENCES `mod_install`(`install_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "manifestId", + "columnName": "manifest_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetPath", + "columnName": "target_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backupPath", + "columnName": "backup_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalHash", + "columnName": "original_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalSize", + "columnName": "original_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalMtime", + "columnName": "original_mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installedHash", + "columnName": "installed_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedSize", + "columnName": "installed_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installedMtime", + "columnName": "installed_mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "manifest_id" + ] + }, + "indices": [ + { + "name": "index_mod_overwrite_manifest_install_id", + "unique": false, + "columnNames": [ + "install_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_overwrite_manifest_install_id` ON `${TABLE_NAME}` (`install_id`)" + }, + { + "name": "index_mod_overwrite_manifest_target_path", + "unique": false, + "columnNames": [ + "target_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_overwrite_manifest_target_path` ON `${TABLE_NAME}` (`target_path`)" + } + ], + "foreignKeys": [ + { + "table": "mod_install", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "install_id" + ], + "referencedColumns": [ + "install_id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '342c7a6e39ea112d85cb3d00d8989b3f')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 96dcade6ae..85cd6356c0 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -897,21 +897,6 @@ object PrefManager { setPref(LIBRARY_STEAM_COLLECTIONS, value.joinToString(COLLECTION_ID_SEPARATOR)) } - /** - * IDs of GOG games the user has hidden on GOG, cached so hidden filtering works offline. - * Encoded the same way as [librarySteamCollections]; empty by default. - */ - private val LIBRARY_GOG_HIDDEN_IDS = stringPreferencesKey("library_gog_hidden_ids") - var libraryGogHiddenIds: Set - get() { - val raw = getPref(LIBRARY_GOG_HIDDEN_IDS, "") - if (raw.isEmpty()) return emptySet() - return raw.split(COLLECTION_ID_SEPARATOR).filter { it.isNotEmpty() }.toSet() - } - set(value) { - setPref(LIBRARY_GOG_HIDDEN_IDS, 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/GOGGame.kt b/app/src/main/java/app/gamenative/data/GOGGame.kt index c8dbe48fd8..d229f42a6b 100644 --- a/app/src/main/java/app/gamenative/data/GOGGame.kt +++ b/app/src/main/java/app/gamenative/data/GOGGame.kt @@ -74,6 +74,9 @@ data class GOGGame( @ColumnInfo(name = "exclude", defaultValue = "0") val exclude: Boolean = false, + + @ColumnInfo(name = "hidden", defaultValue = "0") + val hidden: Boolean = false, ) { companion object { const val GOG_IMAGE_BASE_URL = "https://images.gog.com/images" diff --git a/app/src/main/java/app/gamenative/data/GogHiddenRepository.kt b/app/src/main/java/app/gamenative/data/GogHiddenRepository.kt deleted file mode 100644 index 515315c3e3..0000000000 --- a/app/src/main/java/app/gamenative/data/GogHiddenRepository.kt +++ /dev/null @@ -1,37 +0,0 @@ -package app.gamenative.data - -import app.gamenative.PrefManager -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Cache of GOG product IDs the user has hidden on GOG. - * - * Semantics of [hiddenIds]: - * - `null` means hidden metadata has not been loaded yet, so filtering fails open. - * - `emptySet` means a successful sync found no hidden games. - * - otherwise the set holds the hidden product IDs from the last successful sync. - */ -object GogHiddenRepository { - private val _hiddenIds = MutableStateFlow?>(null) - val hiddenIds: StateFlow?> = _hiddenIds.asStateFlow() - - /** Loads persisted hidden IDs. With no persisted cache the flow stays null (fail open). */ - fun loadFromCache() { - val cached = PrefManager.libraryGogHiddenIds - _hiddenIds.value = if (cached.isEmpty()) null else cached - } - - /** Publishes a successful sync result and persists it. */ - fun update(ids: Set) { - PrefManager.libraryGogHiddenIds = ids - _hiddenIds.value = ids - } - - /** Clears both in-memory and persisted state (e.g. on GOG logout). */ - fun clear() { - PrefManager.libraryGogHiddenIds = emptySet() - _hiddenIds.value = null - } -} diff --git a/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt b/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt index 9dadc98670..bd65902aa8 100644 --- a/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt +++ b/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt @@ -29,13 +29,12 @@ object HiddenGameFilter { /** * Whether a GOG game should appear in the library. * - * @param gameId GOG product ID to test. - * @param hiddenIds Hidden GOG product IDs; null means not loaded yet and fails open. + * @param isHidden Whether the GOG row is flagged hidden (rows default to false until the first + * hidden-metadata refresh, which fails open). * @param showHiddenByDefault Value of [PrefManager.showHiddenGamesByDefault]. */ fun passesGog( - gameId: String, - hiddenIds: Set?, + isHidden: Boolean, showHiddenByDefault: Boolean, - ): Boolean = hiddenIds == null || showHiddenByDefault || gameId !in hiddenIds + ): Boolean = showHiddenByDefault || !isHidden } diff --git a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt index b803248c9f..9b7bd08b8e 100644 --- a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt +++ b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt @@ -69,7 +69,7 @@ const val DATABASE_NAME = "pluvia.db" ModPlacementRecipe::class, ModOverwriteManifest::class, ], - version = 25, + version = 26, // For db migration, visit https://developer.android.com/training/data-storage/room/migrating-db-versions for more information exportSchema = true, // It is better to handle db changes carefully, as GN is getting much more users. autoMigrations = [ @@ -92,6 +92,7 @@ const val DATABASE_NAME = "pluvia.db" AutoMigration(from = 20, to = 21), // Added steam_file_hash_cache table AutoMigration(from = 21, to = 22), // Added GOG vertical_cover_url column AutoMigration(from = 22, to = 23), // Added local library play history table + AutoMigration(from = 25, to = 26), // Added GOG hidden column ] ) @TypeConverters( diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index 6642c9ba6e..97ea7aa95e 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -68,6 +68,24 @@ interface GOGGameDao { @Query("UPDATE gog_games SET vertical_cover_url = :url WHERE id = :gameId") suspend fun updateVerticalCoverUrl(gameId: String, url: String) + /** Clears the hidden flag on every GOG row (used before applying a fresh hidden set). */ + @Query("UPDATE gog_games SET hidden = 0") + suspend fun clearHiddenFlags() + + /** Marks the given GOG product IDs as hidden. */ + @Query("UPDATE gog_games SET hidden = 1 WHERE id IN (:hiddenIds)") + suspend fun markHidden(hiddenIds: Collection) + + /** + * Replaces the stored hidden state with [hiddenIds]: every GOG row is cleared first, then the + * listed product IDs are marked hidden. + */ + @Transaction + suspend fun applyHiddenFlags(hiddenIds: Collection) { + clearHiddenFlags() + markHidden(hiddenIds) + } + /** * Upsert GOG games while preserving install status and paths * This is useful when refreshing the library from GOG API @@ -84,6 +102,7 @@ interface GOGGameDao { installSize = existingGame.installSize, lastPlayed = existingGame.lastPlayed, playTime = existingGame.playTime, + hidden = existingGame.hidden, ) insert(gameToInsert) } else { diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 4f28703d30..094cef1b46 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -2,7 +2,6 @@ package app.gamenative.service.gog import android.content.Context import app.gamenative.PluviaApp -import app.gamenative.data.GogHiddenRepository import app.gamenative.data.GOGCloudSavesLocation import app.gamenative.data.GOGCloudSavesLocationTemplate import app.gamenative.data.GOGGame @@ -154,24 +153,35 @@ class GOGManager @Inject constructor( } /** - * Fetches hidden-product IDs and publishes them via [GogHiddenRepository]. + * Fetches hidden-product IDs and stores them on the matching `gog_games` rows. * - * Failures keep the previous cache (or the unloaded fail-open state) and are logged; this - * never throws and never fails the caller. + * Failures leave the existing hidden flags untouched and are logged; this never throws and + * never fails the caller. + * + * @return the fetched hidden product IDs, or null when the fetch failed or the user is not + * authenticated (callers can use it to stamp newly inserted rows). */ - suspend fun refreshHiddenIds() { - if (!GOGAuthManager.hasStoredCredentials(context)) return + suspend fun refreshHiddenIds(): Set? { + if (!GOGAuthManager.hasStoredCredentials(context)) return null val hiddenIdsResult = GOGApiClient.getHiddenGameIds(context) if (hiddenIdsResult.isSuccess) { - GogHiddenRepository.update(hiddenIdsResult.getOrNull() ?: emptySet()) + val hiddenIds = hiddenIdsResult.getOrNull() ?: emptySet() + gogGameDao.applyHiddenFlags(hiddenIds) + return hiddenIds } else { Timber.tag("GOG").w( hiddenIdsResult.exceptionOrNull(), - "Failed to fetch hidden GOG game IDs; keeping cached hidden list", + "Failed to fetch hidden GOG game IDs; keeping existing hidden flags", ) + return null } } + /** Clears the hidden flag on every GOG row (used when the logged-out account's metadata is removed). */ + suspend fun clearHiddenFlags() { + gogGameDao.clearHiddenFlags() + } + /** * Refresh the entire library (called manually by user) * Fetches all games from GOG API and updates the database @@ -201,8 +211,8 @@ class GOGManager @Inject constructor( Timber.tag("GOG").i("Successfully fetched ${gameIds.size} game IDs from GOG") // Refresh hidden-game metadata even when the owned library itself is unchanged. - // A failure here keeps the previous cache and must not fail the library refresh. - refreshHiddenIds() + // A failure keeps the existing flags and must not fail the library refresh. + val hiddenIds = refreshHiddenIds() if (gameIds.isEmpty()) { Timber.w("No games found in GOG library") @@ -244,12 +254,16 @@ class GOGManager @Inject constructor( Timber.tag("GOG").d("Got Game Details for ID: $id") val parsedGame = parseGameObject(gameDetails) if (parsedGame != null) { + val isHidden = hiddenIds?.contains(id) == true // Only real (non-excluded) games are shown, so only fetch // their portrait cover to avoid wasting GamesDB requests. val game = if (parsedGame.exclude) { - parsedGame + parsedGame.copy(hidden = isHidden) } else { - parsedGame.copy(verticalCoverUrl = GOGApiClient.getVerticalCoverUrl(id)) + parsedGame.copy( + hidden = isHidden, + verticalCoverUrl = GOGApiClient.getVerticalCoverUrl(id), + ) } games.add(game) Timber.tag("GOG").d("Refreshed Game: ${game.title}") diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 4a28b565b4..2f6c038843 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -8,7 +8,6 @@ import android.os.IBinder import app.gamenative.data.DownloadInfo import app.gamenative.data.GOGCredentials import app.gamenative.data.GOGGame -import app.gamenative.data.GogHiddenRepository import app.gamenative.data.LaunchInfo import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent @@ -153,7 +152,7 @@ class GOGService : Service() { Timber.i("[GOGService] All non-installed GOG games removed from database") // Hidden-game metadata belongs to the logged-out account. - GogHiddenRepository.clear() + instance.gogManager.clearHiddenFlags() // Stop the service stop() 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 01cc1c8152..11733534d3 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -13,7 +13,6 @@ import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.GameSource -import app.gamenative.data.GogHiddenRepository import app.gamenative.data.HiddenGameFilter import app.gamenative.data.LibraryItem import app.gamenative.data.gog.GogRecommendationsRepository @@ -134,9 +133,6 @@ class LibraryViewModel @Inject constructor( @Volatile private var steamCollections: List? = null - // null = not loaded yet (fail open); empty = loaded with no hidden games - @Volatile private var gogHiddenIds: Set? = null - // Mirrors PrefManager.showHiddenGamesByDefault without the async DataStore write race. @Volatile private var showHiddenGamesByDefault: Boolean = PrefManager.showHiddenGamesByDefault @@ -290,16 +286,8 @@ class LibraryViewModel @Inject constructor( } } - // Load cached hidden GOG IDs immediately, then observe live updates. - GogHiddenRepository.loadFromCache() - viewModelScope.launch(Dispatchers.IO) { - GogHiddenRepository.hiddenIds.collect { ids -> - gogHiddenIds = ids - onFilterApps(paginationCurrentPage) - } - } // Keep hidden metadata fresh even if the GOG background sync is throttled or has not run - // since this feature was added; failures keep the previous cache (fail open) and are logged. + // since this feature was added; the DAO flow re-emits when flags change and re-filters. viewModelScope.launch(Dispatchers.IO) { gogManager.refreshHiddenIds() } @@ -793,10 +781,10 @@ class LibraryViewModel @Inject constructor( } .filter { game -> // Hidden GOG games stay out of every library view unless the user opted to - // show them by default. Null/unloaded hidden metadata fails open. + // show them by default. Rows default to not hidden, so unloaded metadata + // (before the first refresh) fails open. HiddenGameFilter.passesGog( - gameId = game.id, - hiddenIds = gogHiddenIds, + isHidden = game.hidden, showHiddenByDefault = showHiddenGamesByDefault, ) } diff --git a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt index 8a6435c99d..188ef17fe7 100644 --- a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt +++ b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt @@ -3,7 +3,6 @@ package app.gamenative import app.gamenative.utils.FakeDataStore import app.gamenative.utils.installFakePrefManager import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue import org.junit.Test class PrefManagerHiddenGamesDefaultsTest { @@ -13,10 +12,4 @@ class PrefManagerHiddenGamesDefaultsTest { installFakePrefManager(FakeDataStore()) assertFalse(PrefManager.showHiddenGamesByDefault) } - - @Test - fun libraryGogHiddenIdsDefaultsToEmpty() { - installFakePrefManager(FakeDataStore()) - assertTrue(PrefManager.libraryGogHiddenIds.isEmpty()) - } } diff --git a/app/src/test/java/app/gamenative/data/GogHiddenRepositoryTest.kt b/app/src/test/java/app/gamenative/data/GogHiddenRepositoryTest.kt deleted file mode 100644 index 320b6e58ee..0000000000 --- a/app/src/test/java/app/gamenative/data/GogHiddenRepositoryTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package app.gamenative.data - -import androidx.datastore.preferences.core.mutablePreferencesOf -import androidx.datastore.preferences.core.stringPreferencesKey -import app.gamenative.PrefManager -import app.gamenative.utils.FakeDataStore -import app.gamenative.utils.awaitUpdateCount -import app.gamenative.utils.installFakePrefManager -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test - -class GogHiddenRepositoryTest { - - @Before - fun setUp() { - installFakePrefManager(FakeDataStore()) - GogHiddenRepository.clear() - } - - @Test - fun noCacheStartsAsNull() { - assertNull(GogHiddenRepository.hiddenIds.value) - } - - @Test - fun loadFromCacheWithNoCacheStaysNull() { - GogHiddenRepository.loadFromCache() - assertNull(GogHiddenRepository.hiddenIds.value) - } - - @Test - fun validCacheRestoresIds() { - val initial = mutablePreferencesOf( - stringPreferencesKey("library_gog_hidden_ids") to "1\u001F2", - ) - installFakePrefManager(FakeDataStore(initial)) - - GogHiddenRepository.loadFromCache() - - assertEquals(setOf("1", "2"), GogHiddenRepository.hiddenIds.value) - } - - @Test - fun updateChangesFlowAndPersistence() { - val fake = FakeDataStore() - installFakePrefManager(fake) - - GogHiddenRepository.update(setOf("3", "4")) - - assertEquals(setOf("3", "4"), GogHiddenRepository.hiddenIds.value) - fake.awaitUpdateCount() - assertEquals(setOf("3", "4"), PrefManager.libraryGogHiddenIds) - } - - @Test - fun clearResetsFlowAndPersistence() { - val fake = FakeDataStore() - installFakePrefManager(fake) - GogHiddenRepository.update(setOf("3", "4")) - - GogHiddenRepository.clear() - - assertNull(GogHiddenRepository.hiddenIds.value) - fake.awaitUpdateCount(2) - assertEquals(emptySet(), PrefManager.libraryGogHiddenIds) - } -} diff --git a/app/src/test/java/app/gamenative/data/HiddenGameFilterTest.kt b/app/src/test/java/app/gamenative/data/HiddenGameFilterTest.kt index bb1ce70abc..1bb20316eb 100644 --- a/app/src/test/java/app/gamenative/data/HiddenGameFilterTest.kt +++ b/app/src/test/java/app/gamenative/data/HiddenGameFilterTest.kt @@ -6,7 +6,6 @@ import org.junit.Test class HiddenGameFilterTest { private val hiddenSteamIds = setOf(440, 570) - private val hiddenGogIds = setOf("123", "456") @Test fun steamHiddenGameIsHiddenByDefault() { @@ -60,26 +59,22 @@ class HiddenGameFilterTest { @Test fun gogHiddenGameIsHiddenByDefault() { - assertFalse(HiddenGameFilter.passesGog("123", hiddenGogIds, showHiddenByDefault = false)) + assertFalse(HiddenGameFilter.passesGog(isHidden = true, showHiddenByDefault = false)) } @Test fun gogHiddenGameIsShownWhenSettingOn() { - assertTrue(HiddenGameFilter.passesGog("123", hiddenGogIds, showHiddenByDefault = true)) + assertTrue(HiddenGameFilter.passesGog(isHidden = true, showHiddenByDefault = true)) } @Test fun gogNonHiddenGameIsAlwaysShown() { - assertTrue(HiddenGameFilter.passesGog("789", hiddenGogIds, showHiddenByDefault = false)) + assertTrue(HiddenGameFilter.passesGog(isHidden = false, showHiddenByDefault = false)) } @Test - fun gogNullHiddenStateFailsOpen() { - assertTrue(HiddenGameFilter.passesGog("123", hiddenIds = null, showHiddenByDefault = false)) - } - - @Test - fun gogLoadedEmptySetShowsAll() { - assertTrue(HiddenGameFilter.passesGog("123", emptySet(), showHiddenByDefault = false)) + fun gogUnflaggedRowsFailOpen() { + // Rows default to not hidden before the first hidden-metadata refresh. + assertTrue(HiddenGameFilter.passesGog(isHidden = false, showHiddenByDefault = false)) } } diff --git a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt new file mode 100644 index 0000000000..879667207b --- /dev/null +++ b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt @@ -0,0 +1,89 @@ +package app.gamenative.db.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import app.gamenative.data.GOGGame +import app.gamenative.db.PluviaDatabase +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GOGGameDaoTest { + + private lateinit var db: PluviaDatabase + private lateinit var dao: GOGGameDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, PluviaDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.gogGameDao() + } + + @After + fun tearDown() { + db.close() + } + + private fun game(id: String, hidden: Boolean = false) = GOGGame( + id = id, + title = "Game $id", + hidden = hidden, + ) + + @Test + fun applyHiddenFlagsMarksListedIdsAndClearsOthers() = runBlocking { + dao.insertAll(listOf(game("1"), game("2"), game("3"))) + + dao.applyHiddenFlags(listOf("1", "3")) + + assertTrue(dao.getById("1")!!.hidden) + assertFalse(dao.getById("2")!!.hidden) + assertTrue(dao.getById("3")!!.hidden) + + // A fresh apply clears flags that are no longer hidden. + dao.applyHiddenFlags(listOf("2")) + + assertFalse(dao.getById("1")!!.hidden) + assertTrue(dao.getById("2")!!.hidden) + assertFalse(dao.getById("3")!!.hidden) + } + + @Test + fun upsertPreservingInstallStatusPreservesHiddenFlag() = runBlocking { + dao.insert(game("1", hidden = true)) + + dao.upsertPreservingInstallStatus(listOf(game("1", hidden = false).copy(title = "Updated"))) + + val existing = dao.getById("1")!! + assertTrue(existing.hidden) + assertEquals("Updated", existing.title) + + // New rows are inserted with the hidden value they carry. + dao.upsertPreservingInstallStatus(listOf(game("2", hidden = true))) + assertTrue(dao.getById("2")!!.hidden) + } + + @Test + fun getAllEmitsUpdatedHiddenRows() = runBlocking { + dao.insertAll(listOf(game("1"), game("2"))) + + dao.applyHiddenFlags(listOf("2")) + + val updated = dao.getAll().first { list -> list.any { it.hidden } } + assertEquals(listOf("1", "2"), updated.map { it.id }) + assertFalse(updated.first { it.id == "1" }.hidden) + assertTrue(updated.first { it.id == "2" }.hidden) + } +} From cece3edd3918f73a1a89b5e12575d09869fd100f Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Sat, 8 Aug 2026 18:40:28 -0700 Subject: [PATCH 04/15] Fix hidden-game handling review findings --- .../java/app/gamenative/db/dao/GOGGameDao.kt | 13 +++- .../gamenative/service/gog/GOGApiClient.kt | 19 +++-- .../app/gamenative/service/gog/GOGManager.kt | 41 +++++++---- .../gamenative/steam/SteamCollectionFilter.kt | 15 ++++ .../gamenative/ui/model/LibraryViewModel.kt | 28 +++++--- .../PrefManagerHiddenGamesDefaultsTest.kt | 5 +- .../app/gamenative/db/dao/GOGGameDaoTest.kt | 10 +++ .../steam/SteamCollectionFilterTest.kt | 21 ++++++ .../gamenative/ui/data/LibraryCountsTest.kt | 69 +++++++++++++------ .../app/gamenative/utils/TestPrefManager.kt | 33 ++++++--- 10 files changed, 187 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index 97ea7aa95e..09a3484940 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -16,6 +16,12 @@ import kotlinx.coroutines.flow.Flow @Dao interface GOGGameDao { + // SQLite (and Room's expanded IN lists) bind each entry separately; Android's default bind + // limit is 999, so chunk large hidden sets to stay well under it. + private companion object { + const val MAX_HIDDEN_BIND_PARAMS = 500 + } + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(game: GOGGame) @@ -78,12 +84,15 @@ interface GOGGameDao { /** * Replaces the stored hidden state with [hiddenIds]: every GOG row is cleared first, then the - * listed product IDs are marked hidden. + * listed product IDs are marked hidden. Large sets are applied in chunks to stay under SQLite's + * bind-variable limit. */ @Transaction suspend fun applyHiddenFlags(hiddenIds: Collection) { clearHiddenFlags() - markHidden(hiddenIds) + hiddenIds.chunked(MAX_HIDDEN_BIND_PARAMS).forEach { chunk -> + markHidden(chunk) + } } /** diff --git a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt index 6d2897cd3c..59e0f8f14b 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt @@ -203,20 +203,19 @@ object GOGApiClient { // The embed host returned no hidden products (or failed). Retry on www before // concluding the account has none, because hiddenFlag handling can differ between hosts. val fallbackResult = fetchHiddenGameIdsFrom(credentials, "https://www.gog.com") - val mergedIds = buildSet { - primaryIds?.let { addAll(it) } - fallbackResult.getOrNull()?.let { addAll(it) } - } - if (primaryResult.isFailure && fallbackResult.isFailure) { - val error = primaryResult.exceptionOrNull() + if (fallbackResult.isFailure) { + // The embed host did not confirm the hidden set and the fallback cannot confirm it + // either; return failure so callers keep their previous cache instead of clearing it. + val error = fallbackResult.exceptionOrNull() ?: Exception("Failed to fetch hidden GOG game IDs") - Timber.tag("GOG").e(error, "Failed to fetch hidden GOG game IDs from both hosts") + Timber.tag("GOG").w(error, "Hidden fallback failed; keeping previous hidden set") return@withContext Result.failure(error) } - if (fallbackResult.isFailure) { - Timber.tag("GOG").w(fallbackResult.exceptionOrNull(), "www fallback failed; using embed result") - } + val mergedIds = buildSet { + primaryIds?.let { addAll(it) } + fallbackResult.getOrNull()?.let { addAll(it) } + } Timber.tag("GOG").i("Successfully fetched ${mergedIds.size} hidden GOG game IDs") return@withContext Result.success(mergedIds) } catch (e: Exception) { diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 094cef1b46..204e6d1cfb 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -26,6 +26,8 @@ import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.Request import org.json.JSONObject @@ -60,6 +62,10 @@ class GOGManager @Inject constructor( @ApplicationContext private val context: Context, ) { + // Serializes hidden-flag refreshes against logout's clear so a refresh that started before + // logout cannot restore the previous account's flags afterwards. + private val hiddenRefreshMutex = Mutex() + // Thread-safe cache for download sizes private val downloadSizeCache = ConcurrentHashMap() private val REFRESH_BATCH_SIZE = 10 @@ -162,24 +168,33 @@ class GOGManager @Inject constructor( * authenticated (callers can use it to stamp newly inserted rows). */ suspend fun refreshHiddenIds(): Set? { - if (!GOGAuthManager.hasStoredCredentials(context)) return null - val hiddenIdsResult = GOGApiClient.getHiddenGameIds(context) - if (hiddenIdsResult.isSuccess) { - val hiddenIds = hiddenIdsResult.getOrNull() ?: emptySet() - gogGameDao.applyHiddenFlags(hiddenIds) - return hiddenIds - } else { - Timber.tag("GOG").w( - hiddenIdsResult.exceptionOrNull(), - "Failed to fetch hidden GOG game IDs; keeping existing hidden flags", - ) - return null + return hiddenRefreshMutex.withLock { + if (!GOGAuthManager.hasStoredCredentials(context)) return@withLock null + val hiddenIdsResult = GOGApiClient.getHiddenGameIds(context) + if (hiddenIdsResult.isSuccess) { + val hiddenIds = hiddenIdsResult.getOrNull() ?: emptySet() + try { + gogGameDao.applyHiddenFlags(hiddenIds) + hiddenIds + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to persist hidden GOG game IDs; keeping existing hidden flags") + null + } + } else { + Timber.tag("GOG").w( + hiddenIdsResult.exceptionOrNull(), + "Failed to fetch hidden GOG game IDs; keeping existing hidden flags", + ) + null + } } } /** Clears the hidden flag on every GOG row (used when the logged-out account's metadata is removed). */ suspend fun clearHiddenFlags() { - gogGameDao.clearHiddenFlags() + hiddenRefreshMutex.withLock { + gogGameDao.clearHiddenFlags() + } } /** diff --git a/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt b/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt index 1b963fcd0c..c3166ecb60 100644 --- a/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt +++ b/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt @@ -33,6 +33,21 @@ object SteamCollectionFilter { collection.id to appIds.count { it in collection.appIds } } ?: emptyMap() + /** + * Per-collection counts for the options panel: the Hidden collection uses [preHiddenAppIds] so + * it keeps its full count (and stays discoverable), while every other collection counts only + * [visibleAppIds] so badges match the games actually rendered. + */ + fun visibleCollectionCounts( + collections: List?, + visibleAppIds: Collection, + preHiddenAppIds: Collection, + hiddenCollectionId: String = SteamCollection.ID_HIDDEN, + ): Map = collections?.associate { collection -> + val appIds = if (collection.id == hiddenCollectionId) preHiddenAppIds else visibleAppIds + collection.id to appIds.count { it in collection.appIds } + } ?: emptyMap() + data class Reconciliation(val cleaned: Set, val removedAny: Boolean) /** Drop selected ids no longer present. No-op while collections are not loaded (null). */ 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 11733534d3..14ac5e11d7 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -650,15 +650,6 @@ 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. - // Kept pre-hidden so the Hidden collection keeps its full count while hidden games are - // excluded from the visible list. - val steamCollectionCounts: Map = SteamCollectionFilter.collectionCounts( - collections = steamCollections, - appIds = steamOwnerTypeFiltered.map { it.id }, - ) - // 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( @@ -672,6 +663,25 @@ class LibraryViewModel @Inject constructor( ?.appIds ?: emptySet() val hiddenCollectionSelected = currentState.selectedSteamCollectionIds.contains(SteamCollection.ID_HIDDEN) + + // Per-collection counts: the Hidden collection keeps its full pre-hidden count (so it + // stays discoverable), while every other collection counts only visible games so its + // badge matches what is rendered when selected. + val preHiddenAppIds = steamOwnerTypeFiltered.map { it.id } + val visibleAppIds = preHiddenAppIds.filter { appId -> + HiddenGameFilter.passesSteam( + appId = appId, + hiddenAppIds = hiddenSteamAppIds, + showHiddenByDefault = showHiddenGamesByDefault, + hiddenCollectionSelected = hiddenCollectionSelected, + ) + } + val steamCollectionCounts: Map = SteamCollectionFilter.visibleCollectionCounts( + collections = steamCollections, + visibleAppIds = visibleAppIds, + preHiddenAppIds = preHiddenAppIds, + ) + val steamFilteredBeforeCompatibility: List = ( if (allowedSteamAppIds == null) { diff --git a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt index 188ef17fe7..fe8d012324 100644 --- a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt +++ b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt @@ -9,7 +9,8 @@ class PrefManagerHiddenGamesDefaultsTest { @Test fun showHiddenGamesByDefaultDefaultsToFalse() { - installFakePrefManager(FakeDataStore()) - assertFalse(PrefManager.showHiddenGamesByDefault) + installFakePrefManager(FakeDataStore()).use { + assertFalse(PrefManager.showHiddenGamesByDefault) + } } } diff --git a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt index 879667207b..aab6fc851a 100644 --- a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt +++ b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt @@ -86,4 +86,14 @@ class GOGGameDaoTest { assertFalse(updated.first { it.id == "1" }.hidden) assertTrue(updated.first { it.id == "2" }.hidden) } + + @Test + fun applyHiddenFlagsHandlesMoreThanSqliteBindLimit() = runBlocking { + val count = 1001 + dao.insertAll((1..count).map { game(it.toString()) }) + + dao.applyHiddenFlags((1..count).map { it.toString() }) + + assertEquals(count, dao.getAllAsList().count { it.hidden }) + } } diff --git a/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt b/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt index cc5ac5264e..13406d16a0 100644 --- a/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt +++ b/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt @@ -78,4 +78,25 @@ class SteamCollectionFilterTest { @Test fun collectionCountsAreEmptyWhenCollectionsNotLoaded() { assertEquals(emptyMap(), SteamCollectionFilter.collectionCounts(null, listOf(440))) } + + @Test fun visibleCollectionCountsKeepHiddenCollectionFullButExcludeHiddenElsewhere() { + val hidden = SteamCollection(SteamCollection.ID_HIDDEN, "Hidden", setOf(440, 570)) + val favorites = SteamCollection("fav", "Favorites", setOf(440, 570, 730)) + + val counts = SteamCollectionFilter.visibleCollectionCounts( + collections = listOf(hidden, favorites), + visibleAppIds = listOf(440, 730), // 570 is hidden and filtered out by default + preHiddenAppIds = listOf(440, 570, 730), + ) + + assertEquals(2, counts[SteamCollection.ID_HIDDEN]) // full count, includes the hidden game + assertEquals(2, counts["fav"]) // excludes the hidden game, so the badge matches the list + } + + @Test fun visibleCollectionCountsAreEmptyWhenCollectionsNotLoaded() { + assertEquals( + emptyMap(), + SteamCollectionFilter.visibleCollectionCounts(null, listOf(440), listOf(440)), + ) + } } diff --git a/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt b/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt index 72d24c7d41..304483d1b4 100644 --- a/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt +++ b/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt @@ -1,8 +1,10 @@ package app.gamenative.ui.data import app.gamenative.PrefManager +import app.gamenative.data.GOGGame +import app.gamenative.data.HiddenGameFilter import app.gamenative.utils.FakeDataStore -import app.gamenative.utils.awaitUpdateCount +import app.gamenative.utils.awaitUntil import app.gamenative.utils.installFakePrefManager import org.junit.Assert.assertEquals import org.junit.Test @@ -11,26 +13,53 @@ class LibraryCountsTest { @Test fun persistsPostHiddenVisibilityCounts() { - val fake = FakeDataStore() - installFakePrefManager(fake) + installFakePrefManager(FakeDataStore()).use { + // Fixture: hidden games that the default (off) setting filters out. + val gogGames = listOf( + GOGGame(id = "g1", hidden = true), + GOGGame(id = "g2", hidden = false), + GOGGame(id = "g3", hidden = false), + ) + val visibleGogCount = gogGames.count { + HiddenGameFilter.passesGog(isHidden = it.hidden, showHiddenByDefault = false) + } - LibraryCounts.persist( - customGames = 1, - steamGames = 2, - gogGames = 3, - gogInstalledGames = 4, - epicGames = 5, - epicInstalledGames = 6, - amazonInstalledGames = 7, - ) + val hiddenSteamAppIds = setOf(570) + val steamAppIds = listOf(440, 570) + val visibleSteamCount = steamAppIds.count { appId -> + HiddenGameFilter.passesSteam( + appId = appId, + hiddenAppIds = hiddenSteamAppIds, + showHiddenByDefault = false, + hiddenCollectionSelected = false, + ) + } - fake.awaitUpdateCount(7) - assertEquals(1, PrefManager.customGamesCount) - assertEquals(2, PrefManager.steamGamesCount) - assertEquals(3, PrefManager.gogGamesCount) - assertEquals(4, PrefManager.gogInstalledGamesCount) - assertEquals(5, PrefManager.epicGamesCount) - assertEquals(6, PrefManager.epicInstalledGamesCount) - assertEquals(7, PrefManager.amazonInstalledGamesCount) + LibraryCounts.persist( + customGames = 0, + steamGames = visibleSteamCount, + gogGames = visibleGogCount, + gogInstalledGames = 1, + epicGames = 0, + epicInstalledGames = 0, + amazonInstalledGames = 0, + ) + + // Wait on actual values (not a raw write count) so PrefManager.init's async cleanup + // writes cannot satisfy the wait early. + awaitUntil { + PrefManager.steamGamesCount == visibleSteamCount && + PrefManager.gogGamesCount == visibleGogCount && + PrefManager.gogInstalledGamesCount == 1 + } + + assertEquals(0, PrefManager.customGamesCount) + assertEquals(visibleSteamCount, PrefManager.steamGamesCount) + assertEquals(visibleGogCount, PrefManager.gogGamesCount) + assertEquals(1, PrefManager.gogInstalledGamesCount) + assertEquals(0, PrefManager.epicGamesCount) + assertEquals(0, PrefManager.epicInstalledGamesCount) + assertEquals(0, PrefManager.amazonInstalledGamesCount) + } } } diff --git a/app/src/test/java/app/gamenative/utils/TestPrefManager.kt b/app/src/test/java/app/gamenative/utils/TestPrefManager.kt index 170063150e..f5acaa0d02 100644 --- a/app/src/test/java/app/gamenative/utils/TestPrefManager.kt +++ b/app/src/test/java/app/gamenative/utils/TestPrefManager.kt @@ -8,8 +8,6 @@ import app.gamenative.PrefManager import java.io.File import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.mockito.Mockito @@ -19,9 +17,6 @@ class FakeDataStore(initial: Preferences = emptyPreferences()) : DataStore = state override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { @@ -29,22 +24,36 @@ class FakeDataStore(initial: Preferences = emptyPreferences()) : DataStore= expectedUpdates } } +/** Polls [condition] until it returns true or [timeoutMs] elapses; fails the test on timeout. */ +fun awaitUntil(timeoutMs: Long = 2_000, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (!condition()) { + check(System.currentTimeMillis() <= deadline) { "Timed out after ${timeoutMs}ms waiting for condition" } + Thread.sleep(10) + } +} + +/** Restores [PrefManager]'s original backing store when closed. */ +class PrefManagerTestScope( + private val dataStoreField: java.lang.reflect.Field, + private val originalStore: Any?, +) : AutoCloseable { + override fun close() { + dataStoreField.set(PrefManager, originalStore) + } } /** * Replaces [PrefManager]'s backing store with [fake] so preference defaults, reads, and writes are - * deterministic in unit tests. + * deterministic in unit tests. Returns a scope that restores the previous store, preventing the + * fake from leaking into other tests in the shared JVM. */ -fun installFakePrefManager(fake: FakeDataStore) { +fun installFakePrefManager(fake: FakeDataStore): PrefManagerTestScope { val context = Mockito.mock(Context::class.java) val filesDir = File(System.getProperty("java.io.tmpdir"), "gamenative-pref-test-${System.nanoTime()}") filesDir.mkdirs() @@ -56,5 +65,7 @@ fun installFakePrefManager(fake: FakeDataStore) { val dataStoreField = PrefManager::class.java.getDeclaredField("dataStore") dataStoreField.isAccessible = true + val originalStore = dataStoreField.get(PrefManager) dataStoreField.set(PrefManager, fake) + return PrefManagerTestScope(dataStoreField, originalStore) } From 252fb92cd3d7baa9bae231e42ddbd15e47ca33cd Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Sat, 8 Aug 2026 19:11:44 -0700 Subject: [PATCH 05/15] Address follow-up hidden-game review findings --- .../app/gamenative/service/gog/GOGManager.kt | 31 +++++++++++++------ .../gamenative/steam/SteamCollectionFilter.kt | 10 ------ .../PrefManagerHiddenGamesDefaultsTest.kt | 3 ++ .../steam/SteamCollectionFilterTest.kt | 19 ------------ .../gamenative/ui/data/LibraryCountsTest.kt | 3 ++ .../app/gamenative/utils/TestPrefManager.kt | 11 ++----- 6 files changed, 29 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 204e6d1cfb..f85af49e1b 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -168,11 +168,28 @@ class GOGManager @Inject constructor( * authenticated (callers can use it to stamp newly inserted rows). */ suspend fun refreshHiddenIds(): Set? { + // Fetch outside the lock so logout's clearHiddenFlags() is never stalled by long network + // work. The lock is held only for the credential re-check + DB write. + val fetchUserId = GOGAuthManager.getStoredCredentials(context).getOrNull()?.userId + ?: return null + val hiddenIdsResult = GOGApiClient.getHiddenGameIds(context) + if (hiddenIdsResult.isFailure) { + Timber.tag("GOG").w( + hiddenIdsResult.exceptionOrNull(), + "Failed to fetch hidden GOG game IDs; keeping existing hidden flags", + ) + return null + } + val hiddenIds = hiddenIdsResult.getOrNull() ?: emptySet() + return hiddenRefreshMutex.withLock { - if (!GOGAuthManager.hasStoredCredentials(context)) return@withLock null - val hiddenIdsResult = GOGApiClient.getHiddenGameIds(context) - if (hiddenIdsResult.isSuccess) { - val hiddenIds = hiddenIdsResult.getOrNull() ?: emptySet() + // Re-validate the account after the fetch: if logout (or an account switch) happened + // while we were fetching, do not write the old account's flags. + val currentUserId = GOGAuthManager.getStoredCredentials(context).getOrNull()?.userId + if (currentUserId != fetchUserId) { + Timber.tag("GOG").w("Skipping hidden-flag persist: GOG account changed during fetch") + null + } else { try { gogGameDao.applyHiddenFlags(hiddenIds) hiddenIds @@ -180,12 +197,6 @@ class GOGManager @Inject constructor( Timber.tag("GOG").e(e, "Failed to persist hidden GOG game IDs; keeping existing hidden flags") null } - } else { - Timber.tag("GOG").w( - hiddenIdsResult.exceptionOrNull(), - "Failed to fetch hidden GOG game IDs; keeping existing hidden flags", - ) - null } } } diff --git a/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt b/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt index c3166ecb60..348081fcbf 100644 --- a/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt +++ b/app/src/main/java/app/gamenative/steam/SteamCollectionFilter.kt @@ -23,16 +23,6 @@ object SteamCollectionFilter { return buildSet { selected.forEach { addAll(it.appIds) } } } - /** - * Per-collection counts computed from a pre-hidden app-id set (e.g. the owner/type/search - * filtered list before default hidden filtering). This keeps the Hidden collection's count - * visible even when hidden games are excluded from the main library list. - */ - fun collectionCounts(collections: List?, appIds: Collection): Map = - collections?.associate { collection -> - collection.id to appIds.count { it in collection.appIds } - } ?: emptyMap() - /** * Per-collection counts for the options panel: the Hidden collection uses [preHiddenAppIds] so * it keeps its full count (and stays discoverable), while every other collection counts only diff --git a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt index fe8d012324..50dbba1b44 100644 --- a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt +++ b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt @@ -4,7 +4,10 @@ import app.gamenative.utils.FakeDataStore import app.gamenative.utils.installFakePrefManager import org.junit.Assert.assertFalse import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class PrefManagerHiddenGamesDefaultsTest { @Test diff --git a/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt b/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt index 13406d16a0..493aba51ce 100644 --- a/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt +++ b/app/src/test/java/app/gamenative/steam/SteamCollectionFilterTest.kt @@ -60,25 +60,6 @@ class SteamCollectionFilterTest { assertEquals(setOf(440, 570, 730), SteamCollectionFilter.allowedAppIds(setOf("fav", "sht"), all)) } - @Test fun collectionCountsIncludeHiddenGamesForHiddenCollection() { - val hidden = SteamCollection(SteamCollection.ID_HIDDEN, "Hidden", setOf(440, 570)) - val favorites = SteamCollection("fav", "Favorites", setOf(440, 730)) - - val counts = SteamCollectionFilter.collectionCounts( - collections = listOf(hidden, favorites), - appIds = listOf(440, 570, 730), - ) - - // Counts come from the pre-hidden set, so the Hidden collection keeps its full count even - // when hidden games are excluded from the visible library list. - assertEquals(2, counts[SteamCollection.ID_HIDDEN]) - assertEquals(2, counts["fav"]) - } - - @Test fun collectionCountsAreEmptyWhenCollectionsNotLoaded() { - assertEquals(emptyMap(), SteamCollectionFilter.collectionCounts(null, listOf(440))) - } - @Test fun visibleCollectionCountsKeepHiddenCollectionFullButExcludeHiddenElsewhere() { val hidden = SteamCollection(SteamCollection.ID_HIDDEN, "Hidden", setOf(440, 570)) val favorites = SteamCollection("fav", "Favorites", setOf(440, 570, 730)) diff --git a/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt b/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt index 304483d1b4..bd5cd62f62 100644 --- a/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt +++ b/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt @@ -8,7 +8,10 @@ import app.gamenative.utils.awaitUntil import app.gamenative.utils.installFakePrefManager import org.junit.Assert.assertEquals import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class LibraryCountsTest { @Test diff --git a/app/src/test/java/app/gamenative/utils/TestPrefManager.kt b/app/src/test/java/app/gamenative/utils/TestPrefManager.kt index f5acaa0d02..91d952e98a 100644 --- a/app/src/test/java/app/gamenative/utils/TestPrefManager.kt +++ b/app/src/test/java/app/gamenative/utils/TestPrefManager.kt @@ -4,13 +4,12 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences +import androidx.test.core.app.ApplicationProvider import app.gamenative.PrefManager -import java.io.File import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import org.mockito.Mockito /** In-memory [DataStore] so unit tests can observe [PrefManager] writes. */ class FakeDataStore(initial: Preferences = emptyPreferences()) : DataStore { @@ -54,13 +53,7 @@ class PrefManagerTestScope( * fake from leaking into other tests in the shared JVM. */ fun installFakePrefManager(fake: FakeDataStore): PrefManagerTestScope { - val context = Mockito.mock(Context::class.java) - val filesDir = File(System.getProperty("java.io.tmpdir"), "gamenative-pref-test-${System.nanoTime()}") - filesDir.mkdirs() - Mockito.`when`(context.filesDir).thenReturn(filesDir) - Mockito.`when`(context.dataDir).thenReturn(filesDir) - Mockito.`when`(context.applicationContext).thenReturn(context) - + val context = ApplicationProvider.getApplicationContext() PrefManager.init(context) val dataStoreField = PrefManager::class.java.getDeclaredField("dataStore") From e6f37b01b8538235d5b6d3bfc5ef0c700d53db59 Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Sat, 8 Aug 2026 19:29:10 -0700 Subject: [PATCH 06/15] Prevent stale hidden refreshes from overwriting newer ones --- .../app/gamenative/service/gog/GOGManager.kt | 27 +++++++++++++++++- .../PrefManagerHiddenGamesDefaultsTest.kt | 3 -- .../gog/HiddenRefreshCoordinatorTest.kt | 28 +++++++++++++++++++ .../gamenative/ui/data/LibraryCountsTest.kt | 3 -- .../app/gamenative/utils/TestPrefManager.kt | 5 ---- 5 files changed, 54 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index f85af49e1b..09b112acac 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -22,6 +22,7 @@ import com.winlator.xenvironment.components.GuestProgramLauncherComponent import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers @@ -41,6 +42,24 @@ data class GameSizeInfo( val diskSize: Long, ) +/** + * Tracks hidden-ID refresh generations so a slower, older response cannot overwrite a newer one. + */ +internal class HiddenRefreshCoordinator { + private val counter = AtomicLong(0) + @Volatile private var latestGeneration = 0L + + /** Starts a refresh, marks it as the latest, and returns its generation. */ + fun begin(): Long { + val generation = counter.incrementAndGet() + latestGeneration = generation + return generation + } + + /** True when [generation] is still the latest refresh (nothing newer has started). */ + fun isLatest(generation: Long): Boolean = generation == latestGeneration +} + /** * Unified manager for GOG game and library operations. * @@ -66,6 +85,8 @@ class GOGManager @Inject constructor( // logout cannot restore the previous account's flags afterwards. private val hiddenRefreshMutex = Mutex() + private val hiddenRefreshCoordinator = HiddenRefreshCoordinator() + // Thread-safe cache for download sizes private val downloadSizeCache = ConcurrentHashMap() private val REFRESH_BATCH_SIZE = 10 @@ -170,6 +191,7 @@ class GOGManager @Inject constructor( suspend fun refreshHiddenIds(): Set? { // Fetch outside the lock so logout's clearHiddenFlags() is never stalled by long network // work. The lock is held only for the credential re-check + DB write. + val generation = hiddenRefreshCoordinator.begin() val fetchUserId = GOGAuthManager.getStoredCredentials(context).getOrNull()?.userId ?: return null val hiddenIdsResult = GOGApiClient.getHiddenGameIds(context) @@ -186,7 +208,10 @@ class GOGManager @Inject constructor( // Re-validate the account after the fetch: if logout (or an account switch) happened // while we were fetching, do not write the old account's flags. val currentUserId = GOGAuthManager.getStoredCredentials(context).getOrNull()?.userId - if (currentUserId != fetchUserId) { + if (!hiddenRefreshCoordinator.isLatest(generation)) { + Timber.tag("GOG").w("Skipping hidden-flag persist: superseded by a newer refresh") + null + } else if (currentUserId != fetchUserId) { Timber.tag("GOG").w("Skipping hidden-flag persist: GOG account changed during fetch") null } else { diff --git a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt index 50dbba1b44..fe8d012324 100644 --- a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt +++ b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt @@ -4,10 +4,7 @@ import app.gamenative.utils.FakeDataStore import app.gamenative.utils.installFakePrefManager import org.junit.Assert.assertFalse import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) class PrefManagerHiddenGamesDefaultsTest { @Test diff --git a/app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt b/app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt new file mode 100644 index 0000000000..37df719660 --- /dev/null +++ b/app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt @@ -0,0 +1,28 @@ +package app.gamenative.service.gog + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HiddenRefreshCoordinatorTest { + + @Test + fun beginAssignsIncreasingGenerations() { + val coordinator = HiddenRefreshCoordinator() + + val first = coordinator.begin() + val second = coordinator.begin() + + assertTrue(second > first) + assertTrue(coordinator.isLatest(second)) + assertFalse(coordinator.isLatest(first)) + } + + @Test + fun isLatestTrueForTheOnlyGeneration() { + val coordinator = HiddenRefreshCoordinator() + val generation = coordinator.begin() + + assertTrue(coordinator.isLatest(generation)) + } +} diff --git a/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt b/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt index bd5cd62f62..304483d1b4 100644 --- a/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt +++ b/app/src/test/java/app/gamenative/ui/data/LibraryCountsTest.kt @@ -8,10 +8,7 @@ import app.gamenative.utils.awaitUntil import app.gamenative.utils.installFakePrefManager import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) class LibraryCountsTest { @Test diff --git a/app/src/test/java/app/gamenative/utils/TestPrefManager.kt b/app/src/test/java/app/gamenative/utils/TestPrefManager.kt index 91d952e98a..469911efdf 100644 --- a/app/src/test/java/app/gamenative/utils/TestPrefManager.kt +++ b/app/src/test/java/app/gamenative/utils/TestPrefManager.kt @@ -1,10 +1,8 @@ package app.gamenative.utils -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences -import androidx.test.core.app.ApplicationProvider import app.gamenative.PrefManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -53,9 +51,6 @@ class PrefManagerTestScope( * fake from leaking into other tests in the shared JVM. */ fun installFakePrefManager(fake: FakeDataStore): PrefManagerTestScope { - val context = ApplicationProvider.getApplicationContext() - PrefManager.init(context) - val dataStoreField = PrefManager::class.java.getDeclaredField("dataStore") dataStoreField.isAccessible = true val originalStore = dataStoreField.get(PrefManager) From 96cb36a6a8559cd87b7e592a0410aff63c0c1ef4 Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Sat, 8 Aug 2026 19:45:20 -0700 Subject: [PATCH 07/15] Harden hidden-refresh generation and single-game hidden state --- .../java/app/gamenative/db/dao/GOGGameDao.kt | 10 ++++++++++ .../gamenative/service/gog/GOGApiClient.kt | 5 +++++ .../app/gamenative/service/gog/GOGManager.kt | 20 ++++++++++--------- .../app/gamenative/db/dao/GOGGameDaoTest.kt | 15 ++++++++++++++ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index 09a3484940..76a0ae7a8a 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -28,6 +28,16 @@ interface GOGGameDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(games: List) + /** + * Inserts [game], keeping the existing row's hidden flag when the id already exists so a + * single-game refresh cannot reset hidden state. + */ + @Transaction + suspend fun upsertPreservingHidden(game: GOGGame) { + val existing = getById(game.id) + insert(if (existing != null) game.copy(hidden = existing.hidden) else game) + } + @Update suspend fun update(game: GOGGame) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt index 59e0f8f14b..2c3158a7cf 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt @@ -3,6 +3,7 @@ package app.gamenative.service.gog import android.content.Context import app.gamenative.data.GOGGame import app.gamenative.data.GOGCredentials +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -218,6 +219,8 @@ object GOGApiClient { } Timber.tag("GOG").i("Successfully fetched ${mergedIds.size} hidden GOG game IDs") return@withContext Result.success(mergedIds) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.tag("GOG").e(e, "Exception fetching hidden GOG game IDs: ${e.message}") return@withContext Result.failure(e) @@ -271,6 +274,8 @@ object GOGApiClient { Timber.tag("GOG").d("Fetched ${hiddenIds.size} hidden GOG game IDs from $baseUrl") Result.success(hiddenIds) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Result.failure(e) } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 09b112acac..b1c8a41a57 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -25,6 +25,7 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -46,18 +47,15 @@ data class GameSizeInfo( * Tracks hidden-ID refresh generations so a slower, older response cannot overwrite a newer one. */ internal class HiddenRefreshCoordinator { - private val counter = AtomicLong(0) - @Volatile private var latestGeneration = 0L + // A single atomic counter doubles as both the generation and the latest marker: incrementAndGet + // atomically assigns and publishes, so latestGeneration can never regress. + private val latestGeneration = AtomicLong(0) /** Starts a refresh, marks it as the latest, and returns its generation. */ - fun begin(): Long { - val generation = counter.incrementAndGet() - latestGeneration = generation - return generation - } + fun begin(): Long = latestGeneration.incrementAndGet() /** True when [generation] is still the latest refresh (nothing newer has started). */ - fun isLatest(generation: Long): Boolean = generation == latestGeneration + fun isLatest(generation: Long): Boolean = generation == latestGeneration.get() } /** @@ -125,7 +123,9 @@ class GOGManager @Inject constructor( suspend fun insertGame(game: GOGGame) { withContext(Dispatchers.IO) { - gogGameDao.insert(game) + // Preserve the existing hidden flag when the row already exists, so a single-game + // refresh cannot reset hidden state to false. + gogGameDao.upsertPreservingHidden(game) } } @@ -218,6 +218,8 @@ class GOGManager @Inject constructor( try { gogGameDao.applyHiddenFlags(hiddenIds) hiddenIds + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.tag("GOG").e(e, "Failed to persist hidden GOG game IDs; keeping existing hidden flags") null diff --git a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt index aab6fc851a..74035d1e7b 100644 --- a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt +++ b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt @@ -75,6 +75,21 @@ class GOGGameDaoTest { assertTrue(dao.getById("2")!!.hidden) } + @Test + fun upsertPreservingHiddenKeepsExistingHiddenFlag() = runBlocking { + dao.insert(game("1", hidden = true)) + + dao.upsertPreservingHidden(game("1", hidden = false).copy(title = "Updated")) + + val existing = dao.getById("1")!! + assertTrue(existing.hidden) + assertEquals("Updated", existing.title) + + // New rows keep the hidden value they carry. + dao.upsertPreservingHidden(game("2", hidden = true)) + assertTrue(dao.getById("2")!!.hidden) + } + @Test fun getAllEmitsUpdatedHiddenRows() = runBlocking { dao.insertAll(listOf(game("1"), game("2"))) From 3a470763f99734ad5c62fef368c4ec818480ef97 Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Sat, 8 Aug 2026 20:05:13 -0700 Subject: [PATCH 08/15] Preserve hidden state in single-game refresh and fix cancellation flow --- .../java/app/gamenative/db/dao/GOGGameDao.kt | 10 ---- .../gamenative/service/gog/GOGApiClient.kt | 4 ++ .../app/gamenative/service/gog/GOGManager.kt | 48 +++++++++++++------ .../app/gamenative/db/dao/GOGGameDaoTest.kt | 23 ++------- .../gog/HiddenRefreshCoordinatorTest.kt | 28 +++++++++-- 5 files changed, 66 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index 76a0ae7a8a..09a3484940 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -28,16 +28,6 @@ interface GOGGameDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(games: List) - /** - * Inserts [game], keeping the existing row's hidden flag when the id already exists so a - * single-game refresh cannot reset hidden state. - */ - @Transaction - suspend fun upsertPreservingHidden(game: GOGGame) { - val existing = getById(game.id) - insert(if (existing != null) game.copy(hidden = existing.hidden) else game) - } - @Update suspend fun update(game: GOGGame) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt index 2c3158a7cf..b26c63e352 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt @@ -159,6 +159,8 @@ object GOGApiClient { Timber.tag("GOG").d("First 10 game IDs: ${gameIds.take(10).joinToString()}") return@withContext Result.success(gameIds) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.e(e, "Exception fetching game IDs: ${e.message}") return@withContext Result.failure(e) @@ -356,6 +358,8 @@ object GOGApiClient { return@withContext Result.success(transformedResponse) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.tag("GOG").e(e, "Exception fetching game details for $gameId: ${e.message}") return@withContext Result.failure(e) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index b1c8a41a57..9b41f82af2 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -44,18 +44,23 @@ data class GameSizeInfo( ) /** - * Tracks hidden-ID refresh generations so a slower, older response cannot overwrite a newer one. + * Tracks hidden-ID refresh generations so a slower, older response cannot overwrite a newer + * committed one, while a failed newer attempt does not block an older successful response. */ internal class HiddenRefreshCoordinator { - // A single atomic counter doubles as both the generation and the latest marker: incrementAndGet - // atomically assigns and publishes, so latestGeneration can never regress. - private val latestGeneration = AtomicLong(0) + private val counter = AtomicLong(0) + private val latestCommittedGeneration = AtomicLong(0) - /** Starts a refresh, marks it as the latest, and returns its generation. */ - fun begin(): Long = latestGeneration.incrementAndGet() + /** Starts a refresh and returns its generation. */ + fun begin(): Long = counter.incrementAndGet() - /** True when [generation] is still the latest refresh (nothing newer has started). */ - fun isLatest(generation: Long): Boolean = generation == latestGeneration.get() + /** True if [generation] is newer than the newest refresh that has committed successfully. */ + fun canCommit(generation: Long): Boolean = generation > latestCommittedGeneration.get() + + /** Records [generation] as the newest committed refresh (monotonic). */ + fun markCommitted(generation: Long) { + latestCommittedGeneration.updateAndGet { current -> maxOf(current, generation) } + } } /** @@ -123,9 +128,9 @@ class GOGManager @Inject constructor( suspend fun insertGame(game: GOGGame) { withContext(Dispatchers.IO) { - // Preserve the existing hidden flag when the row already exists, so a single-game - // refresh cannot reset hidden state to false. - gogGameDao.upsertPreservingHidden(game) + // Preserve install state and the hidden flag when the row already exists, so a + // single-game refresh cannot reset them. + gogGameDao.upsertPreservingInstallStatus(listOf(game)) } } @@ -173,6 +178,8 @@ class GOGManager @Inject constructor( Timber.e(error, "Background sync failed: ${error?.message}") return@withContext Result.failure(error ?: Exception("Background sync failed")) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.e(e, "Failed to sync GOG library in background") Result.failure(e) @@ -208,8 +215,8 @@ class GOGManager @Inject constructor( // Re-validate the account after the fetch: if logout (or an account switch) happened // while we were fetching, do not write the old account's flags. val currentUserId = GOGAuthManager.getStoredCredentials(context).getOrNull()?.userId - if (!hiddenRefreshCoordinator.isLatest(generation)) { - Timber.tag("GOG").w("Skipping hidden-flag persist: superseded by a newer refresh") + if (!hiddenRefreshCoordinator.canCommit(generation)) { + Timber.tag("GOG").w("Skipping hidden-flag persist: superseded by a newer committed refresh") null } else if (currentUserId != fetchUserId) { Timber.tag("GOG").w("Skipping hidden-flag persist: GOG account changed during fetch") @@ -217,6 +224,7 @@ class GOGManager @Inject constructor( } else { try { gogGameDao.applyHiddenFlags(hiddenIds) + hiddenRefreshCoordinator.markCommitted(generation) hiddenIds } catch (e: CancellationException) { throw e @@ -326,6 +334,8 @@ class GOGManager @Inject constructor( } else { Timber.w("GOG game ID $id not found in library after refresh") } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.e(e, "Failed to parse game details for ID: $id") } @@ -344,6 +354,8 @@ class GOGManager @Inject constructor( } Timber.tag("GOG").i("Successfully refreshed GOG library with $totalProcessed games") return@withContext Result.success(totalProcessed) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.e(e, "Failed to refresh GOG library") return@withContext Result.failure(e) @@ -562,8 +574,14 @@ class GOGManager @Inject constructor( Timber.tag("GOG").w("Skipping Invalid GOG App with id: $gameId") return Result.success(null) } - insertGame(game) - return Result.success(game) + // Apply the current hidden-ID set so a newly inserted hidden game is not written with + // hidden = false; insertGame preserves install state and the existing hidden flag. + val hiddenIds = refreshHiddenIds() + val gameToInsert = game.copy(hidden = hiddenIds?.contains(gameId) == true) + insertGame(gameToInsert) + return Result.success(gogGameDao.getById(gameId) ?: gameToInsert) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.e(e, "Error fetching single game data for $gameId") Result.failure(e) diff --git a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt index 74035d1e7b..fc4d81de5c 100644 --- a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt +++ b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt @@ -61,35 +61,22 @@ class GOGGameDaoTest { } @Test - fun upsertPreservingInstallStatusPreservesHiddenFlag() = runBlocking { - dao.insert(game("1", hidden = true)) + fun upsertPreservingInstallStatusPreservesHiddenAndInstallState() = runBlocking { + dao.insert(game("1", hidden = true).copy(isInstalled = true, installPath = "/games/g1")) dao.upsertPreservingInstallStatus(listOf(game("1", hidden = false).copy(title = "Updated"))) val existing = dao.getById("1")!! assertTrue(existing.hidden) + assertTrue(existing.isInstalled) + assertEquals("/games/g1", existing.installPath) assertEquals("Updated", existing.title) - // New rows are inserted with the hidden value they carry. + // New rows are inserted with the values they carry. dao.upsertPreservingInstallStatus(listOf(game("2", hidden = true))) assertTrue(dao.getById("2")!!.hidden) } - @Test - fun upsertPreservingHiddenKeepsExistingHiddenFlag() = runBlocking { - dao.insert(game("1", hidden = true)) - - dao.upsertPreservingHidden(game("1", hidden = false).copy(title = "Updated")) - - val existing = dao.getById("1")!! - assertTrue(existing.hidden) - assertEquals("Updated", existing.title) - - // New rows keep the hidden value they carry. - dao.upsertPreservingHidden(game("2", hidden = true)) - assertTrue(dao.getById("2")!!.hidden) - } - @Test fun getAllEmitsUpdatedHiddenRows() = runBlocking { dao.insertAll(listOf(game("1"), game("2"))) diff --git a/app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt b/app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt index 37df719660..a4d738cffb 100644 --- a/app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt +++ b/app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt @@ -14,15 +14,35 @@ class HiddenRefreshCoordinatorTest { val second = coordinator.begin() assertTrue(second > first) - assertTrue(coordinator.isLatest(second)) - assertFalse(coordinator.isLatest(first)) } @Test - fun isLatestTrueForTheOnlyGeneration() { + fun canCommitAllowsTheFirstGeneration() { val coordinator = HiddenRefreshCoordinator() val generation = coordinator.begin() - assertTrue(coordinator.isLatest(generation)) + assertTrue(coordinator.canCommit(generation)) + } + + @Test + fun markCommittedMakesOlderGenerationsIneligible() { + val coordinator = HiddenRefreshCoordinator() + val first = coordinator.begin() + val second = coordinator.begin() + + coordinator.markCommitted(second) + + assertFalse(coordinator.canCommit(first)) + assertFalse(coordinator.canCommit(second)) + assertTrue(coordinator.canCommit(coordinator.begin())) + } + + @Test + fun failedNewerRefreshDoesNotInvalidateOlderGeneration() { + val coordinator = HiddenRefreshCoordinator() + val first = coordinator.begin() + coordinator.begin() // newer refresh that never commits (e.g. fetch failure) + + assertTrue(coordinator.canCommit(first)) } } From 2c3e7b3a52a1838d4436fe87b71d7037424e4cdf Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Sat, 8 Aug 2026 20:10:15 -0700 Subject: [PATCH 09/15] Preserve GOG cover on upsert and propagate sync cancellation --- app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt | 1 + .../main/java/app/gamenative/service/gog/GOGService.kt | 2 ++ .../test/java/app/gamenative/db/dao/GOGGameDaoTest.kt | 9 ++++++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index 09a3484940..c6c0619966 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -111,6 +111,7 @@ interface GOGGameDao { installSize = existingGame.installSize, lastPlayed = existingGame.lastPlayed, playTime = existingGame.playTime, + verticalCoverUrl = existingGame.verticalCoverUrl, hidden = existingGame.hidden, ) insert(gameToInsert) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 2f6c038843..65806e223a 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -801,6 +801,8 @@ class GOGService : Service() { // Mark that initial sync has been performed hasPerformedInitialSync = true } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { Timber.e(e, "[GOGService]: Exception starting background sync") } finally { diff --git a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt index fc4d81de5c..e18fcd490c 100644 --- a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt +++ b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt @@ -62,7 +62,13 @@ class GOGGameDaoTest { @Test fun upsertPreservingInstallStatusPreservesHiddenAndInstallState() = runBlocking { - dao.insert(game("1", hidden = true).copy(isInstalled = true, installPath = "/games/g1")) + dao.insert( + game("1", hidden = true).copy( + isInstalled = true, + installPath = "/games/g1", + verticalCoverUrl = "https://images.gog.com/cover.webp", + ) + ) dao.upsertPreservingInstallStatus(listOf(game("1", hidden = false).copy(title = "Updated"))) @@ -70,6 +76,7 @@ class GOGGameDaoTest { assertTrue(existing.hidden) assertTrue(existing.isInstalled) assertEquals("/games/g1", existing.installPath) + assertEquals("https://images.gog.com/cover.webp", existing.verticalCoverUrl) assertEquals("Updated", existing.title) // New rows are inserted with the values they carry. From 887e4db8e7a822ff2b9e0e80bcaf4c9ad7dbe38d Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Sat, 8 Aug 2026 21:12:51 -0700 Subject: [PATCH 10/15] Stamp new GOG rows with the newest committed hidden set --- .../app/gamenative/service/gog/GOGManager.kt | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 9b41f82af2..08c3c5ce22 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -90,6 +90,10 @@ class GOGManager @Inject constructor( private val hiddenRefreshCoordinator = HiddenRefreshCoordinator() + // The newest hidden-ID set that has committed to gog_games. Written under hiddenRefreshMutex; + // used to stamp newly inserted rows so a concurrent newer refresh cannot leave them stale. + @Volatile private var lastCommittedHiddenIds: Set? = null + // Thread-safe cache for download sizes private val downloadSizeCache = ConcurrentHashMap() private val REFRESH_BATCH_SIZE = 10 @@ -225,6 +229,7 @@ class GOGManager @Inject constructor( try { gogGameDao.applyHiddenFlags(hiddenIds) hiddenRefreshCoordinator.markCommitted(generation) + lastCommittedHiddenIds = hiddenIds hiddenIds } catch (e: CancellationException) { throw e @@ -240,6 +245,7 @@ class GOGManager @Inject constructor( suspend fun clearHiddenFlags() { hiddenRefreshMutex.withLock { gogGameDao.clearHiddenFlags() + lastCommittedHiddenIds = null } } @@ -342,7 +348,17 @@ class GOGManager @Inject constructor( if ((index + 1) % REFRESH_BATCH_SIZE == 0 || index == newGameIds.size - 1) { if (games.isNotEmpty()) { - gogGameDao.upsertPreservingInstallStatus(games) + // Re-stamp hidden from the newest committed refresh so a concurrent newer + // refresh cannot leave newly inserted rows with a stale hidden flag. + hiddenRefreshMutex.withLock { + val currentHiddenIds = lastCommittedHiddenIds ?: hiddenIds + val adjustedGames = if (currentHiddenIds != null) { + games.map { it.copy(hidden = it.id in currentHiddenIds) } + } else { + games + } + gogGameDao.upsertPreservingInstallStatus(adjustedGames) + } Timber.tag("GOG").d("Batch inserted ${games.size} games (processed ${index + 1}/${newGameIds.size})") games.clear() } @@ -575,11 +591,16 @@ class GOGManager @Inject constructor( return Result.success(null) } // Apply the current hidden-ID set so a newly inserted hidden game is not written with - // hidden = false; insertGame preserves install state and the existing hidden flag. - val hiddenIds = refreshHiddenIds() - val gameToInsert = game.copy(hidden = hiddenIds?.contains(gameId) == true) - insertGame(gameToInsert) - return Result.success(gogGameDao.getById(gameId) ?: gameToInsert) + // hidden = false. The insert happens under the same lock as hidden-flag commits, using + // the newest committed set, so a concurrent newer refresh cannot race the stamp. + refreshHiddenIds() + val persisted = hiddenRefreshMutex.withLock { + val currentHiddenIds = lastCommittedHiddenIds + val gameToInsert = game.copy(hidden = currentHiddenIds?.contains(gameId) == true) + insertGame(gameToInsert) + gogGameDao.getById(gameId) ?: gameToInsert + } + return Result.success(persisted) } catch (e: CancellationException) { throw e } catch (e: Exception) { From 909b3b037157c35f4e93ac82d6aa217ccffae2d0 Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Sat, 8 Aug 2026 21:18:27 -0700 Subject: [PATCH 11/15] Use only committed hidden set when stamping refreshed GOG rows --- .../java/app/gamenative/service/gog/GOGManager.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 08c3c5ce22..d9196f9d63 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -349,13 +349,13 @@ class GOGManager @Inject constructor( if ((index + 1) % REFRESH_BATCH_SIZE == 0 || index == newGameIds.size - 1) { if (games.isNotEmpty()) { // Re-stamp hidden from the newest committed refresh so a concurrent newer - // refresh cannot leave newly inserted rows with a stale hidden flag. + // refresh cannot leave newly inserted rows with a stale hidden flag. If + // logout cleared the committed set, new rows fail open instead of + // resurrecting the previous account's flags from the in-flight response. hiddenRefreshMutex.withLock { - val currentHiddenIds = lastCommittedHiddenIds ?: hiddenIds - val adjustedGames = if (currentHiddenIds != null) { - games.map { it.copy(hidden = it.id in currentHiddenIds) } - } else { - games + val currentHiddenIds = lastCommittedHiddenIds + val adjustedGames = games.map { + it.copy(hidden = currentHiddenIds?.contains(it.id) == true) } gogGameDao.upsertPreservingInstallStatus(adjustedGames) } From f88581903082f1c887d919c81bacc049d9cbf2a9 Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Tue, 18 Aug 2026 16:58:59 -0700 Subject: [PATCH 12/15] Default hidden games to visible and simplify GOG hidden handling --- .../main/java/app/gamenative/PrefManager.kt | 6 +- .../app/gamenative/data/HiddenGameFilter.kt | 8 +- .../gamenative/service/gog/GOGApiClient.kt | 33 +----- .../app/gamenative/service/gog/GOGManager.kt | 107 +++--------------- .../gamenative/ui/model/LibraryViewModel.kt | 8 -- app/src/main/res/values/strings.xml | 2 +- .../PrefManagerHiddenGamesDefaultsTest.kt | 6 +- .../gog/HiddenRefreshCoordinatorTest.kt | 48 -------- 8 files changed, 34 insertions(+), 184 deletions(-) delete mode 100644 app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 85cd6356c0..5815fa5f8f 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1165,12 +1165,12 @@ object PrefManager { /** * Whether games marked hidden on Steam/GOG are shown in the library by default. - * Defaults to false so hidden games stay out of the library unless the user explicitly - * selects the Steam Hidden collection or turns this setting on. + * Defaults to true so previously visible games do not disappear after an update; users can + * turn it off to hide them again. */ private val SHOW_HIDDEN_GAMES_BY_DEFAULT = booleanPreferencesKey("show_hidden_games_by_default") var showHiddenGamesByDefault: Boolean - get() = getPref(SHOW_HIDDEN_GAMES_BY_DEFAULT, false) + get() = getPref(SHOW_HIDDEN_GAMES_BY_DEFAULT, true) set(value) { setPref(SHOW_HIDDEN_GAMES_BY_DEFAULT, value) } diff --git a/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt b/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt index bd65902aa8..d6ae5d0fa9 100644 --- a/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt +++ b/app/src/main/java/app/gamenative/data/HiddenGameFilter.kt @@ -5,10 +5,10 @@ import app.gamenative.PrefManager /** * Visibility rules for games the user has hidden on a platform. * - * Hidden games are excluded from the library by default. Steam's built-in Hidden collection can - * explicitly reveal hidden Steam games, and the "show hidden games by default" setting reveals - * hidden games everywhere. Missing hidden metadata fails open so a game is never hidden just - * because the metadata has not loaded yet. + * Hidden games stay visible by default so an update never makes existing library entries + * disappear. Turning off the "show hidden games by default" setting excludes them again, and + * Steam's built-in Hidden collection can still explicitly reveal hidden Steam games. Missing + * hidden metadata fails open so a game is never hidden just because the metadata has not loaded. */ object HiddenGameFilter { /** diff --git a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt index b26c63e352..c6da2e4d0d 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt @@ -171,9 +171,8 @@ object GOGApiClient { * Fetch IDs of games the user has hidden in their GOG library. * * Queries `account/getFilteredProducts?hiddenFlag=1` (all pages), where every returned product - * is hidden. Tries the embed host first, then www as a fallback when embed returns nothing, - * so a host that excludes hidden products cannot silently produce an empty set. Pagination is - * all-or-nothing per host: failures return failure and the caller retains its previous cache. + * is hidden, on the embed host. Pagination is all-or-nothing: a failure returns failure and the + * caller keeps its existing hidden flags until the next sync. * * @param context Application context for auth access * @return Result containing the set of hidden game IDs or error @@ -196,31 +195,11 @@ object GOGApiClient { return@withContext Result.failure(Exception("No valid credentials found")) } - val primaryResult = fetchHiddenGameIdsFrom(credentials, GOGConstants.GOG_EMBED_URL) - val primaryIds = primaryResult.getOrNull() - if (primaryIds != null && primaryIds.isNotEmpty()) { - Timber.tag("GOG").i("Successfully fetched ${primaryIds.size} hidden GOG game IDs") - return@withContext Result.success(primaryIds) + val result = fetchHiddenGameIdsFrom(credentials, GOGConstants.GOG_EMBED_URL) + if (result.isSuccess) { + Timber.tag("GOG").i("Successfully fetched ${result.getOrNull()?.size ?: 0} hidden GOG game IDs") } - - // The embed host returned no hidden products (or failed). Retry on www before - // concluding the account has none, because hiddenFlag handling can differ between hosts. - val fallbackResult = fetchHiddenGameIdsFrom(credentials, "https://www.gog.com") - if (fallbackResult.isFailure) { - // The embed host did not confirm the hidden set and the fallback cannot confirm it - // either; return failure so callers keep their previous cache instead of clearing it. - val error = fallbackResult.exceptionOrNull() - ?: Exception("Failed to fetch hidden GOG game IDs") - Timber.tag("GOG").w(error, "Hidden fallback failed; keeping previous hidden set") - return@withContext Result.failure(error) - } - - val mergedIds = buildSet { - primaryIds?.let { addAll(it) } - fallbackResult.getOrNull()?.let { addAll(it) } - } - Timber.tag("GOG").i("Successfully fetched ${mergedIds.size} hidden GOG game IDs") - return@withContext Result.success(mergedIds) + return@withContext result } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index d9196f9d63..61e563656c 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -22,14 +22,11 @@ import com.winlator.xenvironment.components.GuestProgramLauncherComponent import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.Request import org.json.JSONObject @@ -43,26 +40,6 @@ data class GameSizeInfo( val diskSize: Long, ) -/** - * Tracks hidden-ID refresh generations so a slower, older response cannot overwrite a newer - * committed one, while a failed newer attempt does not block an older successful response. - */ -internal class HiddenRefreshCoordinator { - private val counter = AtomicLong(0) - private val latestCommittedGeneration = AtomicLong(0) - - /** Starts a refresh and returns its generation. */ - fun begin(): Long = counter.incrementAndGet() - - /** True if [generation] is newer than the newest refresh that has committed successfully. */ - fun canCommit(generation: Long): Boolean = generation > latestCommittedGeneration.get() - - /** Records [generation] as the newest committed refresh (monotonic). */ - fun markCommitted(generation: Long) { - latestCommittedGeneration.updateAndGet { current -> maxOf(current, generation) } - } -} - /** * Unified manager for GOG game and library operations. * @@ -84,16 +61,6 @@ class GOGManager @Inject constructor( @ApplicationContext private val context: Context, ) { - // Serializes hidden-flag refreshes against logout's clear so a refresh that started before - // logout cannot restore the previous account's flags afterwards. - private val hiddenRefreshMutex = Mutex() - - private val hiddenRefreshCoordinator = HiddenRefreshCoordinator() - - // The newest hidden-ID set that has committed to gog_games. Written under hiddenRefreshMutex; - // used to stamp newly inserted rows so a concurrent newer refresh cannot leave them stale. - @Volatile private var lastCommittedHiddenIds: Set? = null - // Thread-safe cache for download sizes private val downloadSizeCache = ConcurrentHashMap() private val REFRESH_BATCH_SIZE = 10 @@ -191,20 +158,16 @@ class GOGManager @Inject constructor( } /** - * Fetches hidden-product IDs and stores them on the matching `gog_games` rows. + * Fetches hidden-product IDs once per sync and stores them on the matching `gog_games` rows. * * Failures leave the existing hidden flags untouched and are logged; this never throws and - * never fails the caller. + * never fails the caller. Staleness is corrected on the next sync. * * @return the fetched hidden product IDs, or null when the fetch failed or the user is not * authenticated (callers can use it to stamp newly inserted rows). */ suspend fun refreshHiddenIds(): Set? { - // Fetch outside the lock so logout's clearHiddenFlags() is never stalled by long network - // work. The lock is held only for the credential re-check + DB write. - val generation = hiddenRefreshCoordinator.begin() - val fetchUserId = GOGAuthManager.getStoredCredentials(context).getOrNull()?.userId - ?: return null + if (!GOGAuthManager.hasStoredCredentials(context)) return null val hiddenIdsResult = GOGApiClient.getHiddenGameIds(context) if (hiddenIdsResult.isFailure) { Timber.tag("GOG").w( @@ -214,39 +177,20 @@ class GOGManager @Inject constructor( return null } val hiddenIds = hiddenIdsResult.getOrNull() ?: emptySet() - - return hiddenRefreshMutex.withLock { - // Re-validate the account after the fetch: if logout (or an account switch) happened - // while we were fetching, do not write the old account's flags. - val currentUserId = GOGAuthManager.getStoredCredentials(context).getOrNull()?.userId - if (!hiddenRefreshCoordinator.canCommit(generation)) { - Timber.tag("GOG").w("Skipping hidden-flag persist: superseded by a newer committed refresh") - null - } else if (currentUserId != fetchUserId) { - Timber.tag("GOG").w("Skipping hidden-flag persist: GOG account changed during fetch") - null - } else { - try { - gogGameDao.applyHiddenFlags(hiddenIds) - hiddenRefreshCoordinator.markCommitted(generation) - lastCommittedHiddenIds = hiddenIds - hiddenIds - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.tag("GOG").e(e, "Failed to persist hidden GOG game IDs; keeping existing hidden flags") - null - } - } + return try { + gogGameDao.applyHiddenFlags(hiddenIds) + hiddenIds + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to persist hidden GOG game IDs; keeping existing hidden flags") + null } } /** Clears the hidden flag on every GOG row (used when the logged-out account's metadata is removed). */ suspend fun clearHiddenFlags() { - hiddenRefreshMutex.withLock { - gogGameDao.clearHiddenFlags() - lastCommittedHiddenIds = null - } + gogGameDao.clearHiddenFlags() } /** @@ -348,17 +292,7 @@ class GOGManager @Inject constructor( if ((index + 1) % REFRESH_BATCH_SIZE == 0 || index == newGameIds.size - 1) { if (games.isNotEmpty()) { - // Re-stamp hidden from the newest committed refresh so a concurrent newer - // refresh cannot leave newly inserted rows with a stale hidden flag. If - // logout cleared the committed set, new rows fail open instead of - // resurrecting the previous account's flags from the in-flight response. - hiddenRefreshMutex.withLock { - val currentHiddenIds = lastCommittedHiddenIds - val adjustedGames = games.map { - it.copy(hidden = currentHiddenIds?.contains(it.id) == true) - } - gogGameDao.upsertPreservingInstallStatus(adjustedGames) - } + gogGameDao.upsertPreservingInstallStatus(games) Timber.tag("GOG").d("Batch inserted ${games.size} games (processed ${index + 1}/${newGameIds.size})") games.clear() } @@ -590,17 +524,10 @@ class GOGManager @Inject constructor( Timber.tag("GOG").w("Skipping Invalid GOG App with id: $gameId") return Result.success(null) } - // Apply the current hidden-ID set so a newly inserted hidden game is not written with - // hidden = false. The insert happens under the same lock as hidden-flag commits, using - // the newest committed set, so a concurrent newer refresh cannot race the stamp. - refreshHiddenIds() - val persisted = hiddenRefreshMutex.withLock { - val currentHiddenIds = lastCommittedHiddenIds - val gameToInsert = game.copy(hidden = currentHiddenIds?.contains(gameId) == true) - insertGame(gameToInsert) - gogGameDao.getById(gameId) ?: gameToInsert - } - return Result.success(persisted) + // insertGame preserves install state, hidden, and cover; a hidden game that has not + // been synced yet fails open (visible) until the next sync. + insertGame(game) + return Result.success(gogGameDao.getById(gameId) ?: game) } catch (e: CancellationException) { throw e } catch (e: Exception) { 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 14ac5e11d7..10bba5b97f 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -36,7 +36,6 @@ import app.gamenative.service.SteamService import app.gamenative.service.amazon.AmazonArtwork import app.gamenative.service.amazon.AmazonService import app.gamenative.service.epic.EpicService -import app.gamenative.service.gog.GOGManager import app.gamenative.service.gog.GOGService import app.gamenative.steam.SteamCollectionFilter import app.gamenative.ui.data.LibraryState @@ -89,7 +88,6 @@ class LibraryViewModel @Inject constructor( private val gogGameDao: GOGGameDao, private val epicGameDao: EpicGameDao, private val amazonGameDao: AmazonGameDao, - private val gogManager: GOGManager, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -286,12 +284,6 @@ class LibraryViewModel @Inject constructor( } } - // Keep hidden metadata fresh even if the GOG background sync is throttled or has not run - // since this feature was added; the DAO flow re-emits when flags change and re-filters. - viewModelScope.launch(Dispatchers.IO) { - gogManager.refreshHiddenIds() - } - PluviaApp.events.on(onInstallStatusChanged) PluviaApp.events.on(onCustomGameImagesFetched) PluviaApp.events.on(onRecommendationToggleChanged) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e69106d45f..b53515c19d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2063,7 +2063,7 @@ Show game recommendations Show personalized recommendations. Keeping this on helps support GameNative. Show hidden games by default - Hidden Steam and GOG games appear in library tabs. Selecting the Hidden Steam collection always shows hidden Steam games. + Hidden Steam and GOG games stay visible unless you turn this off. Selecting the Hidden Steam collection always shows hidden Steam games. Main Story diff --git a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt index fe8d012324..70e2778b1c 100644 --- a/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt +++ b/app/src/test/java/app/gamenative/PrefManagerHiddenGamesDefaultsTest.kt @@ -2,15 +2,15 @@ package app.gamenative import app.gamenative.utils.FakeDataStore import app.gamenative.utils.installFakePrefManager -import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class PrefManagerHiddenGamesDefaultsTest { @Test - fun showHiddenGamesByDefaultDefaultsToFalse() { + fun showHiddenGamesByDefaultDefaultsToTrue() { installFakePrefManager(FakeDataStore()).use { - assertFalse(PrefManager.showHiddenGamesByDefault) + assertTrue(PrefManager.showHiddenGamesByDefault) } } } diff --git a/app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt b/app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt deleted file mode 100644 index a4d738cffb..0000000000 --- a/app/src/test/java/app/gamenative/service/gog/HiddenRefreshCoordinatorTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package app.gamenative.service.gog - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class HiddenRefreshCoordinatorTest { - - @Test - fun beginAssignsIncreasingGenerations() { - val coordinator = HiddenRefreshCoordinator() - - val first = coordinator.begin() - val second = coordinator.begin() - - assertTrue(second > first) - } - - @Test - fun canCommitAllowsTheFirstGeneration() { - val coordinator = HiddenRefreshCoordinator() - val generation = coordinator.begin() - - assertTrue(coordinator.canCommit(generation)) - } - - @Test - fun markCommittedMakesOlderGenerationsIneligible() { - val coordinator = HiddenRefreshCoordinator() - val first = coordinator.begin() - val second = coordinator.begin() - - coordinator.markCommitted(second) - - assertFalse(coordinator.canCommit(first)) - assertFalse(coordinator.canCommit(second)) - assertTrue(coordinator.canCommit(coordinator.begin())) - } - - @Test - fun failedNewerRefreshDoesNotInvalidateOlderGeneration() { - val coordinator = HiddenRefreshCoordinator() - val first = coordinator.begin() - coordinator.begin() // newer refresh that never commits (e.g. fetch failure) - - assertTrue(coordinator.canCommit(first)) - } -} From 80cb8fa30cb9ca72a9eee5c503381d4781114d0b Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Tue, 18 Aug 2026 17:05:40 -0700 Subject: [PATCH 13/15] Confirm empty GOG hidden response via fallback before clearing flags --- .../gamenative/service/gog/GOGApiClient.kt | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt index c6da2e4d0d..52ebd72767 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt @@ -171,8 +171,10 @@ object GOGApiClient { * Fetch IDs of games the user has hidden in their GOG library. * * Queries `account/getFilteredProducts?hiddenFlag=1` (all pages), where every returned product - * is hidden, on the embed host. Pagination is all-or-nothing: a failure returns failure and the - * caller keeps its existing hidden flags until the next sync. + * is hidden, on the embed host. If the embed host returns no hidden products, the result is + * confirmed on the www host before concluding the account has none, so a host quirk cannot + * silently clear stored hidden flags. Pagination is all-or-nothing per host: a failure returns + * failure and the caller keeps its existing hidden flags until the next sync. * * @param context Application context for auth access * @return Result containing the set of hidden game IDs or error @@ -195,11 +197,25 @@ object GOGApiClient { return@withContext Result.failure(Exception("No valid credentials found")) } - val result = fetchHiddenGameIdsFrom(credentials, GOGConstants.GOG_EMBED_URL) - if (result.isSuccess) { - Timber.tag("GOG").i("Successfully fetched ${result.getOrNull()?.size ?: 0} hidden GOG game IDs") + val primaryResult = fetchHiddenGameIdsFrom(credentials, GOGConstants.GOG_EMBED_URL) + val primaryIds = primaryResult.getOrNull() + if (primaryIds != null && primaryIds.isNotEmpty()) { + Timber.tag("GOG").i("Successfully fetched ${primaryIds.size} hidden GOG game IDs") + return@withContext Result.success(primaryIds) } - return@withContext result + + // An empty primary response may mean "no hidden games" or that the host omitted them. + // Confirm on www before clearing stored flags. + val fallbackResult = fetchHiddenGameIdsFrom(credentials, "https://www.gog.com") + if (fallbackResult.isFailure) { + return@withContext fallbackResult + } + val mergedIds = buildSet { + primaryIds?.let { addAll(it) } + fallbackResult.getOrNull()?.let { addAll(it) } + } + Timber.tag("GOG").i("Successfully fetched ${mergedIds.size} hidden GOG game IDs") + return@withContext Result.success(mergedIds) } catch (e: CancellationException) { throw e } catch (e: Exception) { From a84a733555921e2659d6c31b052a83bdd2925824 Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Tue, 18 Aug 2026 17:09:55 -0700 Subject: [PATCH 14/15] Preserve primary failure when confirming empty GOG hidden response --- .../app/gamenative/service/gog/GOGApiClient.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt index 52ebd72767..1a1d60926a 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt @@ -171,10 +171,9 @@ object GOGApiClient { * Fetch IDs of games the user has hidden in their GOG library. * * Queries `account/getFilteredProducts?hiddenFlag=1` (all pages), where every returned product - * is hidden, on the embed host. If the embed host returns no hidden products, the result is - * confirmed on the www host before concluding the account has none, so a host quirk cannot - * silently clear stored hidden flags. Pagination is all-or-nothing per host: a failure returns - * failure and the caller keeps its existing hidden flags until the next sync. + * is hidden, on the embed host. A primary failure is returned as-is so callers keep their + * existing flags. An empty primary *success* is confirmed on the www host before concluding the + * account has none, so a host quirk cannot silently clear stored hidden flags. * * @param context Application context for auth access * @return Result containing the set of hidden game IDs or error @@ -198,8 +197,11 @@ object GOGApiClient { } val primaryResult = fetchHiddenGameIdsFrom(credentials, GOGConstants.GOG_EMBED_URL) - val primaryIds = primaryResult.getOrNull() - if (primaryIds != null && primaryIds.isNotEmpty()) { + if (primaryResult.isFailure) { + return@withContext primaryResult + } + val primaryIds = primaryResult.getOrNull() ?: emptySet() + if (primaryIds.isNotEmpty()) { Timber.tag("GOG").i("Successfully fetched ${primaryIds.size} hidden GOG game IDs") return@withContext Result.success(primaryIds) } @@ -211,7 +213,7 @@ object GOGApiClient { return@withContext fallbackResult } val mergedIds = buildSet { - primaryIds?.let { addAll(it) } + addAll(primaryIds) fallbackResult.getOrNull()?.let { addAll(it) } } Timber.tag("GOG").i("Successfully fetched ${mergedIds.size} hidden GOG game IDs") From 147c2ae9fd42e787fdef9c2dae971b81db10be0f Mon Sep 17 00:00:00 2001 From: Daniel Byon Date: Sun, 30 Aug 2026 07:01:43 -0700 Subject: [PATCH 15/15] Preserve incoming nonblank GOG cover URLs during upsert --- .../java/app/gamenative/db/dao/GOGGameDao.kt | 7 +++--- .../app/gamenative/db/dao/GOGGameDaoTest.kt | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index c6c0619966..5d949dec4e 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -96,8 +96,9 @@ interface GOGGameDao { } /** - * Upsert GOG games while preserving install status and paths - * This is useful when refreshing the library from GOG API + * Upserts GOG games while preserving local install state, play history, and hidden state. + * When updating an existing row, a nonblank incoming cover replaces the stored cover; blank + * incoming cover data preserves the existing value. */ @Transaction suspend fun upsertPreservingInstallStatus(games: List) { @@ -111,7 +112,7 @@ interface GOGGameDao { installSize = existingGame.installSize, lastPlayed = existingGame.lastPlayed, playTime = existingGame.playTime, - verticalCoverUrl = existingGame.verticalCoverUrl, + verticalCoverUrl = newGame.verticalCoverUrl.ifBlank { existingGame.verticalCoverUrl }, hidden = existingGame.hidden, ) insert(gameToInsert) diff --git a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt index e18fcd490c..2958b5a2ea 100644 --- a/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt +++ b/app/src/test/java/app/gamenative/db/dao/GOGGameDaoTest.kt @@ -84,6 +84,28 @@ class GOGGameDaoTest { assertTrue(dao.getById("2")!!.hidden) } + @Test + fun upsertPreservingInstallStatusUsesIncomingNonBlankCover() = runBlocking { + dao.insert( + game("1", hidden = true).copy( + isInstalled = true, + installPath = "/games/g1", + verticalCoverUrl = "https://images.gog.com/old-cover.webp", + ) + ) + + val newCoverUrl = "https://images.gog.com/new-cover.webp" + dao.upsertPreservingInstallStatus( + listOf(game("1").copy(verticalCoverUrl = newCoverUrl)) + ) + + val updated = dao.getById("1")!! + assertEquals(newCoverUrl, updated.verticalCoverUrl) + assertTrue(updated.hidden) + assertTrue(updated.isInstalled) + assertEquals("/games/g1", updated.installPath) + } + @Test fun getAllEmitsUpdatedHiddenRows() = runBlocking { dao.insertAll(listOf(game("1"), game("2")))