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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,881 changes: 1,881 additions & 0 deletions app/schemas/app.gamenative.db.PluviaDatabase/26.json

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions app/src/main/java/app/gamenative/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,18 @@ object PrefManager {
setPref(SHOW_RECOMMENDATIONS, value)
}

/**
* Whether games marked hidden on Steam/GOG are shown in the library by default.
* 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, true)
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)
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/app/gamenative/data/GOGGame.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
40 changes: 40 additions & 0 deletions app/src/main/java/app/gamenative/data/HiddenGameFilter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package app.gamenative.data

import app.gamenative.PrefManager

/**
* Visibility rules for games the user has hidden on a platform.
*
* 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 {
/**
* 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<Int>,
showHiddenByDefault: Boolean,
hiddenCollectionSelected: Boolean,
): Boolean = showHiddenByDefault || hiddenCollectionSelected || appId !in hiddenAppIds

/**
* Whether a GOG game should appear in the library.
*
* @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(
isHidden: Boolean,
showHiddenByDefault: Boolean,
): Boolean = showHiddenByDefault || !isHidden
}
3 changes: 2 additions & 1 deletion app/src/main/java/app/gamenative/db/PluviaDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -68,6 +74,27 @@ 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<String>)

/**
* Replaces the stored hidden state with [hiddenIds]: every GOG row is cleared first, then the
* 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<String>) {
clearHiddenFlags()
hiddenIds.chunked(MAX_HIDDEN_BIND_PARAMS).forEach { chunk ->
markHidden(chunk)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Upsert GOG games while preserving install status and paths
* This is useful when refreshing the library from GOG API
Expand All @@ -84,6 +111,8 @@ interface GOGGameDao {
installSize = existingGame.installSize,
lastPlayed = existingGame.lastPlayed,
playTime = existingGame.playTime,
verticalCoverUrl = existingGame.verticalCoverUrl,
hidden = existingGame.hidden,
)
insert(gameToInsert)
} else {
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/app/gamenative/events/AndroidEvent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface AndroidEvent<T> : Event<T> {
data class LibraryInstallStatusChanged(val appId: Int, val source: GameSource) : AndroidEvent<Unit>
data class CustomGameImagesFetched(val appId: String) : AndroidEvent<Unit>
data object RecommendationToggleChanged : AndroidEvent<Unit>
data class HiddenGamesSettingChanged(val showHiddenGamesByDefault: Boolean) : AndroidEvent<Unit>
data class GOGAuthCodeReceived(val authCode: String) : AndroidEvent<Unit>
data class EpicAuthCodeReceived(val authCode: String) : AndroidEvent<Unit>
data object ServiceReady : AndroidEvent<Unit>
Expand Down
119 changes: 119 additions & 0 deletions app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ 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
Expand Down Expand Up @@ -157,12 +159,127 @@ 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)
}
}

/**
* 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. 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
*/
suspend fun getHiddenGameIds(context: Context): Result<Set<String>> = 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)
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)
}

// 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 {
addAll(primaryIds)
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) {
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<Set<String>> {
return try {
var page = 1
var totalPages = 1
val hiddenIds = mutableSetOf<String>()
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: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}

/**
* Fetch detailed information for a specific game by ID
*
Expand Down Expand Up @@ -238,6 +355,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)
Expand Down
Loading