diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c1374fed3..0e9d85b91 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.kotlinter) alias(libs.plugins.ksp) alias(libs.plugins.secrets.gradle) + id("com.chaquo.python") version "16.0.0" } val keystorePropertiesFile = rootProject.file("app/keystores/keystore.properties") @@ -159,8 +160,6 @@ android { excludes += "/DebugProbesKt.bin" excludes += "/junit/runner/smalllogo.gif" excludes += "/junit/runner/logo.gif" - - // Other excludes excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF" } jniLibs { @@ -204,8 +203,27 @@ android { // } } +chaquopy { + defaultConfig { + version = "3.11" // Last Python version supporting armeabi-v7a (32-bit ARM) + pip { + // Install GOGDL dependencies + install("requests") + } + } + sourceSets { + getByName("main") { + srcDir("src/main/python") + } + } +} + dependencies { implementation(libs.material) + + // Chrome Custom Tabs for GOG OAuth + implementation("androidx.browser:browser:1.8.0") + // JavaSteam val localBuild = false // Change to 'true' needed when building JavaSteam manually if (localBuild) { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index af29c3906..926c53002 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,12 +55,12 @@ - + - + - + + + /saves") + */ +data class GOGCloudSavesLocationTemplate( + val name: String, + val location: String +) + +/** + * Resolved GOG cloud save location (after path resolution) + * @param name The name/identifier of the save location + * @param location The absolute path to the save directory on the device + * @param clientId The game's GOG client ID used for cloud storage API + * @param clientSecret The game's GOG client secret for authentication + */ +data class GOGCloudSavesLocation( + val name: String, + val location: String, + val clientId: String, + val clientSecret: String = "" // Default empty for backward compatibility +) + diff --git a/app/src/main/java/app/gamenative/data/GOGGame.kt b/app/src/main/java/app/gamenative/data/GOGGame.kt new file mode 100644 index 000000000..24fc958f4 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/GOGGame.kt @@ -0,0 +1,89 @@ +package app.gamenative.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import app.gamenative.enums.AppType + +/** + * GOG Game entity for Room database + * Represents a game from the GOG platform + */ +@Entity(tableName = "gog_games") +data class GOGGame( + @PrimaryKey + @ColumnInfo("id") + val id: String, + + @ColumnInfo("title") + val title: String = "", + + @ColumnInfo("slug") + val slug: String = "", + + @ColumnInfo("download_size") + val downloadSize: Long = 0, + + @ColumnInfo("install_size") + val installSize: Long = 0, + + @ColumnInfo("is_installed") + val isInstalled: Boolean = false, + + @ColumnInfo("install_path") + val installPath: String = "", + + @ColumnInfo("image_url") + val imageUrl: String = "", + + @ColumnInfo("icon_url") + val iconUrl: String = "", + + @ColumnInfo("description") + val description: String = "", + + @ColumnInfo("release_date") + val releaseDate: String = "", + + @ColumnInfo("developer") + val developer: String = "", + + @ColumnInfo("publisher") + val publisher: String = "", + + @ColumnInfo("genres") + val genres: List = emptyList(), + + @ColumnInfo("languages") + val languages: List = emptyList(), + + @ColumnInfo("last_played") + val lastPlayed: Long = 0, + + @ColumnInfo("play_time") + val playTime: Long = 0, + + @ColumnInfo("type") + val type: AppType = AppType.game, +) { + companion object { + const val GOG_IMAGE_BASE_URL = "https://images.gog.com/images" + } +} + +data class GOGCredentials( + val accessToken: String, + val refreshToken: String, + val userId: String, + val username: String, +) + +data class GOGDownloadInfo( + val gameId: String, + val totalSize: Long, + val downloadedSize: Long = 0, + val progress: Float = 0f, + val isActive: Boolean = false, + val isPaused: Boolean = false, + val error: String? = null, +) diff --git a/app/src/main/java/app/gamenative/data/LibraryItem.kt b/app/src/main/java/app/gamenative/data/LibraryItem.kt index ca55fde9d..dbcd61632 100644 --- a/app/src/main/java/app/gamenative/data/LibraryItem.kt +++ b/app/src/main/java/app/gamenative/data/LibraryItem.kt @@ -6,6 +6,7 @@ import app.gamenative.utils.CustomGameScanner enum class GameSource { STEAM, CUSTOM_GAME, + GOG, // Add other platforms here.. } @@ -44,12 +45,22 @@ data class LibraryItem( "" } } + GameSource.GOG -> { + // GoG Images are typically the full URL, but have fallback just in case. + if (iconHash.isEmpty()) { + "" + } else if (iconHash.startsWith("http")) { + iconHash + } else { + "${GOGGame.GOG_IMAGE_BASE_URL}/$iconHash" + } + } } /** * Helper property to get the game ID as an integer - * Extracts the numeric part by removing the gameSource prefix + * For all game sources, extract the numeric part after the prefix */ val gameId: Int - get() = appId.removePrefix("${gameSource.name}_").toInt() + get() = appId.removePrefix("${gameSource.name}_").toIntOrNull() ?: 0 } diff --git a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt index 4ce460b58..f4f677678 100644 --- a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt +++ b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt @@ -10,12 +10,14 @@ import app.gamenative.data.SteamApp import app.gamenative.data.SteamLicense import app.gamenative.data.CachedLicense import app.gamenative.data.EncryptedAppTicket +import app.gamenative.data.GOGGame import app.gamenative.db.converters.AppConverter import app.gamenative.db.converters.ByteArrayConverter import app.gamenative.db.converters.FriendConverter import app.gamenative.db.converters.LicenseConverter import app.gamenative.db.converters.PathTypeConverter import app.gamenative.db.converters.UserFileInfoListConverter +import app.gamenative.db.converters.GOGConverter import app.gamenative.db.dao.ChangeNumbersDao import app.gamenative.db.dao.FileChangeListsDao import app.gamenative.db.dao.SteamAppDao @@ -23,6 +25,7 @@ import app.gamenative.db.dao.SteamLicenseDao import app.gamenative.db.dao.AppInfoDao import app.gamenative.db.dao.CachedLicenseDao import app.gamenative.db.dao.EncryptedAppTicketDao +import app.gamenative.db.dao.GOGGameDao const val DATABASE_NAME = "pluvia.db" @@ -35,8 +38,9 @@ const val DATABASE_NAME = "pluvia.db" FileChangeLists::class, SteamApp::class, SteamLicense::class, + GOGGame::class, ], - version = 8, + version = 9, exportSchema = false, // Should export once stable. ) @TypeConverters( @@ -46,6 +50,7 @@ const val DATABASE_NAME = "pluvia.db" LicenseConverter::class, PathTypeConverter::class, UserFileInfoListConverter::class, + GOGConverter::class, ) abstract class PluviaDatabase : RoomDatabase() { @@ -62,4 +67,6 @@ abstract class PluviaDatabase : RoomDatabase() { abstract fun cachedLicenseDao(): CachedLicenseDao abstract fun encryptedAppTicketDao(): EncryptedAppTicketDao + + abstract fun gogGameDao(): GOGGameDao } diff --git a/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt b/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt new file mode 100644 index 000000000..21f975581 --- /dev/null +++ b/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt @@ -0,0 +1,25 @@ +package app.gamenative.db.converters + +import androidx.room.TypeConverter +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Room TypeConverter for GOG-specific data types + */ +class GOGConverter { + + @TypeConverter + fun fromStringList(value: List): String { + return Json.encodeToString(value) + } + + @TypeConverter + fun toStringList(value: String): List { + + if (value.isEmpty()) { + return emptyList() + } + return Json.decodeFromString>(value) + } +} diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt new file mode 100644 index 000000000..fb896be00 --- /dev/null +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -0,0 +1,88 @@ +package app.gamenative.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import app.gamenative.data.GOGGame +import kotlinx.coroutines.flow.Flow + +/** + * DAO for GOG games in the Room database + */ +@Dao +interface GOGGameDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(game: GOGGame) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(games: List) + + @Update + suspend fun update(game: GOGGame) + + @Delete + suspend fun delete(game: GOGGame) + + @Query("DELETE FROM gog_games WHERE id = :gameId") + suspend fun deleteById(gameId: String) + + @Query("SELECT * FROM gog_games WHERE id = :gameId") + suspend fun getById(gameId: String): GOGGame? + + @Query("SELECT * FROM gog_games ORDER BY title ASC") + fun getAll(): Flow> + + @Query("SELECT * FROM gog_games ORDER BY title ASC") + suspend fun getAllAsList(): List + + @Query("SELECT * FROM gog_games WHERE is_installed = :isInstalled ORDER BY title ASC") + fun getByInstallStatus(isInstalled: Boolean): Flow> + + @Query("SELECT * FROM gog_games WHERE title LIKE '%' || :searchQuery || '%' ORDER BY title ASC") + fun searchByTitle(searchQuery: String): Flow> + + @Query("DELETE FROM gog_games") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM gog_games") + fun getCount(): Flow + + @Query("SELECT id FROM gog_games") + suspend fun getAllGameIds(): List + + @Transaction + suspend fun replaceAll(games: List) { + deleteAll() + insertAll(games) + } + + /** + * Upsert GOG games while preserving install status and paths + * This is useful when refreshing the library from GOG API + */ + @Transaction + suspend fun upsertPreservingInstallStatus(games: List) { + games.forEach { newGame -> + val existingGame = getById(newGame.id) + if (existingGame != null) { + // Preserve installation status, path, and size from existing game + val gameToInsert = newGame.copy( + isInstalled = existingGame.isInstalled, + installPath = existingGame.installPath, + installSize = existingGame.installSize, + lastPlayed = existingGame.lastPlayed, + playTime = existingGame.playTime, + ) + insert(gameToInsert) + } else { + // New game, insert as-is + insert(newGame) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/di/DatabaseModule.kt b/app/src/main/java/app/gamenative/di/DatabaseModule.kt index 488eb7ca9..a064773d5 100644 --- a/app/src/main/java/app/gamenative/di/DatabaseModule.kt +++ b/app/src/main/java/app/gamenative/di/DatabaseModule.kt @@ -57,4 +57,8 @@ class DatabaseModule { @Provides @Singleton fun provideEncryptedAppTicketDao(db: PluviaDatabase): EncryptedAppTicketDao = db.encryptedAppTicketDao() + + @Provides + @Singleton + fun provideGOGGameDao(db: PluviaDatabase) = db.gogGameDao() } diff --git a/app/src/main/java/app/gamenative/enums/Marker.kt b/app/src/main/java/app/gamenative/enums/Marker.kt index bdf6b39f7..ba1bbbf18 100644 --- a/app/src/main/java/app/gamenative/enums/Marker.kt +++ b/app/src/main/java/app/gamenative/enums/Marker.kt @@ -2,6 +2,7 @@ package app.gamenative.enums enum class Marker(val fileName: String ) { DOWNLOAD_COMPLETE_MARKER(".download_complete"), + DOWNLOAD_IN_PROGRESS_MARKER(".download_in_progress"), STEAM_DLL_REPLACED(".steam_dll_replaced"), STEAM_DLL_RESTORED(".steam_dll_restored"), STEAM_COLDCLIENT_USED(".steam_coldclient_used"), diff --git a/app/src/main/java/app/gamenative/enums/PathType.kt b/app/src/main/java/app/gamenative/enums/PathType.kt index a019a27bf..9b7314573 100644 --- a/app/src/main/java/app/gamenative/enums/PathType.kt +++ b/app/src/main/java/app/gamenative/enums/PathType.kt @@ -102,6 +102,160 @@ enum class PathType { companion object { val DEFAULT = SteamUserData + + /** + * Resolve GOG path variables () to Windows environment variables + * Converts GOG-specific variables like to actual paths or Windows env vars + * @param location Path template with GOG variables (e.g., "/saves") + * @param installPath Game install path (for variable) + * @return Path with GOG variables resolved (may still contain Windows env vars like %LOCALAPPDATA%) + */ + fun resolveGOGPathVariables(location: String, installPath: String): String { + var resolved = location + + // Map of GOG variables to their values + val variableMap = mapOf( + "INSTALL" to installPath, + "SAVED_GAMES" to "%USERPROFILE%/Saved Games", + "APPLICATION_DATA_LOCAL" to "%LOCALAPPDATA%", + "APPLICATION_DATA_LOCAL_LOW" to "%APPDATA%\\..\\LocalLow", + "APPLICATION_DATA_ROAMING" to "%APPDATA%", + "DOCUMENTS" to "%USERPROFILE%\\Documents" + ) + + // Find and replace patterns + val pattern = Regex("<\\?(\\w+)\\?>") + val matches = pattern.findAll(resolved) + + for (match in matches) { + val variableName = match.groupValues[1] + val replacement = variableMap[variableName] + if (replacement != null) { + resolved = resolved.replace(match.value, replacement) + Timber.d("Resolved GOG variable to $replacement") + } else { + Timber.w("Unknown GOG path variable: , leaving as-is") + } + } + + return resolved + } + + /** + * Convert a GOG Windows path with environment variables to an absolute device path + * Used for GOG cloud saves which provide Windows paths that need to be mapped to Wine prefix + * @param context Android context + * @param gogWindowsPath GOG-provided Windows path that may contain env vars like %LOCALAPPDATA%, %APPDATA%, %USERPROFILE% + * @return Absolute Unix path in Wine prefix + */ + fun toAbsPathForGOG(context: Context, gogWindowsPath: String, appId: String? = null): String { + val imageFs = ImageFs.find(context) + // For GOG games, use the container-specific wine prefix if appId is provided + val (winePrefix, useContainerRoot) = if (appId != null) { + val container = app.gamenative.utils.ContainerUtils.getOrCreateContainer(context, appId) + val containerRoot = container.rootDir.absolutePath + Timber.d("[PathType] Using container-specific root for $appId: $containerRoot") + Pair(containerRoot, true) + } else { + Pair(imageFs.rootDir.absolutePath, false) + } + val user = ImageFs.USER + + var mappedPath = gogWindowsPath + + // Map Windows environment variables to their Wine prefix equivalents + // When using container root, paths are relative to containerRoot/.wine/ + // When using imageFs, paths are relative to imageFs/home/xuser/.wine/ + val winePrefixPath = if (useContainerRoot) { + // Container root is already the container dir, wine is at .wine/ + ".wine" + } else { + // ImageFs needs home/xuser/.wine + ImageFs.WINEPREFIX + } + + // Handle %USERPROFILE% first to avoid partial replacements + if (mappedPath.contains("%USERPROFILE%/Saved Games") || mappedPath.contains("%USERPROFILE%\\Saved Games")) { + val savedGamesPath = Paths.get( + winePrefix, winePrefixPath, + "drive_c/users/", user, "Saved Games/" + ).toString() + mappedPath = mappedPath.replace("%USERPROFILE%/Saved Games", savedGamesPath) + .replace("%USERPROFILE%\\Saved Games", savedGamesPath) + } + + if (mappedPath.contains("%USERPROFILE%/Documents") || mappedPath.contains("%USERPROFILE%\\Documents")) { + val documentsPath = Paths.get( + winePrefix, winePrefixPath, + "drive_c/users/", user, "Documents/" + ).toString() + mappedPath = mappedPath.replace("%USERPROFILE%/Documents", documentsPath) + .replace("%USERPROFILE%\\Documents", documentsPath) + } + + // Map standard Windows environment variables + mappedPath = mappedPath.replace("%LOCALAPPDATA%", + Paths.get(winePrefix, winePrefixPath, "drive_c/users/", user, "AppData/Local/").toString()) + mappedPath = mappedPath.replace("%APPDATA%", + Paths.get(winePrefix, winePrefixPath, "drive_c/users/", user, "AppData/Roaming/").toString()) + mappedPath = mappedPath.replace("%USERPROFILE%", + Paths.get(winePrefix, winePrefixPath, "drive_c/users/", user, "").toString()) + + // Normalize path separators + mappedPath = mappedPath.replace("\\", "/") + + // Check if path is already absolute (after env var replacement) + val isAlreadyAbsolute = mappedPath.startsWith(winePrefix) + + // Normalize path to resolve ../ and ./ components + // Split by /, process each component, and rebuild + val pathParts = mappedPath.split("/").toMutableList() + val normalizedParts = mutableListOf() + for (part in pathParts) { + when { + part == ".." && normalizedParts.isNotEmpty() && normalizedParts.last() != ".." -> { + // Go up one directory + normalizedParts.removeAt(normalizedParts.lastIndex) + } + part != "." && part.isNotEmpty() -> { + // Add non-empty, non-current-dir parts + normalizedParts.add(part) + } + // Skip "." and empty parts + } + } + mappedPath = normalizedParts.joinToString("/") + + // Build absolute path - but skip if already absolute after env var replacement + val absolutePath = when { + isAlreadyAbsolute -> { + // Path was already made absolute by env var replacement, use as-is + mappedPath + } + mappedPath.startsWith("drive_c/") || mappedPath.startsWith("/drive_c/") -> { + val cleanPath = mappedPath.removePrefix("/") + Paths.get(winePrefix, winePrefixPath, cleanPath).toString() + } + mappedPath.startsWith(winePrefix) -> { + // Already absolute + mappedPath + } + else -> { + // Relative path, assume it's in drive_c + Paths.get(winePrefix, winePrefixPath, "drive_c", mappedPath).toString() + } + } + + // Ensure path ends with / for directories + val finalPath = if (!absolutePath.endsWith("/") && !absolutePath.endsWith("\\")) { + "$absolutePath/" + } else { + absolutePath + } + + return finalPath + } + fun from(keyValue: String?): PathType { return when (keyValue?.lowercase()) { "%${GameInstall.name.lowercase()}%", diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt index 25b64e5e5..62f6e239d 100644 --- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt +++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt @@ -23,5 +23,6 @@ interface AndroidEvent : Event { data class DownloadStatusChanged(val appId: Int, val isDownloading: Boolean) : AndroidEvent data class LibraryInstallStatusChanged(val appId: Int) : AndroidEvent data class CustomGameImagesFetched(val appId: String) : AndroidEvent + data class GOGAuthCodeReceived(val authCode: String) : AndroidEvent // data class SetAppBarVisibility(val visible: Boolean) : AndroidEvent } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt new file mode 100644 index 000000000..43c5c1ba1 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -0,0 +1,438 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.data.GOGCredentials +import org.json.JSONObject +import timber.log.Timber +import java.io.File + +/** + * Manages GOG authentication and account operations. + * + * - OAuth2 authentication flow + * - Credential storage and validation + * - Token refresh + * - Account logout + * ! Note: We currently don't use redirect flow due to Pluvia Issues + * Uses GOGPythonBridge for all GOGDL command execution. + */ +object GOGAuthManager { + + + fun getAuthConfigPath(context: Context): String { + return "${context.filesDir}/gog_auth.json" + } + + fun hasStoredCredentials(context: Context): Boolean { + val authFile = File(getAuthConfigPath(context)) + return authFile.exists() + } + + /** + * Authenticate with GOG using authorization code from OAuth2 flow + * Users must visit GOG login page, authenticate, and copy the authorization code + */ + suspend fun authenticateWithCode(context: Context, authorizationCode: String): Result { + return try { + Timber.i("Starting GOG authentication with authorization code...") + + // Extract the actual authorization code from URL if needed + val actualCode = extractCodeFromInput(authorizationCode) + if (actualCode.isEmpty()) { + return Result.failure(Exception("Invalid authorization URL: no code parameter found")) + } + + val authConfigPath = getAuthConfigPath(context) + + // Create auth config directory + val authFile = File(authConfigPath) + val authDir = authFile.parentFile + if (authDir != null && !authDir.exists()) { + authDir.mkdirs() + Timber.d("Created auth config directory: ${authDir.absolutePath}") + } + + // Execute GOGDL auth command with the authorization code + Timber.d("Authenticating with auth config path") + + val result = GOGPythonBridge.executeCommand( + "--auth-config-path", authConfigPath, + "auth", "--code", actualCode + ) + + Timber.d("GOGDL executeCommand result: isSuccess=${result.isSuccess}") + + if (result.isSuccess) { + val gogdlOutput = result.getOrNull() ?: "" + Timber.i("GOGDL command completed, checking authentication result...") + + // Parse and validate the authentication result + return parseAuthenticationResult(authConfigPath, gogdlOutput) + } else { + val error = result.exceptionOrNull() + val errorMsg = error?.message ?: "Unknown authentication error" + Timber.e(error, "GOG authentication command failed: $errorMsg") + Result.failure(Exception("Authentication failed: $errorMsg", error)) + } + } catch (e: Exception) { + Timber.e(e, "GOG authentication exception: ${e.message}") + Result.failure(Exception("Authentication exception: ${e.message}", e)) + } + } + + /** + * Get user credentials by calling GOGDL auth command (without --code) + * This will automatically handle token refresh if needed + */ + suspend fun getStoredCredentials(context: Context): Result { + return try { + val authConfigPath = getAuthConfigPath(context) + + if (!hasStoredCredentials(context)) { + return Result.failure(Exception("No stored credentials found")) + } + + // Use GOGDL to get credentials - this will handle token refresh automatically + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "auth") + + if (result.isSuccess) { + val output = result.getOrNull() ?: "" + return parseCredentialsFromOutput(output) + } else { + Timber.e("GOGDL credentials command failed") + Result.failure(Exception("Failed to get credentials from GOG")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to get stored credentials via GOGDL") + Result.failure(e) + } + } + + /** + * Get game-specific credentials using the game's clientId and clientSecret. + * This exchanges the Galaxy app's refresh token for a game-specific access token. + * + * @param context Application context + * @param clientId Game's client ID (from .info file) + * @param clientSecret Game's client secret (from build metadata) + * @return Game-specific credentials or error + */ + suspend fun getGameCredentials( + context: Context, + clientId: String, + clientSecret: String + ): Result { + return try { + val authFile = File(getAuthConfigPath(context)) + if (!authFile.exists()) { + return Result.failure(Exception("No stored credentials found")) + } + + // Read auth file + val authContent = authFile.readText() + val authJson = JSONObject(authContent) + + // Check if we already have credentials for this game + if (authJson.has(clientId)) { + val gameCredentials = authJson.getJSONObject(clientId) + + // Check if expired + val loginTime = gameCredentials.optDouble("loginTime", 0.0) + val expiresIn = gameCredentials.optInt("expires_in", 0) + val isExpired = System.currentTimeMillis() / 1000.0 >= loginTime + expiresIn + + if (!isExpired) { + // Return existing valid credentials + return Result.success(GOGCredentials( + accessToken = gameCredentials.getString("access_token"), + refreshToken = gameCredentials.optString("refresh_token", ""), + userId = gameCredentials.getString("user_id"), + username = gameCredentials.optString("username", "GOG User") + )) + } + } + + // Need to get/refresh game-specific token + // Get Galaxy app's refresh token + val galaxyCredentials = if (authJson.has(GOGConstants.GOG_CLIENT_ID)) { + authJson.getJSONObject(GOGConstants.GOG_CLIENT_ID) + } else { + return Result.failure(Exception("No Galaxy credentials found")) + } + + val refreshToken = galaxyCredentials.optString("refresh_token", "") + if (refreshToken.isEmpty()) { + return Result.failure(Exception("No refresh token available")) + } + + // Request game-specific token using Galaxy's refresh token + Timber.d("Requesting game-specific token for clientId: $clientId") + val tokenUrl = "https://auth.gog.com/token?client_id=$clientId&client_secret=$clientSecret&grant_type=refresh_token&refresh_token=$refreshToken" + + val request = okhttp3.Request.Builder() + .url(tokenUrl) + .get() + .build() + + val tokenJson = okhttp3.OkHttpClient().newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Timber.e("Failed to get game token: HTTP ${response.code} - $errorBody") + return Result.failure(Exception("Failed to get game-specific token: HTTP ${response.code}")) + } + + val responseBody = response.body?.string() ?: return Result.failure(Exception("Empty response")) + val json = JSONObject(responseBody) + + // Store the new game-specific credentials + json.put("loginTime", System.currentTimeMillis() / 1000.0) + authJson.put(clientId, json) + + // Write updated auth file + authFile.writeText(authJson.toString(2)) + + Timber.i("Successfully obtained game-specific token for clientId: $clientId") + json + } + + return Result.success(GOGCredentials( + accessToken = tokenJson.getString("access_token"), + refreshToken = tokenJson.optString("refresh_token", refreshToken), + userId = tokenJson.getString("user_id"), + username = tokenJson.optString("username", "GOG User") + )) + } catch (e: Exception) { + Timber.e(e, "Failed to get game-specific credentials") + Result.failure(e) + } + } + + /** + * Validate credentials by calling GOGDL auth command (without --code) + * This will automatically refresh tokens if they're expired + */ + suspend fun validateCredentials(context: Context): Result { + return try { + val authConfigPath = getAuthConfigPath(context) + + if (!hasStoredCredentials(context)) { + Timber.d("No stored credentials found for validation") + return Result.success(false) + } + + Timber.d("Starting credentials validation with GOGDL") + + // Use GOGDL to validate credentials - this will handle token refresh automatically + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "auth") + + if (!result.isSuccess) { + val error = result.exceptionOrNull() + Timber.e("Credentials validation failed - command failed: ${error?.message}") + return Result.success(false) + } + + val output = result.getOrNull() ?: "" + + try { + val credentialsJson = JSONObject(output.trim()) + + // Check if there's an error + if (credentialsJson.has("error") && credentialsJson.getBoolean("error")) { + val errorDesc = credentialsJson.optString("message", "Unknown error") + Timber.e("Credentials validation failed: $errorDesc") + return Result.success(false) + } + + Timber.d("Credentials validation successful") + return Result.success(true) + } catch (e: Exception) { + Timber.e(e, "Failed to parse validation response") + return Result.success(false) + } + } catch (e: Exception) { + Timber.e(e, "Failed to validate credentials") + return Result.failure(e) + } + } + + /** + * Clear stored credentials (logout) + */ + fun clearStoredCredentials(context: Context): Boolean { + return try { + val authFile = File(getAuthConfigPath(context)) + if (authFile.exists()) { + authFile.delete() + } else { + true + } + } catch (e: Exception) { + Timber.e(e, "Failed to clear GOG credentials") + false + } + } + + private fun extractCodeFromInput(input: String): String { + return if (input.startsWith("http")) { + // Extract code parameter from URL + val codeParam = input.substringAfter("code=", "") + if (codeParam.isEmpty()) { + "" + } else { + // Remove any additional parameters after the code + val cleanCode = codeParam.substringBefore("&") + Timber.d("Extracted authorization code") + cleanCode + } + } else { + input + } + } + + private fun parseAuthenticationResult(authConfigPath: String, gogdlOutput: String): Result { + try { + Timber.d("Attempting to parse GOGDL output as JSON (length: ${gogdlOutput.length})") + val outputJson = JSONObject(gogdlOutput.trim()) + Timber.d("Successfully parsed JSON, keys: ${outputJson.keys().asSequence().toList()}") + + // Check if the response indicates an error + if (outputJson.has("error") && outputJson.getBoolean("error")) { + val errorMsg = outputJson.optString("error_description", "Authentication failed") + val errorDetails = outputJson.optString("message", "No details available") + Timber.e("GOG authentication failed: $errorMsg - Details: $errorDetails") + return Result.failure(Exception("GOG authentication failed: $errorMsg")) + } + + // Check if we have the required fields for successful auth + val accessToken = outputJson.optString("access_token", "") + val userId = outputJson.optString("user_id", "") + + if (accessToken.isEmpty() || userId.isEmpty()) { + Timber.e("GOG authentication incomplete: missing access_token or user_id in output") + return Result.failure(Exception("Authentication incomplete: missing required data")) + } + + // GOGDL output looks good, now check if auth file was created + val authFile = File(authConfigPath) + if (authFile.exists()) { + // Parse authentication result from file + val authData = parseFullCredentialsFromFile(authConfigPath) + if (authData != null) { + Timber.i("GOG authentication successful for user") + return Result.success(authData) + } else { + Timber.e("Failed to parse auth file despite file existing") + return Result.failure(Exception("Failed to parse authentication file")) + } + } + + Timber.w("GOGDL returned success but no auth file created, using output data") + // Create credentials from GOGDL output + val credentials = createCredentialsFromJson(outputJson) + return Result.success(credentials) + + } catch (e: Exception) { + Timber.e(e, "Failed to parse GOGDL output") + // Fallback: check if auth file exists + val authFile = File(authConfigPath) + if (!authFile.exists()) { + Timber.e("GOG authentication failed: no auth file created and failed to parse output") + return Result.failure(Exception("Authentication failed: no credentials available")) + } + try { + val authData = parseFullCredentialsFromFile(authConfigPath) + if (authData != null) { + Timber.i("GOG authentication successful (fallback) for user") + return Result.success(authData) + } else { + Timber.e("Failed to parse auth file (fallback path)") + return Result.failure(Exception("Failed to parse authentication file")) + } + } catch (ex: Exception) { + Timber.e(ex, "Failed to parse auth file") + return Result.failure(Exception("Failed to parse authentication result: ${ex.message}")) + } + } + } + + private fun parseCredentialsFromOutput(output: String): Result { + try { + val credentialsJson = JSONObject(output.trim()) + + // Check if there's an error + if (credentialsJson.has("error") && credentialsJson.getBoolean("error")) { + val errorMsg = credentialsJson.optString("message", "Authentication failed") + Timber.e("GOGDL credentials failed: $errorMsg") + return Result.failure(Exception("Authentication failed: $errorMsg")) + } + + // Extract credentials from GOGDL response + val accessToken = credentialsJson.optString("access_token", "") + val refreshToken = credentialsJson.optString("refresh_token", "") + val username = credentialsJson.optString("username", "GOG User") + val userId = credentialsJson.optString("user_id", "") + + val credentials = GOGCredentials( + accessToken = accessToken, + refreshToken = refreshToken, + username = username, + userId = userId, + ) + + Timber.d("Got credentials for user") + return Result.success(credentials) + } catch (e: Exception) { + Timber.e(e, "Failed to parse GOGDL credentials response") + return Result.failure(e) + } + } + + private fun parseFullCredentialsFromFile(authConfigPath: String): GOGCredentials? { + return try { + val authFile = File(authConfigPath) + if (!authFile.exists()) { + Timber.e("Auth file does not exist: $authConfigPath") + return null + } + + val authContent = authFile.readText() + val authJson = JSONObject(authContent) + + // GOGDL stores credentials nested under client ID + val credentialsJson = if (authJson.has(GOGConstants.GOG_CLIENT_ID)) { + authJson.getJSONObject(GOGConstants.GOG_CLIENT_ID) + } else { + // Fallback: try to read from root level + authJson + } + + val accessToken = credentialsJson.optString("access_token", "") + val refreshToken = credentialsJson.optString("refresh_token", "") + val userId = credentialsJson.optString("user_id", "") + + // Validate required fields + if (accessToken.isEmpty() || userId.isEmpty()) { + Timber.e("Auth file missing required fields (access_token or user_id)") + return null + } + + GOGCredentials( + accessToken = accessToken, + refreshToken = refreshToken, + userId = userId, + username = credentialsJson.optString("username", "GOG User"), + ) + } catch (e: Exception) { + Timber.e(e, "Failed to parse auth result from file: ${e.message}") + null + } + } + private fun createCredentialsFromJson(outputJson: JSONObject): GOGCredentials { + return GOGCredentials( + accessToken = outputJson.optString("access_token", ""), + refreshToken = outputJson.optString("refresh_token", ""), + userId = outputJson.optString("user_id", ""), + username = "GOG User", // We don't have username in the token response + ) + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt new file mode 100644 index 000000000..eeac19fe5 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -0,0 +1,594 @@ +package app.gamenative.service.gog + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.MessageDigest +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import java.util.concurrent.TimeUnit + + +class GOGCloudSavesManager( + private val context: Context +) { + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + companion object { + private const val CLOUD_STORAGE_BASE_URL = "https://cloudstorage.gog.com" + private const val USER_AGENT = "GOGGalaxyCommunicationService/2.0.13.27 (Windows_32bit) dont_sync_marker/true installation_source/gog" + private const val DELETION_MD5 = "aadd86936a80ee8a369579c3926f1b3c" + } + + enum class SyncAction { + UPLOAD, + DOWNLOAD, + CONFLICT, + NONE + } + + /** + * Represents a local save file + */ + data class SyncFile( + val relativePath: String, + val absolutePath: String, + var md5Hash: String? = null, + var updateTime: String? = null, + var updateTimestamp: Long? = null + ) { + /** + * Calculate MD5 hash and metadata for this file + */ + suspend fun calculateMetadata() = withContext(Dispatchers.IO) { + try { + val file = File(absolutePath) + if (!file.exists() || !file.isFile) { + Timber.w("File does not exist: $absolutePath") + return@withContext + } + + // Get file modification timestamp + val timestamp = file.lastModified() + val instant = Instant.ofEpochMilli(timestamp) + updateTime = DateTimeFormatter.ISO_INSTANT.format(instant) + updateTimestamp = timestamp / 1000 // Convert to seconds + + // Calculate MD5 of gzipped content (matching Python implementation) + FileInputStream(file).use { fis -> + val digest = MessageDigest.getInstance("MD5") + val buffer = java.io.ByteArrayOutputStream() + + GZIPOutputStream(buffer).use { gzipOut -> + val fileBuffer = ByteArray(8192) + var bytesRead: Int + while (fis.read(fileBuffer).also { bytesRead = it } != -1) { + gzipOut.write(fileBuffer, 0, bytesRead) + } + } + + md5Hash = digest.digest(buffer.toByteArray()) + .joinToString("") { "%02x".format(it) } + } + + Timber.d("Calculated metadata for $relativePath: md5=$md5Hash, timestamp=$updateTimestamp") + } catch (e: Exception) { + Timber.e(e, "Failed to calculate metadata for $absolutePath") + } + } + } + + /** + * Represents a cloud save file + */ + data class CloudFile( + val relativePath: String, + val md5Hash: String, + val updateTime: String?, + val updateTimestamp: Long? + ) { + val isDeleted: Boolean + get() = md5Hash == DELETION_MD5 + } + + /** + * Classifies sync actions based on file differences + */ + data class SyncClassifier( + val updatedLocal: List = emptyList(), + val updatedCloud: List = emptyList(), + val notExistingLocally: List = emptyList(), + val notExistingRemotely: List = emptyList() + ) { + fun determineAction(): SyncAction { + return when { + updatedLocal.isEmpty() && updatedCloud.isNotEmpty() -> SyncAction.DOWNLOAD + updatedLocal.isNotEmpty() && updatedCloud.isEmpty() -> SyncAction.UPLOAD + updatedLocal.isEmpty() && updatedCloud.isEmpty() -> SyncAction.NONE + else -> SyncAction.CONFLICT + } + } + } + + /** + * Synchronize save files for a game - We grab the directories for ALL games, then download the exact ones we want. + * @param localPath Path to local save directory + * @param dirname Cloud save directory name + * @param clientId Game's client ID (from remote config) + * @param clientSecret Game's client secret (from build metadata) + * @param lastSyncTimestamp Timestamp of last sync (0 for initial sync) + * @param preferredAction User's preferred action (download, upload, or none) + * @return New sync timestamp, or 0 on failure + */ + suspend fun syncSaves( + localPath: String, + dirname: String, + clientId: String, + clientSecret: String, + lastSyncTimestamp: Long = 0, + preferredAction: String = "none" + ): Long = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG-CloudSaves").i("Starting sync for path: $localPath") + Timber.tag("GOG-CloudSaves").i("Cloud dirname: $dirname") + Timber.tag("GOG-CloudSaves").i("Cloud client ID: $clientId") + Timber.tag("GOG-CloudSaves").i("Last sync timestamp: $lastSyncTimestamp") + Timber.tag("GOG-CloudSaves").i("Preferred action: $preferredAction") + + // Ensure directory exists + val syncDir = File(localPath) + if (!syncDir.exists()) { + Timber.tag("GOG-CloudSaves").i("Creating sync directory: $localPath") + syncDir.mkdirs() + } + + // Get local files + val localFiles = scanLocalFiles(syncDir) + Timber.tag("GOG-CloudSaves").i("Found ${localFiles.size} local file(s)") + + // Get game-specific authentication credentials + // This exchanges the Galaxy refresh token for a game-specific access token + val credentials = GOGAuthManager.getGameCredentials(context, clientId, clientSecret).getOrNull() ?: run { + Timber.tag("GOG-CloudSaves").e("Failed to get game-specific credentials") + return@withContext 0L + } + Timber.tag("GOG-CloudSaves").d("Using game-specific credentials for userId: ${credentials.userId}, clientId: $clientId") + + // Get cloud files using game-specific clientId in URL path + Timber.tag("GOG").d("[Cloud Saves] Fetching cloud file list for dirname: $dirname") + val cloudFiles = getCloudFiles(credentials.userId, clientId, dirname, credentials.accessToken) + Timber.tag("GOG").d("[Cloud Saves] Retrieved ${cloudFiles.size} total cloud files") + val downloadableCloud = cloudFiles.filter { !it.isDeleted } + Timber.tag("GOG").i("[Cloud Saves] Found ${downloadableCloud.size} downloadable cloud file(s) (excluding deleted)") + if (downloadableCloud.isNotEmpty()) { + downloadableCloud.forEach { file -> + Timber.tag("GOG").d("[Cloud Saves] - Cloud file: ${file.relativePath} (md5: ${file.md5Hash}, modified: ${file.updateTime})") + } + } + + // Handle simple cases first + when { + localFiles.isNotEmpty() && cloudFiles.isEmpty() -> { + Timber.tag("GOG-CloudSaves").i("No files in cloud, uploading ${localFiles.size} file(s)") + localFiles.forEach { file -> + uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken) + } + return@withContext currentTimestamp() + } + + localFiles.isEmpty() && downloadableCloud.isNotEmpty() -> { + Timber.tag("GOG-CloudSaves").i("No files locally, downloading ${downloadableCloud.size} file(s)") + downloadableCloud.forEach { file -> + downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken) + } + return@withContext currentTimestamp() + } + + localFiles.isEmpty() && cloudFiles.isEmpty() -> { + Timber.tag("GOG-CloudSaves").i("No files locally or in cloud, nothing to sync") + return@withContext currentTimestamp() + } + } + + // Handle preferred action + if (preferredAction == "download" && downloadableCloud.isNotEmpty()) { + Timber.tag("GOG-CloudSaves").i("Forcing download of ${downloadableCloud.size} file(s) (user requested)") + downloadableCloud.forEach { file -> + downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken) + } + return@withContext currentTimestamp() + } + + if (preferredAction == "upload" && localFiles.isNotEmpty()) { + Timber.tag("GOG-CloudSaves").i("Forcing upload of ${localFiles.size} file(s) (user requested)") + localFiles.forEach { file -> + uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken) + } + return@withContext currentTimestamp() + } + + // Complex sync scenario - use classifier + val classifier = classifyFiles(localFiles, cloudFiles, lastSyncTimestamp) + when (classifier.determineAction()) { + SyncAction.DOWNLOAD -> { + Timber.tag("GOG-CloudSaves").i("Downloading ${classifier.updatedCloud.size} updated cloud file(s)") + classifier.updatedCloud.forEach { file -> + downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken) + } + classifier.notExistingLocally.forEach { file -> + if (!file.isDeleted) { + downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken) + } + } + } + + SyncAction.UPLOAD -> { + Timber.tag("GOG-CloudSaves").i("Uploading ${classifier.updatedLocal.size} updated local file(s)") + classifier.updatedLocal.forEach { file -> + uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken) + } + classifier.notExistingRemotely.forEach { file -> + uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken) + } + } + + SyncAction.CONFLICT -> { + Timber.tag("GOG-CloudSaves").w("Sync conflict detected - comparing timestamps") + + // Compare timestamps for matching files + val localMap = classifier.updatedLocal.associateBy { it.relativePath } + val cloudMap = classifier.updatedCloud.associateBy { it.relativePath } + + val toUpload = mutableListOf() + val toDownload = mutableListOf() + + // Check files that exist in both and were both updated + val commonPaths = localMap.keys.intersect(cloudMap.keys) + commonPaths.forEach { path -> + val localFile = localMap[path]!! + val cloudFile = cloudMap[path]!! + + val localTime = localFile.updateTimestamp ?: 0L + val cloudTime = cloudFile.updateTimestamp ?: 0L + + when { + localTime > cloudTime -> { + Timber.tag("GOG-CloudSaves").i("Local file is newer: $path (local: $localTime > cloud: $cloudTime)") + toUpload.add(localFile) + } + cloudTime > localTime -> { + Timber.tag("GOG-CloudSaves").i("Cloud file is newer: $path (cloud: $cloudTime > local: $localTime)") + toDownload.add(cloudFile) + } + else -> { + Timber.tag("GOG-CloudSaves").w("Files have same timestamp, skipping: $path") + } + } + } + + // Upload files that only exist locally or are newer locally + (localMap.keys - commonPaths).forEach { path -> + toUpload.add(localMap[path]!!) + } + + // Download files that only exist in cloud or are newer in cloud + (cloudMap.keys - commonPaths).forEach { path -> + toDownload.add(cloudMap[path]!!) + } + + // Handle files not existing in either location + toUpload.addAll(classifier.notExistingRemotely) + toDownload.addAll(classifier.notExistingLocally.filter { !it.isDeleted }) + + // Execute uploads + if (toUpload.isNotEmpty()) { + Timber.tag("GOG-CloudSaves").i("Uploading ${toUpload.size} file(s) based on timestamp comparison") + toUpload.forEach { file -> + uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken) + } + } + + // Execute downloads + if (toDownload.isNotEmpty()) { + Timber.tag("GOG-CloudSaves").i("Downloading ${toDownload.size} file(s) based on timestamp comparison") + toDownload.forEach { file -> + downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken) + } + } + } + SyncAction.NONE -> { + Timber.tag("GOG-CloudSaves").i("No sync needed - files are up to date") + } + } + + Timber.tag("GOG-CloudSaves").i("Sync completed successfully") + return@withContext currentTimestamp() + + } catch (e: Exception) { + Timber.tag("GOG-CloudSaves").e(e, "Sync failed: ${e.message}") + return@withContext 0L + } + } + + /** + * Scan local directory for save files + */ + private suspend fun scanLocalFiles(directory: File): List = withContext(Dispatchers.IO) { + val files = mutableListOf() + + fun scanRecursive(dir: File, basePath: String) { + dir.listFiles()?.forEach { file -> + if (file.isFile) { + val relativePath = file.absolutePath.removePrefix(basePath) + .removePrefix("/") + .replace("\\", "/") + files.add(SyncFile(relativePath, file.absolutePath)) + } else if (file.isDirectory) { + scanRecursive(file, basePath) + } + } + } + + scanRecursive(directory, directory.absolutePath) + + // Calculate metadata for all files + files.forEach { it.calculateMetadata() } + + files + } + + /** + * Get cloud files list from GOG API + */ + private suspend fun getCloudFiles( + userId: String, + clientId: String, + dirname: String, + authToken: String + ): List = withContext(Dispatchers.IO) { + try { + // List all files (don't include dirname in URL - it's used as a prefix filter) + val url = "$CLOUD_STORAGE_BASE_URL/v1/$userId/$clientId" + Timber.tag("GOG").d("[Cloud Saves] API Request: GET $url (dirname filter: $dirname)") + + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $authToken") + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json") + .header("X-Object-Meta-User-Agent", USER_AGENT) + .build() + + val response = httpClient.newCall(request).execute() + response.use { + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "No response body" + Timber.tag("GOG").e("[Cloud Saves] Failed to fetch cloud files: HTTP ${response.code}") + Timber.tag("GOG").e("[Cloud Saves] Response body: $errorBody") + return@withContext emptyList() + } + + val responseBody = response.body?.string() ?: "" + if (responseBody.isEmpty()) { + Timber.tag("GOG").d("[Cloud Saves] Empty response body from cloud storage API") + return@withContext emptyList() + } + + val items = try { + JSONArray(responseBody) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to parse JSON array response") + Timber.tag("GOG").e("[Cloud Saves] Response was: $responseBody") + return@withContext emptyList() + } + + Timber.tag("GOG").d("[Cloud Saves] Found ${items.length()} total items in cloud storage") + + val files = mutableListOf() + for (i in 0 until items.length()) { + val fileObj = items.getJSONObject(i) + val name = fileObj.optString("name", "") + val hash = fileObj.optString("hash", "") + val lastModified = fileObj.optString("last_modified") + + Timber.tag("GOG").d("[Cloud Saves] Examining item $i: name='$name', dirname='$dirname'") + + // Filter files that belong to this save location (name starts with dirname/) + if (name.isNotEmpty() && hash.isNotEmpty() && name.startsWith("$dirname/")) { + val timestamp = try { + Instant.parse(lastModified).epochSecond + } catch (e: Exception) { + null + } + + // Remove the dirname prefix to get relative path + val relativePath = name.removePrefix("$dirname/") + files.add(CloudFile(relativePath, hash, lastModified, timestamp)) + Timber.tag("GOG").d("[Cloud Saves] ✓ Matched: relativePath='$relativePath'") + } else { + Timber.tag("GOG").d("[Cloud Saves] ✗ Skipped (doesn't match dirname or missing data)") + } + } + + Timber.tag("GOG").i("[Cloud Saves] Retrieved ${files.size} cloud files for dirname '$dirname'") + files + } + + } catch (e: Exception) { + Timber.tag("GOG-CloudSaves").e(e, "Failed to get cloud files") + emptyList() + } + } + + /** + * Upload file to GOG cloud storage + */ + private suspend fun uploadFile( + userId: String, + clientId: String, + dirname: String, + file: SyncFile, + authToken: String + ) = withContext(Dispatchers.IO) { + try { + val localFile = File(file.absolutePath) + val fileSize = localFile.length() + + Timber.tag("GOG-CloudSaves").i("Uploading: ${file.relativePath} (${fileSize} bytes)") + + val url = "$CLOUD_STORAGE_BASE_URL/v1/$userId/$clientId/$dirname/${file.relativePath}" + + val requestBody = localFile.readBytes().toRequestBody("application/octet-stream".toMediaType()) + + val requestBuilder = Request.Builder() + .url(url) + .put(requestBody) + .header("Authorization", "Bearer $authToken") + .header("User-Agent", USER_AGENT) + .header("X-Object-Meta-User-Agent", USER_AGENT) + .header("Content-Type", "application/octet-stream") + + // Add last modified timestamp header if available + file.updateTime?.let { timestamp -> + requestBuilder.header("X-Object-Meta-LocalLastModified", timestamp) + } + + val response = httpClient.newCall(requestBuilder.build()).execute() + response.use { + if (response.isSuccessful) { + Timber.tag("GOG-CloudSaves").i("Successfully uploaded: ${file.relativePath}") + } else { + val errorBody = response.body?.string() ?: "No response body" + Timber.tag("GOG-CloudSaves").e("Failed to upload ${file.relativePath}: HTTP ${response.code}") + Timber.tag("GOG-CloudSaves").e("Upload error body: $errorBody") + } + } + + } catch (e: Exception) { + Timber.tag("GOG-CloudSaves").e(e, "Failed to upload ${file.relativePath}") + } + } + + /** + * Download file from GOG cloud storage + */ + private suspend fun downloadFile( + userId: String, + clientId: String, + dirname: String, + file: CloudFile, + syncDir: File, + authToken: String + ) = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG-CloudSaves").i("Downloading: ${file.relativePath}") + + val url = "$CLOUD_STORAGE_BASE_URL/v1/$userId/$clientId/$dirname/${file.relativePath}" + + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $authToken") + .header("User-Agent", USER_AGENT) + .header("X-Object-Meta-User-Agent", USER_AGENT) + .build() + + val response = httpClient.newCall(request).execute() + response.use { + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "No response body" + Timber.tag("GOG-CloudSaves").e("Failed to download ${file.relativePath}: HTTP ${response.code}") + Timber.tag("GOG-CloudSaves").e("Download error body: $errorBody") + return@withContext + } + + val bytes = response.body?.bytes() ?: return@withContext + Timber.tag("GOG-CloudSaves").d("Downloaded ${bytes.size} bytes for ${file.relativePath}") + + // Save to local file + val localFile = File(syncDir, file.relativePath) + localFile.parentFile?.mkdirs() + + FileOutputStream(localFile).use { fos -> + fos.write(bytes) + } + + // Preserve timestamp if available + file.updateTimestamp?.let { timestamp -> + localFile.setLastModified(timestamp * 1000) + } + + Timber.tag("GOG-CloudSaves").i("Successfully downloaded: ${file.relativePath}") + } + + } catch (e: Exception) { + Timber.tag("GOG-CloudSaves").e(e, "Failed to download ${file.relativePath}") + } + } + + /** + * Classify files for sync decision + */ + private fun classifyFiles( + localFiles: List, + cloudFiles: List, + timestamp: Long + ): SyncClassifier { + val updatedLocal = mutableListOf() + val updatedCloud = mutableListOf() + val notExistingLocally = mutableListOf() + val notExistingRemotely = mutableListOf() + + val localPaths = localFiles.map { it.relativePath }.toSet() + val cloudPaths = cloudFiles.map { it.relativePath }.toSet() + + // Check local files + localFiles.forEach { file -> + if (file.relativePath !in cloudPaths) { + notExistingRemotely.add(file) + } + val fileTimestamp = file.updateTimestamp + if (fileTimestamp != null && fileTimestamp > timestamp) { + updatedLocal.add(file) + } + } + + // Check cloud files + cloudFiles.forEach { file -> + if (file.isDeleted) return@forEach + + if (file.relativePath !in localPaths) { + notExistingLocally.add(file) + } + val fileTimestamp = file.updateTimestamp + if (fileTimestamp != null && fileTimestamp > timestamp) { + updatedCloud.add(file) + } + } + + return SyncClassifier(updatedLocal, updatedCloud, notExistingLocally, notExistingRemotely) + } + + /** + * Get current timestamp in seconds + */ + private fun currentTimestamp(): Long { + return System.currentTimeMillis() / 1000 + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt new file mode 100644 index 000000000..92def55b3 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt @@ -0,0 +1,77 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.PrefManager +import java.io.File +import java.nio.file.Paths +import timber.log.Timber + +/** + * Constants for GOG integration + */ +object GOGConstants { + private var appContext: Context? = null + + /** + * Initialize GOGConstants with application context + */ + fun init(context: Context) { + appContext = context.applicationContext + } + // GOG API URLs + const val GOG_BASE_API_URL = "https://api.gog.com" + const val GOG_AUTH_URL = "https://auth.gog.com" + const val GOG_EMBED_URL = "https://embed.gog.com" + const val GOG_GAMESDB_URL = "https://gamesdb.gog.com" + + // GOG Client ID for authentication + const val GOG_CLIENT_ID = "46899977096215655" + + // GOG uses a standard redirect URI that we can intercept + const val GOG_REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client" + + // GOG OAuth authorization URL with redirect + const val GOG_AUTH_LOGIN_URL = "https://auth.gog.com/auth?client_id=$GOG_CLIENT_ID&redirect_uri=$GOG_REDIRECT_URI&response_type=code&layout=client2" + + /** + * Internal GOG games installation path (similar to Steam's internal path) + * Uses application's internal files directory + */ + val internalGOGGamesPath: String + get() { + val context = appContext ?: throw IllegalStateException("GOGConstants not initialized. Call init() first.") + val path = Paths.get(context.filesDir.absolutePath, "GOG", "games", "common").toString() + // Ensure directory exists for StatFs + File(path).mkdirs() + return path + } + + /** + * External GOG games installation path (similar to Steam's external path) + * {externalStoragePath}/GOG/games/common/ + */ + val externalGOGGamesPath: String + get() { + val path = Paths.get(PrefManager.externalStoragePath, "GOG", "games", "common").toString() + // Ensure directory exists for StatFs + File(path).mkdirs() + return path + } + + val defaultGOGGamesPath: String + get() { + return if (PrefManager.useExternalStorage && File(PrefManager.externalStoragePath).exists()) { + Timber.i("GOG using external storage: $externalGOGGamesPath") + externalGOGGamesPath + } else { + Timber.i("GOG using internal storage: $internalGOGGamesPath") + internalGOGGamesPath + } + } + + fun getGameInstallPath(gameTitle: String): String { + // Sanitize game title for filesystem + val sanitizedTitle = gameTitle.replace(Regex("[^a-zA-Z0-9 ]"), "").trim() + return Paths.get(defaultGOGGamesPath, sanitizedTitle).toString() + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt new file mode 100644 index 000000000..e23d515d1 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -0,0 +1,1510 @@ +package app.gamenative.service.gog + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import app.gamenative.data.DownloadInfo +import app.gamenative.data.GOGCloudSavesLocation +import app.gamenative.data.GOGCloudSavesLocationTemplate +import app.gamenative.data.GOGGame +import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem +import app.gamenative.data.PostSyncInfo +import app.gamenative.data.SteamApp +import app.gamenative.data.GameSource +import app.gamenative.enums.PathType +import okhttp3.Request +import okhttp3.OkHttpClient +import app.gamenative.utils.Net +import app.gamenative.db.dao.GOGGameDao +import app.gamenative.enums.AppType +import app.gamenative.enums.ControllerSupport +import app.gamenative.enums.Marker +import app.gamenative.enums.OS +import app.gamenative.enums.ReleaseState +import app.gamenative.enums.SyncResult +import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.MarkerUtils +import app.gamenative.utils.StorageUtils +import java.util.concurrent.TimeUnit +import com.winlator.container.Container +import com.winlator.core.envvars.EnvVars +import com.winlator.xenvironment.components.GuestProgramLauncherComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.EnumSet +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber + +/** + * Data class to hold size information from gogdl info command + */ +data class GameSizeInfo( + val downloadSize: Long, + val diskSize: Long +) + +/** + * Unified manager for GOG game and library operations. + * + * Responsibilities: + * - Database CRUD for GOG games + * - Library syncing from GOG API + * - Game downloads and installation + * - Installation verification + * - Executable discovery + * - Wine launch commands + * - File system operations + * + * Uses GOGPythonBridge for all GOGDL command execution. + * Uses GOGAuthManager for authentication checks. + */ +@Singleton +class GOGManager @Inject constructor( + private val gogGameDao: GOGGameDao, + @ApplicationContext private val context: Context, +) { + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + // Thread-safe cache for download sizes + private val downloadSizeCache = ConcurrentHashMap() + private val REFRESH_BATCH_SIZE = 10 + + // Cache for remote config API responses (clientId -> save locations) + // This avoids fetching the same config multiple times + private val remoteConfigCache = ConcurrentHashMap>() + + // Timestamp storage for sync state (gameId_locationName -> timestamp) + // Persisted to disk to survive app restarts + private val syncTimestamps = ConcurrentHashMap() + private val timestampFile = File(context.filesDir, "gog_sync_timestamps.json") + + // Track active sync operations to prevent concurrent syncs + private val activeSyncs = ConcurrentHashMap.newKeySet() + + init { + // Load persisted timestamps on initialization + loadTimestampsFromDisk() + } + + suspend fun getGameById(gameId: String): GOGGame? { + return withContext(Dispatchers.IO) { + try { + gogGameDao.getById(gameId) + } catch (e: Exception) { + Timber.e(e, "Failed to get GOG game by ID: $gameId") + null + } + } + } + + suspend fun insertGame(game: GOGGame) { + withContext(Dispatchers.IO) { + gogGameDao.insert(game) + } + } + + suspend fun updateGame(game: GOGGame) { + withContext(Dispatchers.IO) { + gogGameDao.update(game) + } + } + + suspend fun deleteAllGames() { + withContext(Dispatchers.IO) { + gogGameDao.deleteAll() + } + } + + suspend fun startBackgroundSync(context: Context): Result = withContext(Dispatchers.IO) { + try { + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.w("Cannot start background sync: no stored credentials") + return@withContext Result.failure(Exception("No stored credentials found")) + } + + Timber.tag("GOG").i("Starting GOG library background sync...") + + val result = refreshLibrary(context) + + if (result.isSuccess) { + val count = result.getOrNull() ?: 0 + Timber.tag("GOG").i("Background sync completed: $count games synced") + return@withContext Result.success(Unit) + } else { + val error = result.exceptionOrNull() + Timber.e(error, "Background sync failed: ${error?.message}") + return@withContext Result.failure(error ?: Exception("Background sync failed")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to sync GOG library in background") + Result.failure(e) + } + } + + /** + * Refresh the entire library (called manually by user) + * Fetches all games from GOG API and updates the database + */ + suspend fun refreshLibrary(context: Context): Result = withContext(Dispatchers.IO) { + try { + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.w("Cannot refresh library: not authenticated with GOG") + return@withContext Result.failure(Exception("Not authenticated with GOG")) + } + + Timber.tag("GOG").i("Refreshing GOG library from GOG API...") + + // Fetch games from GOG via GOGDL Python backend + + var gameIdList = listGameIds(context) + + if(!gameIdList.isSuccess){ + val error = gameIdList.exceptionOrNull() + Timber.e(error, "Failed to fetch GOG game IDs: ${error?.message}") + return@withContext Result.failure(error ?: Exception("Failed to fetch GOG game IDs")) + } + + val gameIds = gameIdList.getOrNull() ?: emptyList() + Timber.tag("GOG").i("Successfully fetched ${gameIds.size} game IDs from GOG") + + if (gameIds.isEmpty()) { + Timber.w("No games found in GOG library") + return@withContext Result.success(0) + } + + // Get existing game IDs from database to avoid re-fetching + val existingGameIds = gogGameDao.getAllGameIds().toSet() + Timber.tag("GOG").d("Found ${existingGameIds.size} games already in database") + + // Filter to only new games that need details fetched + val newGameIds = gameIds.filter { it !in existingGameIds } + Timber.tag("GOG").d("${newGameIds.size} new games need details fetched") + + if (newGameIds.isEmpty()) { + Timber.tag("GOG").d("No new games to fetch, library is up to date") + return@withContext Result.success(0) + } + + var totalProcessed = 0 + + Timber.tag("GOG").d("Getting Game Details for ${newGameIds.size} new GOG Games...") + + val games = mutableListOf() + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + + for ((index, id) in newGameIds.withIndex()) { + try { + val result = GOGPythonBridge.executeCommand( + "--auth-config-path", authConfigPath, + "game-details", + "--game_id", id, + "--pretty" + ) + + if (result.isSuccess) { + val output = result.getOrNull() ?: "" + Timber.tag("GOG").d("Got Game Details for ID: $id") + val gameDetails = JSONObject(output.trim()) + val game = parseGameObject(gameDetails) + if(game != null) { + games.add(game) + Timber.tag("GOG").d("Refreshed Game: ${game.title}") + totalProcessed++ + } + } else { + Timber.w("GOG game ID $id not found in library after refresh") + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse game details for ID: $id") + } + + if ((index + 1) % REFRESH_BATCH_SIZE == 0 || index == newGameIds.size - 1) { + if (games.isNotEmpty()) { + gogGameDao.upsertPreservingInstallStatus(games) + Timber.tag("GOG").d("Batch inserted ${games.size} games (processed ${index + 1}/${newGameIds.size})") + games.clear() + } + } + } + val detectedCount = detectAndUpdateExistingInstallations() + if (detectedCount > 0) { + Timber.d("Detected and updated $detectedCount existing installations") + } + Timber.tag("GOG").i("Successfully refreshed GOG library with $totalProcessed games") + return@withContext Result.success(totalProcessed) + } catch (e: Exception) { + Timber.e(e, "Failed to refresh GOG library") + return@withContext Result.failure(e) + } + } + + private suspend fun listGameIds(context: Context): Result> { + + Timber.tag("GOG").i("Fetching GOG Game Ids via GOGDL...") + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.e("Cannot list games: not authenticated") + return Result.failure(Exception("Not authenticated. Please log in first.")) + } + + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "game-ids") + + if (result.isFailure) { + val error = result.exceptionOrNull() + Timber.e(error, "Failed to fetch GOG game IDs") + return Result.failure(error ?: Exception("Failed to fetch GOG game IDs")) + } + + val output = result.getOrNull() ?: "" + + if (output.isBlank()) { + Timber.w("Empty response when fetching GOG game IDs") + return Result.failure(Exception("Empty response from GOGDL")) + } + + val gamesArray = org.json.JSONArray(output.trim()) + val gameIds = List(gamesArray.length()) { gamesArray.getString(it) } + Timber.tag("GOG").i("Successfully fetched ${gameIds.size} game IDs") + + return Result.success(gameIds) + } + + private fun parseGameObject(gameObj: JSONObject): GOGGame? { + val genresList = parseJsonArray(gameObj.optJSONArray("genres")) + val languagesList = parseJsonArray(gameObj.optJSONArray("languages")) + + val title = gameObj.optString("title", "Unknown Game") + val id = gameObj.optString("id", "") + + val isInvalidGame = title == "Unknown Game" || title.startsWith("product_title_") + + if(isInvalidGame){ + Timber.tag("GOG").w("Found incorrectly formatted game: $title, $id") + return null + } + + return GOGGame( + id, + title, + slug = gameObj.optString("slug", ""), + imageUrl = gameObj.optString("imageUrl", ""), + iconUrl = gameObj.optString("iconUrl", ""), + description = gameObj.optString("description", ""), + releaseDate = gameObj.optString("releaseDate", ""), + developer = gameObj.optString("developer", ""), + publisher = gameObj.optString("publisher", ""), + genres = genresList, + languages = languagesList, + downloadSize = gameObj.optLong("downloadSize", 0L), + installSize = 0L, + isInstalled = false, + installPath = "", + lastPlayed = 0L, + playTime = 0L, + ) + } + + private fun parseJsonArray(jsonArray: org.json.JSONArray?): List { + val result = mutableListOf() + if (jsonArray != null) { + for (j in 0 until jsonArray.length()) { + result.add(jsonArray.getString(j)) + } + } + return result + } + + /** + * Scan the GOG games directories for existing installations + * and update the database with installation info + * + * @return Number of installations detected and updated + */ + private suspend fun detectAndUpdateExistingInstallations(): Int = withContext(Dispatchers.IO) { + var detectedCount = 0 + + try { + // Check both internal and external storage paths + val pathsToCheck = listOf( + GOGConstants.internalGOGGamesPath, + GOGConstants.externalGOGGamesPath + ) + + for (basePath in pathsToCheck) { + val baseDir = File(basePath) + if (!baseDir.exists() || !baseDir.isDirectory) { + Timber.d("Skipping non-existent path: $basePath") + continue + } + + Timber.d("Scanning for installations in: $basePath") + val installDirs = baseDir.listFiles { file -> file.isDirectory } ?: emptyArray() + + for (installDir in installDirs) { + try { + val detectedGame = detectGameFromDirectory(installDir) + if (detectedGame != null) { + // Update database with installation info + val existingGame = getGameById(detectedGame.id) + if (existingGame != null && !existingGame.isInstalled) { + val updatedGame = existingGame.copy( + isInstalled = true, + installPath = detectedGame.installPath, + installSize = detectedGame.installSize + ) + updateGame(updatedGame) + detectedCount++ + Timber.i("Detected existing installation: ${existingGame.title} at ${installDir.absolutePath}") + } else if (existingGame != null) { + Timber.d("Game ${existingGame.title} already marked as installed") + } + } + } catch (e: Exception) { + Timber.w(e, "Error detecting game in ${installDir.name}") + } + } + } + } catch (e: Exception) { + Timber.e(e, "Error during installation detection") + } + + detectedCount + } + + /** + * Try to detect which game is installed in the given directory + * by looking for GOG-specific files and matching against the database + * + * @param installDir The directory to check + * @return GOGGame with installation info, or null if no game detected + */ + private suspend fun detectGameFromDirectory(installDir: File): GOGGame? { + if (!installDir.exists() || !installDir.isDirectory) { + return null + } + + val dirName = installDir.name + Timber.d("Checking directory: $dirName") + + // Look for .info files which contain game metadata + val infoFiles = installDir.listFiles { file -> + file.isFile && file.extension == "info" + } ?: emptyArray() + + if (infoFiles.isNotEmpty()) { + // Try to parse game ID from .info file + val infoFile = infoFiles.first() + try { + val infoContent = infoFile.readText() + val infoJson = JSONObject(infoContent) + val gameId = infoJson.optString("gameId", "") + if (gameId.isNotEmpty()) { + val game = getGameById(gameId) + if (game != null) { + val installSize = calculateDirectorySize(installDir) + return game.copy( + isInstalled = true, + installPath = installDir.absolutePath, + installSize = installSize + ) + } + } + } catch (e: Exception) { + Timber.w(e, "Error parsing .info file: ${infoFile.name}") + } + } + + // Fallback: Try to match by directory name with game titles in database + val allGames = gogGameDao.getAllAsList() + for (game in allGames) { + // Sanitize game title to match directory naming convention + val sanitizedTitle = game.title.replace(Regex("[^a-zA-Z0-9 ]"), "").trim() + + if (dirName.equals(sanitizedTitle, ignoreCase = true)) { + // Verify it's actually a game directory (has executables or subdirectories) + val hasContent = installDir.listFiles()?.any { + it.isDirectory || it.extension in listOf("exe", "dll", "bat") + } == true + + if (hasContent) { + val installSize = calculateDirectorySize(installDir) + Timber.d("Matched directory '$dirName' to game '${game.title}'") + return game.copy( + isInstalled = true, + installPath = installDir.absolutePath, + installSize = installSize + ) + } + } + } + + return null + } + + /** + * Calculate the total size of a directory recursively + * + * @param directory The directory to calculate size for + * @return Total size in bytes + */ + private fun calculateDirectorySize(directory: File): Long { + var size = 0L + try { + if (!directory.exists() || !directory.isDirectory) { + return 0L + } + + val files = directory.listFiles() ?: return 0L + for (file in files) { + size += if (file.isDirectory) { + calculateDirectorySize(file) + } else { + file.length() + } + } + } catch (e: Exception) { + Timber.w(e, "Error calculating directory size for ${directory.name}") + } + return size + } + + suspend fun refreshSingleGame(gameId: String, context: Context): Result { + return try { + Timber.d("Fetching single game data for gameId: $gameId") + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + + if (!GOGAuthManager.hasStoredCredentials(context)) { + return Result.failure(Exception("Not authenticated")) + } + + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "game-details", "--game_id", gameId, "--pretty") + + if (result.isFailure) { + return Result.failure(result.exceptionOrNull() ?: Exception("Failed to fetch game data")) + } + + val output = result.getOrNull() ?: "" + + if(result == null) { + Timber.w("Game $gameId not found in GOG library") + return Result.success(null) + } + + val gameDetails = org.json.JSONObject(output.trim()) + var game = parseGameObject(gameDetails) + if(game == null){ + Timber.tag("GOG").w("Skipping Invalid GOG App with id: $gameId") + return Result.success(null) + } + insertGame(game) + return Result.success(game) + } catch (e: Exception) { + Timber.e(e, "Error fetching single game data for $gameId") + Result.failure(e) + } + } + + suspend fun downloadGame(context: Context, gameId: String, installPath: String, downloadInfo: DownloadInfo): Result { + return withContext(Dispatchers.IO) { + try { + // Check authentication first + if (!GOGAuthManager.hasStoredCredentials(context)) { + return@withContext Result.failure(Exception("Not authenticated. Please login to GOG first.")) + } + + Timber.i("[Download] Starting GOGDL download for game $gameId to $installPath") + + val installDir = File(installPath) + if (!installDir.exists()) { + Timber.d("[Download] Creating install directory: $installPath") + installDir.mkdirs() + } + + // Create support directory for redistributables + val parentDir = installDir.parentFile + val supportDir = if (parentDir != null) { + File(parentDir, "gog-support") + } else { + Timber.w("[Download] installDir.parentFile is null for $installPath, using installDir as fallback parent") + File(installDir, "gog-support") + } + if (!supportDir.exists()) { + Timber.d("[Download] Creating support directory: ${supportDir.absolutePath}") + supportDir.mkdirs() + } + + // Get expected download size from database for accurate progress tracking + val game = getGameById(gameId) + if (game != null && game.downloadSize > 0L) { + downloadInfo.setTotalExpectedBytes(game.downloadSize) + Timber.d("[Download] Set total expected bytes: ${game.downloadSize} (${game.downloadSize / 1_000_000} MB)") + } else { + Timber.w("[Download] Could not determine download size for game $gameId") + } + + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + val numericGameId = ContainerUtils.extractGameIdFromContainerId(gameId).toString() + + Timber.d("[Download] Calling GOGPythonBridge with gameId=$numericGameId, authConfig=$authConfigPath") + + // Initialize progress and emit download started event + downloadInfo.setProgress(0.0f) + downloadInfo.setActive(true) + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, true) + ) + + val result = GOGPythonBridge.executeCommandWithCallback( + downloadInfo, + "--auth-config-path", authConfigPath, + "download", numericGameId, + "--platform", "windows", + "--path", installPath, + "--support", supportDir.absolutePath, + "--with-dlcs", + "--lang", "en-US", + "--max-workers", "1", + ) + + if (result.isSuccess) { + downloadInfo.setProgress(1.0f) + Timber.i("[Download] GOGDL download completed successfully for game $gameId") + + // Calculate actual disk size + val diskSize = calculateDirectorySize(File(installPath)) + Timber.d("[Download] Calculated install size: $diskSize bytes (${diskSize / 1_000_000} MB)") + + // Update or create database entry + var game = getGameById(gameId) + if (game != null) { + // Game exists - update install status + Timber.d("Updating existing game install status: isInstalled=true, installPath=$installPath, installSize=$diskSize") + val updatedGame = game.copy( + isInstalled = true, + installPath = installPath, + installSize = diskSize + ) + updateGame(updatedGame) + Timber.i("Updated GOG game install status in database for ${game.title}") + } else { + // Game not in database - fetch from API and insert + Timber.w("Game not found in database, fetching from GOG API for gameId: $gameId") + try { + val refreshResult = refreshSingleGame(gameId, context) + if (refreshResult.isSuccess) { + game = refreshResult.getOrNull() + if (game != null) { + val updatedGame = game.copy( + isInstalled = true, + installPath = installPath, + installSize = diskSize + ) + insertGame(updatedGame) + Timber.i("Fetched and inserted GOG game ${game.title} with install status") + } else { + Timber.w("Failed to fetch game data from GOG API for gameId: $gameId") + } + } else { + Timber.e(refreshResult.exceptionOrNull(), "Error fetching game from GOG API: $gameId") + } + } catch (e: Exception) { + Timber.e(e, "Exception fetching game from GOG API: $gameId") + } + } + + // Verify installation + val (isValid, errorMessage) = verifyInstallation(gameId) + if (!isValid) { + Timber.w("Installation verification failed for game $gameId: $errorMessage") + } else { + Timber.i("Installation verified successfully for game: $gameId") + } + + // Emit completion events + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, false) + ) + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(gameId.toIntOrNull() ?: 0) + ) + + Result.success(Unit) + } else { + downloadInfo.setProgress(-1.0f) + val error = result.exceptionOrNull() + Timber.e(error, "[Download] GOGDL download failed for game $gameId") + + // Emit download stopped event on failure + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, false) + ) + + Result.failure(error ?: Exception("Download failed")) + } + } catch (e: Exception) { + Timber.e(e, "[Download] Exception during download for game $gameId") + downloadInfo.setProgress(-1.0f) + + // Emit download stopped event on exception + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, false) + ) + + Result.failure(e) + } + } + } + + suspend fun deleteGame(context: Context, libraryItem: LibraryItem): Result { + return withContext(Dispatchers.IO) { + try { + val gameId = libraryItem.gameId.toString() + val installPath = getGameInstallPath(context, gameId, libraryItem.name) + val installDir = File(installPath) + + // Delete the manifest file + val manifestPath = File(context.filesDir, "manifests/$gameId") + if (manifestPath.exists()) { + manifestPath.delete() + Timber.i("Deleted manifest file for game $gameId") + } + + // Delete game files + if (installDir.exists()) { + val success = installDir.deleteRecursively() + if (success) { + Timber.i("Successfully deleted game directory: $installPath") + } else { + Timber.w("Failed to delete some game files") + } + } else { + Timber.w("GOG game directory doesn't exist: $installPath") + } + + // Remove all markers + val appDirPath = getAppDirPath(libraryItem.appId) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Update database - mark as not installed + val game = getGameById(gameId) + if (game != null) { + val updatedGame = game.copy(isInstalled = false, installPath = "") + gogGameDao.update(updatedGame) + Timber.d("Updated database: game marked as not installed") + } + + // Delete container (must run on Main thread) + withContext(Dispatchers.Main) { + ContainerUtils.deleteContainer(context, libraryItem.appId) + } + + // Trigger library refresh event + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(libraryItem.gameId) + ) + + Result.success(Unit) + } catch (e: Exception) { + Timber.e(e, "Failed to delete GOG game ${libraryItem.gameId}") + Result.failure(e) + } + } + } + + fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean { + try { + val appDirPath = getAppDirPath(libraryItem.appId) + + // Use marker-based approach + val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + val isInstalled = isDownloadComplete && !isDownloadInProgress + + // Update database if status changed + val gameId = libraryItem.gameId.toString() + val game = runBlocking { getGameById(gameId) } + if (game != null && isInstalled != game.isInstalled) { + val installPath = if (isInstalled) getGameInstallPath(context, gameId, libraryItem.name) else "" + val updatedGame = game.copy(isInstalled = isInstalled, installPath = installPath) + runBlocking { gogGameDao.update(updatedGame) } + } + + return isInstalled + } catch (e: Exception) { + Timber.e(e, "Error checking if GOG game is installed") + return false + } + } + + + fun verifyInstallation(gameId: String): Pair { + val game = runBlocking { getGameById(gameId) } + val installPath = game?.installPath + + if (game == null || installPath == null || !game.isInstalled) { + return Pair(false, "Game not marked as installed in database") + } + + val installDir = File(installPath) + if (!installDir.exists()) { + return Pair(false, "Install directory not found: $installPath") + } + + if (!installDir.isDirectory) { + return Pair(false, "Install path is not a directory") + } + + val contents = installDir.listFiles() + if (contents == null || contents.isEmpty()) { + return Pair(false, "Install directory is empty") + } + + Timber.i("Installation verified for game $gameId at $installPath") + return Pair(true, null) + } + + // Get the exe. There is a v1 and v2 depending on the age of the game. + suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { + val gameId = libraryItem.gameId.toString() + try { + val game = getGameById(gameId) ?: return@withContext "" + val installPath = getGameInstallPath(context, game.id, game.title) + + // Try V2 structure first (game_$gameId subdirectory) + val v2GameDir = File(installPath, "game_$gameId") + if (v2GameDir.exists()) { + return@withContext getGameExecutable(installPath, v2GameDir) + } + + // Try V1 structure + val installDirFile = File(installPath) + val subdirs = installDirFile.listFiles()?.filter { + it.isDirectory && it.name != "saves" + } ?: emptyList() + + if (subdirs.isNotEmpty()) { + return@withContext getGameExecutable(installPath, subdirs.first()) + } + + "" + } catch (e: Exception) { + Timber.e(e, "Failed to get executable for GOG game $gameId") + "" + } + } + + private fun getGameExecutable(installPath: String, gameDir: File): String { + val result = getMainExecutableFromGOGInfo(gameDir, installPath) + if (result.isSuccess) { + val exe = result.getOrNull() ?: "" + Timber.d("Found GOG game executable from info file: $exe") + return exe + } + Timber.e(result.exceptionOrNull(), "Failed to find executable from GOG info file in: ${gameDir.absolutePath}") + return "" + } + + private fun findGOGInfoFile(directory: File, gameId: String? = null, maxDepth: Int = 3, currentDepth: Int = 0): File? { + if (!directory.exists() || !directory.isDirectory) { + return null + } + + // Check current directory first + val infoFile = directory.listFiles()?.find { + it.isFile && if (gameId != null) { + it.name == "goggame-$gameId.info" + } else { + it.name.startsWith("goggame-") && it.name.endsWith(".info") + } + } + + if (infoFile != null) { + return infoFile + } + + // If max depth reached, stop searching + if (currentDepth >= maxDepth) { + return null + } + + // Search subdirectories recursively + val subdirs = directory.listFiles()?.filter { it.isDirectory } ?: emptyList() + for (subdir in subdirs) { + val found = findGOGInfoFile(subdir, gameId, maxDepth, currentDepth + 1) + if (found != null) { + return found + } + } + + return null + } + + private fun getMainExecutableFromGOGInfo(gameDir: File, installPath: String): Result { + return try { + val infoFile = findGOGInfoFile(gameDir) + ?: return Result.failure(Exception("GOG info file not found in ${gameDir.absolutePath}")) + + val content = infoFile.readText() + val jsonObject = JSONObject(content) + + if (!jsonObject.has("playTasks")) { + return Result.failure(Exception("playTasks array not found in ${infoFile.name}")) + } + + val playTasks = jsonObject.getJSONArray("playTasks") + for (i in 0 until playTasks.length()) { + val task = playTasks.getJSONObject(i) + if (task.has("isPrimary") && task.getBoolean("isPrimary")) { + val executablePath = task.getString("path") + + // Construct full path - executablePath may include subdirectories + val exeFile = File(gameDir, executablePath) + + if (exeFile.exists()) { + val parentDir = gameDir.parentFile ?: gameDir + val relativePath = exeFile.relativeTo(parentDir).path + return Result.success(relativePath) + } + + return Result.failure(Exception("Primary executable '$executablePath' not found in ${gameDir.absolutePath}")) + } + } + Result.failure(Exception("No primary executable found in playTasks")) + } catch (e: Exception) { + Result.failure(Exception("Error parsing GOG info file in ${gameDir.absolutePath}: ${e.message}", e)) + } + } + + fun getGogWineStartCommand( + context: Context, + libraryItem: LibraryItem, + container: Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: EnvVars, + guestProgramLauncherComponent: GuestProgramLauncherComponent, + ): String { + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId) + + // Verify installation + val (isValid, errorMessage) = verifyInstallation(gameId.toString()) + if (!isValid) { + Timber.e("Installation verification failed: $errorMessage") + return "\"explorer.exe\"" + } + + val game = runBlocking { getGameById(gameId.toString()) } + if (game == null) { + Timber.e("Game not found for ID: $gameId") + return "\"explorer.exe\"" + } + + val gameInstallPath = getGameInstallPath(context, gameId.toString(), game.title) + val gameDir = File(gameInstallPath) + + if (!gameDir.exists()) { + Timber.e("Game directory does not exist: $gameInstallPath") + return "\"explorer.exe\"" + } + + // Use container's configured executable path if available, otherwise auto-detect + val executablePath = if (container.executablePath.isNotEmpty()) { + Timber.d("Using configured executable path from container: ${container.executablePath}") + container.executablePath + } else { + val detectedPath = runBlocking { getInstalledExe(context, libraryItem) } + Timber.d("Auto-detected executable path: $detectedPath") + detectedPath + } + + if (executablePath.isEmpty()) { + Timber.w("No executable found, opening file manager") + return "\"explorer.exe\"" + } + + // Find the drive letter that's mapped to this game's install path + var gogDriveLetter: String? = null + for (drive in com.winlator.container.Container.drivesIterator(container.drives)) { + if (drive[1] == gameInstallPath) { + gogDriveLetter = drive[0] + Timber.d("Found GOG game mapped to ${drive[0]}: drive") + break + } + } + + if (gogDriveLetter == null) { + Timber.e("GOG game directory not mapped to any drive: $gameInstallPath") + return "\"explorer.exe\"" + } + + val gameInstallDir = File(gameInstallPath) + val execFile = File(gameInstallPath, executablePath) + // Handle potential IllegalArgumentException if paths don't share a common ancestor + val relativePath = try { + execFile.relativeTo(gameInstallDir).path.replace('/', '\\') + } catch (e: IllegalArgumentException) { + Timber.e(e, "Failed to compute relative path from $gameInstallDir to $execFile") + return "\"explorer.exe\"" + } + + val windowsPath = "$gogDriveLetter:\\$relativePath" + + // Set working directory + val execWorkingDir = execFile.parentFile + if (execWorkingDir != null) { + guestProgramLauncherComponent.workingDir = execWorkingDir + envVars.put("WINEPATH", "$gogDriveLetter:\\") + } else { + guestProgramLauncherComponent.workingDir = gameDir + } + + Timber.d("GOG Wine command: \"$windowsPath\"") + return "\"$windowsPath\"" + } + + // ========================================================================== + // CLOUD SAVES + // ========================================================================== + + /** + * Read GOG game info file and extract clientId + * @param appId Game ID + * @param installPath Optional install path, if null will try to get from game database + * @return JSONObject with game info, or null if not found + */ + suspend fun readInfoFile(appId: String, installPath: String?): JSONObject? = withContext(Dispatchers.IO) { + try { + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + var path = installPath + + // If no install path provided, try to get from database + if (path == null) { + val game = getGameById(gameId.toString()) + path = game?.installPath + } + + if (path == null || path.isEmpty()) { + Timber.w("No install path found for game $gameId") + return@withContext null + } + + val installDir = File(path) + if (!installDir.exists()) { + Timber.w("Install directory does not exist: $path") + return@withContext null + } + + // Look for goggame-{gameId}.info file - check root first, then common subdirectories + val infoFile = findGOGInfoFile(installDir, gameId.toString()) + + if (infoFile == null || !infoFile.exists()) { + Timber.w("Info file not found for game $gameId in ${installDir.absolutePath}") + return@withContext null + } + + val infoContent = infoFile.readText() + val infoJson = JSONObject(infoContent) + Timber.d("Successfully read info file for game $gameId") + return@withContext infoJson + } catch (e: Exception) { + Timber.e(e, "Failed to read info file for appId $appId") + return@withContext null + } + } + + /** + * Fetch save locations from GOG Remote Config API + * @param context Android context + * @param appId Game app ID + * @param installPath Game install path + * @return Pair of (clientSecret, List of save location templates), or null if cloud saves not enabled or API call fails + */ + suspend fun getSaveSyncLocation( + context: Context, + appId: String, + installPath: String + ): Pair>? = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG").d("[Cloud Saves] Getting save sync location for $appId") + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val infoJson = readInfoFile(appId, installPath) + + if (infoJson == null) { + Timber.tag("GOG").w("[Cloud Saves] Cannot get save sync location: info file not found") + return@withContext null + } + + // Extract clientId from info file + val clientId = infoJson.optString("clientId", "") + if (clientId.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No clientId found in info file for game $gameId") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Client ID: $clientId") + + // Get clientSecret from build metadata + val clientSecret = getClientSecret(gameId.toString(), installPath) ?: "" + if (clientSecret.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No clientSecret available for game $gameId") + } else { + Timber.tag("GOG").d("[Cloud Saves] Got client secret for game") + } + + // Check cache first + remoteConfigCache[clientId]?.let { cachedLocations -> + Timber.tag("GOG").d("[Cloud Saves] Using cached save locations for clientId $clientId (${cachedLocations.size} locations)") + // Cache only contains locations, we still need to fetch clientSecret fresh + return@withContext Pair(clientSecret, cachedLocations) + } + + // Android runs games through Wine, so always use Windows platform + val syncPlatform = "Windows" + + // Fetch remote config + val url = "https://remote-config.gog.com/components/galaxy_client/clients/$clientId?component_version=2.0.45" + Timber.tag("GOG").d("[Cloud Saves] Fetching remote config from: $url") + + val request = Request.Builder() + .url(url) + .build() + + val response = Net.http.newCall(request).execute() + response.use { + if (!response.isSuccessful) { + Timber.tag("GOG").w("[Cloud Saves] Failed to fetch remote config: HTTP ${response.code}") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Successfully fetched remote config") + + val responseBody = response.body?.string() + if (responseBody == null) { + Timber.tag("GOG").w("[Cloud Saves] Empty response body from remote config") + return@withContext null + } + val configJson = JSONObject(responseBody) + + // Parse response: content.Windows.cloudStorage.locations + val content = configJson.optJSONObject("content") + if (content == null) { + Timber.tag("GOG").w("[Cloud Saves] No 'content' field in remote config response") + return@withContext null + } + + val platformContent = content.optJSONObject(syncPlatform) + if (platformContent == null) { + Timber.tag("GOG").d("[Cloud Saves] No cloud storage config for platform $syncPlatform") + return@withContext null + } + + val cloudStorage = platformContent.optJSONObject("cloudStorage") + if (cloudStorage == null) { + Timber.tag("GOG").d("[Cloud Saves] No cloudStorage field for platform $syncPlatform") + return@withContext null + } + + val enabled = cloudStorage.optBoolean("enabled", false) + if (!enabled) { + Timber.tag("GOG").d("[Cloud Saves] Cloud saves not enabled for game $gameId") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Cloud saves are enabled for game $gameId") + + val locationsArray = cloudStorage.optJSONArray("locations") + if (locationsArray == null || locationsArray.length() == 0) { + Timber.tag("GOG").d("[Cloud Saves] No save locations configured for game $gameId") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Found ${locationsArray.length()} location(s) in config") + + val locations = mutableListOf() + for (i in 0 until locationsArray.length()) { + val locationObj = locationsArray.getJSONObject(i) + val name = locationObj.optString("name", "__default") + val location = locationObj.optString("location", "") + if (location.isNotEmpty()) { + Timber.tag("GOG").d("[Cloud Saves] Location ${i + 1}: '$name' = '$location'") + locations.add(GOGCloudSavesLocationTemplate(name, location)) + } else { + Timber.tag("GOG").w("[Cloud Saves] Skipping location ${i + 1} with empty path") + } + } + + // Cache the result + if (locations.isNotEmpty()) { + remoteConfigCache[clientId] = locations + Timber.tag("GOG").d("[Cloud Saves] Cached ${locations.size} save locations for clientId $clientId") + } + + Timber.tag("GOG").i("[Cloud Saves] Found ${locations.size} save location(s) for game $gameId") + return@withContext Pair(clientSecret, locations) + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to get save sync location for appId $appId") + return@withContext null + } + } + + + + /** + * Get resolved save directory paths for a game + * @param context Android context + * @param appId Game app ID + * @param gameTitle Game title (for fallback) + * @return List of resolved save locations, or null if cloud saves not available + */ + suspend fun getSaveDirectoryPath( + context: Context, + appId: String, + gameTitle: String + ): List? = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG").d("[Cloud Saves] Getting save directory path for $appId ($gameTitle)") + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val game = getGameById(gameId.toString()) + + if (game == null) { + Timber.tag("GOG").w("[Cloud Saves] Game not found for appId $appId") + return@withContext null + } + + val installPath = game.installPath + if (installPath.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] Game not installed: $appId") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Game install path: $installPath") + + // Get clientId from info file + val infoJson = readInfoFile(appId, installPath) + val clientId = infoJson?.optString("clientId", "") ?: "" + if (clientId.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No clientId found in info file for game $gameId") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Client ID: $clientId") + + // Fetch save locations from API (Android runs games through Wine, so always Windows) + Timber.tag("GOG").d("[Cloud Saves] Fetching save locations from API") + val result = getSaveSyncLocation(context, appId, installPath) + + val clientSecret: String + val locations: List + + // If no locations from API, use default Windows path + if (result == null || result.second.isEmpty()) { + clientSecret = "" + Timber.tag("GOG").d("[Cloud Saves] No save locations from API, using default for game $gameId") + val defaultLocation = "%LOCALAPPDATA%/GOG.com/Galaxy/Applications/$clientId/Storage/Shared/Files" + Timber.tag("GOG").d("[Cloud Saves] Using default location: $defaultLocation") + locations = listOf(GOGCloudSavesLocationTemplate("__default", defaultLocation)) + } else { + clientSecret = result.first + locations = result.second + Timber.tag("GOG").i("[Cloud Saves] Retrieved ${locations.size} save location(s) from API") + } + + // Resolve each location + val resolvedLocations = mutableListOf() + for ((index, locationTemplate) in locations.withIndex()) { + Timber.tag("GOG").d("[Cloud Saves] Resolving location ${index + 1}/${locations.size}: '${locationTemplate.name}' = '${locationTemplate.location}'") + // Resolve GOG variables (, etc.) to Windows env vars + var resolvedPath = PathType.resolveGOGPathVariables(locationTemplate.location, installPath) + Timber.tag("GOG").d("[Cloud Saves] After GOG variable resolution: $resolvedPath") + + // Map GOG Windows path to device path using PathType + // Pass appId to ensure we use the correct container-specific wine prefix + resolvedPath = PathType.toAbsPathForGOG(context, resolvedPath, appId) + Timber.tag("GOG").d("[Cloud Saves] After path mapping to Wine prefix: $resolvedPath") + + // Normalize path to resolve any '..' or '.' components + try { + val normalizedPath = File(resolvedPath).canonicalPath + // Ensure trailing slash for directories + resolvedPath = if (!normalizedPath.endsWith("/")) "$normalizedPath/" else normalizedPath + Timber.tag("GOG").d("[Cloud Saves] After normalization: $resolvedPath") + } catch (e: Exception) { + Timber.tag("GOG").w(e, "[Cloud Saves] Failed to normalize path, using as-is: $resolvedPath") + } + + resolvedLocations.add( + GOGCloudSavesLocation( + name = locationTemplate.name, + location = resolvedPath, + clientId = clientId, + clientSecret = clientSecret + ) + ) + } + + Timber.tag("GOG").i("[Cloud Saves] Resolved ${resolvedLocations.size} save location(s) for game $gameId") + for (loc in resolvedLocations) { + Timber.tag("GOG").d("[Cloud Saves] - '${loc.name}': ${loc.location}") + } + return@withContext resolvedLocations + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to get save directory path for appId $appId") + return@withContext null + } + } + + /** + * Fetch client secret from GOG build metadata API + * @param gameId GOG game ID + * @param installPath Game install path (for platform detection, defaults to "windows") + * @return Client secret string, or null if not found + */ + private suspend fun getClientSecret(gameId: String, installPath: String?): String? = withContext(Dispatchers.IO) { + try { + val platform = "windows" // For now, assume Windows (proton) + val buildsUrl = "https://content-system.gog.com/products/$gameId/os/$platform/builds?generation=2" + + Timber.tag("GOG").d("[Cloud Saves] Fetching build metadata from: $buildsUrl") + + // Get credentials for API authentication + val credentials = GOGAuthManager.getStoredCredentials(context).getOrNull() + if (credentials == null) { + Timber.tag("GOG").w("[Cloud Saves] No credentials available for build metadata fetch") + return@withContext null + } + + val request = Request.Builder() + .url(buildsUrl) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + // Fetch the builds list and extract manifest link + val manifestLink = httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.tag("GOG").w("[Cloud Saves] Build metadata fetch failed: ${response.code}") + return@withContext null + } + + val jsonStr = response.body?.string() ?: "" + val buildsJson = JSONObject(jsonStr) + + // Get first build + val items = buildsJson.optJSONArray("items") + if (items == null || items.length() == 0) { + Timber.tag("GOG").w("[Cloud Saves] No builds found for game $gameId") + return@withContext null + } + + val firstBuild = items.getJSONObject(0) + val link = firstBuild.optString("link", "") + if (link.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No manifest link in first build") + return@withContext null + } + + Timber.tag("GOG").d("[Cloud Saves] Fetching build manifest from: $link") + link + } + + // Fetch the build manifest + val manifestRequest = Request.Builder() + .url(manifestLink) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + val manifestResponse = httpClient.newCall(manifestRequest).execute() + manifestResponse.use { + if (!manifestResponse.isSuccessful) { + Timber.tag("GOG").w("[Cloud Saves] Manifest fetch failed: ${manifestResponse.code}") + return@withContext null + } + + // Log response headers to debug compression + val contentEncoding = manifestResponse.header("Content-Encoding") + val contentType = manifestResponse.header("Content-Type") + Timber.tag("GOG").d("[Cloud Saves] Response headers - Content-Encoding: $contentEncoding, Content-Type: $contentType") + + // Read the response bytes (can only read body once) + val manifestBytes = manifestResponse.body?.bytes() ?: return@withContext null + + // Check compression type by magic bytes + val isGzipped = manifestBytes.size >= 2 && + manifestBytes[0] == 0x1f.toByte() && + manifestBytes[1] == 0x8b.toByte() + + val isZlib = manifestBytes.size >= 2 && + manifestBytes[0] == 0x78.toByte() && + (manifestBytes[1] == 0x9c.toByte() || + manifestBytes[1] == 0xda.toByte() || + manifestBytes[1] == 0x01.toByte()) + + Timber.tag("GOG").d("[Cloud Saves] Manifest bytes: ${manifestBytes.size}, isGzipped: $isGzipped, isZlib: $isZlib") + + // Decompress based on detected format + val manifestStr = when { + isGzipped -> { + try { + Timber.tag("GOG").d("[Cloud Saves] Decompressing gzip manifest") + val gzipStream = java.util.zip.GZIPInputStream(java.io.ByteArrayInputStream(manifestBytes)) + gzipStream.bufferedReader().use { it.readText() } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Gzip decompression failed") + return@withContext null + } + } + isZlib -> { + try { + Timber.tag("GOG").d("[Cloud Saves] Decompressing zlib manifest") + val inflaterStream = java.util.zip.InflaterInputStream(java.io.ByteArrayInputStream(manifestBytes)) + inflaterStream.bufferedReader().use { it.readText() } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Zlib decompression failed") + return@withContext null + } + } + else -> { + // Not compressed, read as plain text + Timber.tag("GOG").d("[Cloud Saves] Not compressed, reading as UTF-8") + String(manifestBytes, Charsets.UTF_8) + } + } + + if (manifestStr.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] Empty manifest response") + return@withContext null + } + + Timber.tag("GOG").d("[Cloud Saves] Parsing manifest JSON (${manifestStr.take(100)}...)") + val manifestJson = JSONObject(manifestStr) + + // Extract clientSecret from manifest + val clientSecret = manifestJson.optString("clientSecret", "") + if (clientSecret.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No clientSecret in manifest for game $gameId") + return@withContext null + } + + Timber.tag("GOG").d("[Cloud Saves] Successfully retrieved clientSecret for game $gameId") + return@withContext clientSecret + } + + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to get clientSecret for game $gameId") + return@withContext null + } + } + + /** + * Get stored sync timestamp for a game+location + * @param appId Game app ID + * @param locationName Location name + * @return Timestamp string, or "0" if not found + */ + fun getSyncTimestamp(appId: String, locationName: String): String { + val key = "${appId}_$locationName" + return syncTimestamps.getOrDefault(key, "0") + } + + /** + * Store sync timestamp for a game+location + * @param appId Game app ID + * @param locationName Location name + * @param timestamp Timestamp string + */ + fun setSyncTimestamp(appId: String, locationName: String, timestamp: String) { + val key = "${appId}_$locationName" + syncTimestamps[key] = timestamp + Timber.d("Stored sync timestamp for $key: $timestamp") + // Persist to disk + saveTimestampsToDisk() + } + + /** + * Start a sync operation for a game (prevents concurrent syncs) + * @param appId Game app ID + * @return true if sync can proceed, false if one is already in progress + */ + fun startSync(appId: String): Boolean { + return activeSyncs.add(appId) + } + + /** + * End a sync operation for a game + * @param appId Game app ID + */ + fun endSync(appId: String) { + activeSyncs.remove(appId) + } + + /** + * Load timestamps from disk + */ + private fun loadTimestampsFromDisk() { + try { + if (timestampFile.exists()) { + val json = timestampFile.readText() + val map = org.json.JSONObject(json) + map.keys().forEach { key -> + syncTimestamps[key] = map.getString(key) + } + Timber.tag("GOG").i("[Cloud Saves] Loaded ${syncTimestamps.size} sync timestamps from disk") + } else { + Timber.tag("GOG").d("[Cloud Saves] No persisted timestamps found (first run)") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to load timestamps from disk") + } + } + + /** + * Save timestamps to disk + */ + private fun saveTimestampsToDisk() { + try { + val json = org.json.JSONObject() + syncTimestamps.forEach { (key, value) -> + json.put(key, value) + } + timestampFile.writeText(json.toString()) + Timber.tag("GOG").d("[Cloud Saves] Saved ${syncTimestamps.size} timestamps to disk") + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to save timestamps to disk") + } + } + + // ========================================================================== + // FILE SYSTEM & PATHS + // ========================================================================== + + fun getAppDirPath(appId: String): String { + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val game = runBlocking { getGameById(gameId.toString()) } + + if (game != null) { + return GOGConstants.getGameInstallPath(game.title) + } + + Timber.w("Could not find game for appId $appId") + return GOGConstants.defaultGOGGamesPath + } + + fun getGameInstallPath(context: Context, gameId: String, gameTitle: String): String { + return GOGConstants.getGameInstallPath(gameTitle) + } + +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt new file mode 100644 index 000000000..122fc5389 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt @@ -0,0 +1,289 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.data.DownloadInfo +import com.chaquo.python.PyObject +import com.chaquo.python.Python +import com.chaquo.python.android.AndroidPlatform +import kotlinx.coroutines.* +import timber.log.Timber + +/** + * Progress callback that Python code can invoke to report download progress + */ +class ProgressCallback(private val downloadInfo: DownloadInfo) { + @JvmOverloads + fun update(percent: Float = 0f, downloadedMB: Float = 0f, totalMB: Float = 0f, downloadSpeedMBps: Float = 0f, eta: String = "") { + try { + val progress = (percent / 100.0f).coerceIn(0.0f, 1.0f) + + // Update byte-level progress for more accurate tracking + // GOGDL uses binary mebibytes (MiB), so convert using 1024*1024 not 1_000_000 + val downloadedBytes = (downloadedMB * 1024 * 1024).toLong() + val totalBytes = (totalMB * 1024 * 1024).toLong() + + // Set total bytes if we haven't already and it's available + if (totalBytes > 0 && downloadInfo.getTotalExpectedBytes() == 0L) { + downloadInfo.setTotalExpectedBytes(totalBytes) + } + + // Update bytes downloaded (delta from previous update) + val previousBytes = downloadInfo.getBytesDownloaded() + if (downloadedBytes > previousBytes) { + val deltaBytes = downloadedBytes - previousBytes + downloadInfo.updateBytesDownloaded(deltaBytes) + } + + // Also set percentage-based progress for compatibility + downloadInfo.setProgress(progress) + + // Update status message with ETA or progress info + if (eta.isNotEmpty() && eta != "00:00:00") { + downloadInfo.updateStatusMessage("ETA: $eta") + } else if (percent > 0f) { + downloadInfo.updateStatusMessage(String.format("%.1f%%", percent)) + } else { + downloadInfo.updateStatusMessage("Starting...") + } + + if (percent > 0f) { + Timber.d("Download progress: %.1f%% (%.1f/%.1f MB) Speed: %.2f MB/s ETA: %s", + percent, downloadedMB, totalMB, downloadSpeedMBps, eta) + } + } catch (e: Exception) { + Timber.w(e, "Error updating download progress") + } + } +} + +/** + * This an execution Bridge for Python GOGDL functionality + * + * This is purely to initialize and execute GOGDL commands as an abstraction layer to reduce duplication. + */ +object GOGPythonBridge { + private var python: Python? = null + private var isInitialized = false + + /** + * Initialize the Chaquopy Python environment + */ + fun initialize(context: Context): Boolean { + if (isInitialized) return true + + return try { + Timber.i("Initializing GOGPythonBridge with Chaquopy...") + + if (!Python.isStarted()) { + Python.start(AndroidPlatform(context)) + } + python = Python.getInstance() + + isInitialized = true + Timber.i("GOGPythonBridge initialized successfully") + true + } catch (e: Exception) { + Timber.e(e, "Failed to initialize GOGPythonBridge") + false + } + } + + /** + * Check if Python environment is initialized + */ + fun isReady(): Boolean = isInitialized && Python.isStarted() + + /** + * Executes Python GOGDL commands using Chaquopy (Java-Python lib) + * @param args Command line arguments to pass to gogdl CLI + * @return Result containing command output or error + */ + suspend fun executeCommand(vararg args: String): Result { + return withContext(Dispatchers.IO) { + try { + if (!Python.isStarted()) { + Timber.e("Python is not started! Cannot execute GOGDL command") + return@withContext Result.failure(Exception("Python environment not initialized")) + } + + val python = Python.getInstance() + val sys = python.getModule("sys") + val io = python.getModule("io") + val originalArgv = sys.get("argv") + + try { + val gogdlCli = python.getModule("gogdl.cli") + + // Set up arguments for argparse + val argsList = listOf("gogdl") + args.toList() + val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) + sys.put("argv", pythonList) + + // Capture stdout + val stdoutCapture = io.callAttr("StringIO") + val originalStdout = sys.get("stdout") + sys.put("stdout", stdoutCapture) + + // Execute the main function + gogdlCli.callAttr("main") + + // Get the captured output + val output = stdoutCapture.callAttr("getvalue").toString() + + // Restore original stdout + sys.put("stdout", originalStdout) + + if (output.isNotEmpty()) { + Result.success(output) + } else { + Timber.w("GOGDL execution completed but output is empty") + Result.success("GOGDL execution completed") + } + + } catch (e: Exception) { + Timber.e(e, "GOGDL execution failed: ${e.message}") + Result.failure(Exception("GOGDL execution failed: ${e.message}", e)) + } finally { + // Restore original sys.argv + sys.put("argv", originalArgv) + } + } catch (e: Exception) { + Timber.e(e, "Failed to execute GOGDL command: ${args.joinToString(" ")}") + Result.failure(Exception("GOGDL execution failed: ${e.message}", e)) + } + } + } + + /** + * Execute GOGDL command with progress callback for downloads + * + * This variant allows Python code to report progress via a callback object. + * + * @param downloadInfo DownloadInfo object to track progress + * @param args Command line arguments to pass to gogdl CLI + * @return Result containing command output or error + */ + suspend fun executeCommandWithCallback(downloadInfo: DownloadInfo, vararg args: String): Result { + return withContext(Dispatchers.IO) { + try { + val python = Python.getInstance() + val sys = python.getModule("sys") + val originalArgv = sys.get("argv") + + try { + // Create progress callback that Python can invoke + val progressCallback = ProgressCallback(downloadInfo) + + // Get the gogdl module and set up callback + val gogdlModule = python.getModule("gogdl") + + // Try to set progress callback if gogdl supports it + try { + gogdlModule.put("_progress_callback", progressCallback) + } catch (e: Exception) { + Timber.w(e, "Could not register progress callback, will use estimation") + } + + val gogdlCli = python.getModule("gogdl.cli") + + // Set up arguments for argparse + val argsList = listOf("gogdl") + args.toList() + val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) + sys.put("argv", pythonList) + + // Check for cancellation before starting + ensureActive() + + // Start a simple progress estimator in case callback doesn't work + val estimatorJob = CoroutineScope(Dispatchers.IO).launch { + estimateProgress(downloadInfo) + } + + try { + // Execute the main function + gogdlCli.callAttr("main") + Timber.i("GOGDL execution completed successfully") + Result.success("Download completed") + } finally { + estimatorJob.cancel() + } + } catch (e: Exception) { + Timber.e(e, "GOGDL execution failed: ${e.message}") + Result.failure(e) + } finally { + sys.put("argv", originalArgv) + } + } catch (e: CancellationException) { + Timber.i("GOGDL command cancelled") + throw e // Re-throw to propagate cancellation + } catch (e: Exception) { + Timber.e(e, "Failed to execute GOGDL command: ${args.joinToString(" ")}") + Result.failure(e) + } + } + } + + /** + * Estimate progress when callback isn't available + * Shows gradual progress to indicate activity + */ + private suspend fun estimateProgress(downloadInfo: DownloadInfo) { + try { + var lastProgress = 0.0f + var lastBytesDownloaded = 0L + val startTime = System.currentTimeMillis() + var callbackDetected = false + val CHECK_INTERVAL = 3000L + + while (downloadInfo.getProgress() < 1.0f && downloadInfo.getProgress() >= 0.0f) { + delay(CHECK_INTERVAL) + + val currentBytes = downloadInfo.getBytesDownloaded() + val currentProgress = downloadInfo.getProgress() + + // Check if the callback is actively updating (bytes are increasing) + if (currentBytes > lastBytesDownloaded) { + if (!callbackDetected) { + Timber.d("Progress callback detected, disabling estimator") + callbackDetected = true + } + lastBytesDownloaded = currentBytes + lastProgress = currentProgress + continue // Don't override real progress + } + + // Also check if progress increased significantly without estimator intervention + if (currentProgress > lastProgress + 0.02f) { + if (!callbackDetected) { + Timber.d("Progress callback detected (progress jump), disabling estimator") + callbackDetected = true + } + lastProgress = currentProgress + continue // Don't override real progress + } + + // Only estimate if callback hasn't been detected + if (!callbackDetected) { + val elapsed = System.currentTimeMillis() - startTime + val estimatedProgress = when { + elapsed < 5000 -> 0.05f + elapsed < 15000 -> 0.15f + elapsed < 30000 -> 0.30f + elapsed < 60000 -> 0.50f + elapsed < 120000 -> 0.70f + elapsed < 180000 -> 0.85f + else -> 0.95f + }.coerceAtLeast(lastProgress) + + downloadInfo.setProgress(estimatedProgress) + lastProgress = estimatedProgress + Timber.d("Estimated progress: %.1f%%", estimatedProgress * 100) + } + } + } catch (e: CancellationException) { + Timber.d("Progress estimation cancelled") + } catch (e: Exception) { + Timber.w(e, "Error in progress estimation") + } + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt new file mode 100644 index 000000000..0cfe753cb --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -0,0 +1,608 @@ +package app.gamenative.service.gog + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import app.gamenative.data.DownloadInfo +import app.gamenative.data.GOGCredentials +import app.gamenative.data.GOGGame +import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem +import app.gamenative.service.NotificationHelper +import app.gamenative.utils.ContainerUtils +import dagger.hilt.android.AndroidEntryPoint +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.* +import timber.log.Timber +import javax.inject.Inject + +/** + * GOG Service - thin coordinator that delegates to specialized managers. + * + * Architecture: + * - GOGPythonBridge: Low-level Python/GOGDL command execution + * - GOGAuthManager: Authentication and account management + * - GOGManager: Game library, downloads, and installation + * + * This service maintains backward compatibility through static accessors + * while delegating all operations to the appropriate managers. + */ +@AndroidEntryPoint +class GOGService : Service() { + + companion object { + private const val ACTION_SYNC_LIBRARY = "app.gamenative.GOG_SYNC_LIBRARY" + private const val ACTION_MANUAL_SYNC = "app.gamenative.GOG_MANUAL_SYNC" + private const val SYNC_THROTTLE_MILLIS = 15 * 60 * 1000L // 15 minutes + + private var instance: GOGService? = null + + // Sync tracking variables + private var syncInProgress: Boolean = false + private var backgroundSyncJob: Job? = null + private var lastSyncTimestamp: Long = 0L + private var hasPerformedInitialSync: Boolean = false + + val isRunning: Boolean + get() = instance != null + + /** + * Start the GOG service. Handles both first-time start and subsequent automatic syncs. + * - First-time start: Always syncs (no throttle) + * - Subsequent starts: Throttled to once per 15 minutes + */ + fun start(context: Context) { + // If already running, do nothing + if (isRunning) { + Timber.d("[GOGService] Service already running, skipping start") + return + } + + // First-time start: always sync without throttle + if (!hasPerformedInitialSync) { + Timber.i("[GOGService] First-time start - starting service with initial sync") + val intent = Intent(context, GOGService::class.java) + intent.action = ACTION_SYNC_LIBRARY + context.startForegroundService(intent) + return + } + + // Subsequent starts: check throttle + val now = System.currentTimeMillis() + val timeSinceLastSync = now - lastSyncTimestamp + + if (timeSinceLastSync >= SYNC_THROTTLE_MILLIS) { + Timber.i("[GOGService] Starting service with automatic sync (throttle passed)") + val intent = Intent(context, GOGService::class.java) + intent.action = ACTION_SYNC_LIBRARY + context.startForegroundService(intent) + } else { + val remainingMinutes = (SYNC_THROTTLE_MILLIS - timeSinceLastSync) / 1000 / 60 + Timber.d("[GOGService] Skipping start - throttled (${remainingMinutes}min remaining)") + } + } + + fun triggerLibrarySync(context: Context) { + Timber.i("[GOGService] Triggering manual library sync (bypasses throttle)") + val intent = Intent(context, GOGService::class.java) + intent.action = ACTION_MANUAL_SYNC + context.startForegroundService(intent) + } + + fun stop() { + instance?.let { service -> + service.stopSelf() + } + } + + + fun initialize(context: Context): Boolean { + return GOGPythonBridge.initialize(context) + } + + // ========================================================================== + // AUTHENTICATION - Delegate to GOGAuthManager + // ========================================================================== + + suspend fun authenticateWithCode(context: Context, authorizationCode: String): Result { + return GOGAuthManager.authenticateWithCode(context, authorizationCode) + } + + + fun hasStoredCredentials(context: Context): Boolean { + return GOGAuthManager.hasStoredCredentials(context) + } + + + suspend fun getStoredCredentials(context: Context): Result { + return GOGAuthManager.getStoredCredentials(context) + } + + + suspend fun validateCredentials(context: Context): Result { + return GOGAuthManager.validateCredentials(context) + } + + + fun clearStoredCredentials(context: Context): Boolean { + return GOGAuthManager.clearStoredCredentials(context) + } + + /** + * Logout from GOG - clears credentials, database, and stops service + */ + suspend fun logout(context: Context): Result { + return withContext(Dispatchers.IO) { + try { + Timber.i("[GOGService] Logging out from GOG...") + + // Get instance first before stopping the service + val instance = getInstance() + if (instance == null) { + Timber.w("[GOGService] Service instance not available during logout") + return@withContext Result.failure(Exception("Service not running")) + } + + // Clear stored credentials + val credentialsCleared = clearStoredCredentials(context) + if (!credentialsCleared) { + Timber.w("[GOGService] Failed to clear credentials during logout") + } + + // Clear all GOG games from database + instance.gogManager.deleteAllGames() + Timber.i("[GOGService] All GOG games removed from database") + + // Stop the service + stop() + + Timber.i("[GOGService] Logout completed successfully") + Result.success(Unit) + } catch (e: Exception) { + Timber.e(e, "[GOGService] Error during logout") + Result.failure(e) + } + } + } + + // ========================================================================== + // SYNC & OPERATIONS + // ========================================================================== + + fun hasActiveOperations(): Boolean { + return syncInProgress || backgroundSyncJob?.isActive == true + } + + private fun setSyncInProgress(inProgress: Boolean) { + syncInProgress = inProgress + } + + fun isSyncInProgress(): Boolean = syncInProgress + + fun getInstance(): GOGService? = instance + + // ========================================================================== + // DOWNLOAD OPERATIONS - Delegate to instance GOGManager + // ========================================================================== + + fun hasActiveDownload(): Boolean { + return getInstance()?.activeDownloads?.isNotEmpty() ?: false + } + + + fun getCurrentlyDownloadingGame(): String? { + return getInstance()?.activeDownloads?.keys?.firstOrNull() + } + + + fun getDownloadInfo(gameId: String): DownloadInfo? { + return getInstance()?.activeDownloads?.get(gameId) + } + + + fun cleanupDownload(gameId: String) { + getInstance()?.activeDownloads?.remove(gameId) + } + + + fun cancelDownload(gameId: String): Boolean { + val instance = getInstance() + val downloadInfo = instance?.activeDownloads?.get(gameId) + + return if (downloadInfo != null) { + Timber.i("Cancelling download for game: $gameId") + downloadInfo.cancel() + instance.activeDownloads.remove(gameId) + Timber.d("Download cancelled for game: $gameId") + true + } else { + Timber.w("No active download found for game: $gameId") + false + } + } + + // ========================================================================== + // GAME & LIBRARY OPERATIONS - Delegate to instance GOGManager + // ========================================================================== + + fun getGOGGameOf(gameId: String): GOGGame? { + return runBlocking(Dispatchers.IO) { + getInstance()?.gogManager?.getGameById(gameId) + } + } + + suspend fun updateGOGGame(game: GOGGame) { + getInstance()?.gogManager?.updateGame(game) + } + + fun isGameInstalled(gameId: String): Boolean { + return runBlocking(Dispatchers.IO) { + val game = getInstance()?.gogManager?.getGameById(gameId) + if (game?.isInstalled != true) { + return@runBlocking false + } + + // Verify the installation is actually valid + val (isValid, errorMessage) = getInstance()?.gogManager?.verifyInstallation(gameId) + ?: Pair(false, "Service not available") + if (!isValid) { + Timber.w("Game $gameId marked as installed but verification failed: $errorMessage") + } + isValid + } + } + + + fun getInstallPath(gameId: String): String? { + return runBlocking(Dispatchers.IO) { + val game = getInstance()?.gogManager?.getGameById(gameId) + if (game?.isInstalled == true) game.installPath else null + } + } + + + fun verifyInstallation(gameId: String): Pair { + return getInstance()?.gogManager?.verifyInstallation(gameId) + ?: Pair(false, "Service not available") + } + + + suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String { + return getInstance()?.gogManager?.getInstalledExe(context, libraryItem) + ?: "" + } + + + fun getGogWineStartCommand( + context: Context, + libraryItem: LibraryItem, + container: com.winlator.container.Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: com.winlator.core.envvars.EnvVars, + guestProgramLauncherComponent: com.winlator.xenvironment.components.GuestProgramLauncherComponent + ): String { + return getInstance()?.gogManager?.getGogWineStartCommand( + context, libraryItem, container, bootToContainer, appLaunchInfo, envVars, guestProgramLauncherComponent + ) ?: "\"explorer.exe\"" + } + + + suspend fun refreshLibrary(context: Context): Result { + return getInstance()?.gogManager?.refreshLibrary(context) + ?: Result.failure(Exception("Service not available")) + } + + + fun downloadGame(context: Context, gameId: String, installPath: String): Result { + val instance = getInstance() ?: return Result.failure(Exception("Service not available")) + + // Create DownloadInfo for progress tracking + val downloadInfo = DownloadInfo(jobCount = 1) + + // Track in activeDownloads first + instance.activeDownloads[gameId] = downloadInfo + + // Launch download in service scope so it runs independently + instance.scope.launch { + try { + Timber.d("[Download] Starting download for game $gameId") + val result = instance.gogManager.downloadGame(context, gameId, installPath, downloadInfo) + + if (result.isFailure) { + Timber.e(result.exceptionOrNull(), "[Download] Failed for game $gameId") + downloadInfo.setProgress(-1.0f) + downloadInfo.setActive(false) + } else { + Timber.i("[Download] Completed successfully for game $gameId") + downloadInfo.setProgress(1.0f) + downloadInfo.setActive(false) + } + } catch (e: Exception) { + Timber.e(e, "[Download] Exception for game $gameId") + downloadInfo.setProgress(-1.0f) + downloadInfo.setActive(false) + } finally { + // Remove from activeDownloads for both success and failure + // so UI knows download is complete and to prevent stale entries + instance.activeDownloads.remove(gameId) + Timber.d("[Download] Finished for game $gameId, progress: ${downloadInfo.getProgress()}, active: ${downloadInfo.isActive()}") + } + } + + return Result.success(downloadInfo) + } + + + suspend fun refreshSingleGame(gameId: String, context: Context): Result { + return getInstance()?.gogManager?.refreshSingleGame(gameId, context) + ?: Result.failure(Exception("Service not available")) + } + + /** + * Delete/uninstall a GOG game + * Delegates to GOGManager.deleteGame + */ + suspend fun deleteGame(context: Context, libraryItem: LibraryItem): Result { + return getInstance()?.gogManager?.deleteGame(context, libraryItem) + ?: Result.failure(Exception("Service not available")) + } + + /** + * Sync GOG cloud saves for a game + * @param context Android context + * @param appId Game app ID (e.g., "gog_123456") + * @param preferredAction Preferred sync action: "download", "upload", or "none" + * @return true if sync succeeded, false otherwise + */ + suspend fun syncCloudSaves(context: Context, appId: String, preferredAction: String = "none"): Boolean = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG").d("[Cloud Saves] syncCloudSaves called for $appId with action: $preferredAction") + + // Check if there's already a sync in progress for this appId + val serviceInstance = getInstance() + if (serviceInstance == null) { + Timber.tag("GOG").e("[Cloud Saves] Service instance not available for sync start") + return@withContext false + } + + if (!serviceInstance.gogManager.startSync(appId)) { + Timber.tag("GOG").w("[Cloud Saves] Sync already in progress for $appId, skipping duplicate sync") + return@withContext false + } + + try { + val instance = getInstance() + if (instance == null) { + Timber.tag("GOG").e("[Cloud Saves] Service instance not available") + return@withContext false + } + + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.tag("GOG").e("[Cloud Saves] Cannot sync saves: not authenticated") + return@withContext false + } + + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + Timber.tag("GOG").d("[Cloud Saves] Using auth config path: $authConfigPath") + + // Get game info + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + Timber.tag("GOG").d("[Cloud Saves] Extracted game ID: $gameId from appId: $appId") + val game = instance.gogManager.getGameById(gameId.toString()) + + if (game == null) { + Timber.tag("GOG").e("[Cloud Saves] Game not found for appId: $appId") + return@withContext false + } + Timber.tag("GOG").d("[Cloud Saves] Found game: ${game.title}") + + // Get save directory paths (Android runs games through Wine, so always Windows) + Timber.tag("GOG").d("[Cloud Saves] Resolving save directory paths for $appId") + val saveLocations = instance.gogManager.getSaveDirectoryPath(context, appId, game.title) + + if (saveLocations == null || saveLocations.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No save locations found for game $appId (cloud saves may not be enabled)") + return@withContext false + } + Timber.tag("GOG").i("[Cloud Saves] Found ${saveLocations.size} save location(s) for $appId") + + var allSucceeded = true + + // Sync each save location + for ((index, location) in saveLocations.withIndex()) { + try { + Timber.tag("GOG").d("[Cloud Saves] Processing location ${index + 1}/${saveLocations.size}: '${location.name}'") + + // Log directory state BEFORE sync + try { + val saveDir = java.io.File(location.location) + Timber.tag("GOG").d("[Cloud Saves] [BEFORE] Checking directory: ${location.location}") + Timber.tag("GOG").d("[Cloud Saves] [BEFORE] Directory exists: ${saveDir.exists()}, isDirectory: ${saveDir.isDirectory}") + if (saveDir.exists() && saveDir.isDirectory) { + val filesBefore = saveDir.listFiles() + if (filesBefore != null && filesBefore.isNotEmpty()) { + Timber.tag("GOG").i("[Cloud Saves] [BEFORE] ${filesBefore.size} files in '${location.name}': ${filesBefore.joinToString(", ") { it.name }}") + } else { + Timber.tag("GOG").i("[Cloud Saves] [BEFORE] Directory '${location.name}' is empty") + } + } else { + Timber.tag("GOG").i("[Cloud Saves] [BEFORE] Directory '${location.name}' does not exist yet") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] [BEFORE] Failed to check directory") + } + + // Get stored timestamp for this location + val timestampStr = instance.gogManager.getSyncTimestamp(appId, location.name) + val timestamp = timestampStr.toLongOrNull() ?: 0L + + Timber.tag("GOG").i("[Cloud Saves] Syncing '${location.name}' for game $gameId (clientId: ${location.clientId}, path: ${location.location}, timestamp: $timestamp, action: $preferredAction)") + + // Validate clientSecret is available + if (location.clientSecret.isEmpty()) { + Timber.tag("GOG").e("[Cloud Saves] Missing clientSecret for '${location.name}', skipping sync") + continue + } + + val cloudSavesManager = GOGCloudSavesManager(context) + val newTimestamp = cloudSavesManager.syncSaves( + clientId = location.clientId, + clientSecret = location.clientSecret, + localPath = location.location, + dirname = location.name, + lastSyncTimestamp = timestamp, + preferredAction = preferredAction + ) + + if (newTimestamp > 0) { + // Success - store new timestamp + instance.gogManager.setSyncTimestamp(appId, location.name, newTimestamp.toString()) + Timber.tag("GOG").d("[Cloud Saves] Updated timestamp for '${location.name}': $newTimestamp") + + // Log the save files in the directory after sync + try { + val saveDir = java.io.File(location.location) + if (saveDir.exists() && saveDir.isDirectory) { + val files = saveDir.listFiles() + if (files != null && files.isNotEmpty()) { + val fileList = files.joinToString(", ") { it.name } + Timber.tag("GOG").i("[Cloud Saves] [$preferredAction] Files in '${location.name}': $fileList (${files.size} files)") + + // Log detailed file info + files.forEach { file -> + val size = if (file.isFile) "${file.length()} bytes" else "directory" + Timber.tag("GOG").d("[Cloud Saves] [$preferredAction] - ${file.name} ($size)") + } + } else { + Timber.tag("GOG").w("[Cloud Saves] [$preferredAction] Directory '${location.name}' is empty at: ${location.location}") + } + } else { + Timber.tag("GOG").w("[Cloud Saves] [$preferredAction] Directory not found: ${location.location}") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to list files in directory: ${location.location}") + } + + Timber.tag("GOG").i("[Cloud Saves] Successfully synced save location '${location.name}' for game $gameId") + } else { + Timber.tag("GOG").e("[Cloud Saves] Failed to sync save location '${location.name}' for game $gameId (timestamp: $newTimestamp)") + allSucceeded = false + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Exception syncing save location '${location.name}' for game $gameId") + allSucceeded = false + } + } + + if (allSucceeded) { + Timber.tag("GOG").i("[Cloud Saves] All save locations synced successfully for $appId") + return@withContext true + } else { + Timber.tag("GOG").w("[Cloud Saves] Some save locations failed to sync for $appId") + return@withContext false + } + } finally { + // Always end the sync, even if an exception occurred + getInstance()?.gogManager?.endSync(appId) + Timber.tag("GOG").d("[Cloud Saves] Sync completed and lock released for $appId") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to sync cloud saves for App ID: $appId") + return@withContext false + } + } + } + + private lateinit var notificationHelper: NotificationHelper + + @Inject + lateinit var gogManager: GOGManager + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Track active downloads by game ID + private val activeDownloads = ConcurrentHashMap() + + // GOGManager is injected by Hilt + override fun onCreate() { + super.onCreate() + instance = this + + + // Initialize notification helper for foreground service + notificationHelper = NotificationHelper(applicationContext) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Timber.d("[GOGService] onStartCommand() - action: ${intent?.action}") + + // Start as foreground service + val notification = notificationHelper.createForegroundNotification("GOG Service running...") + startForeground(2, notification) // Use different ID than SteamService (which uses 1) + + // Determine if we should sync based on the action + val shouldSync = when (intent?.action) { + ACTION_MANUAL_SYNC -> { + Timber.i("[GOGService] Manual sync requested - bypassing throttle") + true + } + ACTION_SYNC_LIBRARY -> { + Timber.i("[GOGService] Automatic sync requested") + true + } + else -> { + // Service started without sync action (e.g., just to keep it alive) + Timber.d("[GOGService] Service started without sync action") + false + } + } + + // Start background library sync if requested + if (shouldSync && (backgroundSyncJob == null || backgroundSyncJob?.isActive != true)) { + Timber.i("[GOGService] Starting background library sync") + backgroundSyncJob?.cancel() // Cancel any existing job + backgroundSyncJob = scope.launch { + try { + setSyncInProgress(true) + Timber.d("[GOGService]: Starting background library sync") + + val syncResult = gogManager.startBackgroundSync(applicationContext) + if (syncResult.isFailure) { + Timber.w("[GOGService]: Failed to start background sync: ${syncResult.exceptionOrNull()?.message}") + } else { + Timber.i("[GOGService]: Background library sync completed successfully") + // Update last sync timestamp on successful sync + lastSyncTimestamp = System.currentTimeMillis() + // Mark that initial sync has been performed + hasPerformedInitialSync = true + } + } catch (e: Exception) { + Timber.e(e, "[GOGService]: Exception starting background sync") + } finally { + setSyncInProgress(false) + } + } + } else if (shouldSync) { + Timber.d("[GOGService] Background sync already in progress, skipping") + } + + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + + // Cancel sync operations + backgroundSyncJob?.cancel() + setSyncInProgress(false) + + scope.cancel() // Cancel any ongoing operations + stopForeground(STOP_FOREGROUND_REMOVE) + notificationHelper.cancel() + instance = null + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index da1843297..5fafe1c6f 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -402,6 +402,14 @@ fun PluviaMain( isConnecting = true context.startForegroundService(Intent(context, SteamService::class.java)) } + + // Start GOGService if user has GOG credentials + if (app.gamenative.service.gog.GOGService.hasStoredCredentials(context) && + !app.gamenative.service.gog.GOGService.isRunning) { + Timber.d("[PluviaMain]: Starting GOGService for logged-in user") + app.gamenative.service.gog.GOGService.start(context) + } + if (SteamService.isLoggedIn && !SteamService.isGameRunning && state.currentScreen == PluviaScreen.LoginUser) { navController.navigate(PluviaScreen.Home.route) } @@ -1144,6 +1152,30 @@ fun preLaunchApp( return@launch } + // For GOG Games, sync cloud saves before launch + val isGOGGame = ContainerUtils.extractGameSourceFromContainerId(appId) == GameSource.GOG + if (isGOGGame) { + Timber.tag("GOG").i("[Cloud Saves] GOG Game detected for $appId — syncing cloud saves before launch") + + // Sync cloud saves (download latest saves before playing) + Timber.tag("GOG").d("[Cloud Saves] Starting pre-game download sync for $appId") + val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves( + context = context, + appId = appId, + ) + + if (!syncSuccess) { + Timber.tag("GOG").w("[Cloud Saves] Download sync failed for $appId, proceeding with launch anyway") + // Don't block launch on sync failure - log warning and continue + } else { + Timber.tag("GOG").i("[Cloud Saves] Download sync completed successfully for $appId") + } + + setLoadingDialogVisible(false) + onSuccess(context, appId) + return@launch + } + // For Steam games, sync save files and check no pending remote operations are running val prefixToPath: (String) -> String = { prefix -> PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt new file mode 100644 index 000000000..95fa1c9c7 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -0,0 +1,215 @@ +package app.gamenative.ui.component.dialog + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Login +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.service.gog.GOGConstants +import app.gamenative.ui.theme.PluviaTheme +import android.content.Intent +import android.net.Uri +import android.widget.Toast + +private fun extractCodeFromInput(input: String): String { + val trimmed = input.trim() + // Check if it's a URL with code parameter + if (trimmed.startsWith("http")) { + val codeMatch = Regex("[?&]code=([^&]+)").find(trimmed) + return codeMatch?.groupValues?.get(1) ?: "" + } + // Otherwise assume it's already the code + return trimmed +} + +/** + * GOG Login Dialog + * + * GOG uses OAuth2 authentication with automatic callback handling: + * 1. Open GOG login URL in browser + * 2. Login with GOG credentials + * 3. GOG redirects back to app with authorization code automatically + * ! Note: This UI will be temporary as we will migrate to a redirect flow. + */ +@Composable +fun GOGLoginDialog( + visible: Boolean, + onDismissRequest: () -> Unit, + onAuthCodeClick: (authCode: String) -> Unit, + isLoading: Boolean = false, + errorMessage: String? = null, +) { + val context = LocalContext.current + var authCode by rememberSaveable { mutableStateOf("") } + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val scrollState = rememberScrollState() + + if (!visible) return + AlertDialog( + onDismissRequest = onDismissRequest, + icon = { Icon(imageVector = Icons.Default.Login, contentDescription = null) }, + title = { Text(stringResource(R.string.gog_login_title)) }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .then( + if (isLandscape) { + Modifier + .heightIn(max = 300.dp) + .verticalScroll(scrollState) + } else { + Modifier + } + ), + verticalArrangement = Arrangement.spacedBy(if (isLandscape) 8.dp else 12.dp) + ) { + Text( + text = stringResource(R.string.gog_login_auto_auth_info), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + // Open browser button + Button( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(GOGConstants.GOG_AUTH_LOGIN_URL)) + context.startActivity(intent) + } catch (e: Exception) { + Toast.makeText( + context, + context.getString(R.string.gog_login_browser_error), + Toast.LENGTH_SHORT + ).show() + } + }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + contentPadding = if (isLandscape) PaddingValues(8.dp) else ButtonDefaults.ContentPadding + ) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.gog_login_open_button)) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = if (isLandscape) 4.dp else 8.dp)) + + // Manual code entry fallback + Text( + text = stringResource(R.string.gog_login_auth_example), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Authorization code input + OutlinedTextField( + value = authCode, + onValueChange = { authCode = it.trim() }, + label = { Text(stringResource(R.string.gog_login_auth_code_label)) }, + placeholder = { Text(stringResource(R.string.gog_login_auth_code_placeholder)) }, + singleLine = true, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) + + // Error message + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + // Loading indicator + if (isLoading) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth() + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + if (authCode.isNotBlank()) { + val extractedCode = extractCodeFromInput(authCode) + if (extractedCode.isNotEmpty()) { + onAuthCodeClick(extractedCode) + } + } + }, + enabled = !isLoading && authCode.isNotBlank() + ) { + Text(stringResource(R.string.gog_login_button)) + } + }, + dismissButton = { + TextButton( + onClick = onDismissRequest, + enabled = !isLoading + ) { + Text(stringResource(R.string.gog_login_cancel)) + } + } + ) + } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_GOGLoginDialog() { + PluviaTheme { + GOGLoginDialog( + visible = true, + onDismissRequest = {}, + onAuthCodeClick = {}, + isLoading = false, + errorMessage = null + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_GOGLoginDialogWithError() { + PluviaTheme { + GOGLoginDialog( + visible = true, + onDismissRequest = {}, + onAuthCodeClick = {}, + isLoading = false, + errorMessage = "Invalid authorization code. Please try again." + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_GOGLoginDialogLoading() { + PluviaTheme { + GOGLoginDialog( + visible = true, + onDismissRequest = {}, + onAuthCodeClick = {}, + isLoading = true, + errorMessage = null + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt index c6cac8364..d6e751cfd 100644 --- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt +++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt @@ -21,17 +21,18 @@ data class LibraryState( val isSearching: Boolean = false, val searchQuery: String = "", - // App Source filters (Steam / Custom Games) + // App Source filters (Steam / Custom Games / GOG) val showSteamInLibrary: Boolean = PrefManager.showSteamInLibrary, val showCustomGamesInLibrary: Boolean = PrefManager.showCustomGamesInLibrary, - + val showGOGInLibrary: Boolean = PrefManager.showGOGInLibrary, + // Loading state for skeleton loaders val isLoading: Boolean = false, - + // Refresh counter that increments when custom game images are fetched // Used to trigger UI recomposition to show newly downloaded images val imageRefreshCounter: Long = 0, - + // Compatibility status map: game name -> compatibility status val compatibilityMap: Map = emptyMap(), ) 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 9c66b6c70..f3070bede 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -10,8 +10,10 @@ import app.gamenative.PrefManager import app.gamenative.PluviaApp import app.gamenative.data.LibraryItem import app.gamenative.data.SteamApp +import app.gamenative.data.GOGGame import app.gamenative.data.GameSource import app.gamenative.db.dao.SteamAppDao +import app.gamenative.db.dao.GOGGameDao import app.gamenative.service.DownloadService import app.gamenative.service.SteamService import app.gamenative.ui.data.LibraryState @@ -43,6 +45,7 @@ import kotlin.math.min @HiltViewModel class LibraryViewModel @Inject constructor( private val steamAppDao: SteamAppDao, + private val gogGameDao: GOGGameDao, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -68,6 +71,7 @@ class LibraryViewModel @Inject constructor( // Complete and unfiltered app list private var appList: List = emptyList() + private var gogGameList: List = emptyList() // Track if this is the first load to apply minimum load time private var isFirstLoad = true @@ -95,11 +99,21 @@ class LibraryViewModel @Inject constructor( // ownerIds = SteamService.familyMembers.ifEmpty { listOf(SteamService.userSteamId!!.accountID.toInt()) }, ).collect { apps -> Timber.tag("LibraryViewModel").d("Collecting ${apps.size} apps") - + // Check if the list has actually changed before triggering a re-filter if (appList.size != apps.size) { - // Don't filter if it's no change appList = apps + onFilterApps(paginationCurrentPage) + } + } + } + // Collect GOG games + viewModelScope.launch(Dispatchers.IO) { + gogGameDao.getAll().collect { games -> + Timber.tag("LibraryViewModel").d("Collecting ${games.size} GOG games") + // Check if the list has actually changed before triggering a re-filter + if (gogGameList != games) { + gogGameList = games onFilterApps(paginationCurrentPage) } } @@ -139,6 +153,11 @@ class LibraryViewModel @Inject constructor( PrefManager.showCustomGamesInLibrary = newValue _state.update { it.copy(showCustomGamesInLibrary = newValue) } } + GameSource.GOG -> { + val newValue = !current.showGOGInLibrary + PrefManager.showGOGInLibrary = newValue + _state.update { it.copy(showGOGInLibrary = newValue) } + } } onFilterApps(paginationCurrentPage) } @@ -185,6 +204,10 @@ class LibraryViewModel @Inject constructor( } else { Timber.tag("LibraryViewModel").d("No newly owned games discovered during refresh") } + if (app.gamenative.service.gog.GOGService.hasStoredCredentials(context)) { + Timber.tag("LibraryViewModel").i("Triggering GOG library refresh") + app.gamenative.service.gog.GOGService.triggerLibrarySync(context) + } } catch (e: Exception) { Timber.tag("LibraryViewModel").e(e, "Failed to refresh owned games from server") } finally { @@ -193,6 +216,7 @@ class LibraryViewModel @Inject constructor( } } } + fun addCustomGameFolder(path: String) { viewModelScope.launch(Dispatchers.IO) { val normalizedPath = File(path).absolutePath @@ -214,18 +238,19 @@ class LibraryViewModel @Inject constructor( } private fun onFilterApps(paginationPage: Int = 0): Job { - // May be filtering 1000+ apps - in future should paginate at the point of DAO request Timber.tag("LibraryViewModel").d("onFilterApps - appList.size: ${appList.size}, isFirstLoad: $isFirstLoad") - return viewModelScope.launch { + return viewModelScope.launch(Dispatchers.IO) { _state.update { it.copy(isLoading = true) } val currentState = _state.value val currentFilter = AppFilter.getAppType(currentState.appInfoSortType) + // Fetch download directory apps once on IO thread and cache as a HashSet for O(1) lookups val downloadDirectoryApps = DownloadService.getDownloadDirectoryApps() + val downloadDirectorySet = downloadDirectoryApps.toHashSet() // Filter Steam apps first (no pagination yet) - val downloadDirectorySet = downloadDirectoryApps.toHashSet() + // Note: Don't sort individual lists - we'll sort the combined list for consistent ordering val filteredSteamApps: List = appList .asSequence() .filter { item -> @@ -300,28 +325,72 @@ class LibraryViewModel @Inject constructor( } val customEntries = customGameItems.map { LibraryEntry(it, true) } + // Filter GOG games + val filteredGOGGames = gogGameList + .asSequence() + .filter { game -> + if (currentState.searchQuery.isNotEmpty()) { + game.title.contains(currentState.searchQuery, ignoreCase = true) + } else { + true + } + } + .filter { game -> + if (currentState.appInfoSortType.contains(AppFilter.INSTALLED)) { + game.isInstalled + } else { + true + } + } + .toList() + + val gogEntries = filteredGOGGames.map { game -> + LibraryEntry( + item = LibraryItem( + index = 0, + appId = "${GameSource.GOG.name}_${game.id}", + name = game.title, + iconHash = game.imageUrl.ifEmpty { game.iconUrl }, + isShared = false, + gameSource = GameSource.GOG, + ), + isInstalled = game.isInstalled, + ) + } + + val gogInstalledCount = filteredGOGGames.count { it.isInstalled } // 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 = filteredSteamApps.size - Timber.tag("LibraryViewModel").d("Saved counts - Custom: ${customGameItems.size}, Steam: ${filteredSteamApps.size}") + PrefManager.gogGamesCount = filteredGOGGames.size + PrefManager.gogInstalledGamesCount = gogInstalledCount + Timber.tag("LibraryViewModel").d("Saved counts - Custom: ${customGameItems.size}, Steam: ${filteredSteamApps.size}, GOG: ${filteredGOGGames.size}, GOG installed: $gogInstalledCount") } // Apply App Source filters val includeSteam = _state.value.showSteamInLibrary val includeOpen = _state.value.showCustomGamesInLibrary + val includeGOG = _state.value.showGOGInLibrary - // Combine both lists + // Combine all lists and sort: installed games first, then alphabetically val combined = buildList { if (includeSteam) addAll(steamEntries) if (includeOpen) addAll(customEntries) + if (includeGOG) addAll(gogEntries) }.sortedWith( - // Always sort by installed status first (installed games at top), then alphabetically within each group + // Primary sort: installed status (0 = installed at top, 1 = not installed at bottom) + // Secondary sort: alphabetically by name (case-insensitive) compareBy { entry -> if (entry.isInstalled) 0 else 1 - }.thenBy { it.item.name.lowercase() } // Alphabetical sorting within installed and uninstalled groups - ).mapIndexed { idx, entry -> entry.item.copy(index = idx) } + }.thenBy { it.item.name.lowercase() } + ).also { sortedList -> + if (sortedList.isNotEmpty()) { + val installedCount = sortedList.count { it.isInstalled } + val first10 = sortedList.take(10) + } + }.mapIndexed { idx, entry -> entry.item.copy(index = idx) } // Total count for the current filter val totalFound = combined.size diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index 6453a897c..8307aa1fa 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -8,6 +8,7 @@ import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.data.GameProcessInfo import app.gamenative.data.LibraryItem +import app.gamenative.data.GameSource import app.gamenative.di.IAppTheme import app.gamenative.enums.AppTheme import app.gamenative.enums.LoginResult @@ -272,6 +273,7 @@ class MainViewModel @Inject constructor( fun exitSteamApp(context: Context, appId: String) { viewModelScope.launch { + Timber.tag("Exit").i("Exiting, getting feedback for appId: $appId") bootingSplashTimeoutJob?.cancel() bootingSplashTimeoutJob = null setShowBootingSplash(false) @@ -279,11 +281,38 @@ class MainViewModel @Inject constructor( val hadTemporaryOverride = IntentLaunchManager.hasTemporaryOverride(appId) val gameId = ContainerUtils.extractGameIdFromContainerId(appId) - + Timber.tag("Exit").i("Got game id: $gameId") SteamService.notifyRunningProcesses() - SteamService.closeApp(gameId, isOffline.value) { prefix -> - PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) - }.await() + + // Check if this is a GOG game and sync cloud saves + val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) + if (gameSource == GameSource.GOG) { + Timber.tag("GOG").i("[Cloud Saves] GOG Game detected for $appId — syncing cloud saves after close") + // Sync cloud saves (upload local changes to cloud) + // Run in background, don't block UI + viewModelScope.launch(Dispatchers.IO) { + try { + Timber.tag("GOG").d("[Cloud Saves] Starting post-game upload sync for $appId") + val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves( + context = context, + appId = appId, + preferredAction = "upload" + ) + if (syncSuccess) { + Timber.tag("GOG").i("[Cloud Saves] Upload sync completed successfully for $appId") + } else { + Timber.tag("GOG").w("[Cloud Saves] Upload sync failed for $appId") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Exception during upload sync for $appId") + } + } + } else { + // For Steam games, sync cloud saves + SteamService.closeApp(gameId, isOffline.value) { prefix -> + PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) + }.await() + } // Prompt user to save temporary container configuration if one was applied if (hadTemporaryOverride) { @@ -293,22 +322,29 @@ class MainViewModel @Inject constructor( // After app closes, check if we need to show the feedback dialog try { - val container = ContainerUtils.getContainer(context, appId) - val shown = container.getExtra("discord_support_prompt_shown", "false") == "true" - val configChanged = container.getExtra("config_changed", "false") == "true" - if (!shown) { - container.putExtra("discord_support_prompt_shown", "true") - container.saveData() - _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) - } + // Do not show the Feedback form for non-steam games until we can support. + val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) + if(gameSource == GameSource.STEAM) { + val container = ContainerUtils.getContainer(context, appId) + + val shown = container.getExtra("discord_support_prompt_shown", "false") == "true" + val configChanged = container.getExtra("config_changed", "false") == "true" + if (!shown) { + container.putExtra("discord_support_prompt_shown", "true") + container.saveData() + _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) + } - // Only show feedback if container config was changed before this game run - if (configChanged) { - // Clear the flag - container.putExtra("config_changed", "false") - container.saveData() - // Show the feedback dialog - _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) + // Only show feedback if container config was changed before this game run + if (configChanged) { + // Clear the flag + container.putExtra("config_changed", "false") + container.saveData() + // Show the feedback dialog + _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) + } + } else { + Timber.d("Non-Steam Game Detected, not showing feedback") } } catch (_: Exception) { // ignore container errors diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index cc50c4cb0..1dc08fa0b 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -99,6 +99,7 @@ import com.winlator.xenvironment.ImageFsInstaller import com.winlator.fexcore.FEXCoreManager import app.gamenative.ui.screen.library.appscreen.SteamAppScreen import app.gamenative.ui.screen.library.appscreen.CustomGameAppScreen +import app.gamenative.ui.screen.library.appscreen.GOGAppScreen import app.gamenative.ui.data.GameDisplayInfo import java.text.SimpleDateFormat import java.util.Date @@ -187,6 +188,7 @@ fun AppScreen( when (libraryItem.gameSource) { app.gamenative.data.GameSource.STEAM -> SteamAppScreen() app.gamenative.data.GameSource.CUSTOM_GAME -> CustomGameAppScreen() + app.gamenative.data.GameSource.GOG -> GOGAppScreen() } } @@ -225,6 +227,7 @@ internal fun AppScreenContent( downloadProgress: Float, hasPartialDownload: Boolean, isUpdatePending: Boolean, + downloadInfo: app.gamenative.data.DownloadInfo? = null, onDownloadInstallClick: () -> Unit, onPauseResumeClick: () -> Unit, onDeleteDownloadClick: () -> Unit, @@ -510,7 +513,7 @@ internal fun AppScreenContent( // Download progress section if (isDownloading) { - val downloadInfo = SteamService.getAppDownloadInfo(displayInfo.gameId) + // downloadInfo passed from BaseAppScreen based on game source val statusMessageFlow = downloadInfo?.getStatusMessageFlow() val statusMessageState = statusMessageFlow?.collectAsState(initial = statusMessageFlow.value) val statusMessage = statusMessageState?.value @@ -920,6 +923,7 @@ private fun Preview_AppScreen() { downloadProgress = .50f, hasPartialDownload = false, isUpdatePending = false, + downloadInfo = null, onDownloadInstallClick = { isDownloading = !isDownloading }, onPauseResumeClick = { }, onDeleteDownloadClick = { }, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 27fd34f40..6691560f6 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -46,6 +47,22 @@ import kotlinx.coroutines.withContext * This defines the contract that all game source-specific screens must implement. */ abstract class BaseAppScreen { + // Shared state for install dialog - map of appId (String) to MessageDialogState + companion object { + private val installDialogStates = mutableStateMapOf() + + fun showInstallDialog(appId: String, state: app.gamenative.ui.component.dialog.state.MessageDialogState) { + installDialogStates[appId] = state + } + + fun hideInstallDialog(appId: String) { + installDialogStates.remove(appId) + } + + fun getInstallDialogState(appId: String): app.gamenative.ui.component.dialog.state.MessageDialogState? { + return installDialogStates[appId] + } + } /** * Get the game display information for rendering the UI. * This is called to get all the data needed for the common UI layout. @@ -606,6 +623,13 @@ abstract class BaseAppScreen { val optionsMenu = getOptionsMenu(context, libraryItem, onEditContainer, onBack, onClickPlay, onTestGraphics, exportFrontendLauncher) + // Get download info based on game source for progress tracking + val downloadInfo = when (libraryItem.gameSource) { + app.gamenative.data.GameSource.STEAM -> app.gamenative.service.SteamService.getAppDownloadInfo(displayInfo.gameId) + app.gamenative.data.GameSource.GOG -> app.gamenative.service.gog.GOGService.getDownloadInfo(displayInfo.gameId.toString()) + app.gamenative.data.GameSource.CUSTOM_GAME -> null // Custom games don't support downloads yet + } + DisposableEffect(libraryItem.appId) { val dispose = observeGameState( context = context, @@ -634,6 +658,7 @@ abstract class BaseAppScreen { downloadProgress = downloadProgressState, hasPartialDownload = hasPartialDownloadState, isUpdatePending = isUpdatePendingState, + downloadInfo = downloadInfo, onDownloadInstallClick = { onDownloadInstallClick(context, libraryItem, onClickPlay) uiScope.launch { @@ -648,7 +673,9 @@ abstract class BaseAppScreen { performStateRefresh(false) } }, - onDeleteDownloadClick = { onDeleteDownloadClick(context, libraryItem) }, + onDeleteDownloadClick = { + onDeleteDownloadClick(context, libraryItem) + }, onUpdateClick = { onUpdateClick(context, libraryItem) uiScope.launch { diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt new file mode 100644 index 000000000..040bede0a --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt @@ -0,0 +1,631 @@ +package app.gamenative.ui.screen.library.appscreen + +import android.content.Context +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.gamenative.R +import app.gamenative.data.GOGGame +import app.gamenative.data.LibraryItem +import app.gamenative.service.gog.GOGConstants +import app.gamenative.service.gog.GOGService +import app.gamenative.ui.data.AppMenuOption +import app.gamenative.ui.data.GameDisplayInfo +import app.gamenative.ui.enums.AppOptionMenuType +import com.winlator.container.ContainerData +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +/** + * GOG-specific implementation of BaseAppScreen + * Handles GOG games with integration to the Python gogdl backend + */ +class GOGAppScreen : BaseAppScreen() { + companion object { + private const val TAG = "GOGAppScreen" + + // Shared state for uninstall dialog - list of appIds that should show the dialog + private val uninstallDialogAppIds = mutableStateListOf() + + fun showUninstallDialog(appId: String) { + Timber.tag(TAG).d("showUninstallDialog: appId=$appId") + if (!uninstallDialogAppIds.contains(appId)) { + uninstallDialogAppIds.add(appId) + Timber.tag(TAG).d("Added to uninstall dialog list: $appId") + } + } + + fun hideUninstallDialog(appId: String) { + Timber.tag(TAG).d("hideUninstallDialog: appId=$appId") + uninstallDialogAppIds.remove(appId) + } + + fun shouldShowUninstallDialog(appId: String): Boolean { + val result = uninstallDialogAppIds.contains(appId) + Timber.tag(TAG).d("shouldShowUninstallDialog: appId=$appId, result=$result") + return result + } + + /** + * Formats bytes into a human-readable string (KB, MB, GB). + * Uses binary units (1024 base). + */ + private fun formatBytes(bytes: Long): String { + val kb = 1024.0 + val mb = kb * 1024 + val gb = mb * 1024 + return when { + bytes >= gb -> String.format(Locale.US, "%.1f GB", bytes / gb) + bytes >= mb -> String.format(Locale.US, "%.1f MB", bytes / mb) + bytes >= kb -> String.format(Locale.US, "%.1f KB", bytes / kb) + else -> "$bytes B" + } + } + } + + @Composable + override fun getGameDisplayInfo( + context: Context, + libraryItem: LibraryItem, + ): GameDisplayInfo { + Timber.tag(TAG).d("getGameDisplayInfo: appId=${libraryItem.appId}, name=${libraryItem.name}") + // Extract numeric gameId for GOGService calls + val gameId = libraryItem.gameId.toString() + + // Add a refresh trigger to re-fetch game data when install status changes + var refreshTrigger by remember { mutableStateOf(0) } + + // Listen for install status changes to refresh game data + LaunchedEffect(gameId) { + val installListener: (app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged) -> Unit = { event -> + if (event.appId == libraryItem.gameId) { + Timber.tag(TAG).d("Install status changed, refreshing game data for $gameId") + refreshTrigger++ + } + } + app.gamenative.PluviaApp.events.on(installListener) + } + + var gogGame by remember(gameId, refreshTrigger) { mutableStateOf(null) } + + LaunchedEffect(gameId, refreshTrigger) { + gogGame = GOGService.getGOGGameOf(gameId) + } + + val game = gogGame + + // Format sizes for display + val sizeOnDisk = if (game != null && game.isInstalled && game.installSize > 0) { + formatBytes(game.installSize) + } else { + null + } + + val sizeFromStore = if (game != null && game.downloadSize > 0) { + formatBytes(game.downloadSize) + } else { + null + } + + // Parse GOG's ISO 8601 release date string to Unix timestamp + // GOG returns dates like "2022-08-18T17:50:00+0300" (without colon in timezone) + // GameDisplayInfo expects Unix timestamp in SECONDS, not milliseconds + val releaseDateTimestamp = if (game?.releaseDate?.isNotEmpty() == true) { + try { + val formatter = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ") + val timestampMillis = java.time.ZonedDateTime.parse(game.releaseDate, formatter).toInstant().toEpochMilli() + val timestampSeconds = timestampMillis / 1000 + Timber.tag(TAG).d("Parsed release date '${game.releaseDate}' -> $timestampSeconds seconds (${java.util.Date(timestampMillis)})") + timestampSeconds + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to parse release date: ${game.releaseDate}") + 0L + } + } else { + 0L + } + + val displayInfo = GameDisplayInfo( + name = game?.title ?: libraryItem.name, + iconUrl = game?.iconUrl ?: libraryItem.iconHash, + heroImageUrl = game?.imageUrl ?: game?.iconUrl ?: libraryItem.iconHash, + gameId = libraryItem.gameId, // Use gameId property which handles conversion + appId = libraryItem.appId, + releaseDate = releaseDateTimestamp, + developer = game?.developer?.takeIf { it.isNotEmpty() } ?: "", // GOG API doesn't provide this + installLocation = game?.installPath?.takeIf { it.isNotEmpty() }, + sizeOnDisk = sizeOnDisk, + sizeFromStore = sizeFromStore, + ) + Timber.tag(TAG).d("Returning GameDisplayInfo: name=${displayInfo.name}, iconUrl=${displayInfo.iconUrl}, heroImageUrl=${displayInfo.heroImageUrl}, developer=${displayInfo.developer}, installLocation=${displayInfo.installLocation}") + return displayInfo + } + + override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean { + Timber.tag(TAG).d("isInstalled: checking appId=${libraryItem.appId}") + return try { + // GOGService expects numeric gameId + val installed = GOGService.isGameInstalled(libraryItem.gameId.toString()) + Timber.tag(TAG).d("isInstalled: appId=${libraryItem.appId}, result=$installed") + installed + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to check install status for ${libraryItem.appId}") + false + } + } + + override fun isValidToDownload(context: Context, libraryItem: LibraryItem): Boolean { + Timber.tag(TAG).d("isValidToDownload: checking appId=${libraryItem.appId}") + // GOG games can be downloaded if not already installed or downloading + val installed = isInstalled(context, libraryItem) + val downloading = isDownloading(context, libraryItem) + val valid = !installed && !downloading + Timber.tag(TAG).d("isValidToDownload: appId=${libraryItem.appId}, installed=$installed, downloading=$downloading, valid=$valid") + return valid + } + + override fun isDownloading(context: Context, libraryItem: LibraryItem): Boolean { + Timber.tag(TAG).d("isDownloading: checking appId=${libraryItem.appId}") + // Check if there's an active download for this GOG game + // GOGService expects numeric gameId + val downloadInfo = GOGService.getDownloadInfo(libraryItem.gameId.toString()) + val progress = downloadInfo?.getProgress() ?: 0f + val isActive = downloadInfo?.isActive() ?: false + val downloading = downloadInfo != null && isActive && progress < 1f + Timber.tag(TAG).d("isDownloading: appId=${libraryItem.appId}, hasDownloadInfo=${downloadInfo != null}, active=$isActive, progress=$progress, result=$downloading") + return downloading + } + + override fun getDownloadProgress(context: Context, libraryItem: LibraryItem): Float { + // GOGService expects numeric gameId + val downloadInfo = GOGService.getDownloadInfo(libraryItem.gameId.toString()) + val progress = downloadInfo?.getProgress() ?: 0f + Timber.tag(TAG).d("getDownloadProgress: appId=${libraryItem.appId}, progress=$progress") + return progress + } + + override fun hasPartialDownload(context: Context, libraryItem: LibraryItem): Boolean { + // GOG downloads cannot be paused/resumed, so never show as having partial download + // This prevents the UI from showing a resume button + return false + } + + override fun onDownloadInstallClick(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { + Timber.tag(TAG).i("onDownloadInstallClick: appId=${libraryItem.appId}, name=${libraryItem.name}") + // GOGService expects numeric gameId + val gameId = libraryItem.gameId.toString() + val downloadInfo = GOGService.getDownloadInfo(gameId) + val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + val installed = isInstalled(context, libraryItem) + + Timber.tag(TAG).d("onDownloadInstallClick: appId=${libraryItem.appId}, isDownloading=$isDownloading, installed=$installed") + + if (isDownloading) { + // Cancel ongoing download + Timber.tag(TAG).i("Cancelling GOG download for: ${libraryItem.appId}") + downloadInfo.cancel() + GOGService.cleanupDownload(gameId) + } else if (installed) { + // Already installed: launch game + Timber.tag(TAG).i("GOG game already installed, launching: ${libraryItem.appId}") + onClickPlay(false) + } else { + // Show install confirmation dialog + Timber.tag(TAG).i("Showing install confirmation dialog for: ${libraryItem.appId}") + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + try { + val game = GOGService.getGOGGameOf(gameId) + + // Calculate sizes + val downloadSize = app.gamenative.utils.StorageUtils.formatBinarySize(game?.downloadSize ?: 0L) + val availableSpace = app.gamenative.utils.StorageUtils.formatBinarySize( + app.gamenative.utils.StorageUtils.getAvailableSpace(app.gamenative.service.gog.GOGConstants.defaultGOGGamesPath) + ) + + val message = context.getString( + R.string.gog_install_confirmation_message, + downloadSize, + availableSpace + ) + val state = app.gamenative.ui.component.dialog.state.MessageDialogState( + visible = true, + type = app.gamenative.ui.enums.DialogType.INSTALL_APP, + title = context.getString(R.string.gog_install_game_title), + message = message, + confirmBtnText = context.getString(R.string.download), + dismissBtnText = context.getString(R.string.cancel) + ) + BaseAppScreen.showInstallDialog(libraryItem.appId, state) + } catch (e: Exception) { + Timber.e(e, "Failed to show install dialog for: ${libraryItem.appId}") + } + } + } + } + + private fun performDownload(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { + val gameId = libraryItem.gameId.toString() + Timber.i("Starting GOG game download: ${libraryItem.appId}") + CoroutineScope(Dispatchers.IO).launch { + try { + // Get install path + val installPath = GOGConstants.getGameInstallPath(libraryItem.name) + Timber.d("Downloading GOG game to: $installPath") + + // Show starting download toast + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Starting download for ${libraryItem.name}...", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + + // Start download - GOGService will handle monitoring, database updates, verification, and events + val result = GOGService.downloadGame(context, gameId, installPath) + + if (result.isSuccess) { + Timber.i("GOG download started successfully for: $gameId") + // Success toast will be shown when download completes (monitored by GOGService) + } else { + val error = result.exceptionOrNull() + Timber.e(error, "Failed to start GOG download") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Failed to start download: ${error?.message}", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + } catch (e: Exception) { + Timber.e(e, "Error during GOG download") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Download error: ${e.message}", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + } + } + + override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) { + Timber.tag(TAG).i("onPauseResumeClick: appId=${libraryItem.appId}") + // GOG downloads cannot be paused - only canceled + // This method should not be called for GOG since hasPartialDownload returns false, + // but if it is called, just cancel the download + val gameId = libraryItem.gameId.toString() + val downloadInfo = GOGService.getDownloadInfo(gameId) + + if (downloadInfo != null) { + Timber.tag(TAG).i("Cancelling GOG download: ${libraryItem.appId}") + downloadInfo.cancel() + GOGService.cleanupDownload(gameId) + } + } + + override fun onDeleteDownloadClick(context: Context, libraryItem: LibraryItem) { + Timber.tag(TAG).i("onDeleteDownloadClick: appId=${libraryItem.appId}") + // GOGService expects numeric gameId + val gameId = libraryItem.gameId.toString() + val downloadInfo = GOGService.getDownloadInfo(gameId) + val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + val isInstalled = isInstalled(context, libraryItem) + Timber.tag(TAG).d("onDeleteDownloadClick: appId=${libraryItem.appId}, isDownloading=$isDownloading, isInstalled=$isInstalled") + + if (isDownloading) { + // Cancel download immediately if currently downloading + Timber.tag(TAG).i("Cancelling active download for GOG game: ${libraryItem.appId}") + downloadInfo.cancel() + GOGService.cleanupDownload(gameId) + android.widget.Toast.makeText( + context, + "Download cancelled", + android.widget.Toast.LENGTH_SHORT, + ).show() + } else if (isInstalled) { + // Show uninstall confirmation dialog + Timber.tag(TAG).i("Showing uninstall dialog for: ${libraryItem.appId}") + showUninstallDialog(libraryItem.appId) + } + } + + private fun performUninstall(context: Context, libraryItem: LibraryItem) { + Timber.i("Uninstalling GOG game: ${libraryItem.appId}") + CoroutineScope(Dispatchers.IO).launch { + try { + // Delegate to GOGService which calls GOGManager.deleteGame + val result = GOGService.deleteGame(context, libraryItem) + + if (result.isSuccess) { + Timber.i("Successfully uninstalled GOG game: ${libraryItem.appId}") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Game uninstalled successfully", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + } else { + val error = result.exceptionOrNull() + Timber.e(error, "Failed to uninstall GOG game: ${libraryItem.appId}") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Failed to uninstall game: ${error?.message}", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + } catch (e: Exception) { + Timber.e(e, "Error uninstalling GOG game") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Failed to uninstall game: ${e.message}", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + } + } + + override fun onUpdateClick(context: Context, libraryItem: LibraryItem) { + Timber.tag(TAG).i("onUpdateClick: appId=${libraryItem.appId}") + // TODO: Implement update for GOG games + // Check GOG for newer version and download if available + Timber.tag(TAG).d("Update clicked for GOG game: ${libraryItem.appId}") + } + + override fun getExportFileExtension(): String { + Timber.tag(TAG).d("getExportFileExtension: returning 'tzst'") + // GOG containers use the same export format as other Wine containers + return "tzst" + } + + override fun getInstallPath(context: Context, libraryItem: LibraryItem): String? { + Timber.tag(TAG).d("getInstallPath: appId=${libraryItem.appId}") + return try { + // GOGService expects numeric gameId + val path = GOGService.getInstallPath(libraryItem.gameId.toString()) + Timber.tag(TAG).d("getInstallPath: appId=${libraryItem.appId}, path=$path") + path + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to get install path for ${libraryItem.appId}") + null + } + } + + override fun loadContainerData(context: Context, libraryItem: LibraryItem): ContainerData { + Timber.tag(TAG).d("loadContainerData: appId=${libraryItem.appId}") + // Load GOG-specific container data using ContainerUtils + val container = app.gamenative.utils.ContainerUtils.getOrCreateContainer(context, libraryItem.appId) + val containerData = app.gamenative.utils.ContainerUtils.toContainerData(container) + Timber.tag(TAG).d("loadContainerData: loaded container for ${libraryItem.appId}") + return containerData + } + + override fun saveContainerConfig(context: Context, libraryItem: LibraryItem, config: ContainerData) { + Timber.tag(TAG).i("saveContainerConfig: appId=${libraryItem.appId}") + // Save GOG-specific container configuration using ContainerUtils + app.gamenative.utils.ContainerUtils.applyToContainer(context, libraryItem.appId, config) + Timber.tag(TAG).d("saveContainerConfig: saved container config for ${libraryItem.appId}") + } + + override fun supportsContainerConfig(): Boolean { + Timber.tag(TAG).d("supportsContainerConfig: returning true") + // GOG games support container configuration like other Wine games + return true + } + + /** + * GOG games support standard container reset + */ + @Composable + override fun getResetContainerOption( + context: Context, + libraryItem: LibraryItem, + ): AppMenuOption { + return AppMenuOption( + optionType = AppOptionMenuType.ResetToDefaults, + onClick = { + resetContainerToDefaults(context, libraryItem) + }, + ) + } + override fun getGameFolderPathForImageFetch(context: Context, libraryItem: LibraryItem): String? { + return null // GOG Stores full URLs in their database entry. + } + + override fun observeGameState( + context: Context, + libraryItem: LibraryItem, + onStateChanged: () -> Unit, + onProgressChanged: (Float) -> Unit, + onHasPartialDownloadChanged: ((Boolean) -> Unit)?, + ): (() -> Unit)? { + Timber.tag(TAG).d("[OBSERVE] Setting up observeGameState for appId=${libraryItem.appId}, gameId=${libraryItem.gameId}") + val disposables = mutableListOf<() -> Unit>() + var currentProgressListener: ((Float) -> Unit)? = null + + // Listen for download status changes + val downloadStatusListener: (app.gamenative.events.AndroidEvent.DownloadStatusChanged) -> Unit = { event -> + Timber.tag(TAG).d("[OBSERVE] DownloadStatusChanged event received: event.appId=${event.appId}, libraryItem.gameId=${libraryItem.gameId}, match=${event.appId == libraryItem.gameId}") + if (event.appId == libraryItem.gameId) { + Timber.tag(TAG).d("[OBSERVE] Download status changed for ${libraryItem.appId}, isDownloading=${event.isDownloading}") + if (event.isDownloading) { + // Download started - attach progress listener + // GOGService expects numeric gameId + val downloadInfo = GOGService.getDownloadInfo(libraryItem.gameId.toString()) + if (downloadInfo != null) { + // Remove previous listener if exists + currentProgressListener?.let { listener -> + downloadInfo.removeProgressListener(listener) + } + // Add new listener and track it + val progressListener: (Float) -> Unit = { progress -> + onProgressChanged(progress) + } + downloadInfo.addProgressListener(progressListener) + currentProgressListener = progressListener + + // Add cleanup for this listener + disposables += { + currentProgressListener?.let { listener -> + downloadInfo.removeProgressListener(listener) + currentProgressListener = null + } + } + } + } else { + // Download stopped/completed - clean up listener + currentProgressListener?.let { listener -> + val downloadInfo = GOGService.getDownloadInfo(libraryItem.gameId.toString()) + downloadInfo?.removeProgressListener(listener) + currentProgressListener = null + } + onHasPartialDownloadChanged?.invoke(false) + } + onStateChanged() + } + } + app.gamenative.PluviaApp.events.on(downloadStatusListener) + disposables += + { app.gamenative.PluviaApp.events.off(downloadStatusListener) } + + // Listen for install status changes + val installListener: (app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged) -> Unit = { event -> + Timber.tag(TAG).d("[OBSERVE] LibraryInstallStatusChanged event received: event.appId=${event.appId}, libraryItem.gameId=${libraryItem.gameId}, match=${event.appId == libraryItem.gameId}") + if (event.appId == libraryItem.gameId) { + Timber.tag(TAG).d("[OBSERVE] Install status changed for ${libraryItem.appId}, calling onStateChanged()") + onStateChanged() + } + } + app.gamenative.PluviaApp.events.on(installListener) + disposables += + { app.gamenative.PluviaApp.events.off(installListener) } + + // Return cleanup function + return { + disposables.forEach { it() } + } + } + + /** + * GOG-specific dialogs (install confirmation, uninstall confirmation) + */ + @Composable + override fun AdditionalDialogs( + libraryItem: LibraryItem, + onDismiss: () -> Unit, + onEditContainer: () -> Unit, + onBack: () -> Unit, + ) { + Timber.tag(TAG).d("AdditionalDialogs: composing for appId=${libraryItem.appId}") + val context = LocalContext.current + + + // Monitor uninstall dialog state + var showUninstallDialog by remember { mutableStateOf(shouldShowUninstallDialog(libraryItem.appId)) } + LaunchedEffect(libraryItem.appId) { + snapshotFlow { shouldShowUninstallDialog(libraryItem.appId) } + .collect { shouldShow -> + showUninstallDialog = shouldShow + } + } + + // Shared install dialog state (from BaseAppScreen) + val appId = libraryItem.appId + var installDialogState by remember(appId) { + mutableStateOf(BaseAppScreen.getInstallDialogState(appId) ?: app.gamenative.ui.component.dialog.state.MessageDialogState(false)) + } + LaunchedEffect(appId) { + snapshotFlow { BaseAppScreen.getInstallDialogState(appId) } + .collect { state -> + installDialogState = state ?: app.gamenative.ui.component.dialog.state.MessageDialogState(false) + } + } + + // Show install dialog if visible + if (installDialogState.visible) { + val onDismissRequest: (() -> Unit)? = { + BaseAppScreen.hideInstallDialog(appId) + } + val onDismissClick: (() -> Unit)? = { + BaseAppScreen.hideInstallDialog(appId) + } + val onConfirmClick: (() -> Unit)? = when (installDialogState.type) { + app.gamenative.ui.enums.DialogType.INSTALL_APP -> { + { + BaseAppScreen.hideInstallDialog(appId) + performDownload(context, libraryItem) {} + } + } + else -> null + } + app.gamenative.ui.component.dialog.MessageDialog( + visible = installDialogState.visible, + onDismissRequest = onDismissRequest, + onConfirmClick = onConfirmClick, + onDismissClick = onDismissClick, + confirmBtnText = installDialogState.confirmBtnText, + dismissBtnText = installDialogState.dismissBtnText, + title = installDialogState.title, + message = installDialogState.message, + ) + } + + // Show uninstall confirmation dialog + if (showUninstallDialog) { + AlertDialog( + onDismissRequest = { + hideUninstallDialog(libraryItem.appId) + }, + title = { Text(stringResource(R.string.gog_uninstall_game_title)) }, + text = { + Text( + text = stringResource( + R.string.gog_uninstall_confirmation_message, + libraryItem.name, + ), + ) + }, + confirmButton = { + TextButton( + onClick = { + hideUninstallDialog(libraryItem.appId) + performUninstall(context, libraryItem) + }, + ) { + Text(stringResource(R.string.uninstall)) + } + }, + dismissButton = { + TextButton( + onClick = { + hideUninstallDialog(libraryItem.appId) + }, + ) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt index b9f1410b7..8bffafc8d 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt @@ -64,6 +64,7 @@ import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.LibraryItem import app.gamenative.service.DownloadService import app.gamenative.service.SteamService +import app.gamenative.service.gog.GOGService import app.gamenative.ui.enums.PaneType import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.theme.PluviaTheme @@ -199,44 +200,51 @@ internal fun AppItem( } val imageUrl = remember(appInfo.appId, paneType, imageRefreshCounter) { - if (appInfo.gameSource == GameSource.CUSTOM_GAME) { - // For Custom Games, use SteamGridDB images - when (paneType) { - PaneType.GRID_CAPSULE -> { - // Vertical grid for capsule - findSteamGridDBImage("grid_capsule") - ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" - } - PaneType.GRID_HERO -> { - // Horizontal grid for hero view - findSteamGridDBImage("grid_hero") - ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" - } - else -> { - // For list view, use heroes endpoint (not grid_hero) - val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appInfo.appId) - val heroUrl = gameFolderPath?.let { path -> - val folder = java.io.File(path) - val heroFile = folder.listFiles()?.firstOrNull { file -> - file.name.startsWith("steamgriddb_hero") && - !file.name.contains("grid") && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) + val url = when (appInfo.gameSource) { + GameSource.CUSTOM_GAME -> { + // For Custom Games, use SteamGridDB images + when (paneType) { + PaneType.GRID_CAPSULE -> { + // Vertical grid for capsule + findSteamGridDBImage("grid_capsule") + ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" + } + PaneType.GRID_HERO -> { + // Horizontal grid for hero view + findSteamGridDBImage("grid_hero") + ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" + } + else -> { + // For list view, use heroes endpoint (not grid_hero) + val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appInfo.appId) + val heroUrl = gameFolderPath?.let { path -> + val folder = java.io.File(path) + val heroFile = folder.listFiles()?.firstOrNull { file -> + file.name.startsWith("steamgriddb_hero") && + !file.name.contains("grid") && + (file.name.endsWith(".png", ignoreCase = true) || + file.name.endsWith(".jpg", ignoreCase = true) || + file.name.endsWith(".webp", ignoreCase = true)) + } + heroFile?.let { android.net.Uri.fromFile(it).toString() } } - heroFile?.let { android.net.Uri.fromFile(it).toString() } + heroUrl ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" } - heroUrl ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" } } - } else { - // For Steam games, use standard Steam URLs - if (paneType == PaneType.GRID_CAPSULE) { - "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" - } else { - "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" + GameSource.GOG -> { + appInfo.iconHash + } + GameSource.STEAM -> { + // For Steam games, use standard Steam URLs + if (paneType == PaneType.GRID_CAPSULE) { + "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" + } else { + "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" + } } } + url } // Reset alpha and hideText when image URL changes (e.g., when new images are fetched) @@ -299,18 +307,26 @@ internal fun AppItem( ) } else { var isInstalled by remember(appInfo.appId, appInfo.gameSource) { - when (appInfo.gameSource) { - GameSource.STEAM -> mutableStateOf(SteamService.isAppInstalled(appInfo.gameId)) - GameSource.CUSTOM_GAME -> mutableStateOf(true) // Custom Games are always considered installed - else -> mutableStateOf(false) + mutableStateOf(false) + } + + // Initialize installation status + LaunchedEffect(appInfo.appId, appInfo.gameSource) { + isInstalled = when (appInfo.gameSource) { + GameSource.STEAM -> SteamService.isAppInstalled(appInfo.gameId) + GameSource.GOG -> GOGService.isGameInstalled(appInfo.gameId.toString()) + GameSource.CUSTOM_GAME -> true + else -> false } } + // Update installation status when refresh completes LaunchedEffect(isRefreshing) { if (!isRefreshing) { // Refresh just completed, check installation status isInstalled = when (appInfo.gameSource) { GameSource.STEAM -> SteamService.isAppInstalled(appInfo.gameId) + GameSource.GOG -> GOGService.isGameInstalled(appInfo.gameId.toString()) GameSource.CUSTOM_GAME -> true else -> false } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt index 0003ccb89..f1b636d8c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.ui.res.painterResource import app.gamenative.ui.icons.CustomGame import app.gamenative.ui.icons.Steam import androidx.compose.runtime.Composable @@ -24,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.size import app.gamenative.R import app.gamenative.ui.component.FlowFilterChip import app.gamenative.ui.enums.AppFilter @@ -41,6 +43,7 @@ fun LibraryBottomSheet( onViewChanged: (PaneType) -> Unit, showSteam: Boolean, showCustomGames: Boolean, + showGOG: Boolean, onSourceToggle: (app.gamenative.data.GameSource) -> Unit, ) { Column( @@ -102,6 +105,18 @@ fun LibraryBottomSheet( selected = showCustomGames, leadingIcon = { Icon(imageVector = Icons.Filled.CustomGame, contentDescription = null) }, ) + FlowFilterChip( + onClick = { onSourceToggle(GameSource.GOG) }, + label = { Text(text = "GOG") }, + selected = showGOG, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.ic_gog), + contentDescription = "GOG", + modifier = Modifier.size(24.dp) + ) + }, + ) } Spacer(modifier = Modifier.height(16.dp)) @@ -152,6 +167,7 @@ private fun Preview_LibraryBottomSheet() { onViewChanged = { }, showSteam = true, showCustomGames = true, + showGOG = true, onSourceToggle = { }, ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt index d9531e1bd..f58f89a7d 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt @@ -103,7 +103,14 @@ private fun calculateInstalledCount(state: LibraryState): Int { 0 } - return steamCount + customGameCount + // Count GOG games that are installed (from PrefManager) + val gogCount = if (state.showGOGInLibrary) { + PrefManager.gogInstalledGamesCount + } else { + 0 + } + + return steamCount + customGameCount + gogCount } @OptIn(ExperimentalMaterial3Api::class) @@ -133,7 +140,8 @@ internal fun LibraryListPane( state.appInfoSortType, state.showSteamInLibrary, state.showCustomGamesInLibrary, - state.totalAppsInFilter + state.showGOGInLibrary, + state.totalAppsInFilter, ) { calculateInstalledCount(state) } @@ -318,11 +326,12 @@ internal fun LibraryListPane( } } - val totalSkeletonCount = remember(state.showSteamInLibrary, state.showCustomGamesInLibrary) { + val totalSkeletonCount = remember(state.showSteamInLibrary, state.showCustomGamesInLibrary, state.showGOGInLibrary) { val customCount = if (state.showCustomGamesInLibrary) PrefManager.customGamesCount else 0 val steamCount = if (state.showSteamInLibrary) PrefManager.steamGamesCount else 0 - val total = customCount + steamCount - Timber.tag("LibraryListPane").d("Skeleton calculation - Custom: $customCount, Steam: $steamCount, Total: $total") + val gogInstalledCount = if (state.showGOGInLibrary) PrefManager.gogInstalledGamesCount else 0 + val total = customCount + steamCount + gogInstalledCount + Timber.tag("LibraryListPane").d("Skeleton calculation - Custom: $customCount, Steam: $steamCount, GOG installed: $gogInstalledCount, Total: $total") // Show at least a few skeletons, but not more than a reasonable amount if (total == 0) 6 else minOf(total, 20) } @@ -437,6 +446,7 @@ internal fun LibraryListPane( }, showSteam = state.showSteamInLibrary, showCustomGames = state.showCustomGamesInLibrary, + showGOG = state.showGOGInLibrary, onSourceToggle = onSourceToggle, ) }, 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 0bfe64687..bf2ca07b0 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 @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Login import androidx.compose.material.icons.filled.Map import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -57,10 +58,71 @@ import com.winlator.core.AppUtils import app.gamenative.ui.component.dialog.MessageDialog import app.gamenative.ui.component.dialog.LoadingDialog import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch import app.gamenative.utils.LocaleHelper +import app.gamenative.ui.component.dialog.GOGLoginDialog +import app.gamenative.service.gog.GOGService +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import timber.log.Timber +import app.gamenative.PluviaApp +import app.gamenative.events.AndroidEvent + +/** + * Shared GOG authentication handler that manages the complete auth flow. + * + * @param context Android context for service operations + * @param authCode The OAuth authorization code + * @param coroutineScope Coroutine scope for async operations + * @param onLoadingChange Callback when loading state changes + * @param onError Callback when an error occurs (receives error message) + * @param onSuccess Callback when authentication succeeds (receives game count) + * @param onDialogClose Callback to close the login dialog + */ +private suspend fun handleGogAuthentication( + context: Context, + authCode: String, + coroutineScope: CoroutineScope, + onLoadingChange: (Boolean) -> Unit, + onError: (String?) -> Unit, + onSuccess: (Int) -> Unit, + onDialogClose: () -> Unit +) { + onLoadingChange(true) + onError(null) + + try { + Timber.d("[SettingsGOG]: Starting authentication...") + val result = GOGService.authenticateWithCode(context, authCode) + + if (result.isSuccess) { + Timber.i("[SettingsGOG]: ✓ Authentication successful!") + + // Start GOGService which will automatically trigger background library sync + Timber.i("[SettingsGOG]: Starting GOGService (will sync library in background)") + GOGService.start(context) + + // Authentication succeeded - service will handle library sync in background + onSuccess(0) + onLoadingChange(false) + onDialogClose() + } else { + val error = result.exceptionOrNull()?.message ?: "Authentication failed" + Timber.e("[SettingsGOG]: Authentication failed: $error") + onLoadingChange(false) + onError(error) + } + } catch (e: Exception) { + Timber.e(e, "[SettingsGOG]: Authentication exception: ${e.message}") + onLoadingChange(false) + onError(e.message ?: "Authentication failed") + } +} @Composable fun SettingsGroupInterface( @@ -114,6 +176,51 @@ fun SettingsGroupInterface( steamRegionsList.indexOfFirst { it.first == PrefManager.cellId }.takeIf { it >= 0 } ?: 0 ) } + // GOG login dialog state + var openGOGLoginDialog by rememberSaveable { mutableStateOf(false) } + var gogLoginLoading by rememberSaveable { mutableStateOf(false) } + var gogLoginError by rememberSaveable { mutableStateOf(null) } + var gogLoginSuccess by rememberSaveable { mutableStateOf(false) } + + // GOG library sync state + var gogLibrarySyncing by rememberSaveable { mutableStateOf(false) } + var gogLibrarySyncError by rememberSaveable { mutableStateOf(null) } + var gogLibrarySyncSuccess by rememberSaveable { mutableStateOf(false) } + var gogLibraryGameCount by rememberSaveable { mutableStateOf(0) } + + val coroutineScope = rememberCoroutineScope() + + // Listen for GOG OAuth callback + DisposableEffect(Unit) { + Timber.d("[SettingsGOG]: Setting up GOG auth code event listener") + val onGOGAuthCodeReceived: (AndroidEvent.GOGAuthCodeReceived) -> Unit = { event -> + Timber.i("[SettingsGOG]: ✓ Received GOG auth code event! Code: ${event.authCode.take(20)}...") + + coroutineScope.launch { + handleGogAuthentication( + context = context, + authCode = event.authCode, + coroutineScope = coroutineScope, + onLoadingChange = { gogLoginLoading = it }, + onError = { gogLoginError = it }, + onSuccess = { count -> + gogLibraryGameCount = count + gogLoginSuccess = true + }, + onDialogClose = { openGOGLoginDialog = false } + ) + } + } + + PluviaApp.events.on(onGOGAuthCodeReceived) + Timber.d("[SettingsGOG]: GOG auth code event listener registered") + + onDispose { + PluviaApp.events.off(onGOGAuthCodeReceived) + Timber.d("[SettingsGOG]: GOG auth code event listener unregistered") + } + } + SettingsGroup(title = { Text(text = stringResource(R.string.settings_interface_title)) }) { SettingsSwitch( colors = settingsTileColorsAlt(), @@ -182,6 +289,36 @@ fun SettingsGroupInterface( } } + // GOG logout confirmation dialog state + var showGOGLogoutDialog by rememberSaveable { mutableStateOf(false) } + var gogLogoutLoading by rememberSaveable { mutableStateOf(false) } + + // GOG Integration + SettingsGroup(title = { Text(text = stringResource(R.string.gog_integration_title)) }) { + SettingsMenuLink( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.gog_settings_login_title)) }, + subtitle = { Text(text = stringResource(R.string.gog_settings_login_subtitle)) }, + onClick = { + openGOGLoginDialog = true + gogLoginError = null + gogLoginSuccess = false + } + ) + + // Logout button - only show if credentials exist + if (app.gamenative.service.gog.GOGAuthManager.hasStoredCredentials(context)) { + SettingsMenuLink( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.gog_settings_logout_title)) }, + subtitle = { Text(text = stringResource(R.string.gog_settings_logout_subtitle)) }, + onClick = { + showGOGLogoutDialog = true + } + ) + } + } + // Downloads settings SettingsGroup(title = { Text(text = stringResource(R.string.settings_downloads_title)) }) { var wifiOnlyDownload by rememberSaveable { mutableStateOf(PrefManager.downloadOnWifiOnly) } @@ -452,6 +589,107 @@ fun SettingsGroupInterface( progress = -1f, // Indeterminate progress message = stringResource(R.string.settings_language_changing) ) + + // GOG Login Dialog + GOGLoginDialog( + visible = openGOGLoginDialog, + onDismissRequest = { + openGOGLoginDialog = false + gogLoginError = null + gogLoginLoading = false + }, + onAuthCodeClick = { authCode -> + coroutineScope.launch { + handleGogAuthentication( + context = context, + authCode = authCode, + coroutineScope = coroutineScope, + onLoadingChange = { gogLoginLoading = it }, + onError = { gogLoginError = it }, + onSuccess = { count -> + gogLibraryGameCount = count + gogLoginSuccess = true + }, + onDialogClose = { openGOGLoginDialog = false } + ) + } + }, + isLoading = gogLoginLoading, + errorMessage = gogLoginError + ) + + // Success message dialog + if (gogLoginSuccess) { + MessageDialog( + visible = true, + onDismissRequest = { gogLoginSuccess = false }, + onConfirmClick = { gogLoginSuccess = false }, + confirmBtnText = "OK", + icon = Icons.Default.Login, + title = stringResource(R.string.gog_login_success_title), + message = stringResource(R.string.gog_login_success_message) + ) + } + + // GOG logout confirmation dialog + MessageDialog( + visible = showGOGLogoutDialog, + title = stringResource(R.string.gog_logout_confirm_title), + message = stringResource(R.string.gog_logout_confirm_message), + confirmBtnText = stringResource(R.string.gog_logout_confirm), + dismissBtnText = stringResource(R.string.cancel), + onConfirmClick = { + showGOGLogoutDialog = false + gogLogoutLoading = true + coroutineScope.launch { + try { + Timber.d("[SettingsGOG] Starting logout...") + val result = GOGService.logout(context) + + if (result.isSuccess) { + Timber.i("[SettingsGOG] Logout successful") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + context.getString(R.string.gog_logout_success), + android.widget.Toast.LENGTH_SHORT + ).show() + } + } else { + val error = result.exceptionOrNull() + Timber.e(error, "[SettingsGOG] Logout failed") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + context.getString(R.string.gog_logout_failed, error?.message ?: "Unknown error"), + android.widget.Toast.LENGTH_LONG + ).show() + } + } + } catch (e: Exception) { + Timber.e(e, "[SettingsGOG] Exception during logout") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + context.getString(R.string.gog_logout_failed, e.message ?: "Unknown error"), + android.widget.Toast.LENGTH_LONG + ).show() + } + } finally { + gogLogoutLoading = false + } + } + }, + onDismissRequest = { showGOGLogoutDialog = false }, + onDismissClick = { showGOGLogoutDialog = false } + ) + + // GOG logout loading dialog + LoadingDialog( + visible = gogLogoutLoading, + progress = -1f, + message = stringResource(R.string.gog_logout_in_progress) + ) } @@ -508,3 +746,5 @@ private fun Preview_SettingsScreen() { ) } } + + diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index bd0d3346a..3ae2ac75e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -56,10 +56,12 @@ import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.data.GameSource import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem import app.gamenative.data.SteamApp import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent import app.gamenative.service.SteamService +import app.gamenative.service.gog.GOGService import app.gamenative.ui.component.settings.SettingsListDropdown import app.gamenative.ui.data.XServerState import app.gamenative.ui.theme.settingsTileColors @@ -1512,7 +1514,7 @@ private fun setupXEnvironment( guestProgramLauncherComponent.setContainer(container); guestProgramLauncherComponent.setWineInfo(xServerState.value.wineInfo); val guestExecutable = "wine explorer /desktop=shell," + xServer.screenInfo + " " + - getWineStartCommand(appId, container, bootToContainer, testGraphics, appLaunchInfo, envVars, guestProgramLauncherComponent) + + getWineStartCommand(context, appId, container, bootToContainer, testGraphics, appLaunchInfo, envVars, guestProgramLauncherComponent) + (if (container.execArgs.isNotEmpty()) " " + container.execArgs else "") guestProgramLauncherComponent.isWoW64Mode = wow64Mode guestProgramLauncherComponent.guestExecutable = guestExecutable @@ -1660,6 +1662,7 @@ private fun setupXEnvironment( return environment } private fun getWineStartCommand( + context: Context, appId: String, container: Container, bootToContainer: Boolean, @@ -1673,18 +1676,21 @@ private fun getWineStartCommand( Timber.tag("XServerScreen").d("appLaunchInfo is $appLaunchInfo") - // Check if this is a Custom Game - val isCustomGame = ContainerUtils.extractGameSourceFromContainerId(appId) == GameSource.CUSTOM_GAME - val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) + // Check game source + val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) + val isCustomGame = gameSource == GameSource.CUSTOM_GAME + val isGOGGame = gameSource == GameSource.GOG + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) - if (!isCustomGame) { + if (!isCustomGame && !isGOGGame) { + // Steam-specific setup if (container.executablePath.isEmpty()){ - container.executablePath = SteamService.getInstalledExe(steamAppId) + container.executablePath = SteamService.getInstalledExe(gameId) container.saveData() } if (!container.isUseLegacyDRM){ // Create ColdClientLoader.ini file - SteamUtils.writeColdClientIni(steamAppId, container) + SteamUtils.writeColdClientIni(gameId, container) } } @@ -1692,6 +1698,29 @@ private fun getWineStartCommand( "\"Z:/opt/apps/TestD3D.exe\"" } else if (bootToContainer) { "\"wfm.exe\"" + } else if (isGOGGame) { + // For GOG games, use GOGService to get the launch command + Timber.tag("XServerScreen").i("Launching GOG game: $gameId") + + // Create a LibraryItem from the appId + val libraryItem = LibraryItem( + appId = appId, + name = "", // Name not needed for launch command + gameSource = GameSource.GOG + ) + + val gogCommand = GOGService.getGogWineStartCommand( + context = context, + libraryItem = libraryItem, + container = container, + bootToContainer = bootToContainer, + appLaunchInfo = appLaunchInfo, + envVars = envVars, + guestProgramLauncherComponent = guestProgramLauncherComponent + ) + + Timber.tag("XServerScreen").i("GOG launch command: $gogCommand") + return "winhandler.exe $gogCommand" } else if (isCustomGame) { // For Custom Games, we can launch even without appLaunchInfo // Use the executable path from container config. If missing, try to auto-detect @@ -1746,18 +1775,18 @@ private fun getWineStartCommand( if (container.isLaunchRealSteam()) { // Launch Steam with the applaunch parameter to start the game "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" -silent -vgui -tcp " + - "-nobigpicture -nofriendsui -nochatui -nointro -applaunch $steamAppId" + "-nobigpicture -nofriendsui -nochatui -nointro -applaunch $gameId" } else { var executablePath = "" if (container.executablePath.isNotEmpty()) { executablePath = container.executablePath } else { - executablePath = SteamService.getInstalledExe(steamAppId) + executablePath = SteamService.getInstalledExe(gameId) container.executablePath = executablePath container.saveData() } if (container.isUseLegacyDRM) { - val appDirPath = SteamService.getAppDirPath(steamAppId) + val appDirPath = SteamService.getAppDirPath(gameId) val executableDir = appDirPath + "/" + executablePath.substringBeforeLast("/", "") guestProgramLauncherComponent.workingDir = File(executableDir); Timber.i("Working directory is ${executableDir}") diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 0ace7d69b..8bf1fe715 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -5,6 +5,8 @@ import app.gamenative.PrefManager import app.gamenative.data.GameSource import app.gamenative.enums.Marker import app.gamenative.service.SteamService +import app.gamenative.service.gog.GOGConstants +import app.gamenative.service.gog.GOGService import app.gamenative.utils.BestConfigService import app.gamenative.utils.CustomGameScanner import com.winlator.container.Container @@ -503,26 +505,46 @@ object ContainerUtils { // Set up container drives to include app val defaultDrives = PrefManager.drives - val drives = if (gameSource == GameSource.STEAM) { - // For Steam games, set up the app directory path - val gameId = extractGameIdFromContainerId(appId) - val appDirPath = SteamService.getAppDirPath(gameId) - val drive: Char = Container.getNextAvailableDriveLetter(defaultDrives) - "$defaultDrives$drive:$appDirPath" - } else { - // For Custom Games, find the game folder and map it to A: drive - val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appId) - if (gameFolderPath != null) { - // Check if A: is already in defaultDrives, if not use it, otherwise use next available - val drive: Char = if (defaultDrives.contains("A:")) { - Container.getNextAvailableDriveLetter(defaultDrives) + val drives = when (gameSource) { + GameSource.STEAM -> { + // For Steam games, set up the app directory path + val gameId = extractGameIdFromContainerId(appId) + val appDirPath = SteamService.getAppDirPath(gameId) + val drive: Char = Container.getNextAvailableDriveLetter(defaultDrives) + "$defaultDrives$drive:$appDirPath" + } + GameSource.CUSTOM_GAME -> { + // For Custom Games, find the game folder and map it to A: drive + val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appId) + if (gameFolderPath != null) { + // Check if A: is already in defaultDrives, if not use it, otherwise use next available + val drive: Char = if (defaultDrives.contains("A:")) { + Container.getNextAvailableDriveLetter(defaultDrives) + } else { + 'A' + } + "$defaultDrives$drive:$gameFolderPath" } else { - 'A' + Timber.w("Could not find folder path for Custom Game: $appId") + defaultDrives + } + } + GameSource.GOG -> { + // For GOG games, map the specific game directory to A: drive + val gameId = extractGameIdFromContainerId(appId) + val game = GOGService.getGOGGameOf(gameId.toString()) + if (game != null && game.installPath.isNotEmpty()) { + val gameInstallPath = game.installPath + val drive: Char = if (defaultDrives.contains("A:")) { + Container.getNextAvailableDriveLetter(defaultDrives) + } else { + 'A' + } + "$defaultDrives$drive:$gameInstallPath" + } else { + Timber.w("Could not find GOG game info for: $gameId, using default drives") + defaultDrives } - "$defaultDrives$drive:$gameFolderPath" - } else { - Timber.w("Could not find folder path for Custom Game: $appId") - defaultDrives } } Timber.d("Prepared container drives: $drives") @@ -703,6 +725,7 @@ object ContainerUtils { } // No custom config, so determine the DX wrapper synchronously (only for Steam games) + // For GOG and Custom Games, use the default DX wrapper from preferences if (gameSource == GameSource.STEAM) { runBlocking { try { @@ -761,7 +784,8 @@ object ContainerUtils { // Delete any existing FEXCore config files (we use environment variables only) FEXCoreManager.deleteConfigFiles(context, container.id) - // Ensure all games have the A: drive mapped to the game folder + // Ensure Custom Games have the A: drive mapped to the game folder + // and GOG games have a drive mapped to the GOG games directory val gameSource = extractGameSourceFromContainerId(appId) val gameFolderPath: String? = when (gameSource) { GameSource.STEAM -> { @@ -803,6 +827,51 @@ object ContainerUtils { container.saveData() Timber.d("Updated container drives to include A: drive mapping: $updatedDrives") } + } else if (gameSource == GameSource.GOG) { + // Ensure GOG games have the specific game directory mapped + val gameId = extractGameIdFromContainerId(appId) + val game = runBlocking { GOGService.getGOGGameOf(gameId.toString()) } + if (game != null && game.installPath.isNotEmpty()) { + val gameInstallPath = game.installPath + var hasCorrectDriveMapping = false + + // Check if the specific game directory is already mapped + for (drive in Container.drivesIterator(container.drives)) { + if (drive[1] == gameInstallPath) { + hasCorrectDriveMapping = true + break + } + } + + // If specific game directory is not mapped, add/update it + if (!hasCorrectDriveMapping) { + val currentDrives = container.drives + val drivesBuilder = StringBuilder() + + // Use A: drive for game, or next available + val drive: Char = if (!currentDrives.contains("A:")) { + 'A' + } else { + Container.getNextAvailableDriveLetter(currentDrives) + } + + drivesBuilder.append("$drive:$gameInstallPath") + + // Add all other drives (excluding the one we just used) + for (existingDrive in Container.drivesIterator(currentDrives)) { + if (existingDrive[0] != drive.toString()) { + drivesBuilder.append("${existingDrive[0]}:${existingDrive[1]}") + } + } + + val updatedDrives = drivesBuilder.toString() + container.drives = updatedDrives + container.saveData() + Timber.d("Updated container drives to include $drive: drive mapping for GOG game: $updatedDrives") + } + } else { + Timber.w("Could not find GOG game info for $gameId, skipping drive mapping update") + } } return container @@ -866,7 +935,9 @@ object ContainerUtils { * Handles formats like: * - STEAM_123456 -> 123456 * - CUSTOM_GAME_571969840 -> 571969840 + * - GOG_19283103 -> 19283103 * - STEAM_123456(1) -> 123456 + * - 19283103 -> 19283103 (legacy GOG format) */ fun extractGameIdFromContainerId(containerId: String): Int { // Remove duplicate suffix like (1), (2) if present @@ -895,6 +966,7 @@ object ContainerUtils { return when { containerId.startsWith("STEAM_") -> GameSource.STEAM containerId.startsWith("CUSTOM_GAME_") -> GameSource.CUSTOM_GAME + containerId.startsWith("GOG_") -> GameSource.GOG // Add other platforms here.. else -> GameSource.STEAM // default fallback } @@ -1011,3 +1083,4 @@ object ContainerUtils { return systemKeywords.any { fileName.contains(it) } } } + diff --git a/app/src/main/java/app/gamenative/utils/StorageUtils.kt b/app/src/main/java/app/gamenative/utils/StorageUtils.kt index 31b20a9b0..9127bcc1c 100644 --- a/app/src/main/java/app/gamenative/utils/StorageUtils.kt +++ b/app/src/main/java/app/gamenative/utils/StorageUtils.kt @@ -21,6 +21,10 @@ import java.nio.file.Path object StorageUtils { fun getAvailableSpace(path: String): Long { + val file = File(path) + if (!file.exists()) { + throw IllegalArgumentException("Invalid path: $path") + } val stat = StatFs(path) return stat.blockSizeLong * stat.availableBlocksLong } diff --git a/app/src/main/python/gogdl/__init__.py b/app/src/main/python/gogdl/__init__.py new file mode 100644 index 000000000..89b905c65 --- /dev/null +++ b/app/src/main/python/gogdl/__init__.py @@ -0,0 +1,6 @@ +""" +Android-compatible GOGDL implementation +Modified from heroic-gogdl for Android/Chaquopy compatibility +""" + +version = "1.1.2-post1" diff --git a/app/src/main/python/gogdl/api.py b/app/src/main/python/gogdl/api.py new file mode 100644 index 000000000..50a48a45d --- /dev/null +++ b/app/src/main/python/gogdl/api.py @@ -0,0 +1,183 @@ +import logging +import time +import requests +import json +from multiprocessing import cpu_count +from gogdl.dl import dl_utils +import gogdl.constants as constants + + +class ApiHandler: + def __init__(self, auth_manager): + self.auth_manager = auth_manager + self.logger = logging.getLogger("API") + self.session = requests.Session() + adapter = requests.adapters.HTTPAdapter(pool_maxsize=cpu_count()) + self.session.mount("https://", adapter) + self.session.headers = { + 'User-Agent': f'gogdl/1.0.0 (Android GameNative)' + } + self._update_auth_header() + self.owned = [] + + self.endpoints = dict() # Map of secure link endpoints + self.working_on_ids = list() # List of products we are waiting for to complete getting the secure link + + def _update_auth_header(self): + """Update authorization header with fresh token""" + credentials = self.auth_manager.get_credentials() + if credentials: + token = credentials.get("access_token") + if token: + self.session.headers["Authorization"] = f"Bearer {token}" + self.logger.debug(f"Authorization header updated with token: {token[:20]}...") + else: + self.logger.warning("No access_token found in credentials") + else: + self.logger.warning("No credentials available") + + def get_item_data(self, id, expanded=None): + if expanded is None: + expanded = [] + self.logger.info(f"Getting info from products endpoint for id: {id}") + url = f'{constants.GOG_API}/products/{id}' + expanded_arg = '?expand=' + if len(expanded) > 0: + expanded_arg += ','.join(expanded) + url += expanded_arg + response = self.session.get(url) + self.logger.debug(url) + if response.ok: + return response.json() + else: + self.logger.error(f"Request failed {response}") + + def get_game_details(self, id): + url = f'{constants.GOG_EMBED}/account/gameDetails/{id}.json' + response = self.session.get(url) + if response.ok: + return response.json() + else: + self.logger.error(f"Request failed {response}") + + def get_user_data(self): + # Refresh auth header before making request + self._update_auth_header() + + # Try the embed endpoint which is more reliable for getting owned games + url = f'{constants.GOG_EMBED}/user/data/games' + self.logger.info(f"Fetching user data from: {url}") + response = self.session.get(url) + self.logger.debug(f"Response status: {response.status_code}") + if response.ok: + data = response.json() + self.logger.debug(f"User data keys: {list(data.keys())}") + return data + else: + self.logger.error(f"Request failed with status {response.status_code}: {response.text[:200]}") + return None + + def get_builds(self, product_id, platform): + url = f'{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/os/{platform}/builds?generation=2' + response = self.session.get(url) + if response.ok: + return response.json() + else: + self.logger.error(f"Request failed {response}") + + def get_manifest(self, manifest_id, product_id): + url = f'{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/os/windows/builds/{manifest_id}' + response = self.session.get(url) + if response.ok: + return response.json() + else: + self.logger.error(f"Request failed {response}") + + def get_authenticated_request(self, url): + """Make an authenticated request with proper headers""" + return self.session.get(url) + + def does_user_own(self, game_id): + """Check if the user owns a specific game + + Args: + game_id: The GOG game ID to check + + Returns: + bool: True if the user owns the game, False otherwise + """ + # If owned games list is populated, check it + if self.owned: + return str(game_id) in [str(g) for g in self.owned] + + # Otherwise, try to fetch user data and check + try: + user_data = self.get_user_data() + if user_data and 'owned' in user_data: + self.owned = [str(g) for g in user_data['owned']] + return str(game_id) in self.owned + except Exception as e: + self.logger.warning(f"Failed to check game ownership for {game_id}: {e}") + + # If we can't determine, assume they own it (they're trying to download it) + return True + + def get_dependencies_repo(self, depot_version=2): + self.logger.info("Getting Dependencies repository") + url = constants.DEPENDENCIES_URL if depot_version == 2 else constants.DEPENDENCIES_V1_URL + response = self.session.get(url) + if not response.ok: + return None + + json_data = json.loads(response.content) + return json_data + + def get_secure_link(self, product_id, path="", generation=2, root=None, attempt=0, max_retries=3): + """Get secure download links from GOG API with bounded retry + + Args: + product_id: GOG product ID + path: Optional path parameter + generation: API generation version (1 or 2) + root: Optional root parameter + attempt: Current attempt number (internal, default: 0) + max_retries: Maximum number of retry attempts (default: 3) + + Returns: + List of secure URLs, or empty list if all retries exhausted + """ + if attempt >= max_retries: + self.logger.error(f"Failed to get secure link after {max_retries} attempts for product {product_id}") + return [] + + url = "" + if generation == 2: + url = f"{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/secure_link?_version=2&generation=2&path={path}" + elif generation == 1: + url = f"{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/secure_link?_version=2&type=depot&path={path}" + + if root: + url += f"&root={root}" + + try: + response = self.get_authenticated_request(url) + + if response.status_code != 200: + self.logger.warning( + f"Invalid secure link response: {response.status_code} " + f"(attempt {attempt + 1}/{max_retries}) for product {product_id}" + ) + sleep_time = 0.2 * (2 ** attempt) + time.sleep(sleep_time) + return self.get_secure_link(product_id, path, generation, root, attempt + 1, max_retries) + + return response.json().get('urls', []) + + except Exception as e: + self.logger.error( + f"Failed to get secure link: {e} " + f"(attempt {attempt + 1}/{max_retries}) for product {product_id}" + ) + sleep_time = 0.2 * (2 ** attempt) + time.sleep(sleep_time) + return self.get_secure_link(product_id, path, generation, root, attempt + 1, max_retries) diff --git a/app/src/main/python/gogdl/args.py b/app/src/main/python/gogdl/args.py new file mode 100644 index 000000000..47e557538 --- /dev/null +++ b/app/src/main/python/gogdl/args.py @@ -0,0 +1,98 @@ +""" +Android-compatible argument parser for GOGDL +""" + +import argparse +from gogdl import constants + +def init_parser(): + """Initialize argument parser with Android-compatible defaults""" + + parser = argparse.ArgumentParser( + description='Android-compatible GOG downloader', + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument( + '--auth-config-path', + type=str, + default=f"{constants.ANDROID_DATA_DIR}/gog_auth.json", + help='Path to authentication config file' + ) + + parser.add_argument( + '--display-version', + action='store_true', + help='Display version information' + ) + + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Auth command + auth_parser = subparsers.add_parser('auth', help='Authenticate with GOG or get existing credentials') + auth_parser.add_argument('--code', type=str, help='Authorization code from GOG (optional - if not provided, returns existing credentials)') + + # Download command + download_parser = subparsers.add_parser('download', help='Download a game') + download_parser.add_argument('id', type=str, help='Game ID to download') + download_parser.add_argument('--path', type=str, default=constants.ANDROID_GAMES_DIR, help='Download path') + download_parser.add_argument('--platform', type=str, default='windows', choices=['windows', 'linux'], help='Platform') + download_parser.add_argument('--branch', type=str, help='Game branch to download') + download_parser.add_argument('--skip-dlcs', dest='dlcs', action='store_false', help='Skip DLC downloads') + download_parser.add_argument('--with-dlcs', dest='dlcs', action='store_true', help='Download DLCs') + download_parser.add_argument('--dlcs', dest='dlcs_list', default=[], help='List of dlc ids to download (separated by comma)') + download_parser.add_argument('--dlc-only', dest='dlc_only', action='store_true', help='Download only DLC') + + download_parser.add_argument('--lang', type=str, default='en-US', help='Language for the download') + download_parser.add_argument('--max-workers', dest='workers_count', type=int, default=2, help='Number of download workers') + download_parser.add_argument('--support', dest='support_path', type=str, help='Support files path') + download_parser.add_argument('--password', dest='password', help='Password to access other branches') + download_parser.add_argument('--force-gen', choices=['1', '2'], dest='force_generation', help='Force specific manifest generation (FOR DEBUGGING)') + download_parser.add_argument('--build', '-b', dest='build', help='Specify buildId') + + # Info command (same as heroic-gogdl calculate_size_parser) + info_parser = subparsers.add_parser('info', help='Calculates estimated download size and list of DLCs') + info_parser.add_argument('--with-dlcs', dest='dlcs', action='store_true', help='Should download all dlcs') + info_parser.add_argument('--skip-dlcs', dest='dlcs', action='store_false', help='Should skip all dlcs') + info_parser.add_argument('--dlcs', dest='dlcs_list', help='Comma separated list of dlc ids to download') + info_parser.add_argument('--dlc-only', dest='dlc_only', action='store_true', help='Download only DLC') + info_parser.add_argument('id', help='Game ID') + info_parser.add_argument('--platform', '--os', dest='platform', help='Target operating system', choices=['windows', 'linux'], default='windows') + info_parser.add_argument('--build', '-b', dest='build', help='Specify buildId') + info_parser.add_argument('--branch', dest='branch', help='Choose build branch to use') + info_parser.add_argument('--password', dest='password', help='Password to access other branches') + info_parser.add_argument('--force-gen', choices=['1', '2'], dest='force_generation', help='Force specific manifest generation (FOR DEBUGGING)') + info_parser.add_argument('--lang', '-l', dest='lang', help='Specify game language', default='en-US') + info_parser.add_argument('--max-workers', dest='workers_count', type=int, default=2, help='Number of download workers') + + # Repair command + repair_parser = subparsers.add_parser('repair', help='Repair/verify game files') + repair_parser.add_argument('id', type=str, help='Game ID to repair') + repair_parser.add_argument('--path', type=str, default=constants.ANDROID_GAMES_DIR, help='Game path') + repair_parser.add_argument('--platform', type=str, default='windows', choices=['windows', 'linux'], help='Platform') + repair_parser.add_argument('--password', dest='password', help='Password to access other branches') + repair_parser.add_argument('--force-gen', choices=['1', '2'], dest='force_generation', help='Force specific manifest generation (FOR DEBUGGING)') + repair_parser.add_argument('--build', '-b', dest='build', help='Specify buildId') + repair_parser.add_argument('--branch', dest='branch', help='Choose build branch to use') + + # Save sync command + save_parser = subparsers.add_parser('save-sync', help='Sync game saves') + save_parser.add_argument('path', help='Path to sync files') + save_parser.add_argument('--dirname', help='Cloud save directory name') + save_parser.add_argument('--timestamp', type=float, default=0.0, help='Last sync timestamp') + save_parser.add_argument('--prefered-action', choices=['upload', 'download', 'none'], help='Preferred sync action') + + # List command + list_parser = subparsers.add_parser('list', help='List user\'s GOG games') + list_parser.add_argument('--pretty', action='store_true', help='Pretty print JSON output') + + # Game IDs command + game_ids_parser = subparsers.add_parser('game-ids', help='List user\'s GOG game IDs only') + game_ids_parser.add_argument('--pretty', action='store_true', help='Pretty print JSON output') + + # Game details command + game_details_parser = subparsers.add_parser('game-details', help='Get full details for a single game') + game_details_parser.add_argument('game_id', type=str, help='Game ID to fetch details for') + game_details_parser.add_argument('--pretty', action='store_true', help='Pretty print JSON output') + + return parser.parse_known_args() diff --git a/app/src/main/python/gogdl/auth.py b/app/src/main/python/gogdl/auth.py new file mode 100644 index 000000000..9eda306fd --- /dev/null +++ b/app/src/main/python/gogdl/auth.py @@ -0,0 +1,133 @@ +""" +Android-compatible authentication module +Based on original auth.py with Android compatibility +""" + +import json +import os +import logging +import requests +import time +from typing import Optional, Dict, Any + +CLIENT_ID = "46899977096215655" +CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" + +class AuthorizationManager: + """Android-compatible authorization manager with token refresh""" + + def __init__(self, config_path: str): + self.config_path = config_path + self.logger = logging.getLogger("AUTH") + self.credentials_data = {} + self._read_config() + + def _read_config(self): + """Read credentials from config file""" + if os.path.exists(self.config_path): + try: + with open(self.config_path, "r") as f: + self.credentials_data = json.load(f) + except Exception as e: + self.logger.error(f"Failed to read config: {e}") + self.credentials_data = {} + + def _write_config(self): + """Write credentials to config file""" + try: + os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + with open(self.config_path, "w") as f: + json.dump(self.credentials_data, f, indent=2) + except Exception as e: + self.logger.error(f"Failed to write config: {e}") + + def get_credentials(self, client_id=None, client_secret=None): + """ + Reads data from config and returns it with automatic refresh if expired + :param client_id: GOG client ID + :param client_secret: GOG client secret + :return: dict with credentials or None if not present + """ + if not client_id: + client_id = CLIENT_ID + if not client_secret: + client_secret = CLIENT_SECRET + + credentials = self.credentials_data.get(client_id) + if not credentials: + return None + + # Check if credentials are expired and refresh if needed + if self.is_credential_expired(client_id): + if self.refresh_credentials(client_id, client_secret): + credentials = self.credentials_data.get(client_id) + else: + return None + + return credentials + + def is_credential_expired(self, client_id=None) -> bool: + """ + Checks if provided client_id credential is expired + :param client_id: GOG client ID + :return: whether credentials are expired + """ + if not client_id: + client_id = CLIENT_ID + credentials = self.credentials_data.get(client_id) + + if not credentials: + return True + + # If no loginTime or expires_in, assume expired + if "loginTime" not in credentials or "expires_in" not in credentials: + return True + + return time.time() >= credentials["loginTime"] + credentials["expires_in"] + + def refresh_credentials(self, client_id=None, client_secret=None) -> bool: + """ + Refreshes credentials and saves them to config + :param client_id: GOG client ID + :param client_secret: GOG client secret + :return: bool if operation was success + """ + if not client_id: + client_id = CLIENT_ID + if not client_secret: + client_secret = CLIENT_SECRET + + credentials = self.credentials_data.get(CLIENT_ID) + if not credentials or "refresh_token" not in credentials: + self.logger.error("No refresh token available") + return False + + refresh_token = credentials["refresh_token"] + url = f"https://auth.gog.com/token?client_id={client_id}&client_secret={client_secret}&grant_type=refresh_token&refresh_token={refresh_token}" + + try: + response = requests.get(url, timeout=10) + except (requests.ConnectionError, requests.Timeout): + self.logger.error("Failed to refresh credentials") + return False + + if not response.ok: + self.logger.error(f"Failed to refresh credentials: HTTP {response.status_code}") + return False + + data = response.json() + data["loginTime"] = time.time() + self.credentials_data.update({client_id: data}) + self._write_config() + return True + + def get_access_token(self) -> Optional[str]: + """Get access token from auth config""" + credentials = self.get_credentials() + if credentials and 'access_token' in credentials: + return credentials['access_token'] + return None + + def is_authenticated(self) -> bool: + """Check if user is authenticated""" + return self.get_access_token() is not None diff --git a/app/src/main/python/gogdl/cli.py b/app/src/main/python/gogdl/cli.py new file mode 100644 index 000000000..250f03fec --- /dev/null +++ b/app/src/main/python/gogdl/cli.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +""" +Android-compatible GOGDL CLI module +Removes multiprocessing and other Android-incompatible features +""" + +import gogdl.args as args +from gogdl.dl.managers import manager +import gogdl.api as api +import gogdl.auth as auth +from gogdl import version as gogdl_version +import gogdl.constants as constants +import json +import logging + + +def display_version(): + print(f"{gogdl_version}") + + +def get_game_ids(arguments, api_handler): + """List user's GOG games with full details""" + logger = logging.getLogger("GOGDL-GAME-IDS") + try: + # Check if we have valid credentials first + credentials = api_handler.auth_manager.get_credentials() + if not credentials: + logger.error("No valid credentials found. Please authenticate first.") + print(json.dumps([])) # Return empty array instead of error object + return + + logger.info("Fetching user's game library...") + logger.debug(f"Using access token: {credentials.get('access_token', '')[:20]}...") + + # Use the same endpoint as does_user_own - it just returns owned game IDs + response = api_handler.session.get(f'{constants.GOG_EMBED}/user/data/games') + + if not response.ok: + logger.error(f"Failed to fetch user data - HTTP {response.status_code}") + print(json.dumps([])) # Return empty array instead of error object + return + + user_data = response.json() + owned_games = user_data.get('owned', []) + if arguments.pretty: + print(json.dumps(owned_games, indent=2)) + else: + print(json.dumps(owned_games)) + except Exception as e: + logger.error(f"List command failed: {e}") + import traceback + logger.error(traceback.format_exc()) + # Return empty array on error so Kotlin can parse it + print(json.dumps([])) + +def get_game_details(arguments, api_handler): + """Fetch full details for a single game by ID""" + logger = logging.getLogger("GOGDL-GAME-DETAILS") + try: + game_id = arguments.game_id + if(not game_id): + logger.error("No game ID provided!") + print(json.dumps({})) + return + # Check if we have valid credentials first + logger.info(f"Fetching details for game ID: {game_id}") + + # Get full game info with expanded data + game_info = api_handler.get_item_data(game_id, expanded=['downloads', 'description', 'screenshots']) + + if game_info: + logger.info(f"Game {game_id} API response keys: {list(game_info.keys())}") + # Extract image URLs and ensure they have protocol + logo2x = game_info.get('images', {}).get('logo2x', '') + logo = game_info.get('images', {}).get('logo', '') + icon = game_info.get('images', {}).get('icon', '') + + # Add https: protocol if missing + if logo2x and logo2x.startswith('//'): + logo2x = 'https:' + logo2x + if logo and logo.startswith('//'): + logo = 'https:' + logo + if icon and icon.startswith('//'): + icon = 'https:' + icon + + # Extract download size from first installer + download_size = 0 + downloads = game_info.get('downloads', {}) + installers = downloads.get('installers', []) + if installers and len(installers) > 0: + download_size = installers[0].get('total_size', 0) + + # Extract relevant fields + game_entry = { + "id": game_id, + "title": game_info.get('title', 'Unknown'), + "slug": game_info.get('slug', ''), + "imageUrl": logo2x or logo, + "iconUrl": icon, + "developer": game_info.get('developers', [{}])[0].get('name', '') if game_info.get('developers') else '', + "publisher": game_info.get('publisher', {}).get('name', '') if isinstance(game_info.get('publisher'), dict) else game_info.get('publisher', ''), + "genres": [g.get('name', '') if isinstance(g, dict) else str(g) for g in game_info.get('genres', [])], + "languages": list(game_info.get('languages', {}).keys()), + "description": game_info.get('description', {}).get('lead', '') if isinstance(game_info.get('description'), dict) else '', + "releaseDate": game_info.get('release_date', ''), + "downloadSize": download_size + } + # Output as JSON + if arguments.pretty: + print(json.dumps(game_entry, indent=2)) + else: + print(json.dumps(game_entry)) + else: + logger.warning(f"Failed to get details for game {game_id} - API returned None") + print(json.dumps({})) + + except Exception as e: + logger.error(f"Get game details command failed: {e}") + import traceback + logger.error(traceback.format_exc()) + # Return empty object on error so Kotlin can parse it + print(json.dumps({})) + +def handle_list(arguments, api_handler): + """List user's GOG games with full details""" + logger = logging.getLogger("GOGDL-LIST") + + try: + # Check if we have valid credentials first + credentials = api_handler.auth_manager.get_credentials() + if not credentials: + logger.error("No valid credentials found. Please authenticate first.") + print(json.dumps([])) # Return empty array instead of error object + return + + logger.info("Fetching user's game library...") + logger.debug(f"Using access token: {credentials.get('access_token', '')[:20]}...") + + # Use the same endpoint as does_user_own - it just returns owned game IDs + response = api_handler.session.get(f'{constants.GOG_EMBED}/user/data/games') + + if not response.ok: + logger.error(f"Failed to fetch user data - HTTP {response.status_code}") + print(json.dumps([])) # Return empty array instead of error object + return + + user_data = response.json() + owned_games = user_data.get('owned', []) + logger.info(f"Found {len(owned_games)} games in library") + + # Fetch full details for each game + games_list = [] + for index, game_id in enumerate(owned_games, 1): + try: + logger.info(f"Fetching details for game {index}/{len(owned_games)}: {game_id}") + + # Get full game info with expanded data + game_info = api_handler.get_item_data(game_id, expanded=['downloads', 'description', 'screenshots', 'videos']) + + # Log what we got back + if game_info: + logger.info(f"Game {game_id} API response keys: {list(game_info.keys())}") + logger.debug(f"Game {game_id} has developers: {'developers' in game_info}") + logger.debug(f"Game {game_id} has publisher: {'publisher' in game_info}") + logger.debug(f"Game {game_id} has genres: {'genres' in game_info}") + + if game_info: + # Extract image URLs and ensure they have protocol + logo2x = game_info.get('images', {}).get('logo2x', '') + logo = game_info.get('images', {}).get('logo', '') + icon = game_info.get('images', {}).get('icon', '') + + # Add https: protocol if missing + if logo2x and logo2x.startswith('//'): + logo2x = 'https:' + logo2x + if logo and logo.startswith('//'): + logo = 'https:' + logo + if icon and icon.startswith('//'): + icon = 'https:' + icon + + # Extract download size from first installer + download_size = 0 + downloads = game_info.get('downloads', {}) + installers = downloads.get('installers', []) + if installers and len(installers) > 0: + download_size = installers[0].get('total_size', 0) + + # Extract relevant fields + game_entry = { + "id": game_id, + "title": game_info.get('title', 'Unknown'), + "slug": game_info.get('slug', ''), + "imageUrl": logo2x or logo, + "iconUrl": icon, + "developer": game_info.get('developers', [{}])[0].get('name', '') if game_info.get('developers') else '', + "publisher": game_info.get('publisher', {}).get('name', '') if isinstance(game_info.get('publisher'), dict) else game_info.get('publisher', ''), + "genres": [g.get('name', '') if isinstance(g, dict) else str(g) for g in game_info.get('genres', [])], + "languages": list(game_info.get('languages', {}).keys()), + "description": game_info.get('description', {}).get('lead', '') if isinstance(game_info.get('description'), dict) else '', + "releaseDate": game_info.get('release_date', ''), + "downloadSize": download_size + } + games_list.append(game_entry) + logger.debug(f" ✓ {game_entry['title']}") + else: + logger.warning(f"Failed to get details for game {game_id} - API returned None") + # Add minimal entry so we don't lose the game ID + games_list.append({ + "id": game_id, + "title": f"Game {game_id}", + "slug": "", + "imageUrl": "", + "iconUrl": "", + "developer": "", + "publisher": "", + "genres": [], + "languages": [], + "description": "", + "releaseDate": "" + }) + + # Small delay to avoid rate limiting (100ms between requests) + if index < len(owned_games): + import time + time.sleep(0.1) + + except Exception as e: + logger.error(f"Error fetching details for game {game_id}: {e}") + import traceback + logger.debug(traceback.format_exc()) + # Add minimal entry on error + games_list.append({ + "id": game_id, + "title": f"Game {game_id}", + "slug": "", + "imageUrl": "", + "iconUrl": "", + "developer": "", + "publisher": "", + "genres": [], + "languages": [], + "description": "", + "releaseDate": "" + }) + + logger.info(f"Successfully fetched details for {len(games_list)} games") + + # Output as JSON array (always return array, never error object) + if arguments.pretty: + print(json.dumps(games_list, indent=2)) + else: + print(json.dumps(games_list)) + + except Exception as e: + logger.error(f"List command failed: {e}") + import traceback + logger.error(traceback.format_exc()) + # Return empty array on error so Kotlin can parse it + print(json.dumps([])) + + +def handle_auth(arguments, api_handler): + """Handle GOG authentication - exchange authorization code for access token or get existing credentials""" + logger = logging.getLogger("GOGDL-AUTH") + + try: + import requests + import os + import time + + # GOG OAuth constants + GOG_CLIENT_ID = "46899977096215655" + GOG_CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" + GOG_TOKEN_URL = "https://auth.gog.com/token" + GOG_USER_URL = "https://embed.gog.com/userData.json" + + # Initialize authorization manager + auth_manager = api_handler.auth_manager + + if arguments.code: + # Exchange authorization code for access token + logger.info("Exchanging authorization code for access token...") + + token_data = { + "client_id": GOG_CLIENT_ID, + "client_secret": GOG_CLIENT_SECRET, + "grant_type": "authorization_code", + "code": arguments.code, + "redirect_uri": "https://embed.gog.com/on_login_success?origin=client" + } + + response = requests.post(GOG_TOKEN_URL, data=token_data) + + if response.status_code != 200: + error_msg = f"Token exchange failed: HTTP {response.status_code} - {response.text}" + logger.error(error_msg) + print(json.dumps({"error": True, "message": error_msg})) + return + + token_response = response.json() + access_token = token_response.get("access_token") + refresh_token = token_response.get("refresh_token") + + if not access_token: + error_msg = "No access token in response" + logger.error(error_msg) + print(json.dumps({"error": True, "message": error_msg})) + return + + # Get user information + logger.info("Getting user information...") + user_response = requests.get( + GOG_USER_URL, + headers={"Authorization": f"Bearer {access_token}"} + ) + + username = "GOG User" + user_id = "unknown" + + if user_response.status_code == 200: + user_data = user_response.json() + username = user_data.get("username", "GOG User") + user_id = str(user_data.get("userId", "unknown")) + else: + logger.warning(f"Failed to get user info: HTTP {user_response.status_code}") + + # Save credentials with loginTime and expires_in (like original auth.py) + auth_data = { + GOG_CLIENT_ID: { + "access_token": access_token, + "refresh_token": refresh_token, + "user_id": user_id, + "username": username, + "loginTime": time.time(), + "expires_in": token_response.get("expires_in", 3600) + } + } + + os.makedirs(os.path.dirname(arguments.auth_config_path), exist_ok=True) + + with open(arguments.auth_config_path, 'w') as f: + json.dump(auth_data, f, indent=2) + + logger.info(f"Authentication successful for user: {username}") + print(json.dumps(auth_data[GOG_CLIENT_ID])) + + else: + # Get existing credentials (like original auth.py get_credentials) + logger.info("Getting existing credentials...") + credentials = auth_manager.get_credentials() + + if credentials: + logger.info(f"Retrieved credentials for user: {credentials.get('username', 'GOG User')}") + print(json.dumps(credentials)) + else: + logger.warning("No valid credentials found") + print(json.dumps({"error": True, "message": "No valid credentials found"})) + + except Exception as e: + logger.error(f"Authentication failed: {e}") + print(json.dumps({"error": True, "message": str(e)})) + raise + + +def main(): + arguments, unknown_args = args.init_parser() + level = logging.INFO + if '-d' in unknown_args or '--debug' in unknown_args: + level = logging.DEBUG + logging.basicConfig(format="[%(name)s] %(levelname)s: %(message)s", level=level) + logger = logging.getLogger("GOGDL-ANDROID") + logger.debug(arguments) + + if arguments.display_version: + display_version() + return + + if not arguments.command: + print("No command provided!") + return + + # Initialize Android-compatible managers + authorization_manager = auth.AuthorizationManager(arguments.auth_config_path) + api_handler = api.ApiHandler(authorization_manager) + + switcher = {} + + # Handle authentication command + if arguments.command == "auth": + switcher["auth"] = lambda: handle_auth(arguments, api_handler) + + # Handle list command + if arguments.command == "list": + switcher["list"] = lambda: handle_list(arguments, api_handler) + + # Handle game-ids command + if arguments.command == "game-ids": + switcher["game-ids"] = lambda: get_game_ids(arguments, api_handler) + # Handle game-details command + if arguments.command == "game-details": + switcher["game-details"] = lambda: get_game_details(arguments, api_handler) + # Handle download/info commands + if arguments.command in ["download", "repair", "update", "info"]: + download_manager = manager.AndroidManager(arguments, unknown_args, api_handler) + switcher.update({ + "download": download_manager.download, + "repair": download_manager.download, + "update": download_manager.download, + "info": lambda: download_manager.calculate_download_size(arguments, unknown_args), + }) + + # Handle save sync command + if arguments.command == "save-sync": + import gogdl.saves as saves + clouds_storage_manager = saves.CloudStorageManager(api_handler, authorization_manager) + switcher["save-sync"] = lambda: clouds_storage_manager.sync(arguments, unknown_args) + + if arguments.command in switcher: + try: + switcher[arguments.command]() + except Exception as e: + logger.error(f"Command failed: {e}") + raise + else: + logger.error(f"Unknown command: {arguments.command}") + + +if __name__ == "__main__": + main() diff --git a/app/src/main/python/gogdl/constants.py b/app/src/main/python/gogdl/constants.py new file mode 100644 index 000000000..2e8a41c63 --- /dev/null +++ b/app/src/main/python/gogdl/constants.py @@ -0,0 +1,29 @@ +""" +Android-compatible constants for GOGDL +""" + +import os + +# GOG API endpoints (matching original heroic-gogdl) +GOG_CDN = "https://gog-cdn-fastly.gog.com" +GOG_CONTENT_SYSTEM = "https://content-system.gog.com" +GOG_EMBED = "https://embed.gog.com" +GOG_AUTH = "https://auth.gog.com" +GOG_API = "https://api.gog.com" +GOG_CLOUDSTORAGE = "https://cloudstorage.gog.com" +DEPENDENCIES_URL = "https://content-system.gog.com/dependencies/repository?generation=2" +DEPENDENCIES_V1_URL = "https://content-system.gog.com/redists/repository" + +NON_NATIVE_SEP = "\\" if os.sep == "/" else "/" + +# Android-specific paths +ANDROID_DATA_DIR = "/data/user/0/app.gamenative/files" +ANDROID_GAMES_DIR = "/data/data/app.gamenative/storage/gog_games" +CONFIG_DIR = ANDROID_DATA_DIR +MANIFESTS_DIR = os.path.join(CONFIG_DIR, "manifests") + +# Download settings optimized for Android +DEFAULT_CHUNK_SIZE = 1024 * 1024 # 1MB chunks for mobile +MAX_CONCURRENT_DOWNLOADS = 2 # Conservative for mobile +CONNECTION_TIMEOUT = 30 # 30 second timeout +READ_TIMEOUT = 60 # 1 minute read timeout diff --git a/app/src/main/python/gogdl/dl/__init__.py b/app/src/main/python/gogdl/dl/__init__.py new file mode 100644 index 000000000..0c3e11496 --- /dev/null +++ b/app/src/main/python/gogdl/dl/__init__.py @@ -0,0 +1,3 @@ +""" +Android-compatible download module +""" \ No newline at end of file diff --git a/app/src/main/python/gogdl/dl/dl_utils.py b/app/src/main/python/gogdl/dl/dl_utils.py new file mode 100644 index 000000000..1f332a1dd --- /dev/null +++ b/app/src/main/python/gogdl/dl/dl_utils.py @@ -0,0 +1,184 @@ +import json +import zlib +import os +import gogdl.constants as constants +from gogdl.dl.objects import v1, v2 +import shutil +import time +import requests +from sys import exit, platform +import logging + +PATH_SEPARATOR = os.sep +TIMEOUT = 10 + + +def get_json(api_handler, url): + logger = logging.getLogger("DL_UTILS") + logger.info(f"Fetching JSON from: {url}") + x = api_handler.session.get(url, headers={"Accept": "application/json"}) + logger.info(f"Response status: {x.status_code}") + if not x.ok: + logger.error(f"Request failed: {x.status_code} - {x.text}") + return + logger.info("JSON fetch successful") + return x.json() + + +def get_zlib_encoded(api_handler, url): + retries = 5 + while retries > 0: + try: + x = api_handler.session.get(url, timeout=TIMEOUT) + if not x.ok: + return None, None + try: + decompressed = json.loads(zlib.decompress(x.content, 15)) + except zlib.error: + return x.json(), x.headers + return decompressed, x.headers + except Exception: + time.sleep(2) + retries-=1 + return None, None + + +def prepare_location(path, logger=None): + os.makedirs(path, exist_ok=True) + if logger: + logger.debug(f"Created directory {path}") + + +# V1 Compatible +def galaxy_path(manifest: str): + galaxy_path = manifest + if galaxy_path.find("/") == -1: + galaxy_path = manifest[0:2] + "/" + manifest[2:4] + "/" + galaxy_path + return galaxy_path + + +def get_secure_link(api_handler, path, gameId, generation=2, logger=None, root=None): + url = "" + if generation == 2: + url = f"{constants.GOG_CONTENT_SYSTEM}/products/{gameId}/secure_link?_version=2&generation=2&path={path}" + elif generation == 1: + url = f"{constants.GOG_CONTENT_SYSTEM}/products/{gameId}/secure_link?_version=2&type=depot&path={path}" + if root: + url += f"&root={root}" + + try: + r = requests.get(url, headers=api_handler.session.headers, timeout=TIMEOUT) + except BaseException as exception: + if logger: + logger.info(exception) + time.sleep(0.2) + return get_secure_link(api_handler, path, gameId, generation, logger) + + if r.status_code != 200: + if logger: + logger.info("invalid secure link response") + time.sleep(0.2) + return get_secure_link(api_handler, path, gameId, generation, logger) + + js = r.json() + + return js['urls'] + +def get_dependency_link(api_handler): + data = get_json( + api_handler, + f"{constants.GOG_CONTENT_SYSTEM}/open_link?generation=2&_version=2&path=/dependencies/store/", + ) + if not data: + return None + return data["urls"] + + +def merge_url_with_params(url, parameters): + for key in parameters.keys(): + url = url.replace("{" + key + "}", str(parameters[key])) + if not url: + print(f"Error ocurred getting a secure link: {url}") + return url + + +def parent_dir(path: str): + return os.path.split(path)[0] + + +def calculate_sum(path, function, read_speed_function=None): + with open(path, "rb") as f: + calculate = function() + while True: + chunk = f.read(16 * 1024) + if not chunk: + break + if read_speed_function: + read_speed_function(len(chunk)) + calculate.update(chunk) + + return calculate.hexdigest() + + +def get_readable_size(size): + power = 2 ** 10 + n = 0 + power_labels = {0: "", 1: "K", 2: "M", 3: "G"} + while size > power: + size /= power + n += 1 + return size, power_labels[n] + "B" + + +def check_free_space(size: int, path: str): + if not os.path.exists(path): + os.makedirs(path, exist_ok=True) + _, _, available_space = shutil.disk_usage(path) + + return size < available_space + + +def get_range_header(offset, size): + from_value = offset + to_value = (int(offset) + int(size)) - 1 + return f"bytes={from_value}-{to_value}" + +# Creates appropriate Manifest class based on provided meta from json +def create_manifest_class(meta: dict, api_handler): + version = meta.get("version") + if version == 1: + return v1.Manifest.from_json(meta, api_handler) + else: + return v2.Manifest.from_json(meta, api_handler) + +def get_case_insensitive_name(path): + if platform == "win32" or os.path.exists(path): + return path + root = path + # Find existing directory + while not os.path.exists(root): + root = os.path.split(root)[0] + + if not root[len(root) - 1] in ["/", "\\"]: + root = root + os.sep + # Separate unknown path from existing one + s_working_dir = path.replace(root, "").split(os.sep) + paths_to_find = len(s_working_dir) + paths_found = 0 + for directory in s_working_dir: + if not os.path.exists(root): + break + dir_list = os.listdir(root) + found = False + for existing_dir in dir_list: + if existing_dir.lower() == directory.lower(): + root = os.path.join(root, existing_dir) + paths_found += 1 + found = True + if not found: + root = os.path.join(root, directory) + paths_found += 1 + + if paths_to_find != paths_found: + root = os.path.join(root, os.sep.join(s_working_dir[paths_found:])) + return root \ No newline at end of file diff --git a/app/src/main/python/gogdl/dl/managers/__init__.py b/app/src/main/python/gogdl/dl/managers/__init__.py new file mode 100644 index 000000000..58e7b4716 --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/__init__.py @@ -0,0 +1,4 @@ +""" +Android-compatible download managers +""" + diff --git a/app/src/main/python/gogdl/dl/managers/dependencies.py b/app/src/main/python/gogdl/dl/managers/dependencies.py new file mode 100644 index 000000000..8727f7101 --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/dependencies.py @@ -0,0 +1,166 @@ +from sys import exit +import logging +import os +import json +from typing import Optional +from gogdl.dl import dl_utils +import gogdl.constants as constants +from gogdl.dl.managers.task_executor import ExecutingManager +from gogdl.dl.objects import v2 +from gogdl.dl.objects.generic import BaseDiff + + +def get_depot_list(manifest, product_id=None): + download_list = list() + for item in manifest["depot"]["items"]: + if item["type"] == "DepotFile": + download_list.append(v2.DepotFile(item, product_id)) + return download_list + + +# Looks like we can use V2 dependencies for V1 games too WOAH +# We are doing that obviously +class DependenciesManager: + def __init__( + self, ids, path, workers_count, api_handler, print_manifest=False, download_game_deps_only=False + ): + self.api = api_handler + + self.logger = logging.getLogger("REDIST") + + self.path = path + self.installed_manifest = os.path.join(self.path, '.gogdl-redist-manifest') + self.workers_count = int(workers_count) + self.build = self.api.get_dependencies_repo() + self.repository = dl_utils.get_zlib_encoded(self.api, self.build['repository_manifest'])[0] or {} + # Put version for easier serialization + self.repository['build_id'] = self.build['build_id'] + + self.ids = ids + self.download_game_deps_only = download_game_deps_only # Basically skip all redist with path starting with __redist + if self.repository and print_manifest: + print(json.dumps(self.repository)) + + def get_files_for_depot_manifest(self, manifest): + url = f'{constants.GOG_CDN}/content-system/v2/dependencies/meta/{dl_utils.galaxy_path(manifest)}' + manifest = dl_utils.get_zlib_encoded(self.api, url)[0] + + return get_depot_list(manifest, 'redist') + + + def get(self, return_files=False): + old_depots = [] + new_depots = [] + if not self.ids: + return [] + installed = set() + + # This will be always None for redist writen in game dir + existing_manifest = None + if os.path.exists(self.installed_manifest): + try: + with open(self.installed_manifest, 'r') as f: + existing_manifest = json.load(f) + except Exception: + existing_manifest = None + pass + else: + if 'depots' in existing_manifest and 'build_id' in existing_manifest: + already_installed = existing_manifest.get('HGLInstalled') or [] + for depot in existing_manifest["depots"]: + if depot["dependencyId"] in already_installed: + old_depots.append(depot) + + for depot in self.repository["depots"]: + if depot["dependencyId"] in self.ids: + # By default we want to download all redist beginning + # with redist (game installation runs installation of the game's ones) + should_download = depot["executable"]["path"].startswith("__redist") + + # If we want to download redist located in game dir we flip the boolean + if self.download_game_deps_only: + should_download = not should_download + + if should_download: + installed.add(depot['dependencyId']) + new_depots.append(depot) + + new_files = [] + old_files = [] + + # Collect files for each redistributable + for depot in new_depots: + new_files += self.get_files_for_depot_manifest(depot["manifest"]) + + for depot in old_depots: + old_files += self.get_files_for_depot_manifest(depot["manifest"]) + + if return_files: + return new_files + + + diff = DependenciesDiff.compare(new_files, old_files) + + if not len(diff.changed) and not len(diff.deleted) and not len(diff.new): + self.logger.info("Nothing to do") + self._write_manifest(installed) + return + + secure_link = dl_utils.get_dependency_link(self.api) # This should never expire + executor = ExecutingManager(self.api, self.workers_count, self.path, os.path.join(self.path, 'gog-support'), diff, {'redist': secure_link}, 'gog-redist') + success = executor.setup() + if not success: + print('Unable to proceed, Not enough disk space') + exit(2) + cancelled = executor.run() + + if cancelled: + return + + self._write_manifest(installed) + + def _write_manifest(self, installed: set): + repository = self.repository + repository['HGLInstalled'] = list(installed) + with open(self.installed_manifest, 'w') as f: + json.dump(repository, f) + + +class DependenciesDiff(BaseDiff): + def __init__(self): + super().__init__() + + @classmethod + def compare(cls, new_files: list, old_files: Optional[list]): + comparison = cls() + + if not old_files: + comparison.new = new_files + return comparison + + new_files_paths = dict() + for file in new_files: + new_files_paths.update({file.path.lower(): file}) + + old_files_paths = dict() + for file in old_files: + old_files_paths.update({file.path.lower(): file}) + + for old_file in old_files_paths.values(): + if not new_files_paths.get(old_file.path.lower()): + comparison.deleted.append(old_file) + + for new_file in new_files_paths.values(): + old_file = old_files_paths.get(new_file.path.lower()) + if not old_file: + comparison.new.append(new_file) + else: + if len(new_file.chunks) == 1 and len(old_file.chunks) == 1: + if new_file.chunks[0]["md5"] != old_file.chunks[0]["md5"]: + comparison.changed.append(new_file) + else: + if (new_file.md5 and old_file.md5 and new_file.md5 != old_file.md5) or (new_file.sha256 and old_file.sha256 != new_file.sha256): + comparison.changed.append(v2.FileDiff.compare(new_file, old_file)) + elif len(new_file.chunks) != len(old_file.chunks): + comparison.changed.append(v2.FileDiff.compare(new_file, old_file)) + return comparison diff --git a/app/src/main/python/gogdl/dl/managers/linux.py b/app/src/main/python/gogdl/dl/managers/linux.py new file mode 100644 index 000000000..ca7f5a57a --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/linux.py @@ -0,0 +1,19 @@ +""" +Android-compatible Linux manager (simplified) +""" + +import logging +from gogdl.dl.managers.v2 import Manager + +class LinuxManager(Manager): + """Android-compatible Linux download manager""" + + def __init__(self, generic_manager): + super().__init__(generic_manager) + self.logger = logging.getLogger("LinuxManager") + + def download(self): + """Download Linux game (uses similar logic to Windows)""" + self.logger.info(f"Starting Linux download for game {self.game_id}") + # For now, use the same V2 logic but with Linux platform + super().download() diff --git a/app/src/main/python/gogdl/dl/managers/manager.py b/app/src/main/python/gogdl/dl/managers/manager.py new file mode 100644 index 000000000..73c72a13a --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/manager.py @@ -0,0 +1,202 @@ +""" +Android-compatible download manager +Replaces multiprocessing with threading for Android compatibility +""" + +from dataclasses import dataclass +import os +import logging +import json +import threading +from concurrent.futures import ThreadPoolExecutor + +from gogdl import constants +from gogdl.dl.managers import linux, v1, v2 + +@dataclass +class UnsupportedPlatform(Exception): + pass + +class AndroidManager: + """Android-compatible version of GOGDL Manager that uses threading instead of multiprocessing""" + + def __init__(self, arguments, unknown_arguments, api_handler): + self.arguments = arguments + self.unknown_arguments = unknown_arguments + self.api_handler = api_handler + + self.platform = arguments.platform + self.should_append_folder_name = self.arguments.command == "download" + self.is_verifying = self.arguments.command == "repair" + self.game_id = arguments.id + self.branch = getattr(arguments, 'branch', None) + + # Use a reasonable number of threads for Android + if hasattr(arguments, "workers_count"): + self.allowed_threads = min(int(arguments.workers_count), 4) # Limit threads on mobile + else: + self.allowed_threads = 2 # Conservative default for Android + + self.logger = logging.getLogger("AndroidManager") + + def download(self): + """Download game using Android-compatible threading""" + try: + self.logger.info(f"Starting Android download for game {self.game_id}") + + if self.platform == "linux": + # Use Linux manager - pass self as generic_manager like v2.Manager + manager = linux.LinuxManager(self) + manager.download() + return + + # Get builds to determine generation + builds = self.get_builds(self.platform) + if not builds or len(builds['items']) == 0: + raise Exception("No builds found") + + # Select target build (same logic as heroic-gogdl) + target_build = builds['items'][0] # Default to first build + + # Check for specific branch + for build in builds['items']: + if build.get("branch") == self.branch: + target_build = build + break + + # Check for specific build ID + if hasattr(self.arguments, 'build') and self.arguments.build: + for build in builds['items']: + if build.get("build_id") == self.arguments.build: + target_build = build + break + + # Store builds and target_build as instance attributes for V2 Manager + self.builds = builds + self.target_build = target_build + + generation = target_build.get("generation", 2) + self.logger.info(f"Using build {target_build.get('build_id', 'unknown')} for download (generation: {generation})") + + # Use the correct manager based on generation - same as heroic-gogdl + if generation == 1: + self.logger.info("Using V1Manager for generation 1 game") + manager = v1.Manager(self) # Pass self like V2 does + elif generation == 2: + self.logger.info("Using V2Manager for generation 2 game") + manager = v2.Manager(self) + else: + raise Exception(f"Unsupported generation: {generation}") + + manager.download() + + except Exception as e: + self.logger.error(f"Download failed: {e}") + raise + + def setup_download_manager(self): + # TODO: If content system for linux ever appears remove this if statement + # But keep the one below so we have some sort of fallback + # in case not all games were available in content system + if self.platform == "linux": + self.logger.info( + "Platform is Linux, redirecting download to Linux Native installer manager" + ) + + self.download_manager = linux.Manager(self) + + return + + try: + self.builds = self.get_builds(self.platform) + except UnsupportedPlatform: + if self.platform == "linux": + self.logger.info( + "Platform is Linux, redirecting download to Linux Native installer manager" + ) + + self.download_manager = linux.Manager(self) + + return + + self.logger.error(f"Game doesn't support content system api, unable to proceed using platform {self.platform}") + exit(1) + + # If Linux download ever progresses to this point, then it's time for some good party + + if len(self.builds["items"]) == 0: + self.logger.error("No builds found") + exit(1) + self.target_build = self.builds["items"][0] + + for build in self.builds["items"]: + if build["branch"] == None: + self.target_build = build + break + + for build in self.builds["items"]: + if build["branch"] == self.branch: + self.target_build = build + break + + if self.arguments.build: + # Find build + for build in self.builds["items"]: + if build["build_id"] == self.arguments.build: + self.target_build = build + break + self.logger.debug(f'Found build {self.target_build}') + + generation = self.target_build["generation"] + + if self.is_verifying: + manifest_path = os.path.join(constants.MANIFESTS_DIR, self.game_id) + if os.path.exists(manifest_path): + with open(manifest_path, 'r') as f: + manifest_data = json.load(f) + generation = int(manifest_data['version']) + + # This code shouldn't run at all but it's here just in case GOG decides they will return different generation than requested one + # Of course assuming they will ever change their content system generation (I highly doubt they will) + if generation not in [1, 2]: + raise Exception("Unsupported depot version please report this") + + self.logger.info(f"Depot version: {generation}") + + if generation == 1: + self.download_manager = v1.Manager(self) + elif generation == 2: + self.download_manager = v2.Manager(self) + + def calculate_download_size(self, arguments, unknown_arguments): + """Calculate download size - same as heroic-gogdl""" + try: + self.setup_download_manager() + + download_size_response = self.download_manager.get_download_size() + download_size_response['builds'] = self.builds + + # Print JSON output like heroic-gogdl does + import json + print(json.dumps(download_size_response)) + + except Exception as e: + self.logger.error(f"Calculate download size failed: {e}") + raise + + def get_builds(self, build_platform): + password_arg = getattr(self.arguments, 'password', None) + password = '' if not password_arg else '&password=' + password_arg + generation = getattr(self.arguments, 'force_generation', None) or "2" + response = self.api_handler.session.get( + f"{constants.GOG_CONTENT_SYSTEM}/products/{self.game_id}/os/{build_platform}/builds?&generation={generation}{password}" + ) + + if not response.ok: + raise UnsupportedPlatform() + data = response.json() + + if data['total_count'] == 0: + raise UnsupportedPlatform() + + return data diff --git a/app/src/main/python/gogdl/dl/managers/task_executor.py b/app/src/main/python/gogdl/dl/managers/task_executor.py new file mode 100644 index 000000000..3814a7cf6 --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/task_executor.py @@ -0,0 +1,759 @@ +import logging +import os +import signal +import time +from sys import exit +from threading import Thread +from collections import deque, Counter +from queue import Queue # Use threading.Queue instead of multiprocessing.Queue +from threading import Condition +import tempfile +from typing import Union +from gogdl.dl import dl_utils + +from gogdl.dl.dl_utils import get_readable_size +from gogdl.dl.progressbar import ProgressBar +from gogdl.dl.workers import task_executor +from gogdl.dl.objects import generic, v2, v1, linux + +class ExecutingManager: + def __init__(self, api_handler, allowed_threads, path, support, diff, secure_links, game_id=None) -> None: + self.api_handler = api_handler + self.allowed_threads = allowed_threads + self.path = path + self.resume_file = os.path.join(path, '.gogdl-resume') + self.game_id = game_id # Store game_id for cancellation checking + self.support = support or os.path.join(path, 'gog-support') + self.cache = os.path.join(path, '.gogdl-download-cache') + self.diff: generic.BaseDiff = diff + self.secure_links = secure_links + self.logger = logging.getLogger("TASK_EXEC") + self.logger.info(f"ExecutingManager initialized with game_id: {self.game_id}") + + self.download_size = 0 + self.disk_size = 0 + + # Use temporary directory instead of shared memory on Android + self.temp_dir = tempfile.mkdtemp(prefix='gogdl_') + self.temp_files = deque() + self.hash_map = dict() + self.v2_chunks_to_download = deque() + self.v1_chunks_to_download = deque() + self.linux_chunks_to_download = deque() + self.tasks = deque() + self.active_tasks = 0 + + self.processed_items = 0 + self.items_to_complete = 0 + + self.download_workers = list() + self.writer_worker = None + self.threads = list() + + self.temp_cond = Condition() + self.task_cond = Condition() + + self.running = True + + def setup(self): + self.logger.debug("Beginning executor manager setup") + self.logger.debug("Initializing queues") + # Use threading queues instead of multiprocessing + self.download_queue = Queue() + self.download_res_queue = Queue() + self.writer_queue = Queue() + self.writer_res_queue = Queue() + + self.download_speed_updates = Queue() + self.writer_speed_updates = Queue() + + # Required space for download to succeed + required_disk_size_delta = 0 + + # This can be either v1 File or v2 DepotFile + for f in self.diff.deleted + self.diff.removed_redist: + support_flag = generic.TaskFlag.SUPPORT if 'support' in f.flags else generic.TaskFlag.NONE + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.DELETE_FILE | support_flag)) + if isinstance(f, v1.File): + required_disk_size_delta -= f.size + elif isinstance(f, v2.DepotFile): + required_disk_size_delta -= sum([ch['size'] for ch in f.chunks]) + + current_tmp_size = required_disk_size_delta + + shared_chunks_counter = Counter() + completed_files = set() + + missing_files = set() + mismatched_files = set() + + downloaded_v1 = dict() + downloaded_linux = dict() + cached = set() + + # Re-use caches + if os.path.exists(self.cache): + for cache_file in os.listdir(self.cache): + cached.add(cache_file) + + self.biggest_chunk = 0 + # Find biggest chunk to optimize how much memory is 'wasted' per chunk + # Also create hashmap for those files + for f in self.diff.new + self.diff.changed + self.diff.redist: + if isinstance(f, v1.File): + self.hash_map.update({f.path.lower(): f.hash}) + + elif isinstance(f, linux.LinuxFile): + self.hash_map.update({f.path.lower(): f.hash}) + + elif isinstance(f, v2.DepotFile): + first_chunk_checksum = f.chunks[0]['md5'] if len(f.chunks) else None + checksum = f.md5 or f.sha256 or first_chunk_checksum + self.hash_map.update({f.path.lower(): checksum}) + for i, chunk in enumerate(f.chunks): + shared_chunks_counter[chunk["compressedMd5"]] += 1 + if self.biggest_chunk < chunk["size"]: + self.biggest_chunk = chunk["size"] + + elif isinstance(f, v2.FileDiff): + first_chunk_checksum = f.file.chunks[0]['md5'] if len(f.file.chunks) else None + checksum = f.file.md5 or f.file.sha256 or first_chunk_checksum + self.hash_map.update({f.file.path.lower(): checksum}) + for i, chunk in enumerate(f.file.chunks): + if chunk.get("old_offset") is None: + shared_chunks_counter[chunk["compressedMd5"]] += 1 + if self.biggest_chunk < chunk["size"]: + self.biggest_chunk = chunk["size"] + + elif isinstance(f, v2.FilePatchDiff): + first_chunk_checksum = f.new_file.chunks[0]['md5'] if len(f.new_file.chunks) else None + checksum = f.new_file.md5 or f.new_file.sha256 or first_chunk_checksum + self.hash_map.update({f.new_file.path.lower(): checksum}) + for chunk in f.chunks: + shared_chunks_counter[chunk["compressedMd5"]] += 1 + if self.biggest_chunk < chunk["size"]: + self.biggest_chunk = chunk["size"] + + + if not self.biggest_chunk: + self.biggest_chunk = 20 * 1024 * 1024 + else: + # Have at least 10 MiB chunk size for V1 downloads + self.biggest_chunk = max(self.biggest_chunk, 10 * 1024 * 1024) + + if os.path.exists(self.resume_file): + self.logger.info("Attempting to continue the download") + try: + missing = 0 + mismatch = 0 + + with open(self.resume_file, 'r') as f: + for line in f.readlines(): + hash, support, file_path = line.strip().split(':') + + if support == 'support': + abs_path = os.path.join(self.support, file_path) + else: + abs_path = os.path.join(self.path, file_path) + + if not os.path.exists(dl_utils.get_case_insensitive_name(abs_path)): + missing_files.add(file_path.lower()) + missing += 1 + continue + + current_hash = self.hash_map.get(file_path.lower()) + if current_hash != hash: + mismatched_files.add(file_path.lower()) + mismatch += 1 + continue + + completed_files.add(file_path.lower()) + if missing: + self.logger.warning(f'There are {missing} missing files, and will be re-downloaded') + if mismatch: + self.logger.warning(f'There are {mismatch} changed files since last download, and will be re-downloaded') + + except Exception as e: + self.logger.error(f"Unable to resume download, continuing as normal {e}") + + # Create temp files for chunks instead of using shared memory + for i in range(self.allowed_threads * 4): # More temp files than threads + temp_file = os.path.join(self.temp_dir, f'chunk_{i}.tmp') + self.temp_files.append(temp_file) + + # Create tasks for each chunk + for f in self.diff.new + self.diff.changed + self.diff.redist: + if isinstance(f, v1.File): + support_flag = generic.TaskFlag.SUPPORT if 'support' in f.flags else generic.TaskFlag.NONE + if f.size == 0: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CREATE_FILE | support_flag)) + continue + + if f.path.lower() in completed_files: + downloaded_v1[f.hash] = f + continue + + required_disk_size_delta += f.size + # In case of same file we can copy it over + if f.hash in downloaded_v1: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.COPY_FILE | support_flag, old_flags=generic.TaskFlag.SUPPORT if 'support' in downloaded_v1[f.hash].flags else generic.TaskFlag.NONE, old_file=downloaded_v1[f.hash].path)) + if 'executable' in f.flags: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.MAKE_EXE | support_flag)) + continue + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.OPEN_FILE | support_flag)) + self.download_size += f.size + self.disk_size += f.size + size_left = f.size + chunk_offset = 0 + i = 0 + # Split V1 file by chunks, so we can store it in temp files + while size_left: + chunk_size = min(self.biggest_chunk, size_left) + offset = f.offset + chunk_offset + + task = generic.V1Task(f.product_id, i, offset, chunk_size, f.hash) + self.tasks.append(task) + self.v1_chunks_to_download.append((f.product_id, task.compressed_md5, offset, chunk_size)) + + chunk_offset += chunk_size + size_left -= chunk_size + i += 1 + + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CLOSE_FILE | support_flag)) + if 'executable' in f.flags: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.MAKE_EXE | support_flag)) + downloaded_v1[f.hash] = f + + elif isinstance(f, linux.LinuxFile): + if f.size == 0: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CREATE_FILE)) + continue + + if f.path.lower() in completed_files: + downloaded_linux[f.hash] = f + continue + + required_disk_size_delta += f.size + if f.hash in downloaded_linux: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.COPY_FILE, old_flags=generic.TaskFlag.NONE, old_file=downloaded_linux[f.hash].path)) + if 'executable' in f.flags: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.MAKE_EXE)) + continue + + self.tasks.append(generic.FileTask(f.path+'.tmp', flags=generic.TaskFlag.OPEN_FILE)) + self.download_size += f.compressed_size + self.disk_size += f.size + size_left = f.compressed_size + chunk_offset = 0 + i = 0 + # Split V1 file by chunks, so we can store it in temp files + while size_left: + chunk_size = min(self.biggest_chunk, size_left) + offset = f.offset + chunk_offset + + task = generic.V1Task(f.product, i, offset, chunk_size, f.hash) + self.tasks.append(task) + self.linux_chunks_to_download.append((f.product, task.compressed_md5, offset, chunk_size)) + + chunk_offset += chunk_size + size_left -= chunk_size + i += 1 + + self.tasks.append(generic.FileTask(f.path + '.tmp', flags=generic.TaskFlag.CLOSE_FILE)) + if f.compression: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.OPEN_FILE)) + self.tasks.append(generic.ChunkTask(f.product, 0, f.hash+"_dec", f.hash+"_dec", f.compressed_size, f.compressed_size, True, False, 0, old_flags=generic.TaskFlag.ZIP_DEC, old_file=f.path+'.tmp')) + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CLOSE_FILE)) + self.tasks.append(generic.FileTask(f.path + '.tmp', flags=generic.TaskFlag.DELETE_FILE)) + else: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.DELETE_FILE | generic.TaskFlag.RENAME_FILE, old_file=f.path+'.tmp')) + + if 'executable' in f.flags: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.MAKE_EXE)) + downloaded_linux[f.hash] = f + + elif isinstance(f, v2.DepotFile): + support_flag = generic.TaskFlag.SUPPORT if 'support' in f.flags else generic.TaskFlag.NONE + if not len(f.chunks): + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CREATE_FILE | support_flag)) + continue + if f.path.lower() in completed_files: + continue + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.OPEN_FILE | support_flag)) + for i, chunk in enumerate(f.chunks): + new_task = generic.ChunkTask(f.product_id, i, chunk["compressedMd5"], chunk["md5"], chunk["size"], chunk["compressedSize"]) + is_cached = chunk["md5"] in cached + if shared_chunks_counter[chunk["compressedMd5"]] > 1 and not is_cached: + self.v2_chunks_to_download.append((f.product_id, chunk["compressedMd5"])) + self.download_size += chunk['compressedSize'] + new_task.offload_to_cache = True + new_task.cleanup = True + cached.add(chunk["md5"]) + current_tmp_size += chunk['size'] + elif is_cached: + new_task.old_offset = 0 + # This can safely be absolute path, due to + # how os.path.join works in Writer + new_task.old_file = os.path.join(self.cache, chunk["md5"]) + else: + self.v2_chunks_to_download.append((f.product_id, chunk["compressedMd5"])) + self.download_size += chunk['compressedSize'] + self.disk_size += chunk['size'] + current_tmp_size += chunk['size'] + shared_chunks_counter[chunk["compressedMd5"]] -= 1 + new_task.cleanup = True + self.tasks.append(new_task) + if is_cached and shared_chunks_counter[chunk["compressedMd5"]] == 0: + cached.remove(chunk["md5"]) + self.tasks.append(generic.FileTask(os.path.join(self.cache, chunk["md5"]), flags=generic.TaskFlag.DELETE_FILE)) + current_tmp_size -= chunk['size'] + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CLOSE_FILE | support_flag)) + if 'executable' in f.flags: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.MAKE_EXE | support_flag)) + + elif isinstance(f, v2.FileDiff): + chunk_tasks = [] + reused = 0 + file_size = 0 + support_flag = generic.TaskFlag.SUPPORT if 'support' in f.file.flags else generic.TaskFlag.NONE + old_support_flag = generic.TaskFlag.SUPPORT if 'support' in f.old_file_flags else generic.TaskFlag.NONE + if f.file.path.lower() in completed_files: + continue + for i, chunk in enumerate(f.file.chunks): + chunk_task = generic.ChunkTask(f.file.product_id, i, chunk["compressedMd5"], chunk["md5"], chunk["size"], chunk["compressedSize"]) + file_size += chunk['size'] + if chunk.get("old_offset") is not None and f.file.path.lower() not in mismatched_files and f.file.path.lower() not in missing_files: + chunk_task.old_offset = chunk["old_offset"] + chunk_task.old_flags = old_support_flag + chunk_task.old_file = f.file.path + reused += 1 + + chunk_tasks.append(chunk_task) + else: + is_cached = chunk["md5"] in cached + if shared_chunks_counter[chunk["compressedMd5"]] > 1 and not is_cached: + self.v2_chunks_to_download.append((f.file.product_id, chunk["compressedMd5"])) + self.download_size += chunk['compressedSize'] + chunk_task.offload_to_cache = True + cached.add(chunk["md5"]) + current_tmp_size += chunk['size'] + elif is_cached: + chunk_task.old_offset = 0 + chunk_task.old_file = os.path.join(self.cache, chunk["md5"]) + else: + self.v2_chunks_to_download.append((f.file.product_id, chunk["compressedMd5"])) + self.download_size += chunk['compressedSize'] + + shared_chunks_counter[chunk["compressedMd5"]] -= 1 + chunk_task.cleanup = True + chunk_tasks.append(chunk_task) + if is_cached and shared_chunks_counter[chunk["compressedMd5"]] == 0: + cached.remove(chunk["md5"]) + self.tasks.append(generic.FileTask(os.path.join(self.cache, chunk["md5"]), flags=generic.TaskFlag.DELETE_FILE)) + current_tmp_size -= chunk['size'] + current_tmp_size += file_size + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + if reused: + self.tasks.append(generic.FileTask(f.file.path + ".tmp", flags=generic.TaskFlag.OPEN_FILE | support_flag)) + self.tasks.extend(chunk_tasks) + self.tasks.append(generic.FileTask(f.file.path + ".tmp", flags=generic.TaskFlag.CLOSE_FILE | support_flag)) + self.tasks.append(generic.FileTask(f.file.path, flags=generic.TaskFlag.RENAME_FILE | generic.TaskFlag.DELETE_FILE | support_flag, old_file=f.file.path + ".tmp")) + current_tmp_size -= file_size + else: + self.tasks.append(generic.FileTask(f.file.path, flags=generic.TaskFlag.OPEN_FILE | support_flag)) + self.tasks.extend(chunk_tasks) + self.tasks.append(generic.FileTask(f.file.path, flags=generic.TaskFlag.CLOSE_FILE | support_flag)) + if 'executable' in f.file.flags: + self.tasks.append(generic.FileTask(f.file.path, flags=generic.TaskFlag.MAKE_EXE | support_flag)) + self.disk_size += file_size + + elif isinstance(f, v2.FilePatchDiff): + chunk_tasks = [] + patch_size = 0 + old_file_size = 0 + out_file_size = 0 + if f.target.lower() in completed_files: + continue + + # Calculate output size + for chunk in f.new_file.chunks: + out_file_size += chunk['size'] + + # Calculate old size + for chunk in f.old_file.chunks: + old_file_size += chunk['size'] + + # Make chunk tasks + for i, chunk in enumerate(f.chunks): + chunk_task = generic.ChunkTask(f'{f.new_file.product_id}_patch', i, chunk['compressedMd5'], chunk['md5'], chunk['size'], chunk['compressedSize']) + chunk_task.cleanup = True + patch_size += chunk['size'] + is_cached = chunk["md5"] in cached + if shared_chunks_counter[chunk["compressedMd5"]] > 1 and not is_cached: + self.v2_chunks_to_download.append((f'{f.new_file.product_id}_patch', chunk["compressedMd5"])) + chunk_task.offload_to_cache = True + cached.add(chunk["md5"]) + self.download_size += chunk['compressedSize'] + current_tmp_size += chunk['size'] + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + elif is_cached: + chunk_task.old_offset = 0 + chunk_task.old_file = os.path.join(self.cache, chunk["md5"]) + else: + self.v2_chunks_to_download.append((f'{f.new_file.product_id}_patch', chunk["compressedMd5"])) + self.download_size += chunk['compressedSize'] + shared_chunks_counter[chunk['compressedMd5']] -= 1 + chunk_tasks.append(chunk_task) + if is_cached and shared_chunks_counter[chunk["compressedMd5"]] == 0: + cached.remove(chunk["md5"]) + self.tasks.append(generic.FileTask(os.path.join(self.cache, chunk["md5"]), flags=generic.TaskFlag.DELETE_FILE)) + current_tmp_size -= chunk['size'] + + self.disk_size += patch_size + current_tmp_size += patch_size + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + + # Download patch + self.tasks.append(generic.FileTask(f.target + ".delta", flags=generic.TaskFlag.OPEN_FILE)) + self.tasks.extend(chunk_tasks) + self.tasks.append(generic.FileTask(f.target + ".delta", flags=generic.TaskFlag.CLOSE_FILE)) + + current_tmp_size += out_file_size + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + + # Apply patch to .tmp file + self.tasks.append(generic.FileTask(f.target + ".tmp", flags=generic.TaskFlag.PATCH, patch_file=(f.target + '.delta'), old_file=f.source)) + current_tmp_size -= patch_size + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + # Remove patch file + self.tasks.append(generic.FileTask(f.target + ".delta", flags=generic.TaskFlag.DELETE_FILE)) + current_tmp_size -= old_file_size + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + # Move new file to old one's location + self.tasks.append(generic.FileTask(f.target, flags=generic.TaskFlag.RENAME_FILE | generic.TaskFlag.DELETE_FILE, old_file=f.target + ".tmp")) + self.disk_size += out_file_size + + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + + + for f in self.diff.links: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CREATE_SYMLINK, old_file=f.target)) + + self.items_to_complete = len(self.tasks) + + print(get_readable_size(self.download_size), self.download_size) + print(get_readable_size(required_disk_size_delta), required_disk_size_delta) + + return dl_utils.check_free_space(required_disk_size_delta, self.path) + + + def run(self): + self.logger.debug(f"Using temp directory: {self.temp_dir}") + interrupted = False + self.fatal_error = False + + def handle_sig(num, frame): + nonlocal interrupted + self.interrupt_shutdown() + interrupted = True + exit(-num) + + try: + self.threads.append(Thread(target=self.download_manager, args=(self.task_cond, self.temp_cond))) + self.threads.append(Thread(target=self.process_task_results, args=(self.task_cond,))) + self.threads.append(Thread(target=self.process_writer_task_results, args=(self.temp_cond,))) + self.progress = ProgressBar(self.disk_size, self.download_speed_updates, self.writer_speed_updates, self.game_id) + + # Spawn workers using threads instead of processes + self.logger.info(f"Starting {self.allowed_threads} download workers for game {self.game_id}") + for i in range(self.allowed_threads): + worker = Thread(target=task_executor.download_worker, args=( + self.download_queue, self.download_res_queue, + self.download_speed_updates, self.secure_links, self.temp_dir, self.game_id + )) + worker.start() + self.download_workers.append(worker) + + self.writer_worker = Thread(target=task_executor.writer_worker, args=( + self.writer_queue, self.writer_res_queue, + self.writer_speed_updates, self.cache, self.temp_dir + )) + self.writer_worker.start() + + [th.start() for th in self.threads] + + # Signal handling - Android compatibility + try: + signal.signal(signal.SIGTERM, handle_sig) + signal.signal(signal.SIGINT, handle_sig) + except ValueError as e: + # Android: signal only works in main thread + self.logger.debug(f"Signal handling not available: {e}") + + if self.disk_size: + self.progress.start() + + while self.processed_items < self.items_to_complete and not interrupted and not self.fatal_error: + # Check for Android cancellation signal + try: + import builtins + flag_name = f'GOGDL_CANCEL_{self.game_id}' + if hasattr(builtins, flag_name): + flag_value = getattr(builtins, flag_name, False) + if flag_value: + self.logger.info(f"Download cancelled by user for game {self.game_id}") + self.fatal_error = True # Mark as error to prevent completion + interrupted = True + break + except Exception as e: + self.logger.debug(f"Error checking cancellation flag: {e}") + + time.sleep(1) + if interrupted: + return True + except KeyboardInterrupt: + return True + + self.shutdown() + return self.fatal_error + + def interrupt_shutdown(self): + self.progress.completed = True + self.running = False + + with self.task_cond: + self.task_cond.notify() + + with self.temp_cond: + self.temp_cond.notify() + + for t in self.threads: + t.join(timeout=5.0) + if t.is_alive(): + self.logger.warning(f'Thread did not terminate! {repr(t)}') + + for worker in self.download_workers: + worker.join(timeout=5.0) + + def shutdown(self): + self.logger.debug("Stopping progressbar") + self.progress.completed = True + + self.logger.debug("Sending terminate instruction to workers") + for _ in range(self.allowed_threads): + self.download_queue.put(generic.TerminateWorker()) + + self.writer_queue.put(generic.TerminateWorker()) + + for worker in self.download_workers: + worker.join(timeout=2) + + if self.writer_worker: + self.writer_worker.join(timeout=10) + + self.running = False + with self.task_cond: + self.task_cond.notify() + + with self.temp_cond: + self.temp_cond.notify() + + # Clean up temp directory + import shutil + try: + shutil.rmtree(self.temp_dir) + except: + self.logger.warning("Failed to clean up temp directory") + + try: + if os.path.exists(self.resume_file): + os.remove(self.resume_file) + except: + self.logger.error("Failed to remove resume file") + + def download_manager(self, task_cond: Condition, temp_cond: Condition): + self.logger.debug("Starting download scheduler") + no_temp = False + while self.running: + while self.active_tasks <= self.allowed_threads * 2 and (self.v2_chunks_to_download or self.v1_chunks_to_download): + + try: + temp_file = self.temp_files.popleft() + no_temp = False + except IndexError: + no_temp = True + break + + if self.v1_chunks_to_download: + product_id, chunk_id, offset, chunk_size = self.v1_chunks_to_download.popleft() + + try: + self.download_queue.put(task_executor.DownloadTask1(product_id, offset, chunk_size, chunk_id, temp_file)) + self.logger.debug(f"Pushed v1 download to queue {chunk_id} {product_id} {offset} {chunk_size}") + self.active_tasks += 1 + continue + except Exception as e: + self.logger.warning(f"Failed to push v1 task to download {e}") + self.v1_chunks_to_download.appendleft((product_id, chunk_id, offset, chunk_size)) + self.temp_files.appendleft(temp_file) + break + + elif self.v2_chunks_to_download: + product_id, chunk_hash = self.v2_chunks_to_download.popleft() + try: + self.download_queue.put(task_executor.DownloadTask2(product_id, chunk_hash, temp_file)) + self.logger.debug(f"Pushed DownloadTask2 for {chunk_hash}") + self.active_tasks += 1 + except Exception as e: + self.logger.warning(f"Failed to push task to download {e}") + self.v2_chunks_to_download.appendleft((product_id, chunk_hash)) + self.temp_files.appendleft(temp_file) + break + + else: + with task_cond: + self.logger.debug("Waiting for more tasks") + task_cond.wait(timeout=1.0) + continue + + if no_temp: + with temp_cond: + self.logger.debug(f"Waiting for more temp files") + temp_cond.wait(timeout=1.0) + + self.logger.debug("Download scheduler out..") + + def process_task_results(self, task_cond: Condition): + self.logger.debug("Download results collector starting") + ready_chunks = dict() + + try: + task = self.tasks.popleft() + except IndexError: + task = None + + current_dest = self.path + current_file = '' + + while task and self.running: + if isinstance(task, generic.FileTask): + try: + task_dest = self.path + old_destination = self.path + if task.flags & generic.TaskFlag.SUPPORT: + task_dest = self.support + if task.old_flags & generic.TaskFlag.SUPPORT: + old_destination = self.support + + writer_task = task_executor.WriterTask(task_dest, task.path, task.flags, old_destination=old_destination, old_file=task.old_file, patch_file=task.patch_file) + self.writer_queue.put(writer_task) + if task.flags & generic.TaskFlag.OPEN_FILE: + current_file = task.path + current_dest = task_dest + except Exception as e: + self.tasks.appendleft(task) + self.logger.warning(f"Failed to add queue element {e}") + continue + + try: + task: Union[generic.ChunkTask, generic.V1Task] = self.tasks.popleft() + except IndexError: + break + continue + + while ((task.compressed_md5 in ready_chunks) or task.old_file): + temp_file = None + if not task.old_file: + temp_file = ready_chunks[task.compressed_md5].temp_file + + try: + self.logger.debug(f"Adding {task.compressed_md5} to writer") + flags = generic.TaskFlag.NONE + old_destination = None + if task.cleanup: + flags |= generic.TaskFlag.RELEASE_TEMP + if task.offload_to_cache: + flags |= generic.TaskFlag.OFFLOAD_TO_CACHE + if task.old_flags & generic.TaskFlag.SUPPORT: + old_destination = self.support + self.writer_queue.put(task_executor.WriterTask(current_dest, current_file, flags=flags, temp_file=temp_file, old_destination=old_destination, old_file=task.old_file, old_offset=task.old_offset, size=task.size, hash=task.md5)) + except Exception as e: + self.logger.error(f"Adding to writer queue failed {e}") + break + + if task.cleanup and not task.old_file: + del ready_chunks[task.compressed_md5] + + try: + task = self.tasks.popleft() + if isinstance(task, generic.FileTask): + break + except IndexError: + task = None + break + + else: + try: + res: task_executor.DownloadTaskResult = self.download_res_queue.get(timeout=1) + if res.success: + self.logger.debug(f"Chunk {res.task.compressed_sum} ready") + ready_chunks[res.task.compressed_sum] = res + self.progress.update_downloaded_size(res.download_size) + self.progress.update_decompressed_size(res.decompressed_size) + self.active_tasks -= 1 + else: + self.logger.warning(f"Chunk download failed, reason {res.fail_reason}") + try: + self.download_queue.put(res.task) + except Exception as e: + self.logger.warning("Failed to resubmit download task") + + with task_cond: + task_cond.notify() + except: + pass + + self.logger.debug("Download results collector exiting...") + + def process_writer_task_results(self, temp_cond: Condition): + self.logger.debug("Starting writer results collector") + while self.running: + try: + res: task_executor.WriterTaskResult = self.writer_res_queue.get(timeout=1) + + if isinstance(res.task, generic.TerminateWorker): + break + + if res.success and res.task.flags & generic.TaskFlag.CLOSE_FILE and not res.task.file_path.endswith('.delta'): + if res.task.file_path.endswith('.tmp'): + res.task.file_path = res.task.file_path[:-4] + + checksum = self.hash_map.get(res.task.file_path.lower()) + if not checksum: + self.logger.warning(f"No checksum for closed file, unable to push to resume file {res.task.file_path}") + else: + if res.task.flags & generic.TaskFlag.SUPPORT: + support = "support" + else: + support = "" + + with open(self.resume_file, 'a') as f: + f.write(f"{checksum}:{support}:{res.task.file_path}\n") + + if not res.success: + self.logger.fatal("Task writer failed") + self.fatal_error = True + return + + self.progress.update_bytes_written(res.written) + if res.task.flags & generic.TaskFlag.RELEASE_TEMP and res.task.temp_file: + self.logger.debug(f"Releasing temp file {res.task.temp_file}") + self.temp_files.appendleft(res.task.temp_file) + with temp_cond: + temp_cond.notify() + self.processed_items += 1 + + except: + continue + + self.logger.debug("Writer results collector exiting...") diff --git a/app/src/main/python/gogdl/dl/managers/v1.py b/app/src/main/python/gogdl/dl/managers/v1.py new file mode 100644 index 000000000..eef5f902e --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/v1.py @@ -0,0 +1,313 @@ +""" +Android-compatible V1 manager for generation 1 games +Based on heroic-gogdl v1.py but with Android compatibility +""" + +# Handle old games downloading via V1 depot system +# V1 is there since GOG 1.0 days, it has no compression and relies on downloading chunks from big main.bin file +import hashlib +from sys import exit +import os +import logging +import json +from typing import Union +from gogdl import constants +from gogdl.dl import dl_utils +from gogdl.dl.managers.dependencies import DependenciesManager +from gogdl.dl.managers.task_executor import ExecutingManager +from gogdl.dl.workers.task_executor import DownloadTask1, DownloadTask2, WriterTask +from gogdl.dl.objects import v1 +from gogdl.languages import Language + + +class Manager: + def __init__(self, generic_manager): + self.game_id = generic_manager.game_id + self.arguments = generic_manager.arguments + self.unknown_arguments = generic_manager.unknown_arguments + if "path" in self.arguments: + self.path = self.arguments.path + else: + self.path = "" + + if "support_path" in self.arguments: + self.support = self.arguments.support_path + else: + self.support = "" + + self.api_handler = generic_manager.api_handler + self.should_append_folder_name = generic_manager.should_append_folder_name + self.is_verifying = generic_manager.is_verifying + self.allowed_threads = generic_manager.allowed_threads + + self.platform = generic_manager.platform + + self.builds = generic_manager.builds + self.build = generic_manager.target_build + self.version_name = self.build["version_name"] + + self.lang = Language.parse(self.arguments.lang or "English") + self.dlcs_should_be_downloaded = self.arguments.dlcs + if self.arguments.dlcs_list: + self.dlcs_list = self.arguments.dlcs_list.split(",") + + else: + self.dlcs_list = list() + + self.dlc_only = self.arguments.dlc_only + + self.manifest = None + self.meta = None + + self.logger = logging.getLogger("V1") + self.logger.info("Initialized V1 Download Manager") + + # Get manifest of selected build + def get_meta(self): + meta_url = self.build["link"] + self.meta, headers = dl_utils.get_zlib_encoded(self.api_handler, meta_url) + if not self.meta: + raise Exception("There was an error obtaining meta") + if headers: + self.version_etag = headers.get("Etag") + + # Append folder name when downloading + if self.should_append_folder_name: + self.path = os.path.join(self.path, self.meta["product"]["installDirectory"]) + + def get_download_size(self): + self.get_meta() + dlcs = self.get_dlcs_user_owns(True) + self.manifest = v1.Manifest(self.platform, self.meta, self.lang, dlcs, self.api_handler, False) + + build = self.api_handler.get_dependencies_repo() + repository = dl_utils.get_zlib_encoded(self.api_handler, build['repository_manifest'])[0] or {} + + size_data = self.manifest.calculate_download_size() + + for depot in repository["depots"]: + if depot["dependencyId"] in self.manifest.dependencies_ids: + if not depot["executable"]["path"].startswith("__redist"): + size_data[self.game_id]['*']["download_size"] += depot["compressedSize"] + size_data[self.game_id]['*']["disk_size"] += depot["size"] + + available_branches = set([build["branch"] for build in self.builds["items"] if build["branch"]]) + available_branches_list = [None] + list(available_branches) + + for dlc in dlcs: + dlc.update({"size": size_data[dlc["id"]]}) + + response = { + "size": size_data[self.game_id], + "dlcs": dlcs, + "buildId": self.build["legacy_build_id"], + "languages": self.manifest.list_languages(), + "folder_name": self.meta["product"]["installDirectory"], + "dependencies": [dep.id for dep in self.manifest.dependencies], + "versionEtag": self.version_etag, + "versionName": self.version_name, + "available_branches": available_branches_list + } + return response + + + def get_dlcs_user_owns(self, info_command=False, requested_dlcs=None): + if requested_dlcs is None: + requested_dlcs = list() + if not self.dlcs_should_be_downloaded and not info_command: + return [] + self.logger.debug("Getting dlcs user owns") + dlcs = [] + if len(requested_dlcs) > 0: + for product in self.meta["product"]["gameIDs"]: + if ( + product["gameID"] != self.game_id # Check if not base game + and product["gameID"] in requested_dlcs # Check if requested by user + and self.api_handler.does_user_own(product["gameID"]) # Check if owned + ): + dlcs.append({"title": product["name"]["en"], "id": product["gameID"]}) + return dlcs + for product in self.meta["product"]["gameIDs"]: + # Check if not base game and if owned + if product["gameID"] != self.game_id and self.api_handler.does_user_own( + product["gameID"] + ): + dlcs.append({"title": product["name"]["en"], "id": product["gameID"]}) + return dlcs + + + def download(self): + manifest_path = os.path.join(constants.MANIFESTS_DIR, self.game_id) + old_manifest = None + + # Load old manifest + if os.path.exists(manifest_path): + with open(manifest_path, "r") as f_handle: + try: + json_data = json.load(f_handle) + old_manifest = dl_utils.create_manifest_class(json_data, self.api_handler) + except json.JSONDecodeError: + old_manifest = None + pass + + if self.is_verifying: + if old_manifest: + self.manifest = old_manifest + old_manifest = None + dlcs_user_owns = self.manifest.dlcs or [] + else: + raise Exception("No manifest stored locally, unable to verify") + else: + self.get_meta() + dlcs_user_owns = self.get_dlcs_user_owns(requested_dlcs=self.dlcs_list) + + if self.arguments.dlcs_list: + self.logger.info(f"Requested dlcs {self.arguments.dlcs_list}") + self.logger.info(f"Owned dlcs {dlcs_user_owns}") + self.logger.debug("Parsing manifest") + self.manifest = v1.Manifest(self.platform, self.meta, self.lang, dlcs_user_owns, self.api_handler, self.dlc_only) + + if self.manifest: + self.manifest.get_files() + + if old_manifest: + old_manifest.get_files() + + diff = v1.ManifestDiff.compare(self.manifest, old_manifest) + + self.logger.info(f"{diff}") + self.logger.info(f"Old manifest files count: {len(old_manifest.files) if old_manifest else 0}") + self.logger.info(f"New manifest files count: {len(self.manifest.files)}") + + # Calculate total expected size + total_size = sum(file.size for file in self.manifest.files) + self.logger.info(f"Total expected game size: {total_size} bytes ({total_size / (1024*1024):.2f} MB)") + + # Show some example files + if self.manifest.files: + self.logger.info(f"Example files in manifest:") + for i, file in enumerate(self.manifest.files[:5]): # Show first 5 files + self.logger.info(f" {file.path}: {file.size} bytes") + if len(self.manifest.files) > 5: + self.logger.info(f" ... and {len(self.manifest.files) - 5} more files") + + + has_dependencies = len(self.manifest.dependencies) > 0 + + secure_link_endpoints_ids = [product["id"] for product in dlcs_user_owns] + if not self.dlc_only: + secure_link_endpoints_ids.append(self.game_id) + secure_links = dict() + for product_id in secure_link_endpoints_ids: + secure_links.update( + { + product_id: dl_utils.get_secure_link( + self.api_handler, f"/{self.platform}/{self.manifest.data['product']['timestamp']}/", product_id, generation=1 + ) + } + ) + + dependency_manager = DependenciesManager([dep.id for dep in self.manifest.dependencies], self.path, self.allowed_threads, self.api_handler, download_game_deps_only=True) + + # Find dependencies that are no longer used + if old_manifest: + removed_dependencies = [id for id in old_manifest.dependencies_ids if id not in self.manifest.dependencies_ids] + + for depot in dependency_manager.repository["depots"]: + if depot["dependencyId"] in removed_dependencies and not depot["executable"]["path"].startswith("__redist"): + diff.removed_redist += dependency_manager.get_files_for_depot_manifest(depot['manifest']) + + if has_dependencies: + secure_links.update({'redist': dl_utils.get_dependency_link(self.api_handler)}) + + diff.redist = dependency_manager.get(return_files=True) or [] + + + if not len(diff.changed) and not len(diff.deleted) and not len(diff.new) and not len(diff.redist) and not len(diff.removed_redist): + self.logger.info("Nothing to do") + return + + if self.is_verifying: + new_diff = v1.ManifestDiff() + invalid = 0 + for file in diff.new: + # V1 only files + if not file.size: + continue + + if 'support' in file.flags: + file_path = os.path.join(self.support, file.path) + else: + file_path = os.path.join(self.path, file.path) + file_path = dl_utils.get_case_insensitive_name(file_path) + + if not os.path.exists(file_path): + invalid += 1 + new_diff.new.append(file) + continue + + with open(file_path, 'rb') as fh: + file_sum = hashlib.md5() + + while chunk := fh.read(8 * 1024 * 1024): + file_sum.update(chunk) + + if file_sum.hexdigest() != file.hash: + invalid += 1 + new_diff.new.append(file) + continue + + for file in diff.redist: + if len(file.chunks) == 0: + continue + file_path = dl_utils.get_case_insensitive_name(os.path.join(self.path, file.path)) + if not os.path.exists(file_path): + invalid += 1 + new_diff.redist.append(file) + continue + valid = True + with open(file_path, 'rb') as fh: + for chunk in file.chunks: + chunk_sum = hashlib.md5() + chunk_data = fh.read(chunk['size']) + chunk_sum.update(chunk_data) + + if chunk_sum.hexdigest() != chunk['md5']: + valid = False + break + if not valid: + invalid += 1 + new_diff.redist.append(file) + continue + if not invalid: + self.logger.info("All files look good") + return + + self.logger.info(f"Found {invalid} broken files, repairing...") + diff = new_diff + + executor = ExecutingManager(self.api_handler, self.allowed_threads, self.path, self.support, diff, secure_links, self.game_id) + success = executor.setup() + if not success: + print('Unable to proceed, Not enough disk space') + exit(2) + dl_utils.prepare_location(self.path) + + for dir in self.manifest.dirs: + manifest_dir_path = os.path.join(self.path, dir.path) + dl_utils.prepare_location(dl_utils.get_case_insensitive_name(manifest_dir_path)) + + cancelled = executor.run() + + if cancelled: + return + + dl_utils.prepare_location(constants.MANIFESTS_DIR) + if self.manifest: + with open(manifest_path, 'w') as f_handle: + data = self.manifest.serialize_to_json() + f_handle.write(data) + + self.logger.info(f"Old manifest files count: {len(old_manifest.files) if old_manifest else 0}") + self.logger.info(f"New manifest files count: {len(self.manifest.files)}") + self.logger.info(f"Target directory: {self.path}") \ No newline at end of file diff --git a/app/src/main/python/gogdl/dl/managers/v2.py b/app/src/main/python/gogdl/dl/managers/v2.py new file mode 100644 index 000000000..9b51033bd --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/v2.py @@ -0,0 +1,310 @@ +""" +Android-compatible V2 manager for Windows game downloads +""" + +# Handle newer depots download +# This was introduced in GOG Galaxy 2.0, it features compression and files split by chunks +import json +from sys import exit +from gogdl.dl import dl_utils +import gogdl.dl.objects.v2 as v2 +import hashlib +from gogdl.dl.managers import dependencies +from gogdl.dl.managers.task_executor import ExecutingManager +from gogdl.dl.workers import task_executor +from gogdl.languages import Language +from gogdl import constants +import os +import logging + + +class Manager: + def __init__(self, generic_manager): + self.game_id = generic_manager.game_id + self.arguments = generic_manager.arguments + self.unknown_arguments = generic_manager.unknown_arguments + if "path" in self.arguments: + self.path = self.arguments.path + else: + self.path = "" + if "support_path" in self.arguments: + self.support = self.arguments.support_path + else: + self.support = "" + + self.allowed_threads = generic_manager.allowed_threads + + self.api_handler = generic_manager.api_handler + self.should_append_folder_name = generic_manager.should_append_folder_name + self.is_verifying = generic_manager.is_verifying + + self.builds = generic_manager.builds + self.build = generic_manager.target_build + self.version_name = self.build["version_name"] + + self.lang = Language.parse(self.arguments.lang or "en-US") + self.dlcs_should_be_downloaded = self.arguments.dlcs + if self.arguments.dlcs_list: + self.dlcs_list = self.arguments.dlcs_list.split(",") + else: + self.dlcs_list = list() + self.dlc_only = self.arguments.dlc_only + + self.manifest = None + self.stop_all_threads = False + + self.logger = logging.getLogger("V2") + self.logger.info("Initialized V2 Download Manager") + + def get_download_size(self): + self.get_meta() + dlcs = self.get_dlcs_user_owns(info_command=True) + self.manifest = v2.Manifest(self.meta, self.lang, dlcs, self.api_handler, False) + + build = self.api_handler.get_dependencies_repo() + repository = dl_utils.get_zlib_encoded(self.api_handler, build['repository_manifest'])[0] or {} + + size_data = self.manifest.calculate_download_size() + + for depot in repository["depots"]: + if depot["dependencyId"] in self.manifest.dependencies_ids: + if not depot["executable"]["path"].startswith("__redist"): + size_data[self.game_id]['*']["download_size"] += depot.get("compressedSize") or 0 + size_data[self.game_id]['*']["disk_size"] += depot.get("size") or 0 + + available_branches = set([build["branch"] for build in self.builds["items"] if build["branch"]]) + available_branches_list = [None] + list(available_branches) + + + for dlc in dlcs: + dlc.update({"size": size_data[dlc["id"]]}) + + response = { + "size": size_data[self.game_id], + "dlcs": dlcs, + "buildId": self.build["build_id"], + "languages": self.manifest.list_languages(), + "folder_name": self.meta["installDirectory"], + "dependencies": self.manifest.dependencies_ids, + "versionEtag": self.version_etag, + "versionName": self.version_name, + "available_branches": available_branches_list + } + return response + + def download(self): + manifest_path = os.path.join(constants.MANIFESTS_DIR, self.game_id) + old_manifest = None + + # Load old manifest + if os.path.exists(manifest_path): + self.logger.debug(f"Loading existing manifest for game {self.game_id}") + with open(manifest_path, 'r') as f_handle: + try: + json_data = json.load(f_handle) + self.logger.info("Creating Manifest instance from existing manifest") + old_manifest = dl_utils.create_manifest_class(json_data, self.api_handler) + except json.JSONDecodeError: + old_manifest = None + pass + + if self.is_verifying: + if old_manifest: + self.logger.warning("Verifying - ignoring obtained manifest in favor of existing one") + self.manifest = old_manifest + dlcs_user_owns = self.manifest.dlcs or [] + old_manifest = None + else: + raise Exception("No manifest stored locally, unable to verify") + else: + self.get_meta() + dlcs_user_owns = self.get_dlcs_user_owns( + requested_dlcs=self.dlcs_list + ) + + if self.arguments.dlcs_list: + self.logger.info(f"Requested dlcs {self.arguments.dlcs_list}") + self.logger.info(f"Owned dlcs {dlcs_user_owns}") + + self.logger.debug("Parsing manifest") + self.manifest = v2.Manifest( + self.meta, self.lang, dlcs_user_owns, self.api_handler, self.dlc_only + ) + patch = None + if self.manifest: + self.logger.debug("Requesting files of primary manifest") + self.manifest.get_files() + if old_manifest: + self.logger.debug("Requesting files of previous manifest") + old_manifest.get_files() + patch = v2.Patch.get(self.manifest, old_manifest, self.lang, dlcs_user_owns, self.api_handler) + if not patch: + self.logger.info("No patch found, falling back to chunk based updates") + + diff = v2.ManifestDiff.compare(self.manifest, old_manifest, patch) + self.logger.info(diff) + + + dependencies_manager = dependencies.DependenciesManager(self.manifest.dependencies_ids, self.path, + self.arguments.workers_count, self.api_handler, download_game_deps_only=True) + + # Find dependencies that are no longer used + if old_manifest: + removed_dependencies = [id for id in old_manifest.dependencies_ids if id not in self.manifest.dependencies_ids] + + for depot in dependencies_manager.repository["depots"]: + if depot["dependencyId"] in removed_dependencies and not depot["executable"]["path"].startswith("__redist"): + diff.removed_redist += dependencies_manager.get_files_for_depot_manifest(depot['manifest']) + + + diff.redist = dependencies_manager.get(True) or [] + + if not len(diff.changed) and not len(diff.deleted) and not len(diff.new) and not len(diff.redist) and not len(diff.removed_redist): + self.logger.info("Nothing to do") + return + secure_link_endpoints_ids = [product["id"] for product in dlcs_user_owns] + if not self.dlc_only: + secure_link_endpoints_ids.append(self.game_id) + secure_links = dict() + for product_id in secure_link_endpoints_ids: + secure_links.update( + { + product_id: dl_utils.get_secure_link( + self.api_handler, "/", product_id + ) + } + ) + if patch: + secure_links.update( + { + f"{product_id}_patch": dl_utils.get_secure_link( + self.api_handler, "/", product_id, root="/patches/store" + ) + } + ) + + if len(diff.redist) > 0: + secure_links.update( + { + 'redist': dl_utils.get_dependency_link(self.api_handler) + } + ) + + if self.is_verifying: + new_diff = v2.ManifestDiff() + invalid = 0 + + for file in diff.new: + if len(file.chunks) == 0: + continue + if 'support' in file.flags: + file_path = os.path.join(self.support, file.path) + else: + file_path = os.path.join(self.path, file.path) + file_path = dl_utils.get_case_insensitive_name(file_path) + if not os.path.exists(file_path): + invalid += 1 + new_diff.new.append(file) + continue + valid = True + with open(file_path, 'rb') as fh: + for chunk in file.chunks: + chunk_sum = hashlib.md5() + chunk_data = fh.read(chunk['size']) + chunk_sum.update(chunk_data) + + if chunk_sum.hexdigest() != chunk['md5']: + valid = False + break + if not valid: + invalid += 1 + new_diff.new.append(file) + continue + + for file in diff.redist: + if len(file.chunks) == 0: + continue + file_path = dl_utils.get_case_insensitive_name(os.path.join(self.path, file.path)) + if not os.path.exists(file_path): + invalid += 1 + new_diff.redist.append(file) + continue + valid = True + with open(file_path, 'rb') as fh: + for chunk in file.chunks: + chunk_sum = hashlib.md5() + chunk_data = fh.read(chunk['size']) + chunk_sum.update(chunk_data) + + if chunk_sum.hexdigest() != chunk['md5']: + valid = False + break + if not valid: + invalid += 1 + new_diff.redist.append(file) + continue + for file in diff.links: + file_path = os.path.join(self.path, file.path) + file_path = dl_utils.get_case_insensitive_name(file_path) + if not os.path.exists(file_path): + new_diff.links.append(file) + + if not invalid: + self.logger.info("All files look good") + return + + self.logger.info(f"Found {invalid} broken files, repairing...") + diff = new_diff + + executor = ExecutingManager(self.api_handler, self.allowed_threads, self.path, self.support, diff, secure_links, self.game_id) + success = executor.setup() + if not success: + print('Unable to proceed, Not enough disk space') + exit(2) + dl_utils.prepare_location(self.path) + + for dir in self.manifest.dirs: + manifest_dir_path = os.path.join(self.path, dir.path) + dl_utils.prepare_location(dl_utils.get_case_insensitive_name(manifest_dir_path)) + cancelled = executor.run() + + if cancelled: + return + + dl_utils.prepare_location(constants.MANIFESTS_DIR) + if self.manifest: + with open(manifest_path, 'w') as f_handle: + data = self.manifest.serialize_to_json() + f_handle.write(data) + + def get_meta(self): + meta_url = self.build["link"] + self.meta, headers = dl_utils.get_zlib_encoded(self.api_handler, meta_url) + self.version_etag = headers.get("Etag") + + # Append folder name when downloading + if self.should_append_folder_name: + self.path = os.path.join(self.path, self.meta["installDirectory"]) + + def get_dlcs_user_owns(self, info_command=False, requested_dlcs=None): + if requested_dlcs is None: + requested_dlcs = list() + if not self.dlcs_should_be_downloaded and not info_command: + return [] + self.logger.debug("Getting dlcs user owns") + dlcs = [] + if len(requested_dlcs) > 0: + for product in self.meta["products"]: + if ( + product["productId"] != self.game_id + and product["productId"] in requested_dlcs + and self.api_handler.does_user_own(product["productId"]) + ): + dlcs.append({"title": product["name"], "id": product["productId"]}) + return dlcs + for product in self.meta["products"]: + if product["productId"] != self.game_id and self.api_handler.does_user_own( + product["productId"] + ): + dlcs.append({"title": product["name"], "id": product["productId"]}) + return dlcs diff --git a/app/src/main/python/gogdl/dl/objects/__init__.py b/app/src/main/python/gogdl/dl/objects/__init__.py new file mode 100644 index 000000000..587f18fe5 --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/__init__.py @@ -0,0 +1,2 @@ +# Data objects for GOG content system +from . import v1, v2, generic diff --git a/app/src/main/python/gogdl/dl/objects/generic.py b/app/src/main/python/gogdl/dl/objects/generic.py new file mode 100644 index 000000000..a5ecd3344 --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/generic.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from enum import Flag, auto +from typing import Optional + + +class BaseDiff: + def __init__(self): + self.deleted = [] + self.new = [] + self.changed = [] + self.redist = [] + self.removed_redist = [] + + self.links = [] # Unix only + + def __str__(self): + return f"Deleted: {len(self.deleted)} New: {len(self.new)} Changed: {len(self.changed)}" + +class TaskFlag(Flag): + NONE = 0 + SUPPORT = auto() + OPEN_FILE = auto() + CLOSE_FILE = auto() + CREATE_FILE = auto() + CREATE_SYMLINK = auto() + RENAME_FILE = auto() + COPY_FILE = auto() + DELETE_FILE = auto() + OFFLOAD_TO_CACHE = auto() + MAKE_EXE = auto() + PATCH = auto() + RELEASE_MEM = auto() + RELEASE_TEMP = auto() + ZIP_DEC = auto() + +@dataclass +class MemorySegment: + offset: int + end: int + + @property + def size(self): + return self.end - self.offset + +@dataclass +class ChunkTask: + product: str + index: int + + compressed_md5: str + md5: str + size: int + download_size: int + + cleanup: bool = False + offload_to_cache: bool = False + old_offset: Optional[int] = None + old_flags: TaskFlag = TaskFlag.NONE + old_file: Optional[str] = None + +@dataclass +class V1Task: + product: str + index: int + offset: int + size: int + md5: str + cleanup: Optional[bool] = True + + old_offset: Optional[int] = None + offload_to_cache: Optional[bool] = False + old_flags: TaskFlag = TaskFlag.NONE + old_file: Optional[str] = None + + # This isn't actual sum, but unique id of chunk we use to decide + # if we should push it to writer + @property + def compressed_md5(self): + return self.md5 + "_" + str(self.index) + +@dataclass +class Task: + flag: TaskFlag + file_path: Optional[str] = None + file_index: Optional[int] = None + + chunks: Optional[list[ChunkTask]] = None + + target_path: Optional[str] = None + source_path: Optional[str] = None + + old_file_index: Optional[int] = None + + data: Optional[bytes] = None + +@dataclass +class FileTask: + path: str + flags: TaskFlag + + old_flags: TaskFlag = TaskFlag.NONE + old_file: Optional[str] = None + + patch_file: Optional[str] = None + +@dataclass +class FileInfo: + index: int + path: str + md5: str + size: int + + def __eq__(self, other): + if not isinstance(other, FileInfo): + return False + return (self.path, self.md5, self.size) == (other.path, other.md5, other.size) + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash((self.path, self.md5, self.size)) + + +@dataclass +class TerminateWorker: + pass diff --git a/app/src/main/python/gogdl/dl/objects/linux.py b/app/src/main/python/gogdl/dl/objects/linux.py new file mode 100644 index 000000000..94bdbbfa8 --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/linux.py @@ -0,0 +1,419 @@ +from io import BytesIO +import stat + + +END_OF_CENTRAL_DIRECTORY = b"\x50\x4b\x05\x06" +CENTRAL_DIRECTORY = b"\x50\x4b\x01\x02" +LOCAL_FILE_HEADER = b"\x50\x4b\x03\x04" + +# ZIP64 +ZIP_64_END_OF_CD_LOCATOR = b"\x50\x4b\x06\x07" +ZIP_64_END_OF_CD = b"\x50\x4b\x06\x06" + +class LocalFile: + def __init__(self) -> None: + self.relative_local_file_offset: int + self.version_needed: bytes + self.general_purpose_bit_flag: bytes + self.compression_method: int + self.last_modification_time: bytes + self.last_modification_date: bytes + self.crc32: bytes + self.compressed_size: int + self.uncompressed_size: int + self.file_name_length: int + self.extra_field_length: int + self.file_name: str + self.extra_field: bytes + self.last_byte: int + + def load_data(self, handler): + return handler.get_bytes_from_file( + from_b=self.last_byte + self.relative_local_file_offset, + size=self.compressed_size, + raw_response=True + ) + + @classmethod + def from_bytes(cls, data, offset, handler): + local_file = cls() + local_file.relative_local_file_offset = 0 + local_file.version_needed = data[4:6] + local_file.general_purpose_bit_flag = data[6:8] + local_file.compression_method = int.from_bytes(data[8:10], "little") + local_file.last_modification_time = data[10:12] + local_file.last_modification_date = data[12:14] + local_file.crc32 = data[14:18] + local_file.compressed_size = int.from_bytes(data[18:22], "little") + local_file.uncompressed_size = int.from_bytes(data[22:26], "little") + local_file.file_name_length = int.from_bytes(data[26:28], "little") + local_file.extra_field_length = int.from_bytes(data[28:30], "little") + + extra_data = handler.get_bytes_from_file( + from_b=30 + offset, + size=local_file.file_name_length + local_file.extra_field_length, + ) + + local_file.file_name = bytes( + extra_data[0: local_file.file_name_length] + ).decode() + + local_file.extra_field = data[ + local_file.file_name_length: local_file.file_name_length + + local_file.extra_field_length + ] + local_file.last_byte = ( + local_file.file_name_length + local_file.extra_field_length + 30 + ) + return local_file + + def __str__(self): + return f"\nCompressionMethod: {self.compression_method} \nFileNameLen: {self.file_name_length} \nFileName: {self.file_name} \nCompressedSize: {self.compressed_size} \nUncompressedSize: {self.uncompressed_size}" + + +class CentralDirectoryFile: + def __init__(self, product): + self.product = product + self.version_made_by: bytes + self.version_needed_to_extract: bytes + self.general_purpose_bit_flag: bytes + self.compression_method: int + self.last_modification_time: bytes + self.last_modification_date: bytes + self.crc32: int + self.compressed_size: int + self.uncompressed_size: int + self.file_name_length: int + self.extra_field_length: int + self.file_comment_length: int + self.disk_number_start: bytes + self.int_file_attrs: bytes + self.ext_file_attrs: bytes + self.relative_local_file_offset: int + self.file_name: str + self.extra_field: BytesIO + self.comment: bytes + self.last_byte: int + self.file_data_offset: int + + @classmethod + def from_bytes(cls, data, product): + cd_file = cls(product) + + cd_file.version_made_by = data[4:6] + cd_file.version_needed_to_extract = data[6:8] + cd_file.general_purpose_bit_flag = data[8:10] + cd_file.compression_method = int.from_bytes(data[10:12], "little") + cd_file.last_modification_time = data[12:14] + cd_file.last_modification_date = data[14:16] + cd_file.crc32 = int.from_bytes(data[16:20], "little") + cd_file.compressed_size = int.from_bytes(data[20:24], "little") + cd_file.uncompressed_size = int.from_bytes(data[24:28], "little") + cd_file.file_name_length = int.from_bytes(data[28:30], "little") + cd_file.extra_field_length = int.from_bytes(data[30:32], "little") + cd_file.file_comment_length = int.from_bytes(data[32:34], "little") + cd_file.disk_number_start = data[34:36] + cd_file.int_file_attrs = data[36:38] + cd_file.ext_file_attrs = data[38:42] + cd_file.relative_local_file_offset = int.from_bytes(data[42:46], "little") + cd_file.file_data_offset = 0 + + extra_field_start = 46 + cd_file.file_name_length + cd_file.file_name = bytes(data[46:extra_field_start]).decode() + + cd_file.extra_field = BytesIO(data[ + extra_field_start: extra_field_start + cd_file.extra_field_length + ]) + + field = None + while True: + id = int.from_bytes(cd_file.extra_field.read(2), "little") + size = int.from_bytes(cd_file.extra_field.read(2), "little") + + if id == 0x01: + if cd_file.extra_field_length - cd_file.extra_field.tell() >= size: + field = BytesIO(cd_file.extra_field.read(size)) + break + + cd_file.extra_field.seek(size, 1) + + if cd_file.extra_field_length - cd_file.extra_field.tell() == 0: + break + + + if field: + if cd_file.uncompressed_size == 0xFFFFFFFF: + cd_file.uncompressed_size = int.from_bytes(field.read(8), "little") + + if cd_file.compressed_size == 0xFFFFFFFF: + cd_file.compressed_size = int.from_bytes(field.read(8), "little") + + if cd_file.relative_local_file_offset == 0xFFFFFFFF: + cd_file.relative_local_file_offset = int.from_bytes(field.read(8), "little") + + comment_start = extra_field_start + cd_file.extra_field_length + cd_file.comment = data[ + comment_start: comment_start + cd_file.file_comment_length + ] + + cd_file.last_byte = comment_start + cd_file.file_comment_length + + return cd_file, comment_start + cd_file.file_comment_length + + def is_symlink(self): + return stat.S_ISLNK(int.from_bytes(self.ext_file_attrs, "little") >> 16) + + def as_dict(self): + return {'file_name': self.file_name, 'crc32': self.crc32, 'compressed_size': self.compressed_size, 'size': self.uncompressed_size, 'is_symlink': self.is_symlink()} + + def __str__(self): + return f"\nCompressionMethod: {self.compression_method} \nFileNameLen: {self.file_name_length} \nFileName: {self.file_name} \nStartDisk: {self.disk_number_start} \nCompressedSize: {self.compressed_size} \nUncompressedSize: {self.uncompressed_size}" + + def __repr__(self): + return self.file_name + + +class CentralDirectory: + def __init__(self, product): + self.files = [] + self.product = product + + @staticmethod + def create_central_dir_file(data, product): + return CentralDirectoryFile.from_bytes(data, product) + + @classmethod + def from_bytes(cls, data, n, product): + central_dir = cls(product) + for record in range(n): + cd_file, next_offset = central_dir.create_central_dir_file(data, product) + central_dir.files.append(cd_file) + data = data[next_offset:] + if record == 0: + continue + + prev_i = record - 1 + if not (prev_i >= 0 and prev_i < len(central_dir.files)): + continue + prev = central_dir.files[prev_i] + prev.file_data_offset = cd_file.relative_local_file_offset - prev.compressed_size + + return central_dir + +class Zip64EndOfCentralDirLocator: + def __init__(self): + self.number_of_disk: int + self.zip64_end_of_cd_offset: int + self.total_number_of_disks: int + + @classmethod + def from_bytes(cls, data): + zip64_end_of_cd = cls() + zip64_end_of_cd.number_of_disk = int.from_bytes(data[4:8], "little") + zip64_end_of_cd.zip64_end_of_cd_offset = int.from_bytes(data[8:16], "little") + zip64_end_of_cd.total_number_of_disks = int.from_bytes(data[16:20], "little") + return zip64_end_of_cd + + def __str__(self): + return f"\nZIP64EOCDLocator\nDisk Number: {self.number_of_disk}\nZ64_EOCD Offset: {self.zip64_end_of_cd_offset}\nNumber of disks: {self.total_number_of_disks}" + +class Zip64EndOfCentralDir: + def __init__(self): + self.size: int + self.version_made_by: bytes + self.version_needed: bytes + self.number_of_disk: bytes + self.central_directory_start_disk: bytes + self.number_of_entries_on_this_disk: int + self.number_of_entries_total: int + self.size_of_central_directory: int + self.central_directory_offset: int + self.extensible_data = None + + @classmethod + def from_bytes(cls, data): + end_of_cd = cls() + + end_of_cd.size = int.from_bytes(data[4:12], "little") + end_of_cd.version_made_by = data[12:14] + end_of_cd.version_needed = data[14:16] + end_of_cd.number_of_disk = data[16:20] + end_of_cd.central_directory_start_disk = data[20:24] + end_of_cd.number_of_entries_on_this_disk = int.from_bytes(data[24:32], "little") + end_of_cd.number_of_entries_total = int.from_bytes(data[32:40], "little") + end_of_cd.size_of_central_directory = int.from_bytes(data[40:48], "little") + end_of_cd.central_directory_offset = int.from_bytes(data[48:56], "little") + + return end_of_cd + + def __str__(self) -> str: + return f"\nZ64 EndOfCD\nSize: {self.size}\nNumber of disk: {self.number_of_disk}\nEntries on this disk: {self.number_of_entries_on_this_disk}\nEntries total: {self.number_of_entries_total}\nCD offset: {self.central_directory_offset}" + + +class EndOfCentralDir: + def __init__(self): + self.number_of_disk: bytes + self.central_directory_disk: bytes + self.central_directory_records: int + self.size_of_central_directory: int + self.central_directory_offset: int + self.comment_length: bytes + self.comment: bytes + + @classmethod + def from_bytes(cls, data): + central_dir = cls() + central_dir.number_of_disk = data[4:6] + central_dir.central_directory_disk = data[6:8] + central_dir.central_directory_records = int.from_bytes(data[8:10], "little") + central_dir.size_of_central_directory = int.from_bytes(data[12:16], "little") + central_dir.central_directory_offset = int.from_bytes(data[16:20], "little") + central_dir.comment_length = data[20:22] + central_dir.comment = data[ + 22: 22 + int.from_bytes(central_dir.comment_length, "little") + ] + + return central_dir + + def __str__(self): + return f"\nDiskNumber: {self.number_of_disk} \nCentralDirRecords: {self.central_directory_records} \nCentralDirSize: {self.size_of_central_directory} \nCentralDirOffset: {self.central_directory_offset}" + + +class InstallerHandler: + def __init__(self, url, product_id, session): + self.url = url + self.product = product_id + self.session = session + self.file_size = 0 + + SEARCH_OFFSET = 0 + SEARCH_RANGE = 2 * 1024 * 1024 # 2 MiB + + beginning_of_file = self.get_bytes_from_file( + from_b=SEARCH_OFFSET, size=SEARCH_RANGE, add_archive_index=False + ) + + self.start_of_archive_index = beginning_of_file.find(LOCAL_FILE_HEADER) + SEARCH_OFFSET + + # ZIP contents + self.central_directory_offset: int + self.central_directory_records: int + self.size_of_central_directory: int + self.central_directory: CentralDirectory + + def get_bytes_from_file(self, from_b=-1, size=None, add_archive_index=True, raw_response=False, redirect_count=0): + """Get bytes from file with redirect handling and bounds checking + + Args: + from_b: Starting byte offset + size: Number of bytes to read + add_archive_index: Whether to add archive index offset + raw_response: Whether to return raw response object + redirect_count: Current redirect depth (internal, max 5) + + Returns: + Response object or bytes data + """ + MAX_REDIRECTS = 5 + + if redirect_count >= MAX_REDIRECTS: + raise Exception(f"Too many redirects ({MAX_REDIRECTS}) when fetching bytes from {self.url}") + + # Store original from_b before applying archive index + original_from_b = from_b + if add_archive_index: + from_b += self.start_of_archive_index + + from_b_repr = str(from_b) if from_b > -1 else "" + if size: + end_b = from_b + size - 1 + else: + end_b = "" + range_header = self.get_range_header(from_b_repr, end_b) + + response = self.session.get(self.url, headers={'Range': range_header}, + allow_redirects=False, stream=raw_response) + if response.status_code == 302: + # Skip content-system API and follow redirect + self.url = response.headers.get('Location') or self.url + # Use original from_b and set add_archive_index=False to prevent double-applying the index + return self.get_bytes_from_file(original_from_b, size, add_archive_index=False, + raw_response=raw_response, redirect_count=redirect_count + 1) + + # Safely extract file_size from Content-Range header if present + if not self.file_size: + content_range = response.headers.get("Content-Range") + if content_range: + try: + self.file_size = int(content_range.split("/")[-1]) + except (ValueError, IndexError) as e: + raise Exception(f"Invalid Content-Range header: {content_range}") from e + else: + raise Exception("Content-Range header missing and file_size not set") + + if raw_response: + return response + else: + data = response.content + return data + + @staticmethod + def get_range_header(from_b="", to_b=""): + return f"bytes={from_b}-{to_b}" + + def setup(self): + self.__find_end_of_cd() + self.__find_central_directory() + + def __find_end_of_cd(self): + end_of_cd_data = self.get_bytes_from_file( + from_b=self.file_size - 100, add_archive_index=False + ) + + end_of_cd_header_data_index = end_of_cd_data.find(END_OF_CENTRAL_DIRECTORY) + zip64_end_of_cd_locator_index = end_of_cd_data.find(ZIP_64_END_OF_CD_LOCATOR) + assert end_of_cd_header_data_index != -1 + end_of_cd = EndOfCentralDir.from_bytes(end_of_cd_data[end_of_cd_header_data_index:]) + if end_of_cd.central_directory_offset == 0xFFFFFFFF: + assert zip64_end_of_cd_locator_index != -1 + # We need to find zip64 headers + + zip64_end_of_cd_locator = Zip64EndOfCentralDirLocator.from_bytes(end_of_cd_data[zip64_end_of_cd_locator_index:]) + zip64_end_of_cd_data = self.get_bytes_from_file(from_b=zip64_end_of_cd_locator.zip64_end_of_cd_offset, size=200) + zip64_end_of_cd = Zip64EndOfCentralDir.from_bytes(zip64_end_of_cd_data) + + self.central_directory_offset = zip64_end_of_cd.central_directory_offset + self.size_of_central_directory = zip64_end_of_cd.size_of_central_directory + self.central_directory_records = zip64_end_of_cd.number_of_entries_total + else: + self.central_directory_offset = end_of_cd.central_directory_offset + self.size_of_central_directory = end_of_cd.size_of_central_directory + self.central_directory_records = end_of_cd.central_directory_records + + def __find_central_directory(self): + central_directory_data = self.get_bytes_from_file( + from_b=self.central_directory_offset, + size=self.size_of_central_directory, + ) + + assert central_directory_data[:4] == CENTRAL_DIRECTORY + + self.central_directory = CentralDirectory.from_bytes( + central_directory_data, self.central_directory_records, self.product + ) + last_entry = self.central_directory.files[-1] + last_entry.file_data_offset = self.central_directory_offset - last_entry.compressed_size + + +class LinuxFile: + def __init__(self, product, path, compression, start, compressed_size, size, checksum, executable): + self.product = product + self.path = path + self.compression = compression == 8 + self.offset = start + self.compressed_size = compressed_size + self.size = size + self.hash = str(checksum) + self.flags = [] + if executable: + self.flags.append("executable") diff --git a/app/src/main/python/gogdl/dl/objects/v1.py b/app/src/main/python/gogdl/dl/objects/v1.py new file mode 100644 index 000000000..41f279b9f --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/v1.py @@ -0,0 +1,168 @@ +import json +import os +from gogdl.dl import dl_utils +from gogdl.dl.objects import generic, v2 +from gogdl import constants +from gogdl.languages import Language + + +class Depot: + def __init__(self, target_lang, depot_data): + self.target_lang = target_lang + self.languages = depot_data["languages"] + self.game_ids = depot_data["gameIDs"] + self.size = int(depot_data["size"]) + self.manifest = depot_data["manifest"] + + def check_language(self): + status = True + for lang in self.languages: + status = lang == "Neutral" or lang == self.target_lang + if status: + break + return status + +class Directory: + def __init__(self, item_data): + self.path = item_data["path"].replace(constants.NON_NATIVE_SEP, os.sep).lstrip(os.sep) + +class Dependency: + def __init__(self, data): + self.id = data["redist"] + self.size = data.get("size") + self.target_dir = data.get("targetDir") + + +class File: + def __init__(self, data, product_id): + self.offset = data.get("offset") + self.hash = data.get("hash") + self.url = data.get("url") + self.path = data["path"].lstrip("/") + self.size = data["size"] + self.flags = [] + if data.get("support"): + self.flags.append("support") + if data.get("executable"): + self.flags.append("executble") + + self.product_id = product_id + +class Manifest: + def __init__(self, platform, meta, language, dlcs, api_handler, dlc_only): + self.platform = platform + self.data = meta + self.data['HGLPlatform'] = platform + self.data["HGLInstallLanguage"] = language.code + self.data["HGLdlcs"] = dlcs + self.product_id = meta["product"]["rootGameID"] + self.dlcs = dlcs + self.dlc_only = dlc_only + self.all_depots = [] + self.depots = self.parse_depots(language, meta["product"]["depots"]) + self.dependencies = [Dependency(depot) for depot in meta["product"]["depots"] if depot.get('redist')] + self.dependencies_ids = [depot['redist'] for depot in meta["product"]["depots"] if depot.get('redist')] + + self.api_handler = api_handler + + self.files = [] + self.dirs = [] + + @classmethod + def from_json(cls, meta, api_handler): + manifest = cls(meta['HGLPlatform'], meta, Language.parse(meta['HGLInstallLanguage']), meta["HGLdlcs"], api_handler, False) + return manifest + + def serialize_to_json(self): + return json.dumps(self.data) + + def parse_depots(self, language, depots): + parsed = [] + dlc_ids = [dlc["id"] for dlc in self.dlcs] + for depot in depots: + if depot.get("redist"): + continue + + for g_id in depot["gameIDs"]: + if g_id in dlc_ids or (not self.dlc_only and self.product_id == g_id): + new_depot = Depot(language, depot) + parsed.append(new_depot) + self.all_depots.append(new_depot) + break + return list(filter(lambda x: x.check_language(), parsed)) + + def list_languages(self): + languages_dict = set() + for depot in self.all_depots: + for language in depot.languages: + if language != "Neutral": + languages_dict.add(Language.parse(language).code) + + return list(languages_dict) + + def calculate_download_size(self): + data = dict() + + for depot in self.all_depots: + for product_id in depot.game_ids: + if not product_id in data: + data[product_id] = dict() + product_data = data[product_id] + for lang in depot.languages: + if lang == "Neutral": + lang = "*" + if not lang in product_data: + product_data[lang] = {"download_size": 0, "disk_size": 0} + + product_data[lang]["download_size"] += depot.size + product_data[lang]["disk_size"] += depot.size + + return data + + + def get_files(self): + for depot in self.depots: + manifest = dl_utils.get_json(self.api_handler, f"{constants.GOG_CDN}/content-system/v1/manifests/{depot.game_ids[0]}/{self.platform}/{self.data['product']['timestamp']}/{depot.manifest}") + for record in manifest["depot"]["files"]: + if "directory" in record: + self.dirs.append(Directory(record)) + else: + self.files.append(File(record, depot.game_ids[0])) + +class ManifestDiff(generic.BaseDiff): + def __init__(self): + super().__init__() + + @classmethod + def compare(cls, new_manifest, old_manifest=None): + comparison = cls() + + if not old_manifest: + comparison.new = new_manifest.files + return comparison + + new_files = dict() + for file in new_manifest.files: + new_files.update({file.path.lower(): file}) + + old_files = dict() + for file in old_manifest.files: + old_files.update({file.path.lower(): file}) + + for old_file in old_files.values(): + if not new_files.get(old_file.path.lower()): + comparison.deleted.append(old_file) + + if type(old_manifest) == v2.Manifest: + comparison.new = new_manifest.files + return comparison + + for new_file in new_files.values(): + old_file = old_files.get(new_file.path.lower()) + if not old_file: + comparison.new.append(new_file) + else: + if new_file.hash != old_file.hash: + comparison.changed.append(new_file) + + return comparison \ No newline at end of file diff --git a/app/src/main/python/gogdl/dl/objects/v2.py b/app/src/main/python/gogdl/dl/objects/v2.py new file mode 100644 index 000000000..102a71a1c --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/v2.py @@ -0,0 +1,295 @@ +import json +import os + +from gogdl.dl import dl_utils +from gogdl.dl.objects import generic, v1 +from gogdl import constants +from gogdl.languages import Language + + +class DepotFile: + def __init__(self, item_data, product_id): + self.flags = item_data.get("flags") or list() + self.path = item_data["path"].replace(constants.NON_NATIVE_SEP, os.sep).lstrip(os.sep) + if "support" in self.flags: + self.path = os.path.join(product_id, self.path) + self.chunks = item_data["chunks"] + self.md5 = item_data.get("md5") + self.sha256 = item_data.get("sha256") + self.product_id = product_id + + +# That exists in some depots, indicates directory to be created, it has only path in it +# Yes that's the thing +class DepotDirectory: + def __init__(self, item_data): + self.path = item_data["path"].replace(constants.NON_NATIVE_SEP, os.sep).rstrip(os.sep) + +class DepotLink: + def __init__(self, item_data): + self.path = item_data["path"] + self.target = item_data["target"] + + +class Depot: + def __init__(self, target_lang, depot_data): + self.target_lang = target_lang + self.languages = depot_data["languages"] + self.bitness = depot_data.get("osBitness") + self.product_id = depot_data["productId"] + self.compressed_size = depot_data.get("compressedSize") or 0 + self.size = depot_data.get("size") or 0 + self.manifest = depot_data["manifest"] + + def check_language(self): + status = False + for lang in self.languages: + status = ( + lang == "*" + or self.target_lang == lang + ) + if status: + break + return status + +class Manifest: + def __init__(self, meta, language, dlcs, api_handler, dlc_only): + self.data = meta + self.data["HGLInstallLanguage"] = language.code + self.data["HGLdlcs"] = dlcs + self.product_id = meta["baseProductId"] + self.dlcs = dlcs + self.dlc_only = dlc_only + self.all_depots = [] + self.depots = self.parse_depots(language, meta["depots"]) + self.dependencies_ids = meta.get("dependencies") + if not self.dependencies_ids: + self.dependencies_ids = list() + self.install_directory = meta["installDirectory"] + + self.api_handler = api_handler + + self.files = [] + self.dirs = [] + + @classmethod + def from_json(cls, meta, api_handler): + manifest = cls(meta, Language.parse(meta["HGLInstallLanguage"]), meta["HGLdlcs"], api_handler, False) + return manifest + + def serialize_to_json(self): + return json.dumps(self.data) + + def parse_depots(self, language, depots): + parsed = [] + dlc_ids = [dlc["id"] for dlc in self.dlcs] + for depot in depots: + if depot["productId"] in dlc_ids or ( + not self.dlc_only and self.product_id == depot["productId"] + ): + new_depot = Depot(language, depot) + parsed.append(new_depot) + self.all_depots.append(new_depot) + + + return list(filter(lambda x: x.check_language(), parsed)) + + def list_languages(self): + languages_dict = set() + for depot in self.all_depots: + for language in depot.languages: + if language != "*": + languages_dict.add(Language.parse(language).code) + + return list(languages_dict) + + def calculate_download_size(self): + data = dict() + + for depot in self.all_depots: + if not depot.product_id in data: + data[depot.product_id] = dict() + data[depot.product_id]['*'] = {"download_size": 0, "disk_size": 0} + product_data = data[depot.product_id] + for lang in depot.languages: + if not lang in product_data: + product_data[lang] = {"download_size":0, "disk_size":0} + + product_data[lang]["download_size"] += depot.compressed_size + product_data[lang]["disk_size"] += depot.size + + return data + + def get_files(self): + for depot in self.depots: + manifest = dl_utils.get_zlib_encoded( + self.api_handler, + f"{constants.GOG_CDN}/content-system/v2/meta/{dl_utils.galaxy_path(depot.manifest)}", + )[0] + for item in manifest["depot"]["items"]: + if item["type"] == "DepotFile": + self.files.append(DepotFile(item, depot.product_id)) + elif item["type"] == "DepotLink": + self.files.append(DepotLink(item)) + else: + self.dirs.append(DepotDirectory(item)) + +class FileDiff: + def __init__(self): + self.file: DepotFile + self.old_file_flags: list[str] + self.disk_size_diff: int = 0 + + @classmethod + def compare(cls, new: DepotFile, old: DepotFile): + diff = cls() + diff.disk_size_diff = sum([ch['size'] for ch in new.chunks]) + diff.disk_size_diff -= sum([ch['size'] for ch in old.chunks]) + diff.old_file_flags = old.flags + for new_chunk in new.chunks: + old_offset = 0 + for old_chunk in old.chunks: + if old_chunk["md5"] == new_chunk["md5"]: + new_chunk["old_offset"] = old_offset + old_offset += old_chunk["size"] + diff.file = new + return diff + +# Using xdelta patching +class FilePatchDiff: + def __init__(self, data): + self.md5_source = data['md5_source'] + self.md5_target = data['md5_target'] + self.source = data['path_source'].replace('\\', '/') + self.target = data['path_target'].replace('\\', '/') + self.md5 = data['md5'] + self.chunks = data['chunks'] + + self.old_file: DepotFile + self.new_file: DepotFile + +class ManifestDiff(generic.BaseDiff): + def __init__(self): + super().__init__() + + @classmethod + def compare(cls, manifest, old_manifest=None, patch=None): + comparison = cls() + is_manifest_upgrade = isinstance(old_manifest, v1.Manifest) + + if not old_manifest: + comparison.new = manifest.files + return comparison + + new_files = dict() + for file in manifest.files: + new_files.update({file.path.lower(): file}) + + old_files = dict() + for file in old_manifest.files: + old_files.update({file.path.lower(): file}) + + for old_file in old_files.values(): + if not new_files.get(old_file.path.lower()): + comparison.deleted.append(old_file) + + for new_file in new_files.values(): + old_file = old_files.get(new_file.path.lower()) + if isinstance(new_file, DepotLink): + comparison.links.append(new_file) + continue + if not old_file: + comparison.new.append(new_file) + else: + if is_manifest_upgrade: + if len(new_file.chunks) == 0: + continue + new_final_sum = new_file.md5 or new_file.chunks[0]["md5"] + if new_final_sum: + if old_file.hash != new_final_sum: + comparison.changed.append(new_file) + continue + + patch_file = None + if patch and len(old_file.chunks): + for p_file in patch.files: + old_final_sum = old_file.md5 or old_file.chunks[0]["md5"] + if p_file.md5_source == old_final_sum: + patch_file = p_file + patch_file.old_file = old_file + patch_file.new_file = new_file + + if patch_file: + comparison.changed.append(patch_file) + continue + + if len(new_file.chunks) == 1 and len(old_file.chunks) == 1: + if new_file.chunks[0]["md5"] != old_file.chunks[0]["md5"]: + comparison.changed.append(new_file) + else: + if (new_file.md5 and old_file.md5 and new_file.md5 != old_file.md5) or (new_file.sha256 and old_file.sha256 and old_file.sha256 != new_file.sha256): + comparison.changed.append(FileDiff.compare(new_file, old_file)) + elif len(new_file.chunks) != len(old_file.chunks): + comparison.changed.append(FileDiff.compare(new_file, old_file)) + return comparison + +class Patch: + def __init__(self): + self.patch_data = {} + self.files = [] + + @classmethod + def get(cls, manifest, old_manifest, lang: str, dlcs: list, api_handler): + if isinstance(manifest, v1.Manifest) or isinstance(old_manifest, v1.Manifest): + return None + from_build = old_manifest.data.get('buildId') + to_build = manifest.data.get('buildId') + if not from_build or not to_build: + return None + dlc_ids = [dlc["id"] for dlc in dlcs] + patch_meta = dl_utils.get_zlib_encoded(api_handler, f'{constants.GOG_CONTENT_SYSTEM}/products/{manifest.product_id}/patches?_version=4&from_build_id={from_build}&to_build_id={to_build}')[0] + if not patch_meta or patch_meta.get('error'): + return None + patch_data = dl_utils.get_zlib_encoded(api_handler, patch_meta['link'])[0] + if not patch_data: + return None + + if patch_data['algorithm'] != 'xdelta3': + print("Unsupported patch algorithm") + return None + + depots = [] + # Get depots we need + for depot in patch_data['depots']: + if depot['productId'] == patch_data['baseProductId'] or depot['productId'] in dlc_ids: + if lang in depot['languages']: + depots.append(depot) + + if not depots: + return None + + files = [] + fail = False + for depot in depots: + depotdiffs = dl_utils.get_zlib_encoded(api_handler, f'{constants.GOG_CDN}/content-system/v2/patches/meta/{dl_utils.galaxy_path(depot["manifest"])}')[0] + if not depotdiffs: + fail = True + break + for diff in depotdiffs['depot']['items']: + if diff['type'] == 'DepotDiff': + files.append(FilePatchDiff(diff)) + else: + print('Unknown type in patcher', diff['type']) + return None + + if fail: + # TODO: Handle this beter + # Maybe exception? + print("Failed to get patch manifests") + return None + + patch = cls() + patch.patch_data = patch_data + patch.files = files + + return patch diff --git a/app/src/main/python/gogdl/dl/progressbar.py b/app/src/main/python/gogdl/dl/progressbar.py new file mode 100644 index 000000000..47271afb1 --- /dev/null +++ b/app/src/main/python/gogdl/dl/progressbar.py @@ -0,0 +1,143 @@ +import queue +from multiprocessing import Queue +import threading +import logging +from time import sleep, time + + +class ProgressBar(threading.Thread): + def __init__(self, max_val: int, speed_queue: Queue, write_queue: Queue, game_id=None): + self.logger = logging.getLogger("PROGRESS") + self.downloaded = 0 + self.total = max_val + self.speed_queue = speed_queue + self.write_queue = write_queue + self.started_at = time() + self.last_update = time() + self.completed = False + self.game_id = game_id + + self.decompressed = 0 + + self.downloaded_since_last_update = 0 + self.decompressed_since_last_update = 0 + self.written_since_last_update = 0 + self.read_since_last_update = 0 + + self.written_total = 0 + + super().__init__(target=self.loop) + + def loop(self): + while not self.completed: + # Check for cancellation signal + if self.game_id: + try: + import builtins + flag_name = f'GOGDL_CANCEL_{self.game_id}' + if hasattr(builtins, flag_name) and getattr(builtins, flag_name, False): + self.logger.info(f"Progress reporting cancelled for game {self.game_id}") + self.completed = True + break + except (ImportError, AttributeError) as e: + self.logger.debug(f"Failed to check cancellation flag: {e}") + + self.print_progressbar() + self.downloaded_since_last_update = self.decompressed_since_last_update = 0 + self.written_since_last_update = self.read_since_last_update = 0 + timestamp = time() + while not self.completed and (time() - timestamp) < 1: + try: + dl, dec = self.speed_queue.get(timeout=0.3) + self.downloaded_since_last_update += dl + self.decompressed_since_last_update += dec + except queue.Empty: + pass + try: + wr, r = self.write_queue.get(timeout=0.3) + self.written_since_last_update += wr + self.read_since_last_update += r + except queue.Empty: + pass + + self.print_progressbar() + def print_progressbar(self): + # Guard against division by zero when total is 0 + if self.total: + percentage = (self.written_total / self.total) * 100 + else: + percentage = 0 + running_time = time() - self.started_at + runtime_h = int(running_time // 3600) + runtime_m = int((running_time % 3600) // 60) + runtime_s = int((running_time % 3600) % 60) + + print_time_delta = time() - self.last_update + + current_dl_speed = 0 + current_decompress = 0 + if print_time_delta: + current_dl_speed = self.downloaded_since_last_update / print_time_delta + current_decompress = self.decompressed_since_last_update / print_time_delta + current_w_speed = self.written_since_last_update / print_time_delta + current_r_speed = self.read_since_last_update / print_time_delta + else: + current_w_speed = 0 + current_r_speed = 0 + + if percentage > 0: + estimated_time = (100 * running_time) / percentage - running_time + else: + estimated_time = 0 + estimated_time = max(estimated_time, 0) # Floor at 0 + + estimated_h = int(estimated_time // 3600) + estimated_time = estimated_time % 3600 + estimated_m = int(estimated_time // 60) + estimated_s = int(estimated_time % 60) + + self.logger.info( + f"= Progress: {percentage:.02f} {self.written_total}/{self.total}, " + + f"Running for: {runtime_h:02d}:{runtime_m:02d}:{runtime_s:02d}, " + + f"ETA: {estimated_h:02d}:{estimated_m:02d}:{estimated_s:02d}" + ) + + self.logger.info( + f"= Downloaded: {self.downloaded / 1024 / 1024:.02f} MiB, " + f"Written: {self.written_total / 1024 / 1024:.02f} MiB" + ) + + self.logger.info( + f" + Download\t- {current_dl_speed / 1024 / 1024:.02f} MiB/s (raw) " + f"/ {current_decompress / 1024 / 1024:.02f} MiB/s (decompressed)" + ) + + self.logger.info( + f" + Disk\t- {current_w_speed / 1024 / 1024:.02f} MiB/s (write) / " + f"{current_r_speed / 1024 / 1024:.02f} MiB/s (read)" + ) + + # Call Android progress callback if available + try: + import gogdl + if hasattr(gogdl, '_progress_callback'): + callback = gogdl._progress_callback + downloaded_mb = self.downloaded / 1024 / 1024 + total_mb = self.total / 1024 / 1024 + speed_mbps = current_dl_speed / 1024 / 1024 + eta_str = f"{estimated_h:02d}:{estimated_m:02d}:{estimated_s:02d}" + callback.update(percentage, downloaded_mb, total_mb, speed_mbps, eta_str) + except Exception as e: + # Silently ignore if callback not available (e.g. running standalone) + pass + + self.last_update = time() + + def update_downloaded_size(self, addition): + self.downloaded += addition + + def update_decompressed_size(self, addition): + self.decompressed += addition + + def update_bytes_written(self, addition): + self.written_total += addition diff --git a/app/src/main/python/gogdl/dl/workers/task_executor.py b/app/src/main/python/gogdl/dl/workers/task_executor.py new file mode 100644 index 000000000..f105c482e --- /dev/null +++ b/app/src/main/python/gogdl/dl/workers/task_executor.py @@ -0,0 +1,366 @@ +import os +import shutil +import sys +import stat +import traceback +import time +import requests +import zlib +import hashlib +from io import BytesIO +from typing import Optional, Union +from copy import copy, deepcopy +from gogdl.dl import dl_utils +from dataclasses import dataclass +from enum import Enum, auto +from gogdl.dl.objects.generic import TaskFlag, TerminateWorker +from gogdl.xdelta import patcher + + +class FailReason(Enum): + UNKNOWN = 0 + CHECKSUM = auto() + CONNECTION = auto() + UNAUTHORIZED = auto() + MISSING_CHUNK = auto() + + +@dataclass +class DownloadTask: + product_id: str + +@dataclass +class DownloadTask1(DownloadTask): + offset: int + size: int + compressed_sum: str + temp_file: str # Use temp file instead of memory segment + +@dataclass +class DownloadTask2(DownloadTask): + compressed_sum: str + temp_file: str # Use temp file instead of memory segment + + +@dataclass +class WriterTask: + destination: str + file_path: str + flags: TaskFlag + + hash: Optional[str] = None + size: Optional[int] = None + temp_file: Optional[str] = None # Use temp file instead of shared memory + old_destination: Optional[str] = None + old_file: Optional[str] = None + old_offset: Optional[int] = None + patch_file: Optional[str] = None + +@dataclass +class DownloadTaskResult: + success: bool + fail_reason: Optional[FailReason] + task: Union[DownloadTask2, DownloadTask1] + temp_file: Optional[str] = None + download_size: Optional[int] = None + decompressed_size: Optional[int] = None + +@dataclass +class WriterTaskResult: + success: bool + task: Union[WriterTask, TerminateWorker] + written: int = 0 + + +def download_worker(download_queue, results_queue, speed_queue, secure_links, temp_dir, game_id): + """Download worker function that runs in a thread""" + session = requests.session() + + while True: + # Check for cancellation signal before processing next task + try: + import builtins + flag_name = f'GOGDL_CANCEL_{game_id}' + if hasattr(builtins, flag_name) and getattr(builtins, flag_name, False): + session.close() + return # Exit worker thread if cancelled + except: + pass # Continue if cancellation check fails + + try: + task: Union[DownloadTask1, DownloadTask2, TerminateWorker] = download_queue.get(timeout=1) + except: + continue + + if isinstance(task, TerminateWorker): + break + + if type(task) == DownloadTask2: + download_v2_chunk(task, session, secure_links, results_queue, speed_queue, game_id) + elif type(task) == DownloadTask1: + download_v1_chunk(task, session, secure_links, results_queue, speed_queue, game_id) + + session.close() + + +def download_v2_chunk(task: DownloadTask2, session, secure_links, results_queue, speed_queue, game_id): + retries = 5 + urls = secure_links[task.product_id] + compressed_md5 = task.compressed_sum + + endpoint = deepcopy(urls[0]) # Use deepcopy for thread safety + if task.product_id != 'redist': + endpoint["parameters"]["path"] += f"/{dl_utils.galaxy_path(compressed_md5)}" + url = dl_utils.merge_url_with_params( + endpoint["url_format"], endpoint["parameters"] + ) + else: + endpoint["url"] += "/" + dl_utils.galaxy_path(compressed_md5) + url = endpoint["url"] + + buffer = bytes() + compressed_sum = hashlib.md5() + download_size = 0 + response = None + + while retries > 0: + buffer = bytes() + compressed_sum = hashlib.md5() + download_size = 0 + decompressor = zlib.decompressobj() + + try: + response = session.get(url, stream=True, timeout=10) + response.raise_for_status() + for chunk in response.iter_content(1024 * 512): + # Check for cancellation during download + try: + import builtins + flag_name = f'GOGDL_CANCEL_{game_id}' + if hasattr(builtins, flag_name) and getattr(builtins, flag_name, False): + return # Exit immediately if cancelled + except: + pass + + download_size += len(chunk) + compressed_sum.update(chunk) + decompressed = decompressor.decompress(chunk) + buffer += decompressed + speed_queue.put((len(chunk), len(decompressed))) + + except Exception as e: + print("Connection failed", e) + if response and response.status_code == 401: + results_queue.put(DownloadTaskResult(False, FailReason.UNAUTHORIZED, task)) + return + retries -= 1 + time.sleep(2) + continue + break + else: + results_queue.put(DownloadTaskResult(False, FailReason.CHECKSUM, task)) + return + + decompressed_size = len(buffer) + + # Write to temp file instead of shared memory + try: + with open(task.temp_file, 'wb') as f: + f.write(buffer) + except Exception as e: + print("ERROR writing temp file", e) + results_queue.put(DownloadTaskResult(False, FailReason.UNKNOWN, task)) + return + + if compressed_sum.hexdigest() != compressed_md5: + results_queue.put(DownloadTaskResult(False, FailReason.CHECKSUM, task)) + return + + results_queue.put(DownloadTaskResult(True, None, task, temp_file=task.temp_file, download_size=download_size, decompressed_size=decompressed_size)) + + +def download_v1_chunk(task: DownloadTask1, session, secure_links, results_queue, speed_queue, game_id): + retries = 5 + urls = secure_links[task.product_id] + + response = None + if type(urls) == str: + url = urls + else: + endpoint = deepcopy(urls[0]) + endpoint["parameters"]["path"] += "/main.bin" + url = dl_utils.merge_url_with_params( + endpoint["url_format"], endpoint["parameters"] + ) + range_header = dl_utils.get_range_header(task.offset, task.size) + + # Stream directly to temp file for V1 to avoid memory issues with large files + download_size = 0 + while retries > 0: + download_size = 0 + try: + response = session.get(url, stream=True, timeout=10, headers={'Range': range_header}) + response.raise_for_status() + + # Stream directly to temp file instead of loading into memory + with open(task.temp_file, 'wb') as temp_f: + for chunk in response.iter_content(1024 * 512): # 512KB chunks + # Check for cancellation during download + try: + import builtins + flag_name = f'GOGDL_CANCEL_{game_id}' + if hasattr(builtins, flag_name) and getattr(builtins, flag_name, False): + return # Exit immediately if cancelled + except: + pass + + temp_f.write(chunk) + download_size += len(chunk) + speed_queue.put((len(chunk), len(chunk))) + + except Exception as e: + print("Connection failed", e) + if response and response.status_code == 401: + results_queue.put(DownloadTaskResult(False, FailReason.UNAUTHORIZED, task)) + return + retries -= 1 + time.sleep(2) + continue + break + else: + results_queue.put(DownloadTaskResult(False, FailReason.CHECKSUM, task)) + return + + # Verify file size + if download_size != task.size: + results_queue.put(DownloadTaskResult(False, FailReason.CHECKSUM, task)) + return + + results_queue.put(DownloadTaskResult(True, None, task, temp_file=task.temp_file, download_size=download_size, decompressed_size=download_size)) + + +def writer_worker(writer_queue, results_queue, speed_queue, cache, temp_dir): + """Writer worker function that runs in a thread""" + file_handle = None + current_file = '' + + while True: + try: + task: Union[WriterTask, TerminateWorker] = writer_queue.get(timeout=2) + except: + continue + + if isinstance(task, TerminateWorker): + results_queue.put(WriterTaskResult(True, task)) + break + + written = 0 + + task_path = dl_utils.get_case_insensitive_name(os.path.join(task.destination, task.file_path)) + split_path = os.path.split(task_path) + if split_path[0] and not os.path.exists(split_path[0]): + dl_utils.prepare_location(split_path[0]) + + if task.flags & TaskFlag.CREATE_FILE: + open(task_path, 'a').close() + results_queue.put(WriterTaskResult(True, task)) + continue + + elif task.flags & TaskFlag.OPEN_FILE: + if file_handle: + print("Opening on unclosed file") + file_handle.close() + file_handle = open(task_path, 'wb') + current_file = task_path + results_queue.put(WriterTaskResult(True, task)) + continue + + elif task.flags & TaskFlag.CLOSE_FILE: + if file_handle: + file_handle.close() + file_handle = None + results_queue.put(WriterTaskResult(True, task)) + continue + + elif task.flags & TaskFlag.COPY_FILE: + if file_handle and task.file_path == current_file: + print("Copy on unclosed file") + file_handle.close() + file_handle = None + + if not task.old_file: + results_queue.put(WriterTaskResult(False, task)) + continue + + dest = task.old_destination or task.destination + try: + shutil.copy(dl_utils.get_case_insensitive_name(os.path.join(dest, task.old_file)), task_path) + except shutil.SameFileError: + pass + except Exception: + results_queue.put(WriterTaskResult(False, task)) + continue + results_queue.put(WriterTaskResult(True, task)) + continue + + elif task.flags & TaskFlag.MAKE_EXE: + if file_handle and task.file_path == current_file: + print("Making exe on unclosed file") + file_handle.close() + file_handle = None + if sys.platform != 'win32': + try: + st = os.stat(task_path) + os.chmod(task_path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + except Exception as e: + results_queue.put(WriterTaskResult(False, task)) + continue + results_queue.put(WriterTaskResult(True, task)) + continue + + try: + if task.temp_file: + if not task.size: + print("No size") + results_queue.put(WriterTaskResult(False, task)) + continue + + # Read from temp file instead of shared memory + with open(task.temp_file, 'rb') as temp_f: + left = task.size + while left > 0: + chunk = temp_f.read(min(1024 * 1024, left)) + written += file_handle.write(chunk) + speed_queue.put((len(chunk), 0)) + left -= len(chunk) + + if task.flags & TaskFlag.OFFLOAD_TO_CACHE and task.hash: + cache_file_path = os.path.join(cache, task.hash) + dl_utils.prepare_location(cache) + shutil.copy(task.temp_file, cache_file_path) + speed_queue.put((task.size, 0)) + + elif task.old_file: + if not task.size: + print("No size") + results_queue.put(WriterTaskResult(False, task)) + continue + dest = task.old_destination or task.destination + old_file_path = dl_utils.get_case_insensitive_name(os.path.join(dest, task.old_file)) + old_file_handle = open(old_file_path, "rb") + if task.old_offset: + old_file_handle.seek(task.old_offset) + left = task.size + while left > 0: + chunk = old_file_handle.read(min(1024*1024, left)) + data = chunk + written += file_handle.write(data) + speed_queue.put((len(data), len(chunk))) + left -= len(chunk) + old_file_handle.close() + + except Exception as e: + print("Writer exception", e) + results_queue.put(WriterTaskResult(False, task)) + else: + results_queue.put(WriterTaskResult(True, task, written=written)) \ No newline at end of file diff --git a/app/src/main/python/gogdl/imports.py b/app/src/main/python/gogdl/imports.py new file mode 100644 index 000000000..27af656e3 --- /dev/null +++ b/app/src/main/python/gogdl/imports.py @@ -0,0 +1,159 @@ +import os +import glob +import json +import logging +from sys import exit +from gogdl import constants +import requests + + +def get_info(args, unknown_args): + logger = logging.getLogger("IMPORT") + path = args.path + if not os.path.exists(path): + logger.error("Provided path is invalid!") + exit(1) + game_details = load_game_details(path) + + info_file = game_details[0] + build_id_file = game_details[1] + platform = game_details[2] + with_dlcs = game_details[3] + build_id = "" + installed_language = None + info = {} + + # Initialize variables to safe defaults to prevent UnboundLocalError + title = None + game_id = None + version_name = None + + if platform != "linux": + if not info_file: + logger.error("Error importing, no info file found") + print("Error importing, no info file") + return + f = open(info_file, "r") + info = json.loads(f.read()) + f.close() + + title = info["name"] + game_id = info["rootGameId"] + build_id = info.get("buildId") + if "languages" in info: + installed_language = info["languages"][0] + elif "language" in info: + installed_language = info["language"] + else: + installed_language = "en-US" + if build_id_file: + f = open(build_id_file, "r") + build = json.loads(f.read()) + f.close() + build_id = build.get("buildId") + + version_name = build_id + if build_id and platform != "linux": + # Get version name + try: + builds_res = requests.get( + f"{constants.GOG_CONTENT_SYSTEM}/products/{game_id}/os/{platform}/builds?generation=2", + headers={ + "User-Agent": "GOGGalaxyCommunicationService/2.0.4.164 (Windows_32bit)" + }, + timeout=30 + ) + builds_res.raise_for_status() + builds = builds_res.json() + target_build = builds["items"][0] + for build in builds["items"]: + if build["build_id"] == build_id: + target_build = build + break + version_name = target_build["version_name"] + except requests.exceptions.Timeout: + logger.warning(f"Timeout fetching build info for game {game_id}, using build_id as version") + version_name = build_id + except requests.exceptions.RequestException as e: + logger.warning(f"Error fetching build info for game {game_id}: {e}, using build_id as version") + version_name = build_id + except (KeyError, IndexError, json.JSONDecodeError) as e: + logger.warning(f"Error parsing build info for game {game_id}: {e}, using build_id as version") + version_name = build_id + if platform == "linux" and os.path.exists(os.path.join(path, "gameinfo")): + # Linux version installed using installer + gameinfo_file = open(os.path.join(path, "gameinfo"), "r") + data = gameinfo_file.read() + lines = data.split("\n") + title = lines[0] + version_name = lines[1] + + if not installed_language: + installed_language = lines[3] + if len(lines) > 4: + game_id = lines[4] + build_id = lines[6] + else: + game_id = None + build_id = None + + # Validate that metadata was successfully loaded + if title is None or game_id is None or version_name is None: + logger.error( + f"Failed to load game metadata from path: {path}. " + f"Platform: {platform}, gameinfo exists: {os.path.exists(os.path.join(path, 'gameinfo'))}" + ) + print(f"Error: Unable to load game metadata. Missing gameinfo file or invalid installation.") + return + + print( + json.dumps( + { + "appName": game_id, + "buildId": build_id, + "title": title, + "tasks": info["playTasks"] if info and info.get("playTasks") else None, + "installedLanguage": installed_language, + "dlcs": with_dlcs, + "platform": platform, + "versionName": version_name, + } + ) + ) + + +def load_game_details(path): + base_path = path + found = glob.glob(os.path.join(path, "goggame-*.info")) + build_id = glob.glob(os.path.join(path, "goggame-*.id")) + platform = "windows" + if not found: + base_path = os.path.join(path, "Contents", "Resources") + found = glob.glob(os.path.join(path, "Contents", "Resources", "goggame-*.info")) + build_id = glob.glob( + os.path.join(path, "Contents", "Resources", "goggame-*.id") + ) + platform = "osx" + if not found: + base_path = os.path.join(path, "game") + found = glob.glob(os.path.join(path, "game", "goggame-*.info")) + build_id = glob.glob(os.path.join(path, "game", "goggame-*.id")) + platform = "linux" + if not found: + if os.path.exists(os.path.join(path, "gameinfo")): + return (None, None, "linux", []) + + root_id = None + # Array of DLC game ids + dlcs = [] + for info in found: + with open(info) as info_file: + data = json.load(info_file) + if not root_id: + root_id = data.get("rootGameId") + if data["gameId"] == root_id: + continue + + dlcs.append(data["gameId"]) + + return (os.path.join(base_path, f"goggame-{root_id}.info"), os.path.join(base_path, f"goggame-{root_id}.id") if build_id else None, platform, dlcs) diff --git a/app/src/main/python/gogdl/languages.py b/app/src/main/python/gogdl/languages.py new file mode 100644 index 000000000..ca37cebee --- /dev/null +++ b/app/src/main/python/gogdl/languages.py @@ -0,0 +1,123 @@ +from dataclasses import dataclass + + +@dataclass +class Language: + code: str + name: str + native_name: str + deprecated_codes: list[str] + + def __eq__(self, value: object) -> bool: + # Compare the class by language code + if isinstance(value, Language): + return self.code == value.code + # If comparing to string, look for the code, name and deprecated code + if type(value) is str: + return ( + value == self.code + or value.lower() == self.name.lower() + or value in self.deprecated_codes + ) + return NotImplemented + + def __hash__(self): + return hash(self.code) + + def __repr__(self): + return self.code + + @staticmethod + def parse(val: str): + for lang in LANGUAGES: + if lang == val: + return lang + + +# Auto-generated list of languages +LANGUAGES = [ + Language("af-ZA", "Afrikaans", "Afrikaans", []), + Language("ar", "Arabic", "العربية", []), + Language("az-AZ", "Azeri", "Azərbaycan­ılı", []), + Language("be-BY", "Belarusian", "Беларускі", ["be"]), + Language("bn-BD", "Bengali", "বাংলা", ["bn_BD"]), + Language("bg-BG", "Bulgarian", "български", ["bg", "bl"]), + Language("bs-BA", "Bosnian", "босански", []), + Language("ca-ES", "Catalan", "Català", ["ca"]), + Language("cs-CZ", "Czech", "Čeština", ["cz"]), + Language("cy-GB", "Welsh", "Cymraeg", []), + Language("da-DK", "Danish", "Dansk", ["da"]), + Language("de-DE", "German", "Deutsch", ["de"]), + Language("dv-MV", "Divehi", "ދިވެހިބަސް", []), + Language("el-GR", "Greek", "ελληνικά", ["gk", "el-GK"]), + Language("en-GB", "British English", "British English", ["en_GB"]), + Language("en-US", "English", "English", ["en"]), + Language("es-ES", "Spanish", "Español", ["es"]), + Language("es-MX", "Latin American Spanish", "Español (AL)", ["es_mx"]), + Language("et-EE", "Estonian", "Eesti", ["et"]), + Language("eu-ES", "Basque", "Euskara", []), + Language("fa-IR", "Persian", "فارسى", ["fa"]), + Language("fi-FI", "Finnish", "Suomi", ["fi"]), + Language("fo-FO", "Faroese", "Føroyskt", []), + Language("fr-FR", "French", "Français", ["fr"]), + Language("gl-ES", "Galician", "Galego", []), + Language("gu-IN", "Gujarati", "ગુજરાતી", ["gu"]), + Language("he-IL", "Hebrew", "עברית", ["he"]), + Language("hi-IN", "Hindi", "हिंदी", ["hi"]), + Language("hr-HR", "Croatian", "Hrvatski", []), + Language("hu-HU", "Hungarian", "Magyar", ["hu"]), + Language("hy-AM", "Armenian", "Հայերեն", []), + Language("id-ID", "Indonesian", "Bahasa Indonesia", []), + Language("is-IS", "Icelandic", "Íslenska", ["is"]), + Language("it-IT", "Italian", "Italiano", ["it"]), + Language("ja-JP", "Japanese", "日本語", ["jp"]), + Language("jv-ID", "Javanese", "ꦧꦱꦗꦮ", ["jv"]), + Language("ka-GE", "Georgian", "ქართული", []), + Language("kk-KZ", "Kazakh", "Қазақ", []), + Language("kn-IN", "Kannada", "ಕನ್ನಡ", []), + Language("ko-KR", "Korean", "한국어", ["ko"]), + Language("kok-IN", "Konkani", "कोंकणी", []), + Language("ky-KG", "Kyrgyz", "Кыргыз", []), + Language("la", "Latin", "latine", []), + Language("lt-LT", "Lithuanian", "Lietuvių", []), + Language("lv-LV", "Latvian", "Latviešu", []), + Language("ml-IN", "Malayalam", "മലയാളം", ["ml"]), + Language("mi-NZ", "Maori", "Reo Māori", []), + Language("mk-MK", "Macedonian", "Mакедонски јазик", []), + Language("mn-MN", "Mongolian", "Монгол хэл", []), + Language("mr-IN", "Marathi", "मराठी", ["mr"]), + Language("ms-MY", "Malay", "Bahasa Malaysia", []), + Language("mt-MT", "Maltese", "Malti", []), + Language("nb-NO", "Norwegian", "Norsk", ["no"]), + Language("nl-NL", "Dutch", "Nederlands", ["nl"]), + Language("ns-ZA", "Northern Sotho", "Sesotho sa Leboa", []), + Language("pa-IN", "Punjabi", "ਪੰਜਾਬੀ", []), + Language("pl-PL", "Polish", "Polski", ["pl"]), + Language("ps-AR", "Pashto", "پښتو", []), + Language("pt-BR", "Portuguese (Brazilian)", "Português do Brasil", ["br"]), + Language("pt-PT", "Portuguese", "Português", ["pt"]), + Language("ro-RO", "Romanian", "Română", ["ro"]), + Language("ru-RU", "Russian", "Pусский", ["ru"]), + Language("sa-IN", "Sanskrit", "संस्कृत", []), + Language("sk-SK", "Slovak", "Slovenčina", ["sk"]), + Language("sl-SI", "Slovenian", "Slovenski", []), + Language("sq-AL", "Albanian", "Shqipe", []), + Language("sr-SP", "Serbian", "Srpski", ["sb"]), + Language("sv-SE", "Swedish", "Svenska", ["sv"]), + Language("sw-KE", "Kiswahili", "Kiswahili", []), + Language("ta-IN", "Tamil", "தமிழ்", ["ta_IN"]), + Language("te-IN", "Telugu", "తెలుగు", ["te"]), + Language("th-TH", "Thai", "ไทย", ["th"]), + Language("tl-PH", "Tagalog", "Filipino", []), + Language("tn-ZA", "Setswana", "Setswana", []), + Language("tr-TR", "Turkish", "Türkçe", ["tr"]), + Language("tt-RU", "Tatar", "Татар", []), + Language("uk-UA", "Ukrainian", "Українська", ["uk"]), + Language("ur-PK", "Urdu", "اُردو", ["ur_PK"]), + Language("uz-UZ", "Uzbek", "U'zbek", []), + Language("vi-VN", "Vietnamese", "Tiếng Việt", ["vi"]), + Language("xh-ZA", "isiXhosa", "isiXhosa", []), + Language("zh-Hans", "Chinese (Simplified)", "中文(简体)", ["zh_Hans", "zh", "cn"]), + Language("zh-Hant", "Chinese (Traditional)", "中文(繁體)", ["zh_Hant"]), + Language("zu-ZA", "isiZulu", "isiZulu", []), +] diff --git a/app/src/main/python/gogdl/launch.py b/app/src/main/python/gogdl/launch.py new file mode 100644 index 000000000..ab3a96253 --- /dev/null +++ b/app/src/main/python/gogdl/launch.py @@ -0,0 +1,284 @@ +import os +import json +import sys +import subprocess +import time +from gogdl.dl.dl_utils import get_case_insensitive_name +from ctypes import * +from gogdl.process import Process +import signal +import shutil +import shlex + +class NoMoreChildren(Exception): + pass + +def get_flatpak_command(id: str) -> list[str]: + if sys.platform != "linux": + return [] + new_process_command = [] + process_command = ["flatpak", "info", id] + if os.path.exists("/.flatpak-info"): + try: + spawn_test = subprocess.run(["flatpak-spawn", "--host", "ls"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except FileNotFoundError: + return [] + if spawn_test.returncode != 0: + return [] + + new_process_command = ["flatpak-spawn", "--host"] + process_command = new_process_command + process_command + + try: + output = subprocess.run(process_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + if output.returncode == 0: + return new_process_command + ["flatpak", "run", id] + + except FileNotFoundError: + pass + return [] + + +# Supports launching linux builds +def launch(arguments, unknown_args): + # print(arguments) + info = load_game_info(arguments.path, arguments.id, arguments.platform) + + wrapper = [] + if arguments.wrapper: + wrapper = shlex.split(arguments.wrapper) + envvars = {} + + unified_platform = {"win32": "windows", "darwin": "osx", "linux": "linux"} + command = list() + working_dir = arguments.path + heroic_exe_wrapper = os.environ.get("HEROIC_GOGDL_WRAPPER_EXE") + # If type is a string we know it's a path to start.sh on linux + if type(info) != str: + if sys.platform != "win32": + if not arguments.dont_use_wine and arguments.platform != unified_platform[sys.platform]: + if arguments.wine_prefix: + envvars["WINEPREFIX"] = arguments.wine_prefix + wrapper.append(arguments.wine) + + primary_task = get_preferred_task(info, arguments.preferred_task) + launch_arguments = primary_task.get("arguments") + compatibility_flags = primary_task.get("compatibilityFlags") + executable = os.path.join(arguments.path, primary_task["path"]) + if arguments.platform == "linux": + executable = os.path.join(arguments.path, "game", primary_task["path"]) + if launch_arguments is None: + launch_arguments = [] + if type(launch_arguments) == str: + launch_arguments = launch_arguments.replace('\\', '/') + launch_arguments = shlex.split(launch_arguments) + if compatibility_flags is None: + compatibility_flags = [] + + relative_working_dir = ( + primary_task["workingDir"] if primary_task.get("workingDir") else "" + ) + if sys.platform != "win32": + relative_working_dir = relative_working_dir.replace("\\", os.sep) + executable = executable.replace("\\", os.sep) + working_dir = os.path.join(arguments.path, relative_working_dir) + + if not os.path.exists(executable): + executable = get_case_insensitive_name(executable) + # Handle case sensitive file systems + if not os.path.exists(working_dir): + working_dir = get_case_insensitive_name(working_dir) + + os.chdir(working_dir) + + if sys.platform != "win32" and arguments.platform == 'windows' and not arguments.override_exe: + if "scummvm.exe" in executable.lower(): + flatpak_scummvm = get_flatpak_command("org.scummvm.ScummVM") + native_scummvm = shutil.which("scummvm") + if native_scummvm: + native_scummvm = [native_scummvm] + + native_runner = flatpak_scummvm or native_scummvm + if native_runner: + wrapper = native_runner + executable = None + elif "dosbox.exe" in executable.lower(): + flatpak_dosbox = get_flatpak_command("io.github.dosbox-staging") + native_dosbox= shutil.which("dosbox") + if native_dosbox: + native_dosbox = [native_dosbox] + + native_runner = flatpak_dosbox or native_dosbox + if native_runner: + wrapper = native_runner + executable = None + + if len(wrapper) > 0 and wrapper[0] is not None: + command.extend(wrapper) + + if heroic_exe_wrapper: + command.append(heroic_exe_wrapper.strip()) + + if arguments.override_exe: + command.append(arguments.override_exe) + working_dir = os.path.split(arguments.override_exe)[0] + if not os.path.exists(working_dir): + working_dir = get_case_insensitive_name(working_dir) + elif executable: + command.append(executable) + command.extend(launch_arguments) + else: + if len(wrapper) > 0 and wrapper[0] is not None: + command.extend(wrapper) + + if heroic_exe_wrapper: + command.append(heroic_exe_wrapper.strip()) + + if arguments.override_exe: + command.append(arguments.override_exe) + working_dir = os.path.split(arguments.override_exe)[0] + # Handle case sensitive file systems + if not os.path.exists(working_dir): + working_dir = get_case_insensitive_name(working_dir) + else: + command.append(info) + + os.chdir(working_dir) + command.extend(unknown_args) + environment = os.environ.copy() + environment.update(envvars) + + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + bundle_dir = sys._MEIPASS + ld_library = environment.get("LD_LIBRARY_PATH") + if ld_library: + splitted = ld_library.split(":") + try: + splitted.remove(bundle_dir) + except ValueError: + pass + environment.update({"LD_LIBRARY_PATH": ":".join(splitted)}) + + print("Launch command:", command) + + status = None + if sys.platform == 'linux': + libc = cdll.LoadLibrary("libc.so.6") + prctl = libc.prctl + result = prctl(36 ,1, 0, 0, 0, 0) # PR_SET_CHILD_SUBREAPER = 36 + + if result == -1: + print("PR_SET_CHILD_SUBREAPER is not supported by your kernel (Linux 3.4 and above)") + + process = subprocess.Popen(command, env=environment) + process_pid = process.pid + + def iterate_processes(): + for child in Process(os.getpid()).iter_children(): + if child.state == 'Z': + continue + + if child.name: + yield child + + def hard_sig_handler(signum, _frame): + for _ in range(3): # just in case we race a new process. + for child in Process(os.getpid()).iter_children(): + try: + os.kill(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + + def sig_handler(signum, _frame): + signal.signal(signal.SIGTERM, hard_sig_handler) + signal.signal(signal.SIGINT, hard_sig_handler) + for _ in range(3): # just in case we race a new process. + for child in Process(os.getpid()).iter_children(): + try: + os.kill(child.pid, signal.SIGTERM) + except ProcessLookupError: + pass + + def is_alive(): + return next(iterate_processes(), None) is not None + + signal.signal(signal.SIGTERM, sig_handler) + signal.signal(signal.SIGINT, sig_handler) + + def reap_children(): + nonlocal status + while True: + try: + child_pid, child_returncode, _resource_usage = os.wait3(os.WNOHANG) + except ChildProcessError: + raise NoMoreChildren from None # No processes remain. + if child_pid == process_pid: + status = child_returncode + + if child_pid == 0: + break + + try: + # The initial wait loop: + # the initial process may have been excluded. Wait for the game + # to be considered "started". + if not is_alive(): + while not is_alive(): + reap_children() + time.sleep(0.1) + while is_alive(): + reap_children() + time.sleep(0.1) + reap_children() + except NoMoreChildren: + print("All processes exited") + + + else: + process = subprocess.Popen(command, env=environment, + shell=sys.platform=="win32") + status = process.wait() + + sys.exit(status) + + +def get_preferred_task(info, index): + primaryTask = None + for task in info["playTasks"]: + if task.get("isPrimary") == True: + primaryTask = task + break + if index is None: + return primaryTask + indexI = int(index) + if len(info["playTasks"]) > indexI: + return info["playTasks"][indexI] + + return primaryTask + + + + +def load_game_info(path, id, platform): + filename = f"goggame-{id}.info" + abs_path = ( + ( + os.path.join(path, filename) + if platform == "windows" + else os.path.join(path, "start.sh") + ) + if platform != "osx" + else os.path.join(path, "Contents", "Resources", filename) + ) + if not os.path.isfile(abs_path): + sys.exit(1) + if platform == "linux": + return abs_path + with open(abs_path) as f: + data = f.read() + f.close() + return json.loads(data) + + diff --git a/app/src/main/python/gogdl/process.py b/app/src/main/python/gogdl/process.py new file mode 100644 index 000000000..c54cac082 --- /dev/null +++ b/app/src/main/python/gogdl/process.py @@ -0,0 +1,138 @@ +import os + + +class InvalidPid(Exception): + + """Exception raised when an operation on a non-existent PID is called""" + + +class Process: + + """Python abstraction a Linux process""" + + def __init__(self, pid): + try: + self.pid = int(pid) + self.error_cache = [] + except ValueError as err: + raise InvalidPid("'%s' is not a valid pid" % pid) from err + + def __repr__(self): + return "Process {}".format(self.pid) + + def __str__(self): + return "{} ({}:{})".format(self.name, self.pid, self.state) + + def _read_content(self, file_path): + """Return the contents from a file in /proc""" + try: + with open(file_path, encoding='utf-8') as proc_file: + content = proc_file.read() + except (ProcessLookupError, FileNotFoundError, PermissionError): + return "" + return content + + def get_stat(self, parsed=True): + stat_filename = "/proc/{}/stat".format(self.pid) + try: + with open(stat_filename, encoding='utf-8') as stat_file: + _stat = stat_file.readline() + except (ProcessLookupError, FileNotFoundError): + return None + if parsed: + return _stat[_stat.rfind(")") + 1:].split() + return _stat + + def get_thread_ids(self): + """Return a list of thread ids opened by process.""" + basedir = "/proc/{}/task/".format(self.pid) + if os.path.isdir(basedir): + try: + return os.listdir(basedir) + except FileNotFoundError: + return [] + else: + return [] + + def get_children_pids_of_thread(self, tid): + """Return pids of child processes opened by thread `tid` of process.""" + children_path = "/proc/{}/task/{}/children".format(self.pid, tid) + try: + with open(children_path, encoding='utf-8') as children_file: + children_content = children_file.read() + except (FileNotFoundError, ProcessLookupError): + children_content = "" + return children_content.strip().split() + + @property + def name(self): + """Filename of the executable.""" + _stat = self.get_stat(parsed=False) + if _stat: + return _stat[_stat.find("(") + 1:_stat.rfind(")")] + return None + + @property + def state(self): + """One character from the string "RSDZTW" where R is running, S is + sleeping in an interruptible wait, D is waiting in uninterruptible disk + sleep, Z is zombie, T is traced or stopped (on a signal), and W is + paging. + """ + _stat = self.get_stat() + if _stat: + return _stat[0] + return None + + @property + def cmdline(self): + """Return command line used to run the process `pid`.""" + cmdline_path = "/proc/{}/cmdline".format(self.pid) + _cmdline_content = self._read_content(cmdline_path) + if _cmdline_content: + return _cmdline_content.replace("\x00", " ").replace("\\", "/") + + @property + def cwd(self): + """Return current working dir of process""" + cwd_path = "/proc/%d/cwd" % int(self.pid) + return os.readlink(cwd_path) + + @property + def environ(self): + """Return the process' environment variables""" + environ_path = "/proc/{}/environ".format(self.pid) + _environ_text = self._read_content(environ_path) + if not _environ_text: + return {} + try: + return dict([line.split("=", 1) for line in _environ_text.split("\x00") if line]) + except ValueError: + if environ_path not in self.error_cache: + self.error_cache.append(environ_path) + return {} + + @property + def children(self): + """Return the child processes of this process""" + _children = [] + for tid in self.get_thread_ids(): + for child_pid in self.get_children_pids_of_thread(tid): + _children.append(Process(child_pid)) + return _children + + def iter_children(self): + """Iterator that yields all the children of a process""" + for child in self.children: + yield child + yield from child.iter_children() + + def wait_for_finish(self): + """Waits until the process finishes + This only works if self.pid is a child process of Lutris + """ + try: + pid, ret_status = os.waitpid(int(self.pid) * -1, 0) + except OSError as ex: + return -1 + return ret_status diff --git a/app/src/main/python/gogdl/saves.py b/app/src/main/python/gogdl/saves.py new file mode 100644 index 000000000..27fff8b64 --- /dev/null +++ b/app/src/main/python/gogdl/saves.py @@ -0,0 +1,365 @@ +""" +Android-compatible GOG cloud save synchronization +Adapted from heroic-gogdl saves.py +""" + +import os +import sys +import logging +import requests +import hashlib +import datetime +import gzip +from enum import Enum + +import gogdl.dl.dl_utils as dl_utils +import gogdl.constants as constants + +LOCAL_TIMEZONE = datetime.datetime.utcnow().astimezone().tzinfo + + +class SyncAction(Enum): + DOWNLOAD = 0 + UPLOAD = 1 + CONFLICT = 2 + NONE = 3 + + +class SyncFile: + def __init__(self, path, abs_path, md5=None, update_time=None): + self.relative_path = path.replace('\\', '/') # cloud file identifier + self.absolute_path = abs_path + self.md5 = md5 + self.update_time = update_time + self.update_ts = ( + datetime.datetime.fromisoformat(update_time).astimezone().timestamp() + if update_time + else None + ) + + def get_file_metadata(self): + ts = os.stat(self.absolute_path).st_mtime + date_time_obj = datetime.datetime.fromtimestamp( + ts, tz=LOCAL_TIMEZONE + ).astimezone(datetime.timezone.utc) + with open(self.absolute_path, "rb") as f: + self.md5 = hashlib.md5( + gzip.compress(f.read(), 6, mtime=0) + ).hexdigest() + self.update_time = date_time_obj.isoformat(timespec="seconds") + self.update_ts = date_time_obj.timestamp() + + def __repr__(self): + return f"{self.md5} {self.relative_path}" + + +class CloudStorageManager: + def __init__(self, api_handler, authorization_manager): + self.api = api_handler + self.auth_manager = authorization_manager + self.session = requests.Session() + self.logger = logging.getLogger("SAVES") + + self.session.headers.update( + {"User-Agent": "GOGGalaxyCommunicationService/2.0.13.27 (Windows_32bit) dont_sync_marker/true installation_source/gog", + "X-Object-Meta-User-Agent": "GOGGalaxyCommunicationService/2.0.13.27 (Windows_32bit) dont_sync_marker/true installation_source/gog"} + ) + + self.credentials = dict() + self.client_id = str() + self.client_secret = str() + + def create_directory_map(self, path: str) -> list: + """ + Creates list of every file in directory to be synced + """ + files = list() + try: + directory_contents = os.listdir(path) + except (OSError, FileNotFoundError): + self.logger.warning(f"Cannot access directory: {path}") + return files + + for content in directory_contents: + abs_path = os.path.join(path, content) + if os.path.isdir(abs_path): + files.extend(self.create_directory_map(abs_path)) + else: + files.append(abs_path) + return files + + @staticmethod + def get_relative_path(root: str, path: str) -> str: + if not root.endswith("/") and not root.endswith("\\"): + root = root + os.sep + return path.replace(root, "") + + def sync(self, arguments, unknown_args): + try: + prefered_action = getattr(arguments, 'prefered_action', None) + self.sync_path = os.path.normpath(arguments.path.strip('"')) + self.sync_path = self.sync_path.replace("\\", os.sep) + self.cloud_save_dir_name = getattr(arguments, 'dirname', 'saves') + self.arguments = arguments + self.unknown_args = unknown_args + + if not os.path.exists(self.sync_path): + self.logger.warning("Provided path doesn't exist, creating") + os.makedirs(self.sync_path, exist_ok=True) + + dir_list = self.create_directory_map(self.sync_path) + if len(dir_list) == 0: + self.logger.info("No files in directory") + + local_files = [ + SyncFile(self.get_relative_path(self.sync_path, f), f) for f in dir_list + ] + + for f in local_files: + try: + f.get_file_metadata() + except Exception as e: + self.logger.warning(f"Failed to get metadata for {f.absolute_path}: {e}") + + self.logger.info(f"Local files: {len(dir_list)}") + + # Get authentication credentials + try: + self.client_id, self.client_secret = self.get_auth_ids() + self.get_auth_token() + except Exception as e: + self.logger.error(f"Authentication failed: {e}") + return + + # Get cloud files + try: + cloud_files = self.get_cloud_files_list() + downloadable_cloud = [f for f in cloud_files if f.md5 != "aadd86936a80ee8a369579c3926f1b3c"] + except Exception as e: + self.logger.error(f"Failed to get cloud files: {e}") + return + + # Handle sync logic + if len(local_files) > 0 and len(cloud_files) == 0: + self.logger.info("No files in cloud, uploading") + for f in local_files: + try: + self.upload_file(f) + except Exception as e: + self.logger.error(f"Failed to upload {f.relative_path}: {e}") + self.logger.info("Done") + sys.stdout.write(str(datetime.datetime.now().timestamp())) + sys.stdout.flush() + return + + elif len(local_files) == 0 and len(cloud_files) > 0: + self.logger.info("No files locally, downloading") + for f in downloadable_cloud: + try: + self.download_file(f) + except Exception as e: + self.logger.error(f"Failed to download {f.relative_path}: {e}") + self.logger.info("Done") + sys.stdout.write(str(datetime.datetime.now().timestamp())) + sys.stdout.flush() + return + + # Handle more complex sync scenarios + timestamp = float(getattr(arguments, 'timestamp', 0.0)) + classifier = SyncClassifier.classify(local_files, cloud_files, timestamp) + + action = classifier.get_action() + if action == SyncAction.DOWNLOAD: + self.logger.info("Downloading newer cloud files") + for f in classifier.updated_cloud: + try: + self.download_file(f) + except Exception as e: + self.logger.error(f"Failed to download {f.relative_path}: {e}") + + elif action == SyncAction.UPLOAD: + self.logger.info("Uploading newer local files") + for f in classifier.updated_local: + try: + self.upload_file(f) + except Exception as e: + self.logger.error(f"Failed to upload {f.relative_path}: {e}") + + elif action == SyncAction.CONFLICT: + self.logger.warning("Sync conflict detected - manual intervention required") + + self.logger.info("Sync completed") + sys.stdout.write(str(datetime.datetime.now().timestamp())) + sys.stdout.flush() + + except Exception as e: + self.logger.error(f"Sync failed: {e}") + raise + + def get_auth_ids(self): + """Get client credentials from auth manager""" + try: + # Use the same client ID as the main app + client_id = "46899977096215655" + client_secret = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" + return client_id, client_secret + except Exception as e: + self.logger.error(f"Failed to get auth IDs: {e}") + raise + + def get_auth_token(self): + """Get authentication token""" + try: + # Load credentials from auth file + import json + with open(self.auth_manager.config_path, 'r') as f: + auth_data = json.load(f) + + # Extract credentials for our client ID + client_creds = auth_data.get(self.client_id, {}) + self.credentials = { + 'access_token': client_creds.get('access_token', ''), + 'user_id': client_creds.get('user_id', '') + } + + if not self.credentials['access_token']: + raise Exception("No valid access token found") + + # Update session headers + self.session.headers.update({ + 'Authorization': f"Bearer {self.credentials['access_token']}" + }) + + except Exception as e: + self.logger.error(f"Failed to get auth token: {e}") + raise + + def get_cloud_files_list(self): + """Get list of files from GOG cloud storage""" + try: + url = f"{constants.GOG_CLOUDSTORAGE}/v1/{self.credentials['user_id']}/{self.client_id}" + response = self.session.get(url) + + if not response.ok: + self.logger.error(f"Failed to get cloud files: {response.status_code}") + return [] + + cloud_data = response.json() + cloud_files = [] + + for item in cloud_data.get('items', []): + if self.is_save_file(item): + cloud_file = SyncFile( + self.get_relative_path(f"{self.cloud_save_dir_name}/", item['name']), + "", # No local path for cloud files + item.get('hash'), + item.get('last_modified') + ) + cloud_files.append(cloud_file) + + return cloud_files + + except Exception as e: + self.logger.error(f"Failed to get cloud files list: {e}") + return [] + + def is_save_file(self, item): + """Check if cloud item is a save file""" + return item.get("name", "").startswith(self.cloud_save_dir_name) + + def upload_file(self, file: SyncFile): + """Upload file to GOG cloud storage""" + try: + url = f"{constants.GOG_CLOUDSTORAGE}/v1/{self.credentials['user_id']}/{self.client_id}/{self.cloud_save_dir_name}/{file.relative_path}" + + with open(file.absolute_path, 'rb') as f: + headers = { + 'X-Object-Meta-LocalLastModified': file.update_time, + 'Content-Type': 'application/octet-stream' + } + response = self.session.put(url, data=f, headers=headers) + + if not response.ok: + self.logger.error(f"Upload failed for {file.relative_path}: {response.status_code}") + + except Exception as e: + self.logger.error(f"Failed to upload {file.relative_path}: {e}") + + def download_file(self, file: SyncFile, retries=3): + """Download file from GOG cloud storage""" + try: + url = f"{constants.GOG_CLOUDSTORAGE}/v1/{self.credentials['user_id']}/{self.client_id}/{self.cloud_save_dir_name}/{file.relative_path}" + response = self.session.get(url, stream=True) + + if not response.ok: + self.logger.error(f"Download failed for {file.relative_path}: {response.status_code}") + return + + # Create local directory structure + local_path = os.path.join(self.sync_path, file.relative_path) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + + # Download file + with open(local_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + # Set file timestamp if available + if 'X-Object-Meta-LocalLastModified' in response.headers: + try: + timestamp = datetime.datetime.fromisoformat( + response.headers['X-Object-Meta-LocalLastModified'] + ).timestamp() + os.utime(local_path, (timestamp, timestamp)) + except Exception as e: + self.logger.warning(f"Failed to set timestamp for {file.relative_path}: {e}") + + except Exception as e: + if retries > 1: + self.logger.debug(f"Failed sync of {file.relative_path}, retrying (retries left {retries - 1})") + self.download_file(file, retries - 1) + else: + self.logger.error(f"Failed to download {file.relative_path}: {e}") + + +class SyncClassifier: + def __init__(self): + self.action = None + self.updated_local = list() + self.updated_cloud = list() + self.not_existing_locally = list() + self.not_existing_remotely = list() + + def get_action(self): + if len(self.updated_local) == 0 and len(self.updated_cloud) > 0: + self.action = SyncAction.DOWNLOAD + elif len(self.updated_local) > 0 and len(self.updated_cloud) == 0: + self.action = SyncAction.UPLOAD + elif len(self.updated_local) == 0 and len(self.updated_cloud) == 0: + self.action = SyncAction.NONE + else: + self.action = SyncAction.CONFLICT + return self.action + + @classmethod + def classify(cls, local, cloud, timestamp): + classifier = cls() + + local_paths = [f.relative_path for f in local] + cloud_paths = [f.relative_path for f in cloud] + + for f in local: + if f.relative_path not in cloud_paths: + classifier.not_existing_remotely.append(f) + if f.update_ts and f.update_ts > timestamp: + classifier.updated_local.append(f) + + for f in cloud: + if f.md5 == "aadd86936a80ee8a369579c3926f1b3c": + continue + if f.relative_path not in local_paths: + classifier.not_existing_locally.append(f) + if f.update_ts and f.update_ts > timestamp: + classifier.updated_cloud.append(f) + + return classifier diff --git a/app/src/main/python/gogdl/xdelta/__init__.py b/app/src/main/python/gogdl/xdelta/__init__.py new file mode 100644 index 000000000..6ccc12390 --- /dev/null +++ b/app/src/main/python/gogdl/xdelta/__init__.py @@ -0,0 +1 @@ +# Python implementation of xdelta3 decoding only diff --git a/app/src/main/python/gogdl/xdelta/objects.py b/app/src/main/python/gogdl/xdelta/objects.py new file mode 100644 index 000000000..14ed48858 --- /dev/null +++ b/app/src/main/python/gogdl/xdelta/objects.py @@ -0,0 +1,139 @@ +from dataclasses import dataclass, field +from io import IOBase, BytesIO +from typing import Optional, List + +@dataclass +class CodeTable: + add_sizes = 17 + near_modes = 4 + same_modes = 3 + + cpy_sizes = 15 + + addcopy_add_max = 4 + addcopy_near_cpy_max = 6 + addcopy_same_cpy_max = 4 + + copyadd_add_max = 1 + copyadd_near_cpy_max = 4 + copyadd_same_cpy_max = 4 + + addcopy_max_sizes = [ [6,163,3],[6,175,3],[6,187,3],[6,199,3],[6,211,3],[6,223,3], + [4,235,1],[4,239,1],[4,243,1]] + copyadd_max_sizes = [[4,247,1],[4,248,1],[4,249,1],[4,250,1],[4,251,1],[4,252,1], + [4,253,1],[4,254,1],[4,255,1]] + +XD3_NOOP = 0 +XD3_ADD = 1 +XD3_RUN = 2 +XD3_CPY = 3 + +@dataclass +class Instruction: + type1:int = 0 + size1:int = 0 + type2:int = 0 + size2:int = 0 + +@dataclass +class HalfInstruction: + type: int = 0 + size: int = 0 + addr: int = 0 + + +@dataclass +class AddressCache: + s_near: int = field(default=CodeTable.near_modes) + s_same: int = field(default=CodeTable.same_modes) + next_slot: int = field(default=0) + near_array: List[int] = field(default_factory=lambda: [0] * CodeTable.near_modes) + same_array: List[int] = field(default_factory=lambda: [0] * (CodeTable.same_modes * 256)) + + def update(self, addr): + self.near_array[self.next_slot] = addr + self.next_slot = (self.next_slot + 1) % self.s_near + + self.same_array[addr % (self.s_same*256)] = addr + +@dataclass +class Context: + source: IOBase + target: IOBase + + data_sec: BytesIO + inst_sec: BytesIO + addr_sec: BytesIO + + acache: AddressCache + dec_pos: int = 0 + cpy_len: int = 0 + cpy_off: int = 0 + dec_winoff: int = 0 + + target_buffer: Optional[bytearray] = None + +def build_code_table(): + table: list[Instruction] = [] + for _ in range(256): + table.append(Instruction()) + + cpy_modes = 2 + CodeTable.near_modes + CodeTable.same_modes + i = 0 + + table[i].type1 = XD3_RUN + i+=1 + table[i].type1 = XD3_ADD + i+=1 + + size1 = 1 + + for size1 in range(1, CodeTable.add_sizes + 1): + table[i].type1 = XD3_ADD + table[i].size1 = size1 + i+=1 + + for mode in range(0, cpy_modes): + table[i].type1 = XD3_CPY + mode + i += 1 + for size1 in range(4, 4 + CodeTable.cpy_sizes): + table[i].type1 = XD3_CPY + mode + table[i].size1 = size1 + i+=1 + + + for mode in range(cpy_modes): + for size1 in range(1, CodeTable.addcopy_add_max + 1): + is_near = mode < (2 + CodeTable.near_modes) + if is_near: + max = CodeTable.addcopy_near_cpy_max + else: + max = CodeTable.addcopy_same_cpy_max + for size2 in range(4, max + 1): + table[i].type1 = XD3_ADD + table[i].size1 = size1 + table[i].type2 = XD3_CPY + mode + table[i].size2 = size2 + i+=1 + + + for mode in range(cpy_modes): + is_near = mode < (2 + CodeTable.near_modes) + if is_near: + max = CodeTable.copyadd_near_cpy_max + else: + max = CodeTable.copyadd_same_cpy_max + for size1 in range(4, max + 1): + for size2 in range(1, CodeTable.copyadd_add_max + 1): + table[i].type1 = XD3_CPY + mode + table[i].size1 = size1 + table[i].type2 = XD3_ADD + table[i].size2 = size2 + i+=1 + + return table + +CODE_TABLE = build_code_table() + +class ChecksumMissmatch(AssertionError): + pass diff --git a/app/src/main/python/gogdl/xdelta/patcher.py b/app/src/main/python/gogdl/xdelta/patcher.py new file mode 100644 index 000000000..19f3a9f1b --- /dev/null +++ b/app/src/main/python/gogdl/xdelta/patcher.py @@ -0,0 +1,204 @@ +from io import BytesIO +import math +from multiprocessing import Queue +from zlib import adler32 +from gogdl.xdelta import objects + +# Convert stfio integer +def read_integer_stream(stream): + res = 0 + while True: + res <<= 7 + integer = stream.read(1)[0] + res |= (integer & 0b1111111) + if not (integer & 0b10000000): + break + + return res + +def parse_halfinst(context: objects.Context, halfinst: objects.HalfInstruction): + if halfinst.size == 0: + halfinst.size = read_integer_stream(context.inst_sec) + + if halfinst.type >= objects.XD3_CPY: + # Decode address + mode = halfinst.type - objects.XD3_CPY + same_start = 2 + context.acache.s_near + + if mode < same_start: + halfinst.addr = read_integer_stream(context.addr_sec) + + if mode == 0: + pass + elif mode == 1: + halfinst.addr = context.dec_pos - halfinst.addr + if halfinst.addr < 0: + halfinst.addr = context.cpy_len + halfinst.addr + else: + halfinst.addr += context.acache.near_array[mode - 2] + else: + mode -= same_start + addr = context.addr_sec.read(1)[0] + halfinst.addr = context.acache.same_array[(mode * 256) + addr] + context.acache.update(halfinst.addr) + + context.dec_pos += halfinst.size + + +def decode_halfinst(context:objects.Context, halfinst: objects.HalfInstruction, speed_queue: Queue): + take = halfinst.size + + if halfinst.type == objects.XD3_RUN: + byte = context.data_sec.read(1) + + for _ in range(take): + context.target_buffer.extend(byte) + + halfinst.type = objects.XD3_NOOP + elif halfinst.type == objects.XD3_ADD: + buffer = context.data_sec.read(take) + assert len(buffer) == take + context.target_buffer.extend(buffer) + halfinst.type = objects.XD3_NOOP + else: # XD3_CPY and higher + if halfinst.addr < (context.cpy_len or 0): + context.source.seek(context.cpy_off + halfinst.addr) + left = take + while left > 0: + buffer = context.source.read(min(1024 * 1024, left)) + size = len(buffer) + speed_queue.put((0, size)) + context.target_buffer.extend(buffer) + left -= size + + else: + print("OVERLAP NOT IMPLEMENTED") + raise Exception("OVERLAP") + halfinst.type = objects.XD3_NOOP + + +def patch(source: str, patch: str, out: str, speed_queue: Queue): + src_handle = open(source, 'rb') + patch_handle = open(patch, 'rb') + dst_handle = open(out, 'wb') + + + # Verify if patch is actually xdelta patch + headers = patch_handle.read(5) + try: + assert headers[0] == 0xD6 + assert headers[1] == 0xC3 + assert headers[2] == 0xC4 + except AssertionError: + print("Specified patch file is unlikely to be xdelta patch") + return + + HDR_INDICATOR = headers[4] + COMPRESSOR_ID = HDR_INDICATOR & (1 << 0) != 0 + CODE_TABLE = HDR_INDICATOR & (1 << 1) != 0 + APP_HEADER = HDR_INDICATOR & (1 << 2) != 0 + app_header_data = bytes() + + if COMPRESSOR_ID or CODE_TABLE: + print("Compressor ID and codetable are yet not supported") + return + + if APP_HEADER: + app_header_size = read_integer_stream(patch_handle) + app_header_data = patch_handle.read(app_header_size) + + context = objects.Context(src_handle, dst_handle, BytesIO(), BytesIO(), BytesIO(), objects.AddressCache()) + + win_number = 0 + win_indicator = patch_handle.read(1)[0] + while win_indicator is not None: + context.acache = objects.AddressCache() + source_used = win_indicator & (1 << 0) != 0 + target_used = win_indicator & (1 << 1) != 0 + adler32_sum = win_indicator & (1 << 2) != 0 + + if source_used: + source_segment_length = read_integer_stream(patch_handle) + source_segment_position = read_integer_stream(patch_handle) + else: + source_segment_length = 0 + source_segment_position = 0 + + context.cpy_len = source_segment_length + context.cpy_off = source_segment_position + context.source.seek(context.cpy_off or 0) + context.dec_pos = 0 + + # Parse delta + delta_encoding_length = read_integer_stream(patch_handle) + + window_length = read_integer_stream(patch_handle) + context.target_buffer = bytearray() + + delta_indicator = patch_handle.read(1)[0] + + add_run_data_length = read_integer_stream(patch_handle) + instructions_length = read_integer_stream(patch_handle) + addresses_length = read_integer_stream(patch_handle) + + parsed_sum = 0 + if adler32_sum: + checksum = patch_handle.read(4) + parsed_sum = int.from_bytes(checksum, 'big') + + + context.data_sec = BytesIO(patch_handle.read(add_run_data_length)) + context.inst_sec = BytesIO(patch_handle.read(instructions_length)) + context.addr_sec = BytesIO(patch_handle.read(addresses_length)) + + + current1 = objects.HalfInstruction() + current2 = objects.HalfInstruction() + + while context.inst_sec.tell() < instructions_length or current1.type != objects.XD3_NOOP or current2.type != objects.XD3_NOOP: + if current1.type == objects.XD3_NOOP and current2.type == objects.XD3_NOOP: + ins = objects.CODE_TABLE[context.inst_sec.read(1)[0]] + current1.type = ins.type1 + current2.type = ins.type2 + current1.size = ins.size1 + current2.size = ins.size2 + + if current1.type != objects.XD3_NOOP: + parse_halfinst(context, current1) + if current2.type != objects.XD3_NOOP: + parse_halfinst(context, current2) + + while current1.type != objects.XD3_NOOP: + decode_halfinst(context, current1, speed_queue) + + while current2.type != objects.XD3_NOOP: + decode_halfinst(context, current2, speed_queue) + + if adler32_sum: + calculated_sum = adler32(context.target_buffer) + if parsed_sum != calculated_sum: + raise objects.ChecksumMissmatch + + total_size = len(context.target_buffer) + chunk_size = 1024 * 1024 + for i in range(math.ceil(total_size / chunk_size)): + chunk = context.target_buffer[i * chunk_size : min((i + 1) * chunk_size, total_size)] + context.target.write(chunk) + speed_queue.put((len(chunk), 0)) + + context.target.flush() + + indicator = patch_handle.read(1) + if not len(indicator): + win_indicator = None + continue + win_indicator = indicator[0] + win_number += 1 + + + dst_handle.flush() + src_handle.close() + patch_handle.close() + dst_handle.close() + + diff --git a/app/src/main/res/drawable/ic_gog.png b/app/src/main/res/drawable/ic_gog.png new file mode 100644 index 000000000..861288bd8 Binary files /dev/null and b/app/src/main/res/drawable/ic_gog.png differ diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 867383db9..bd4e82901 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -17,6 +17,10 @@ Slet Afinstallér Spil + Afinstallér spil + Er du sikker på, at du vil afinstallere %1$s? Denne handling kan ikke fortrydes. + Download spil + Appen der installeres har følgende pladskrav. Vil du fortsætte?\n\n\tDownload-størrelse: %1$s\n\tTilgængelig plads: %2$s Installér app Slet app Annullér download @@ -795,4 +799,36 @@ Eksporteret Kunne ikke eksportere: %s Eksport annulleret + + + GOG Integration (Alpha) + GOG Login + Log ind på din GOG-konto + Synkroniserer… + Fejl: %1$s + ✓ Synkroniseret %1$d spil + Hent dit GOG-spilbibliotek + Login lykkedes + Du er nu logget ind på GOG.\nVi vil nu synkronisere dit bibliotek i baggrunden. + + + Log ind på GOG + Tryk på \'Åbn GOG Login\' og log ind. Når du er logget ind, skal du kopiere URL\'en og indsætte nedenfor + Eksempel: https://embed.gog.com/on_login_success?origin=client&code=aaa + Åbn GOG Login + Godkendelseskode eller login-succes-URL + Indsæt kode eller url her + Log ind + Annuller + Kunne ikke åbne browser + + + Log ud + Log ud fra din GOG-konto + Log ud fra GOG? + Dette vil fjerne dine GOG-legitimationsoplysninger og rydde dit GOG-bibliotek fra denne enhed. Du kan logge ind igen når som helst. + Log ud + Logget ud fra GOG + Kunne ikke logge ud: %s + Logger ud fra GOG… diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 07439a5b5..aebb899b9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -40,6 +40,10 @@ Êtes-vous sûr de vouloir désinstaller %1$s ? Cette action ne peut pas être annulée. %1$s a été désinstallé Échec de la désinstallation du jeu + Désinstaller le jeu + Êtes-vous sûr de vouloir désinstaller %1$s ? Cette action ne peut pas être annulée. + Télécharger le jeu + L\'application en cours d\'installation nécessite l\'espace suivant. Voulez-vous continuer ?\n\n\tTaille du téléchargement : %1$s\n\tEspace disponible : %2$s Jamais Continuer Image d\'en-tête de l\'application @@ -928,6 +932,38 @@ Conteneurs utilisant cette version : Aucun conteneur n\'utilise actuellement cette version. Ces conteneurs ne fonctionneront plus si vous continuez : + + + Intégration GOG (Alpha) + Connexion GOG + Connectez-vous à votre compte GOG + Synchronisation… + Erreur : %1$s + ✓ %1$d jeux synchronisés + Récupérer votre bibliothèque de jeux GOG + Connexion réussie + Vous êtes maintenant connecté à GOG.\nNous allons maintenant synchroniser votre bibliothèque en arrière-plan. + + + Se connecter à GOG + Appuyez sur \'Ouvrir la connexion GOG\' et connectez-vous. Une fois connecté, veuillez copier l\'URL et coller ci-dessous + Exemple : https://embed.gog.com/on_login_success?origin=client&code=aaa + Ouvrir la connexion GOG + Code d\'autorisation ou URL de réussite de connexion + Collez le code ou l\'url ici + Se connecter + Annuler + Impossible d\'ouvrir le navigateur + + + Déconnexion + Se déconnecter de votre compte GOG + Se déconnecter de GOG ? + Cela supprimera vos identifiants GOG et effacera votre bibliothèque GOG de cet appareil. Vous pouvez vous reconnecter à tout moment. + Déconnexion + Déconnecté de GOG avec succès + Échec de la déconnexion : %s + Déconnexion de GOG… diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6264a98ef..eef60bce1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -17,6 +17,10 @@ Deletar Desinstalar Jogar + Desinstalar Jogo + Tem certeza que deseja desinstalar %1$s? Esta ação não pode ser desfeita. + Baixar Jogo + O aplicativo sendo instalado tem os seguintes requisitos de espaço. Deseja continuar?\n\n\tTamanho do download: %1$s\n\tEspaço disponível: %2$s Instalar App Deletar App Cancelar Download @@ -795,4 +799,36 @@ Exportado Falha ao exportar: %s Exportação cancelada + + + Integração GOG (Alpha) + Login GOG + Entre na sua conta GOG + Sincronizando… + Erro: %1$s + ✓ %1$d jogos sincronizados + Buscar sua biblioteca de jogos GOG + Login bem-sucedido + Você está conectado ao GOG.\nVamos sincronizar sua biblioteca em segundo plano. + + + Entrar no GOG + Toque em \'Abrir Login GOG\' e entre. Após fazer login, copie a URL e cole abaixo + Exemplo: https://embed.gog.com/on_login_success?origin=client&code=aaa + Abrir Login GOG + Código de autorização ou URL de sucesso do login + Cole o código ou url aqui + Entrar + Cancelar + Não foi possível abrir o navegador + + + Sair + Desconectar da sua conta GOG + Sair do GOG? + Isso removerá suas credenciais GOG e limpará sua biblioteca GOG deste dispositivo. Você pode entrar novamente a qualquer momento. + Sair + Desconectado do GOG com sucesso + Falha ao sair: %s + Saindo do GOG… diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index eb587b880..8fafa6ce2 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -19,6 +19,10 @@ Видалити Деінсталювати Грати + Деінсталювати гру + Ви впевнені, що хочете деінсталювати %1$s? Цю дію не можна скасувати. + Завантажити гру + Гра має наступні вимоги до простору. Бажаєте продовжити?\n\n\tРозмір завантаження: %1$s\n\tДоступний простір: %2$s Інсталювати застосунок Деінсталювати застосунок Скасувати завантаження @@ -574,6 +578,38 @@ Вміст успішно інстальовано Не вдалося інсталювати вміст Помилка інсталяції: %1$s + + + Інтеграція GOG (Альфа) + Вхід у GOG + Увійдіть у свій обліковий запис GOG + Синхронізація… + Помилка: %1$s + ✓ Синхронізовано %1$d ігор + Отримати вашу бібліотеку ігор GOG + Вхід успішний + Ви увійшли в GOG.\nМи тепер синхронізуємо вашу бібліотеку у фоновому режимі. + + + Увійти в GOG + Натисніть \'Відкрити вхід GOG\' і увійдіть. Після входу скопіюйте URL-адресу та вставте нижче + Приклад: https://embed.gog.com/on_login_success?origin=client&code=aaa + Відкрити вхід GOG + Код авторизації або URL успішного входу + Вставте код або url сюди + Увійти + Скасувати + Не вдалося відкрити браузер + + + Вийти + Вийти з облікового запису GOG + Вийти з GOG? + Це видалить ваші облікові дані GOG та очистить бібліотеку GOG на цьому пристрої. Ви можете увійти знову в будь-який час. + Вийти + Успішно вийшли з GOG + Не вдалося вийти: %s + Вихід з GOG… diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index aa90a7b7f..0be8a9c69 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -39,6 +39,10 @@ 确定要卸载 %1$s 吗?此操作无法撤销 %1$s 已卸载 卸载游戏失败 + 卸载游戏 + 您确定要卸载 %1$s 吗? 此操作无法撤回 + 下载游戏 + 正在安装的应用程式有以下空间需求:\n\n\t下载大小: %1$s\n\t可用空间: %2$s 从不 继续 应用头图 @@ -921,4 +925,36 @@ 使用此版本的容器: 目前没有容器使用此版本 若您继续操作,这些容器将无法继续运行: + + + GOG 集成 (Alpha) + GOG 登录 + 登录到您的 GOG 账户 + 同步中… + 错误: %1$s + ✓ 已同步 %1$d 个游戏 + 获取您的 GOG 游戏库 + 登录成功 + 您现已登录到 GOG。\n我们将在后台同步您的游戏库。 + + + 登录到 GOG + 点击\'打开 GOG 登录\'并登录。登录后, 请复制 URL 并粘贴到下方 + 示例: https://embed.gog.com/on_login_success?origin=client&code=aaa + 打开 GOG 登录 + 授权码或登录成功 URL + 在此粘贴代码或 url + 登录 + 取消 + 无法打开浏览器 + + + 注销 + 从您的 GOG 账户登出 + 从 GOG 注销? + 这将删除您的 GOG 凭据并清除此设备上的 GOG 库。您可以随时重新登录。 + 注销 + 成功从 GOG 注销 + 注销失败: %s + 正在从 GOG 注销… diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8207e6ccd..d0a4aa0a4 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -39,6 +39,10 @@ 您確定要卸載 %1$s 嗎? 此操作無法撤回 %1$s 已卸載 卸載遊戲失敗 + 卸載遊戲 + 您確定要卸載 %1$s 嗎? 此操作無法撤回 + 下載遊戲 + 正在安裝的應用程式有以下空間需求:\n\n\t下載大小: %1$s\n\t可用空間: %2$s 從不 繼續 App header image @@ -925,6 +929,38 @@ 使用此版本的容器: 目前沒有容器使用此版本 若您繼續操作, 這些容器將無法繼續運作: + + + GOG 整合 (Alpha) + GOG 登入 + 登入您的 GOG 帳戶 + 同步中… + 錯誤: %1$s + ✓ 已同步 %1$d 個遊戲 + 獲取您的 GOG 遊戲庫 + 登入成功 + 您現已登入到 GOG。\n我們將在背景中同步您的遊戲庫。 + + + 登入到 GOG + 點擊\'開啟 GOG 登入\'並登入。登入後, 請複製 URL 並貼到下方 + 範例: https://embed.gog.com/on_login_success?origin=client&code=aaa + 開啟 GOG 登入 + 授權碼或登入成功 URL + 在此貼上代碼或 url + 登入 + 取消 + 無法開啟瀏覽器 + + + 登出 + 從您的 GOG 帳戶登出 + 從 GOG 登出? + 這將刪除您的 GOG 憑證並清除此裝置上的 GOG 遊戲庫。您可以隨時重新登入。 + 登出 + 成功從 GOG 登出 + 登出失敗: %s + 正在從 GOG 登出… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe546612f..4b8c42622 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,10 @@ Are you sure you want to uninstall %1$s? This action cannot be undone. %1$s has been uninstalled Failed to uninstall game + Uninstall Game + Are you sure you want to uninstall %1$s? This action cannot be undone. + Download Game + The app being installed has the following space requirements. Would you like to proceed?\n\n\tDownload Size: %1$s\n\tAvailable Space: %2$s Never Continue App header image @@ -932,5 +936,37 @@ Containers using this version: No containers are currently using this version. These containers will no longer work if you proceed: + + + GOG Integration (Alpha) + GOG Login + Sign in to your GOG account + Syncing… + Error: %1$s + ✓ Synced %1$d games + Fetch your GOG games library + Login Successful + You are now signed in to GOG.\nWe will now sync your library in the background. + + + Sign in to GOG + Tap \'Open GOG Login\' and sign in. Once logged in, please copy the URL and paste below + Example: https://embed.gog.com/on_login_success?origin=client&code=aaa + Open GOG Login + Authorization Code or login success URL + Paste code or url here + Login + Cancel + Could not open browser + + + Logout + Sign out from your GOG account + Logout from GOG? + This will remove your GOG credentials and clear your GOG library from this device. You can sign in again at any time. + Logout + Logged out from GOG successfully + Failed to logout: %s + Logging out from GOG…