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 $variableName?> to $replacement")
+ } else {
+ Timber.w("Unknown GOG path variable: $variableName?>, 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…