diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afaa56012..c23215bb4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.jetbrains.serialization) alias(libs.plugins.kotlinter) alias(libs.plugins.ksp) + id("com.chaquo.python") version "15.0.1" } val keystorePropertiesFile = rootProject.file("app/keystores/keystore.properties") @@ -76,8 +77,8 @@ android { useSupportLibrary = true } + // Restore the original ProGuard configuration proguardFiles( - // getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro", ) @@ -170,8 +171,30 @@ android { // } } +chaquopy { + defaultConfig { + version = "3.11" + pip { + // Install GOGDL dependencies + install("requests") + // Use your Android-compatible fork instead of the original + // install("git+https://github.com/unbelievableflavour/heroic-gogdl-android.git@0.0.4") + } + } + sourceSets { + getByName("main") { + // Remove local Python source directory since we're using the external package + srcDir("src/main/python") + } + } +} + dependencies { implementation(libs.material) + + // Chrome Custom Tabs for OAuth + implementation("androidx.browser:browser:1.8.0") + // JavaSteaml 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 ee6c72c5c..92331f3f4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -59,11 +59,22 @@ + + + + diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 5f19eace2..01b1866d7 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -30,6 +30,7 @@ import coil.memory.MemoryCache import coil.request.CachePolicy import app.gamenative.events.AndroidEvent import app.gamenative.service.SteamService +import app.gamenative.service.GOG.GOGService import app.gamenative.ui.PluviaMain import app.gamenative.ui.enums.Orientation import app.gamenative.utils.AnimatedPngDecoder @@ -223,6 +224,11 @@ class MainActivity : ComponentActivity() { Timber.i("Stopping Steam Service") SteamService.stop() } + + if (GOGService.isRunning && !isChangingConfigurations) { + Timber.i("Stopping GOG Service") + GOGService.stop() + } } override fun onResume() { @@ -254,6 +260,15 @@ class MainActivity : ComponentActivity() { Timber.i("Stopping SteamService - no active operations") SteamService.stop() } + + // stop GOGService only if no downloads or sync are in progress + if (!isChangingConfigurations && + GOGService.isRunning && + !GOGService.hasActiveOperations() + ) { + Timber.i("Stopping GOGService - no active operations") + GOGService.stop() + } } // override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { diff --git a/app/src/main/java/app/gamenative/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt index 57eb16099..53772042f 100644 --- a/app/src/main/java/app/gamenative/PluviaApp.kt +++ b/app/src/main/java/app/gamenative/PluviaApp.kt @@ -3,6 +3,9 @@ package app.gamenative import android.os.StrictMode import androidx.navigation.NavController import app.gamenative.events.EventDispatcher +import app.gamenative.service.DownloadService +import app.gamenative.service.GOG.GOGService +import app.gamenative.service.GameManagerService import app.gamenative.utils.IntentLaunchManager import com.google.android.play.core.splitcompat.SplitCompatApplication import com.posthog.PersonProfiles @@ -13,6 +16,8 @@ import com.winlator.widget.XServerView import com.winlator.xenvironment.XEnvironment import dagger.hilt.android.HiltAndroidApp import timber.log.Timber +import okhttp3.OkHttpClient +import javax.inject.Inject // Add PostHog imports import com.posthog.android.PostHogAndroid @@ -31,6 +36,9 @@ typealias NavChangedListener = NavController.OnDestinationChangedListener @HiltAndroidApp class PluviaApp : SplitCompatApplication() { + @Inject + lateinit var httpClient: OkHttpClient + override fun onCreate() { super.onCreate() @@ -54,6 +62,8 @@ class PluviaApp : SplitCompatApplication() { // Init our datastore preferences. PrefManager.init(this) + DownloadService.populateDownloadService(this) + // Clear any stale temporary config overrides from previous app sessions try { IntentLaunchManager.clearAllTemporaryOverrides() @@ -80,6 +90,23 @@ class PluviaApp : SplitCompatApplication() { Timber.e(e, "Failed to initialize Supabase client: ${e.message}") e.printStackTrace() } + + // Initialize GameManagerService + try { + GameManagerService.initialize(this) + Timber.i("[PluviaApp]: GameManagerService initialized successfully") + } catch (e: Exception) { + Timber.e(e, "[PluviaApp]: Failed to initialize GameManagerService") + } + + // Initialize GOG Service + try { + GOGService.initialize(this) + GOGService.setHttpClient(httpClient) + Timber.i("[PluviaApp]: GOG Service initialized successfully") + } catch (e: Exception) { + Timber.e(e, "[PluviaApp]: Failed to initialize GOG Service") + } } companion object { diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index d7a9194c0..ea7f151bf 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -409,9 +409,6 @@ object PrefManager { } } - /** - * Get or Set the last known Persona State. See [EPersonaState] - */ private val LIBRARY_FILTER = intPreferencesKey("library_filter") var libraryFilter: EnumSet get() { @@ -422,6 +419,9 @@ object PrefManager { setPref(LIBRARY_FILTER, AppFilter.toFlags(value)) } + /** + * Get or Set the last known Persona State. See [EPersonaState] + */ private val PERSONA_STATE = intPreferencesKey("persona_state") var personaState: EPersonaState get() { @@ -432,6 +432,14 @@ object PrefManager { setPref(PERSONA_STATE, value.code()) } + private val STEAM_USER_ACCOUNT_ID = intPreferencesKey("steam_user_account_id") + var steamUserAccountId: Int + get() = getPref(STEAM_USER_ACCOUNT_ID, 0) + set(value) { + setPref(STEAM_USER_ACCOUNT_ID, value) + } + + private val ALLOWED_ORIENTATION = intPreferencesKey("allowed_orientation") var allowedOrientation: EnumSet get() { diff --git a/app/src/main/java/app/gamenative/data/DownloadInfo.kt b/app/src/main/java/app/gamenative/data/DownloadInfo.kt index cab140afe..95423db20 100644 --- a/app/src/main/java/app/gamenative/data/DownloadInfo.kt +++ b/app/src/main/java/app/gamenative/data/DownloadInfo.kt @@ -7,19 +7,31 @@ data class DownloadInfo( val jobCount: Int = 1, ) { private var downloadJob: Job? = null + private var progressMonitorJob: Job? = null private val downloadProgressListeners = mutableListOf<((Float) -> Unit)>() private val progresses: Array = Array(jobCount) { 0f } private val weights = FloatArray(jobCount) { 1f } // ⇐ new private var weightSum = jobCount.toFloat() + + @Volatile + private var isCancelled = false fun cancel() { + isCancelled = true downloadJob?.cancel(CancellationException("Cancelled by user")) + progressMonitorJob?.cancel(CancellationException("Progress monitoring cancelled by user")) } + + fun isCancelled(): Boolean = isCancelled fun setDownloadJob(job: Job) { downloadJob = job } + + fun setProgressMonitorJob(job: Job) { + progressMonitorJob = job + } fun getProgress(): Float { var total = 0f 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..b8e9daa8b --- /dev/null +++ b/app/src/main/java/app/gamenative/data/GOGGame.kt @@ -0,0 +1,43 @@ +package app.gamenative.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "gog_games") +data class GOGGame( + @PrimaryKey + val id: String, + val title: String, + val slug: String, + val downloadSize: Long = 0, + val installSize: Long = 0, + val isInstalled: Boolean = false, + val installPath: String = "", + val imageUrl: String = "", + val iconUrl: String = "", + val description: String = "", + val releaseDate: String = "", + val developer: String = "", + val publisher: String = "", + val genres: List = emptyList(), + val languages: List = emptyList(), + val lastPlayed: Long = 0, + val playTime: Long = 0, +) + +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/Game.kt b/app/src/main/java/app/gamenative/data/Game.kt new file mode 100644 index 000000000..a53a5e45b --- /dev/null +++ b/app/src/main/java/app/gamenative/data/Game.kt @@ -0,0 +1,18 @@ +package app.gamenative.data + +import app.gamenative.enums.AppType + +/** + * Unified interface for all game types (Steam, GOG, etc.) + */ +interface Game { + val id: String + val name: String + val source: GameSource + val isInstalled: Boolean + val isShared: Boolean + val iconUrl: String + val appType: AppType + + fun toLibraryItem(index: Int): LibraryItem +} diff --git a/app/src/main/java/app/gamenative/data/GameSource.kt b/app/src/main/java/app/gamenative/data/GameSource.kt new file mode 100644 index 000000000..35bcd2cda --- /dev/null +++ b/app/src/main/java/app/gamenative/data/GameSource.kt @@ -0,0 +1,7 @@ +package app.gamenative.data + +enum class GameSource { + STEAM, + GOG, + // Add new game sources here +} diff --git a/app/src/main/java/app/gamenative/data/LibraryItem.kt b/app/src/main/java/app/gamenative/data/LibraryItem.kt index 7fade51d9..e714c153c 100644 --- a/app/src/main/java/app/gamenative/data/LibraryItem.kt +++ b/app/src/main/java/app/gamenative/data/LibraryItem.kt @@ -1,17 +1,26 @@ package app.gamenative.data import app.gamenative.Constants +import app.gamenative.service.GameManagerService /** * Data class for the Library list */ data class LibraryItem( val index: Int = 0, - val appId: Int = 0, + val appId: String = "", val name: String = "", val iconHash: String = "", val isShared: Boolean = false, + val gameSource: GameSource = GameSource.STEAM, ) { val clientIconUrl: String - get() = Constants.Library.ICON_URL + "$appId/$iconHash.ico" + get() = GameManagerService.getIconImage(this) + + /** + * Helper property to get the game ID as an integer + * Extracts the numeric part by removing the gameSource prefix + */ + val gameId: Int + get() = appId.removePrefix("${gameSource.name}_").toInt() } diff --git a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt index 4d201f557..874cd7165 100644 --- a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt +++ b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt @@ -7,12 +7,14 @@ import app.gamenative.data.ChangeNumbers import app.gamenative.data.Emoticon import app.gamenative.data.FileChangeLists import app.gamenative.data.FriendMessage +import app.gamenative.data.GOGGame import app.gamenative.data.SteamApp import app.gamenative.data.SteamFriend import app.gamenative.data.SteamLicense import app.gamenative.db.converters.AppConverter import app.gamenative.db.converters.ByteArrayConverter import app.gamenative.db.converters.FriendConverter +import app.gamenative.db.converters.GOGConverter import app.gamenative.db.converters.LicenseConverter import app.gamenative.db.converters.PathTypeConverter import app.gamenative.db.converters.UserFileInfoListConverter @@ -20,6 +22,7 @@ import app.gamenative.db.dao.ChangeNumbersDao import app.gamenative.db.dao.EmoticonDao import app.gamenative.db.dao.FileChangeListsDao import app.gamenative.db.dao.FriendMessagesDao +import app.gamenative.db.dao.GOGGameDao import app.gamenative.db.dao.SteamAppDao import app.gamenative.db.dao.SteamFriendDao import app.gamenative.db.dao.SteamLicenseDao @@ -35,14 +38,16 @@ const val DATABASE_NAME = "pluvia.db" FileChangeLists::class, FriendMessage::class, Emoticon::class, + GOGGame::class, ], - version = 3, + version = 4, // Increment version for new entity exportSchema = false, // Should export once stable. ) @TypeConverters( AppConverter::class, ByteArrayConverter::class, FriendConverter::class, + GOGConverter::class, LicenseConverter::class, PathTypeConverter::class, UserFileInfoListConverter::class, @@ -62,4 +67,6 @@ abstract class PluviaDatabase : RoomDatabase() { abstract fun friendMessagesDao(): FriendMessagesDao abstract fun emoticonDao(): EmoticonDao + + 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..c68901338 --- /dev/null +++ b/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt @@ -0,0 +1,21 @@ +package app.gamenative.db.converters + +import androidx.room.TypeConverter +import kotlinx.serialization.json.Json + +class GOGConverter { + + @TypeConverter + fun fromStringList(value: List): String { + return Json.encodeToString(value) + } + + @TypeConverter + fun toStringList(value: String): List { + return try { + Json.decodeFromString>(value) + } catch (e: Exception) { + emptyList() + } + } +} 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..dff5f5625 --- /dev/null +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -0,0 +1,75 @@ +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 +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 isInstalled = :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 + + @Transaction + suspend fun replaceAll(games: List) { + deleteAll() + insertAll(games) + } + + @Transaction + suspend fun upsertPreservingInstallStatus(games: List) { + games.forEach { newGame -> + val existingGame = getById(newGame.id) + if (existingGame != null) { + // Preserve installation status and path from existing game + val gameToInsert = newGame.copy( + isInstalled = existingGame.isInstalled, + installPath = existingGame.installPath, + ) + 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 c6f890e29..63331ee44 100644 --- a/app/src/main/java/app/gamenative/di/DatabaseModule.kt +++ b/app/src/main/java/app/gamenative/di/DatabaseModule.kt @@ -52,4 +52,8 @@ class DatabaseModule { @Provides @Singleton fun provideEmoticonDao(db: PluviaDatabase) = db.emoticonDao() + + @Provides + @Singleton + fun provideGOGGameDao(db: PluviaDatabase) = db.gogGameDao() } diff --git a/app/src/main/java/app/gamenative/di/NetworkModule.kt b/app/src/main/java/app/gamenative/di/NetworkModule.kt new file mode 100644 index 000000000..e14644d5b --- /dev/null +++ b/app/src/main/java/app/gamenative/di/NetworkModule.kt @@ -0,0 +1,24 @@ +package app.gamenative.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.concurrent.TimeUnit +import javax.inject.Singleton +import okhttp3.OkHttpClient + +@InstallIn(SingletonComponent::class) +@Module +class NetworkModule { + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + } +} diff --git a/app/src/main/java/app/gamenative/enums/Marker.kt b/app/src/main/java/app/gamenative/enums/Marker.kt index 4a2140f7b..bbd7388f6 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"), } diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt index 1ffeddfc6..91bc64248 100644 --- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt +++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt @@ -13,8 +13,8 @@ interface AndroidEvent : Event { data class KeyEvent(val event: android.view.KeyEvent) : AndroidEvent data class MotionEvent(val event: android.view.MotionEvent?) : AndroidEvent data object EndProcess : AndroidEvent - data class ExternalGameLaunch(val appId: Int) : AndroidEvent - data class PromptSaveContainerConfig(val appId: Int) : AndroidEvent - data class ShowGameFeedback(val appId: Int) : AndroidEvent + data class ExternalGameLaunch(val appId: String) : AndroidEvent + data class PromptSaveContainerConfig(val appId: String) : AndroidEvent + data class ShowGameFeedback(val appId: String) : AndroidEvent // data class SetAppBarVisibility(val visible: Boolean) : AndroidEvent } diff --git a/app/src/main/java/app/gamenative/service/DownloadService.kt b/app/src/main/java/app/gamenative/service/DownloadService.kt index 25100c06d..b2b7f67ca 100644 --- a/app/src/main/java/app/gamenative/service/DownloadService.kt +++ b/app/src/main/java/app/gamenative/service/DownloadService.kt @@ -1,5 +1,6 @@ package app.gamenative.service +import android.content.Context import app.gamenative.utils.StorageUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -7,12 +8,21 @@ import timber.log.Timber import java.io.File object DownloadService { - init { - getDownloadDirectoryApps() - } - private var lastUpdateTime: Long = 0 - private lateinit var downloadDirectoryApps: MutableList + private var downloadDirectoryApps: MutableList? = null + var baseDataDirPath: String = "" + private set(value) { + field = value + } + var baseCacheDirPath: String = "" + private set(value) { + field = value + } + + fun populateDownloadService(context: Context) { + baseDataDirPath = context.dataDir.path + baseCacheDirPath = context.cacheDir.path + } fun getDownloadDirectoryApps (): MutableList { // What apps have folders in the download area? @@ -30,7 +40,7 @@ object DownloadService { downloadDirectoryApps = subDir } - return downloadDirectoryApps + return downloadDirectoryApps ?: mutableListOf() } private fun getSubdirectories (path: String): MutableList { 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..693ec0d49 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/GOG/GOGConstants.kt @@ -0,0 +1,25 @@ +package app.gamenative.service.GOG + +/** + * Constants for GOG game service + */ +object GOGConstants { + /** + * Base storage path for GOG games + * This path must match the E: drive mount in Winlator: /data/data/app.gamenative/storage + */ + const val GOG_GAMES_BASE_PATH = "/data/data/app.gamenative/storage/gog_games" + + /** + * Default directory name for GOG game installations + */ + const val GOG_GAME_DIR_PREFIX = "game_" + + /** + * Get the full path for a GOG game installation + */ + fun getGameInstallPath(gameTitle: String): String { + val sanitizedTitle = gameTitle.replace(Regex("[^a-zA-Z0-9\\s-_]"), "").trim() + return "$GOG_GAMES_BASE_PATH/$sanitizedTitle" + } +} diff --git a/app/src/main/java/app/gamenative/service/GOG/GOGGameManager.kt b/app/src/main/java/app/gamenative/service/GOG/GOGGameManager.kt new file mode 100644 index 000000000..1015b7b56 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/GOG/GOGGameManager.kt @@ -0,0 +1,734 @@ +package app.gamenative.service.GOG + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import app.gamenative.R +import app.gamenative.data.DownloadInfo +import app.gamenative.data.GOGGame +import app.gamenative.data.GOGGameWrapper +import app.gamenative.data.Game +import app.gamenative.data.GameSource +import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem +import app.gamenative.data.PostSyncInfo +import app.gamenative.data.SteamApp +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.service.GameManager +import app.gamenative.ui.component.dialog.state.MessageDialogState +import app.gamenative.ui.enums.DialogType +import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.MarkerUtils +import app.gamenative.utils.StorageUtils +import com.winlator.container.Container +import com.winlator.core.envvars.EnvVars +import com.winlator.xenvironment.components.GuestProgramLauncherComponent +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.EnumSet +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import timber.log.Timber + + +@Singleton +class GOGGameManager @Inject constructor( + private val gogGameDao: GOGGameDao, +) : GameManager { + + + override fun downloadGame(context: Context, libraryItem: LibraryItem): Result { + try { + // Check if another download is already in progress + if (GOGService.hasActiveDownload()) { + return Result.failure(Exception("Another GOG game is already downloading. Please wait for it to finish before starting a new download.")) + } + + // Check authentication first + if (!GOGService.hasStoredCredentials(context)) { + return Result.failure(Exception("GOG authentication required. Please log in to your GOG account first.")) + } + + // Validate credentials and refresh if needed + val validationResult = runBlocking { GOGService.validateCredentials(context) } + if (!validationResult.isSuccess || !validationResult.getOrDefault(false)) { + return Result.failure(Exception("GOG authentication is invalid. Please re-authenticate.")) + } + + val installPath = getGameInstallPath(context, libraryItem.appId, libraryItem.name) + val authConfigPath = "${context.filesDir}/gog_auth.json" + + Timber.i("Starting GOG game installation: ${libraryItem.name} to $installPath") + + // Use the new download method that returns DownloadInfo + val result = runBlocking { GOGService.downloadGame(libraryItem.appId, installPath, authConfigPath) } + + if (result.isSuccess) { + val downloadInfo = result.getOrNull() + if (downloadInfo != null) { + // Add download in progress marker and remove completion marker + val appDirPath = getAppDirPath(libraryItem.appId) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Add a progress listener to update markers when download completes + downloadInfo.addProgressListener { progress -> + when { + progress >= 1.0f -> { + // Download completed successfully + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + Timber.i("GOG game installation completed: ${libraryItem.name}") + } + progress < 0.0f -> { + // Download failed or cancelled + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + Timber.i("GOG game installation failed/cancelled: ${libraryItem.name}") + } + } + } + + Timber.i("GOG game installation started successfully: ${libraryItem.name}") + } + return Result.success(downloadInfo) + } else { + val error = result.exceptionOrNull() ?: Exception("Unknown download error") + Timber.e(error, "Failed to install GOG game: ${libraryItem.name}") + return Result.failure(error) + } + } catch (e: Exception) { + Timber.e(e, "Failed to install GOG game: ${libraryItem.name}") + return Result.failure(e) + } + } + + override fun deleteGame(context: Context, libraryItem: LibraryItem): Result { + try { + val gameId = libraryItem.gameId.toString() + val installPath = getGameInstallPath(context, gameId, libraryItem.name) + val installDir = File(installPath) + + // Delete the manifest file to ensure fresh downloads on reinstall + val manifestPath = File(context.filesDir, "manifests/$gameId") + if (manifestPath.exists()) { + val manifestDeleted = manifestPath.delete() + if (manifestDeleted) { + Timber.i("Deleted manifest file for game $gameId") + } else { + Timber.w("Failed to delete manifest file for game $gameId") + } + } + + if (installDir.exists()) { + val success = installDir.deleteRecursively() + if (success) { + // Remove all markers + val appDirPath = getAppDirPath(libraryItem.appId) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Cancel and clean up any active download + GOGService.cancelDownload(libraryItem.appId) + GOGService.cleanupDownload(libraryItem.appId) + + // Update database to mark as not installed + val game = runBlocking { getGameById(gameId) } + if (game != null) { + val updatedGame = game.copy( + isInstalled = false, + installPath = "", + ) + runBlocking { gogGameDao.update(updatedGame) } + } + + Timber.i("GOG game ${libraryItem.name} deleted successfully") + return Result.success(Unit) + } else { + return Result.failure(Exception("Failed to delete GOG game directory")) + } + } else { + Timber.w("GOG game directory doesn't exist: $installPath") + // Remove all markers even if directory doesn't exist + val appDirPath = getAppDirPath(libraryItem.appId) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Cancel and clean up any active download + GOGService.cancelDownload(libraryItem.appId) + GOGService.cleanupDownload(libraryItem.appId) + + // Update database anyway to ensure consistency + val game = runBlocking { getGameById(gameId) } + if (game != null) { + val updatedGame = game.copy( + isInstalled = false, + installPath = "", + ) + runBlocking { gogGameDao.update(updatedGame) } + } + + return Result.success(Unit) // Consider it already deleted + } + } catch (e: Exception) { + Timber.e(e, "Failed to delete GOG game ${libraryItem.gameId}") + return Result.failure(e) + } + } + + override fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean { + try { + val appDirPath = getAppDirPath(libraryItem.appId) + + // Use marker-based approach for reliable state tracking + val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Game is installed only if download is complete and not in progress + val isInstalled = isDownloadComplete && !isDownloadInProgress + + // Update database if the install status has 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 + } + } + + override suspend fun isUpdatePending(libraryItem: LibraryItem): Boolean { + return false // Not implemented yet. + } + + override fun getDownloadInfo(libraryItem: LibraryItem): DownloadInfo? { + return GOGService.getDownloadInfo(libraryItem.appId) + } + + override fun hasPartialDownload(libraryItem: LibraryItem): Boolean { + try { + val appDirPath = getAppDirPath(libraryItem.appId) + + // Use marker-based approach for reliable state tracking + val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + + // Has partial download if download is in progress or if there are files but no completion marker + if (isDownloadInProgress) { + return true + } + + // Also check if there are files in the directory but no completion marker (interrupted download) + if (!isDownloadComplete) { + val gameId = libraryItem.gameId.toString() + val gameName = libraryItem.name + // Use GOGConstants directly since we don't have context here and it's not needed + val installPath = GOGConstants.getGameInstallPath(gameName) + val installDir = File(installPath) + + // If directory has files but no completion marker, it's a partial download + return installDir.exists() && installDir.listFiles()?.isNotEmpty() == true + } + + return false + } catch (e: Exception) { + Timber.w(e, "Error checking partial download status for ${libraryItem.name}") + return false + } + } + + override suspend fun getGameDiskSize(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { + // Calculate size from install directory + val installPath = getGameInstallPath(context, libraryItem.appId, libraryItem.name) + val folderSize = StorageUtils.getFolderSize(installPath) + + StorageUtils.formatBinarySize(folderSize) + } + + override fun getAppDirPath(appId: String): String { + // Extract the numeric game ID from the appId + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + + // Get the game details to find the correct title + val game = runBlocking { getGameById(gameId.toString()) } + if (game != null) { + // Return the specific game installation path + val gamePath = GOGConstants.getGameInstallPath(game.title) + Timber.d("GOG getAppDirPath for appId $appId (game: ${game.title}) -> $gamePath") + return gamePath + } + + // Fallback to base path if game not found (shouldn't happen normally) + Timber.w("Could not find game for appId $appId, using base path") + return GOGConstants.GOG_GAMES_BASE_PATH + } + + override suspend fun launchGameWithSaveSync( + context: Context, + libraryItem: LibraryItem, + parentScope: CoroutineScope, + ignorePendingOperations: Boolean, + preferredSave: Int?, + ): PostSyncInfo = withContext(Dispatchers.IO) { + try { + Timber.i("Starting GOG game launch with save sync for ${libraryItem.name}") + + // Check if GOG credentials exist + if (!GOGService.hasStoredCredentials(context)) { + Timber.w("No GOG credentials found, skipping cloud save sync") + return@withContext PostSyncInfo(SyncResult.Success) // Continue without sync + } + + // Determine save path for GOG game + val savePath = "${getGameInstallPath(context, libraryItem.appId, libraryItem.name)}/saves" + val authConfigPath = "${context.filesDir}/gog_auth.json" + + Timber.i("Starting GOG cloud save sync for game ${libraryItem.gameId}") + + // Perform GOG cloud save sync + val syncResult = GOGService.syncCloudSaves( + gameId = libraryItem.gameId.toString(), + savePath = savePath, + authConfigPath = authConfigPath, + timestamp = 0.0f, + ) + + if (syncResult.isSuccess) { + Timber.i("GOG cloud save sync completed successfully") + PostSyncInfo(SyncResult.Success) + } else { + val error = syncResult.exceptionOrNull() + Timber.e(error, "GOG cloud save sync failed") + PostSyncInfo(SyncResult.UnknownFail) + } + } catch (e: Exception) { + Timber.e(e, "GOG cloud save sync exception for game ${libraryItem.gameId}") + PostSyncInfo(SyncResult.UnknownFail) + } + } + + override fun getStoreUrl(libraryItem: LibraryItem): Uri { + val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } + val slug = gogGame?.slug ?: "" + return "https://www.gog.com/en/game/$slug".toUri() + } + + override fun getWineStartCommand( + context: Context, + libraryItem: LibraryItem, + container: Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: EnvVars, + guestProgramLauncherComponent: GuestProgramLauncherComponent, + ): String { + // For GOG games, we always want to launch the actual game + // because GOG doesn't have appLaunchInfo like Steam does + + // Extract the numeric game ID from appId using the existing utility function + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId) + + // Get the game details to find the correct title + val game = runBlocking { getGameById(gameId.toString()) } + if (game == null) { + Timber.e("Game not found for ID: $gameId") + return "\"explorer.exe\"" + } + + Timber.i("Looking for GOG game '${game.title}' with ID: $gameId") + + // Get the specific game installation directory using the existing function + val gameInstallPath = getGameInstallPath(context, gameId.toString(), game.title) + val gameDir = File(gameInstallPath) + + if (!gameDir.exists()) { + Timber.e("Game installation directory does not exist: $gameInstallPath") + return "\"explorer.exe\"" + } + + Timber.i("Found game directory: ${gameDir.absolutePath}") + + // Use GOGGameManager to get the correct executable + val executablePath = runBlocking { getInstalledExe(context, libraryItem) } + + if (executablePath.isEmpty()) { + Timber.w("No executable found for GOG game ${libraryItem.name}, opening file manager") + return "\"explorer.exe\"" + } + + // Calculate the Windows path for the game subdirectory + val gameSubDirRelativePath = gameDir.relativeTo(File(GOGConstants.GOG_GAMES_BASE_PATH)).path.replace('\\', '/') + val windowsGamePath = "E:/gog_games/$gameSubDirRelativePath" + + // Set WINEPATH to the game subdirectory on E: drive + envVars.put("WINEPATH", windowsGamePath) + + // Set the working directory to the game directory + val gameWorkingDir = File(GOGConstants.GOG_GAMES_BASE_PATH, gameSubDirRelativePath) + guestProgramLauncherComponent.workingDir = gameWorkingDir + Timber.i("Setting working directory to: ${gameWorkingDir.absolutePath}") + + val executableName = File(executablePath).name + Timber.i("GOG game executable name: $executableName") + Timber.i("GOG game Windows path: $windowsGamePath") + Timber.i("GOG game subdirectory relative path: $gameSubDirRelativePath") + + // Determine structure type by checking if game_* subdirectory exists + val isV2Structure = gameDir.listFiles()?.any { + it.isDirectory && it.name.startsWith("game_$gameId") + } ?: false + Timber.i("Game structure type: ${if (isV2Structure) "V2" else "V1"}") + + val fullCommand = "\"$windowsGamePath/$executablePath\"" + + Timber.i("Full Wine command will be: $fullCommand") + return fullCommand + } + + override fun createLibraryItem(appId: String, gameId: String, context: Context): LibraryItem { + val gogGame = runBlocking { getGameById(gameId) } + + return LibraryItem( + appId = appId, + name = gogGame?.title ?: "Unknown GOG Game", + iconHash = "", // GOG games don't have icon hashes like Steam + gameSource = GameSource.GOG, + ) + } + + // Simple cache for download sizes + private val downloadSizeCache = mutableMapOf() + private val loadingSizes = mutableSetOf() + + override suspend fun getDownloadSize(libraryItem: LibraryItem): String { + val gameId = libraryItem.gameId.toString() + + // Return cached result if available + downloadSizeCache[gameId]?.let { return it } + + // Get size info directly (now properly async) + return try { + Timber.d("Getting download size for game $gameId") + val sizeInfo = GOGService.getGameSizeInfo(gameId) + val formattedSize = sizeInfo?.let { StorageUtils.formatBinarySize(it.downloadSize) } ?: "Unknown" + + // Cache the result + downloadSizeCache[gameId] = formattedSize + Timber.d("Got download size for game $gameId: $formattedSize") + + formattedSize + } catch (e: Exception) { + Timber.w(e, "Failed to get download size for game $gameId") + val errorResult = "Unknown" + downloadSizeCache[gameId] = errorResult + errorResult + } + } + + /** + * Get cached download size if available + */ + fun getCachedDownloadSize(gameId: String): String? { + return downloadSizeCache[gameId] + } + + override fun isValidToDownload(library: LibraryItem): Boolean { + return true // GOG games are always downloadable if owned + } + + override fun getAppInfo(libraryItem: LibraryItem): SteamApp? { + val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } + return if (gogGame != null) { + convertGOGGameToSteamApp(gogGame) + } else { + null + } + } + + override fun getReleaseDate(libraryItem: LibraryItem): String { + val appInfo = getAppInfo(libraryItem) + if (appInfo?.releaseDate == null || appInfo.releaseDate == 0L) { + return "Unknown" + } + val date = Date(appInfo.releaseDate) + return SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(date) + } + + override fun getHeroImage(libraryItem: LibraryItem): String { + val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } + val imageUrl = gogGame?.imageUrl ?: "" + + // Fix GOG URLs that are missing the protocol + return if (imageUrl.startsWith("//")) { + "https:$imageUrl" + } else { + imageUrl + } + } + + override fun getIconImage(libraryItem: LibraryItem): String { + return libraryItem.iconHash + } + + override fun getInstallInfoDialog(context: Context, libraryItem: LibraryItem): MessageDialogState { + // GOG install logic + val gogInstallPath = "${context.dataDir.path}/gog_games" + val availableBytes = StorageUtils.getAvailableSpace(context.dataDir.path) + val availableSpace = StorageUtils.formatBinarySize(availableBytes) + + // Get cached download size if available, otherwise show "Calculating..." + val gameId = libraryItem.gameId.toString() + val downloadSize = getCachedDownloadSize(gameId) ?: "Calculating..." + + return MessageDialogState( + visible = true, + type = DialogType.INSTALL_APP, + title = context.getString(R.string.download_prompt_title), + message = "Install ${libraryItem.name} from GOG?" + + "\n\nDownload Size: $downloadSize" + + "\nInstall Path: $gogInstallPath/${libraryItem.name}" + + "\nAvailable Space: $availableSpace", + confirmBtnText = context.getString(R.string.proceed), + dismissBtnText = context.getString(R.string.cancel), + ) + } + + override fun runBeforeLaunch(context: Context, libraryItem: LibraryItem) { + // Don't run anything before launch for GOG games + } + + override fun getAllGames(): Flow> { + return gogGameDao.getAll().map { gogGames -> + gogGames.map { gogGame -> GOGGameWrapper(gogGame) } + } + } + + /** + * Get install path for a specific GOG game + */ + fun getGameInstallPath(context: Context, gameId: String, gameTitle: String): String { + return GOGConstants.getGameInstallPath(gameTitle) + } + + /** + * Get GOG game by ID from database + */ + suspend fun getGameById(gameId: String): GOGGame? = withContext(Dispatchers.IO) { + try { + gogGameDao.getById(gameId) + } catch (e: Exception) { + Timber.e(e, "Failed to get GOG game by ID: $gameId") + null + } + } + + /** + * Get the executable path for an installed GOG game. + * Handles both V1 and V2 game directory structures. + */ + suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { + val gameId = libraryItem.gameId + try { + val game = runBlocking { getGameById(gameId.toString()) } ?: 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()) { + Timber.i("Found V2 game structure: ${v2GameDir.absolutePath}") + return@withContext getGameExecutable(installPath, v2GameDir) + } else { + // Try V1 structure (look for any subdirectory in the install path) + val installDirFile = File(installPath) + val subdirs = installDirFile.listFiles()?.filter { + it.isDirectory && it.name != "saves" + } ?: emptyList() + + if (subdirs.isNotEmpty()) { + // For V1 games, find the subdirectory with .exe files + val v1GameDir = subdirs.find { subdir -> + val exeFiles = subdir.listFiles()?.filter { + it.isFile && + it.name.endsWith(".exe", ignoreCase = true) && + !isGOGUtilityExecutable(it.name) + } ?: emptyList() + exeFiles.isNotEmpty() + } + + if (v1GameDir != null) { + Timber.i("Found V1 game structure: ${v1GameDir.absolutePath}") + return@withContext getGameExecutable(installPath, v1GameDir) + } else { + Timber.w("No V1 game subdirectories with executables found in: $installPath") + return@withContext "" + } + } else { + Timber.w("No game directories found in: $installPath") + return@withContext "" + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to get executable for GOG game $gameId") + "" + } + } + + /** + * Check if an executable is a GOG utility (should be skipped) + */ + private fun isGOGUtilityExecutable(filename: String): Boolean { + return filename.equals("unins000.exe", ignoreCase = true) || + filename.equals("CheckApplication.exe", ignoreCase = true) || + filename.equals("SettingsApplication.exe", ignoreCase = true) + } + + private fun getGameExecutable(installPath: String, gameDir: File): String { + // Get the main executable from GOG game info file + val mainExe = getMainExecutableFromGOGInfo(gameDir, installPath) + + if (mainExe.isNotEmpty()) { + Timber.i("Found GOG game executable from info file: $mainExe") + return mainExe + } + + Timber.e("Failed to find executable from GOG info file in: ${gameDir.absolutePath}") + return "" + } + + private fun getMainExecutableFromGOGInfo(gameDir: File, installPath: String): String { + // Look for goggame-*.info file + val infoFile = gameDir.listFiles()?.find { + it.isFile && it.name.startsWith("goggame-") && it.name.endsWith(".info") + } + + if (infoFile == null) { + throw Exception("GOG info file not found in: ${gameDir.absolutePath}") + } + + val content = infoFile.readText() + Timber.d("GOG info file content: $content") + + // Parse JSON to find the primary task + val jsonObject = org.json.JSONObject(content) + + // Look for playTasks array + if (!jsonObject.has("playTasks")) { + throw Exception("GOG info file does not contain playTasks array") + } + + val playTasks = jsonObject.getJSONArray("playTasks") + + // Find the primary task + for (i in 0 until playTasks.length()) { + val task = playTasks.getJSONObject(i) + if (task.has("isPrimary") && task.getBoolean("isPrimary")) { + val executablePath = task.getString("path") + + Timber.i("Found primary task executable path: $executablePath") + + // Check if the executable actually exists (case-insensitive) + val actualExeFile = gameDir.listFiles()?.find { + it.name.equals(executablePath, ignoreCase = true) + } + if (actualExeFile != null && actualExeFile.exists()) { + return "${gameDir.name}/${actualExeFile.name}" + } else { + Timber.w("Primary task executable '$executablePath' not found in game directory") + } + break + } + } + + return "" + } + + + /** + * Convert GOGGame to SteamApp format for compatibility with existing UI components. + * This allows GOG games to be displayed using the same UI components as Steam games. + */ + private fun convertGOGGameToSteamApp(gogGame: GOGGame): SteamApp { + // Convert release date string (ISO format like "2021-06-17T15:55:+0300") to timestamp + val releaseTimestamp = try { + if (gogGame.releaseDate.isNotEmpty()) { + // Try different date formats that GOG might use + val formats = arrayOf( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ZZZZZ", Locale.US), // 2021-06-17T15:55:+0300 + SimpleDateFormat("yyyy-MM-dd'T'HH:mmZ", Locale.US), // 2021-06-17T15:55+0300 + SimpleDateFormat("yyyy-MM-dd", Locale.US), // 2021-06-17 + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US), // 2021-06-17T15:55:30 + ) + + var parsedDate: Date? = null + for (format in formats) { + try { + parsedDate = format.parse(gogGame.releaseDate) + break + } catch (e: Exception) { + // Try next format + } + } + + parsedDate?.time ?: 0L + } else { + 0L + } + } catch (e: Exception) { + Timber.w(e, "Failed to parse release date: ${gogGame.releaseDate}") + 0L + } + + // Convert GOG game ID (string) to integer for SteamApp compatibility + val appId = try { + gogGame.id.toIntOrNull() ?: gogGame.id.hashCode() + } catch (e: Exception) { + gogGame.id.hashCode() + } + + return SteamApp( + id = appId, + name = gogGame.title, + type = AppType.game, + osList = EnumSet.of(OS.windows), + releaseState = ReleaseState.released, + releaseDate = releaseTimestamp, + developer = gogGame.developer.takeIf { it.isNotEmpty() } ?: "Unknown Developer", + publisher = gogGame.publisher.takeIf { it.isNotEmpty() } ?: "Unknown Publisher", + controllerSupport = ControllerSupport.none, + logoHash = "", + iconHash = "", + clientIconHash = "", + installDir = gogGame.title.replace(Regex("[^a-zA-Z0-9 ]"), "").trim(), + ) + } + + private suspend fun ensureValidCredentials(context: Context): Boolean { + val validationResult = GOGService.validateCredentials(context) + return validationResult.isSuccess && validationResult.getOrDefault(false) + } + +} diff --git a/app/src/main/java/app/gamenative/service/GOG/GOGGameWrapper.kt b/app/src/main/java/app/gamenative/service/GOG/GOGGameWrapper.kt new file mode 100644 index 000000000..1d9dfc037 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/GOG/GOGGameWrapper.kt @@ -0,0 +1,27 @@ +package app.gamenative.data + +import app.gamenative.enums.AppType + +/** + * GOG game implementation + */ +data class GOGGameWrapper( + private val gogGame: GOGGame, +) : Game { + override val id: String get() = gogGame.id + override val name: String get() = gogGame.title + override val source: GameSource get() = GameSource.GOG + override val isInstalled: Boolean get() = gogGame.isInstalled + override val isShared: Boolean get() = false + override val iconUrl: String get() = gogGame.iconUrl + override val appType: AppType get() = AppType.game + + override fun toLibraryItem(index: Int): LibraryItem = LibraryItem( + index = index, + appId = "GOG_${gogGame.id}", + name = gogGame.title, + iconHash = iconUrl, + isShared = false, + gameSource = GameSource.GOG, + ) +} diff --git a/app/src/main/java/app/gamenative/service/GOG/GOGLibraryManager.kt b/app/src/main/java/app/gamenative/service/GOG/GOGLibraryManager.kt new file mode 100644 index 000000000..4da90d56e --- /dev/null +++ b/app/src/main/java/app/gamenative/service/GOG/GOGLibraryManager.kt @@ -0,0 +1,158 @@ +package app.gamenative.service.GOG + +import android.content.Context +import app.gamenative.db.dao.GOGGameDao +import javax.inject.Inject +import kotlinx.coroutines.* +import timber.log.Timber + +class GOGLibraryManager @Inject constructor( + private val gogGameDao: GOGGameDao, +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // Track if background sync is already running + private var backgroundSyncInProgress = false + + /** + * Start background library sync that progressively syncs games in batches + * Returns a Result indicating whether the sync was started successfully + */ + suspend fun startBackgroundSync(context: Context, clearExisting: Boolean = false): Result { + if (backgroundSyncInProgress) { + Timber.i("Background GOG sync already in progress, skipping") + return Result.failure(Exception("Background sync already in progress")) + } + + // Validate credentials before starting background sync + return try { + if (!GOGService.hasStoredCredentials(context)) { + Timber.w("No GOG credentials found, cannot start background sync") + return Result.failure(Exception("No GOG credentials found. Please log in first.")) + } + + val validationResult = GOGService.validateCredentials(context) + if (validationResult.isFailure || !validationResult.getOrThrow()) { + Timber.w("GOG credentials validation failed, cannot start background sync") + return Result.failure(Exception("GOG credentials validation failed. Please log in again.")) + } + + scope.launch { + backgroundSyncInProgress = true + syncLibraryInBackground(context, clearExisting) + backgroundSyncInProgress = false + } + + Result.success(Unit) + } catch (e: Exception) { + Timber.e(e, "Failed to start background sync") + Result.failure(e) + } + } + + /** + * Clear all GOG games from the database + */ + suspend fun clearLibrary(): Result = withContext(Dispatchers.IO) { + try { + Timber.i("Clearing GOG library from database") + gogGameDao.deleteAll() + Timber.i("GOG library cleared successfully") + Result.success(Unit) + } catch (e: Exception) { + Timber.e(e, "Failed to clear GOG library") + Result.failure(e) + } + } + + /** + * Background sync implementation with true progressive syncing + * Games appear in the library as soon as they're fetched from GOG API + */ + private suspend fun syncLibraryInBackground(context: Context, clearExisting: Boolean = false) { + try { + Timber.i("Starting progressive background GOG library sync...") + + val authConfigPath = "${context.filesDir}/gog_auth.json" + + // Clear existing games if requested + if (clearExisting) { + Timber.i("Clearing existing GOG games before sync") + clearLibrary() + } + + // Try progressive sync first (if available), fallback to batch sync + syncLibraryProgressively(context, authConfigPath) + } catch (e: Exception) { + Timber.e(e, "Exception during background GOG sync") + } + } + + /** + * Progressive sync method + * Insert games one by one as they are fetched + */ + private suspend fun syncLibraryProgressively(context: Context, authConfigPath: String): Result { + return try { + Timber.i("Starting progressive GOG library sync...") + + // Validate credentials before making GOGDL calls + val validationResult = GOGService.validateCredentials(context) + if (validationResult.isFailure || !validationResult.getOrThrow()) { + Timber.w("GOG credentials validation failed, aborting progressive sync") + return Result.failure(Exception("GOG credentials validation failed")) + } + + // Use the new progressive method that inserts games one by one + val libraryResult = GOGService.getUserLibraryProgressively( + context, + onGameFetched = { game -> + // Insert each game immediately as it's fetched + // All database operations are already in the same coroutine context + try { + val existingGame = gogGameDao.getById(game.id) + val gameToInsert = if (existingGame != null) { + game.copy(isInstalled = existingGame.isInstalled, installPath = existingGame.installPath) + } else { + game + } + gogGameDao.insert(gameToInsert) + + Timber.d("Inserted game: ${game.title}") + } catch (e: Exception) { + Timber.e(e, "Failed to insert game: ${game.title}") + } + }, + onTotalCount = { totalCount -> + Timber.d("Total games to sync: $totalCount") + }, + ) + + if (libraryResult.isSuccess) { + val totalGames = libraryResult.getOrThrow() + Timber.i("Progressive GOG library sync completed successfully: $totalGames games") + Result.success(Unit) + } else { + val error = libraryResult.exceptionOrNull() + Timber.e("Failed to get library from GOG API: ${error?.message}") + Result.failure(error ?: Exception("Failed to get library")) + } + } catch (e: Exception) { + Timber.e(e, "Exception during progressive sync") + Result.failure(e) + } + } + + /** + * Get the count of games in the local database + */ + suspend fun getLocalGameCount(): Int = withContext(Dispatchers.IO) { + try { + gogGameDao.getAllAsList().size + } catch (e: Exception) { + Timber.e(e, "Failed to get local GOG game count") + 0 + } + } +} diff --git a/app/src/main/java/app/gamenative/service/GOG/GOGService.kt b/app/src/main/java/app/gamenative/service/GOG/GOGService.kt new file mode 100644 index 000000000..f4788e531 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/GOG/GOGService.kt @@ -0,0 +1,1663 @@ +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.service.NotificationHelper +import app.gamenative.utils.ContainerUtils +import com.chaquo.python.Kwarg +import com.chaquo.python.PyObject +import com.chaquo.python.Python +import com.chaquo.python.android.AndroidPlatform +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.* +import okhttp3.OkHttpClient +import org.json.JSONObject +import timber.log.Timber + +/** + * Data class to hold metadata extracted from GOG GamesDB + */ +private data class GameMetadata( + val developer: String = "Unknown Developer", + val publisher: String = "Unknown Publisher", + val title: String? = null, + val description: String? = null +) + +/** + * Data class to hold size information from gogdl info command + */ +data class GameSizeInfo( + val downloadSize: Long, + val diskSize: Long +) + +@Singleton +class GOGService @Inject constructor() : Service() { + + companion object { + private var instance: GOGService? = null + private var appContext: Context? = null + private var isInitialized = false + private var httpClient: OkHttpClient? = null + private var python: Python? = null + + // Constants + private const val GOG_CLIENT_ID = "46899977096215655" + + // Add sync tracking variables + private var syncInProgress: Boolean = false + private var backgroundSyncJob: Job? = null + + val isRunning: Boolean + get() = instance != null + + fun start(context: Context) { + if (!isRunning) { + val intent = Intent(context, GOGService::class.java) + context.startForegroundService(intent) + } + } + + fun stop() { + instance?.let { service -> + service.stopSelf() + } + } + + fun setHttpClient(client: OkHttpClient) { + httpClient = client + } + + /** + * Initialize the GOG service with Chaquopy Python + */ + fun initialize(context: Context): Boolean { + if (isInitialized) return true + + try { + // Store the application context + appContext = context.applicationContext + + Timber.i("Initializing GOG service with Chaquopy...") + + // Initialize Python if not already started + if (!Python.isStarted()) { + Python.start(AndroidPlatform(context)) + } + python = Python.getInstance() + + isInitialized = true + Timber.i("GOG service initialized successfully with Chaquopy") + + return isInitialized + } catch (e: Exception) { + Timber.e(e, "Exception during GOG service initialization") + return false + } + } + + /** + * Execute GOGDL command using Chaquopy + */ + suspend fun executeCommand(vararg args: String): Result { + return withContext(Dispatchers.IO) { + try { + val python = Python.getInstance() + val sys = python.getModule("sys") + val io = python.getModule("io") + val originalArgv = sys.get("argv") + + try { + // Now import our Android-compatible GOGDL CLI module + val gogdlCli = python.getModule("gogdl.cli") + + // Set up arguments for argparse + val argsList = listOf("gogdl") + args.toList() + Timber.d("Setting GOGDL arguments for argparse: ${args.joinToString(" ")}") + // Convert to Python list to avoid jarray issues + val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) + sys.put("argv", pythonList) + Timber.d("sys.argv set to: $argsList") + + // 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() + Timber.d("GOGDL output: $output") + + // Restore original stdout + sys.put("stdout", originalStdout) + + if (output.isNotEmpty()) { + Result.success(output) + } else { + Result.success("GOGDL execution completed") + } + } catch (e: Exception) { + Timber.d("GOGDL execution completed with exception: ${e.javaClass.simpleName} - ${e.message}") + Result.failure(Exception("GOGDL execution failed: $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")) + } + } + } + + /** + * Read and parse auth credentials from file + */ + private fun readAuthCredentials(authConfigPath: String): Result> { + return try { + val authFile = File(authConfigPath) + Timber.d("Checking auth file at: ${authFile.absolutePath}") + Timber.d("Auth file exists: ${authFile.exists()}") + + if (!authFile.exists()) { + return Result.failure(Exception("No authentication found. Please log in first.")) + } + + val authContent = authFile.readText() + Timber.d("Auth file content: $authContent") + + val authJson = JSONObject(authContent) + + // GOGDL stores credentials nested under client ID + val credentialsJson = if (authJson.has(GOG_CLIENT_ID)) { + authJson.getJSONObject(GOG_CLIENT_ID) + } else { + // Fallback: try to read from root level + authJson + } + + val accessToken = credentialsJson.optString("access_token", "") + val userId = credentialsJson.optString("user_id", "") + + Timber.d("Parsed access_token: ${if (accessToken.isNotEmpty()) "${accessToken.take(20)}..." else "EMPTY"}") + Timber.d("Parsed user_id: $userId") + + if (accessToken.isEmpty() || userId.isEmpty()) { + Timber.e("Auth data validation failed - accessToken empty: ${accessToken.isEmpty()}, userId empty: ${userId.isEmpty()}") + return Result.failure(Exception("Invalid authentication data. Please log in again.")) + } + + Result.success(Pair(accessToken, userId)) + } catch (e: Exception) { + Timber.e(e, "Failed to read auth credentials") + Result.failure(e) + } + } + + /** + * Parse full GOGCredentials from auth file + */ + private fun parseFullCredentials(authConfigPath: String): GOGCredentials { + return try { + val authFile = File(authConfigPath) + if (authFile.exists()) { + val authContent = authFile.readText() + val authJson = JSONObject(authContent) + + // GOGDL stores credentials nested under client ID + val credentialsJson = if (authJson.has(GOG_CLIENT_ID)) { + authJson.getJSONObject(GOG_CLIENT_ID) + } else { + // Fallback: try to read from root level + authJson + } + + GOGCredentials( + accessToken = credentialsJson.optString("access_token", ""), + refreshToken = credentialsJson.optString("refresh_token", ""), + userId = credentialsJson.optString("user_id", ""), + username = credentialsJson.optString("username", "GOG User"), + ) + } else { + // Return dummy credentials for successful auth + GOGCredentials( + accessToken = "authenticated_${System.currentTimeMillis()}", + refreshToken = "refresh_${System.currentTimeMillis()}", + userId = "user_123", + username = "GOG User", + ) + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse auth result") + // Return dummy credentials as fallback + GOGCredentials( + accessToken = "fallback_token", + refreshToken = "fallback_refresh", + userId = "fallback_user", + username = "GOG User", + ) + } + } + + /** + * Create GOGCredentials from JSON output + */ + 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 + ) + } + + /** + * Authenticate with GOG using authorization code + */ + suspend fun authenticateWithCode(authConfigPath: String, authorizationCode: String): Result { + return try { + Timber.i("Starting GOG authentication with authorization code...") + + // Extract the actual authorization code from URL if needed + val actualCode = if (authorizationCode.startsWith("http")) { + // Extract code parameter from URL + val codeParam = authorizationCode.substringAfter("code=", "") + if (codeParam.isEmpty()) { + return Result.failure(Exception("Invalid authorization URL: no code parameter found")) + } + // Remove any additional parameters after the code + val cleanCode = codeParam.substringBefore("&") + Timber.d("Extracted authorization code from URL: ${cleanCode.take(20)}...") + cleanCode + } else { + authorizationCode + } + + // 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: $authConfigPath, code: ${actualCode.take(10)}...") + Timber.d("Full auth command: --auth-config-path $authConfigPath auth --code ${actualCode.take(20)}...") + + val result = executeCommand("--auth-config-path", authConfigPath, "auth", "--code=$actualCode") + + if (result.isSuccess) { + val gogdlOutput = result.getOrNull() ?: "" + Timber.i("GOGDL command completed, checking authentication result...") + Timber.d("GOGDL output for auth: $gogdlOutput") + + // First, check if GOGDL output indicates success + try { + val outputJson = JSONObject(gogdlOutput.trim()) + + // Check if the response indicates an error + if (outputJson.has("error") && outputJson.getBoolean("error")) { + val errorMsg = outputJson.optString("error_description", "Authentication failed") + Timber.e("GOG authentication failed: $errorMsg") + 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 = parseFullCredentials(authConfigPath) + Timber.i("GOG authentication successful for user: ${authData.username}") + Result.success(authData) + } else { + Timber.w("GOGDL returned success but no auth file created, using output data") + // Create credentials from GOGDL output + val credentials = createCredentialsFromJson(outputJson) + 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()) { + try { + val authData = parseFullCredentials(authConfigPath) + Timber.i("GOG authentication successful (fallback) for user: ${authData.username}") + Result.success(authData) + } catch (ex: Exception) { + Timber.e(ex, "Failed to parse auth file") + Result.failure(Exception("Failed to parse authentication result: ${ex.message}")) + } + } else { + Timber.e("GOG authentication failed: no auth file created and failed to parse output") + Result.failure(Exception("Authentication failed: no credentials available")) + } + } + } else { + val error = result.exceptionOrNull()?.message ?: "Authentication failed" + Timber.e("GOG authentication command failed: $error") + Result.failure(Exception(error)) + } + } catch (e: Exception) { + Timber.e(e, "GOG authentication exception") + Result.failure(e) + } + } + + /** + * Fetch rich metadata from GOG GamesDB API including developer and publisher info + */ + private suspend fun fetchGamesDBMetadata(gameId: String): GameMetadata = withContext(Dispatchers.IO) { + try { + val python = Python.getInstance() + val requests = python.getModule("requests") + + val gamesDbUrl = "https://gamesdb.gog.com/platforms/gog/external_releases/$gameId" + + // Create headers dictionary for GamesDB + val gamesDbHeaders = python.builtins.callAttr("dict") + gamesDbHeaders.callAttr("__setitem__", "User-Agent", "GOGGalaxyClient/2.0.45.61 (Windows_x86_64)") + + Timber.d("Fetching GOG game metadata from GamesDB for ID: $gameId") + + val gamesDbResponse = requests.callAttr( + "get", gamesDbUrl, + Kwarg("headers", gamesDbHeaders), + Kwarg("timeout", 10), + ) + + val gamesDbStatusCode = gamesDbResponse.get("status_code")?.toInt() ?: 0 + if (gamesDbStatusCode == 200) { + val gamesDbJson = gamesDbResponse.callAttr("json") + val gameData = gamesDbJson?.callAttr("get", "game") + + // Extract developer information + val developers = extractDevelopers(gameData, gameId) + + // Extract publisher information + val publishers = extractPublishers(gameData, gameId) + + // Extract title and description from GamesDB + val title = gamesDbJson?.callAttr("get", "title")?.callAttr("get", "*")?.toString() + val description = gamesDbJson?.callAttr("get", "summary")?.callAttr("get", "*")?.toString() + + return@withContext GameMetadata( + developer = if (developers.isNotEmpty()) developers.joinToString(", ") else "Unknown Developer", + publisher = if (publishers.isNotEmpty()) publishers.joinToString(", ") else "Unknown Publisher", + title = title, + description = description + ) + } + } catch (e: Exception) { + Timber.w(e, "Error fetching GamesDB metadata for game $gameId") + } + + return@withContext GameMetadata() + } + + /** + * Extract developer names from GamesDB game data + */ + private fun extractDevelopers(gameData: PyObject?, gameId: String): List { + val developers = gameData?.callAttr("get", "developers") ?: return emptyList() + + return try { + val developersList = mutableListOf() + val length = developers.callAttr("__len__")?.toInt() ?: 0 + for (i in 0 until length) { + val dev = developers.callAttr("__getitem__", i) + val devName = dev?.callAttr("get", "name")?.toString() + if (!devName.isNullOrEmpty()) { + developersList.add(devName) + } + } + developersList + } catch (e: Exception) { + Timber.w(e, "Error parsing developers for game $gameId") + emptyList() + } + } + + /** + * Extract publisher names from GamesDB game data + */ + private fun extractPublishers(gameData: PyObject?, gameId: String): List { + val publishers = gameData?.callAttr("get", "publishers") ?: return emptyList() + + return try { + val publishersList = mutableListOf() + val length = publishers.callAttr("__len__")?.toInt() ?: 0 + for (i in 0 until length) { + val pub = publishers.callAttr("__getitem__", i) + val pubName = pub?.callAttr("get", "name")?.toString() + if (!pubName.isNullOrEmpty()) { + publishersList.add(pubName) + } + } + publishersList + } catch (e: Exception) { + Timber.w(e, "Error parsing publishers for game $gameId") + emptyList() + } + } + + /** + * Fetch detailed information for a specific GOG game + */ + private suspend fun fetchGameDetails(gameId: String, accessToken: String): GOGGame? = withContext(Dispatchers.IO) { + try { + val python = Python.getInstance() + val requests = python.getModule("requests") + + // First get rich metadata from GamesDB + val metadata = fetchGamesDBMetadata(gameId) + + // Now fetch basic product info from the standard GOG API + val url = "https://api.gog.com/products/$gameId" + + // Create headers dictionary + val pyDict = python.builtins.callAttr("dict") + pyDict.callAttr("__setitem__", "Authorization", "Bearer $accessToken") + pyDict.callAttr("__setitem__", "User-Agent", "GOGGalaxyClient/2.0.45.61 (Windows_x86_64)") + + Timber.d("Fetching GOG game details for ID: $gameId") + + val response = requests.callAttr( + "get", url, + Kwarg("headers", pyDict), + Kwarg("timeout", 10), + ) + + val statusCode = response.get("status_code")?.toInt() ?: 0 + + if (statusCode == 200) { + val gameJson = response.callAttr("json") + + // Extract game information, using GamesDB data as fallback + val title = gameJson?.callAttr("get", "title")?.toString() ?: metadata.title ?: "Unknown Game" + val slug = gameJson?.callAttr("get", "slug")?.toString() ?: gameId + + // Check the game_type field for filtering + val gameType = gameJson?.callAttr("get", "game_type")?.toString() ?: "" + + // Filter based on game_type - only keep if it's a proper game + if (gameType != "game") { + return@withContext null + } + + // Get description - prefer GamesDB but fallback to product API + val description = metadata.description ?: try { + gameJson?.callAttr("get", "description")?.callAttr("get", "full")?.toString() + ?: gameJson?.callAttr("get", "description")?.toString() + ?: "" + } catch (e: Exception) { + "" + } + + // Get best available image URL - try different types in order of preference + val imageUrl = try { + val images = gameJson?.callAttr("get", "images") + if (images != null) { + // Try logo2x (high resolution) first, then logo, then other options + val imageTypes = listOf("logo2x", "logo", "icon", "background") + + var foundUrl = "" + for (imageType in imageTypes) { + val imageData = images.callAttr("get", imageType)?.toString() + if (!imageData.isNullOrEmpty()) { + // GOG URLs start with // so we need to add https: + val fullUrl = if (imageData.startsWith("//")) { + "https:$imageData" + } else { + imageData + } + + // Try to upgrade logo images to highest quality background version + foundUrl = when { + fullUrl.contains("_glx_logo.jpg") -> { + val baseUrl = fullUrl.substringBefore("_glx_logo.jpg") + "$baseUrl.jpg" + } + fullUrl.contains("_glx_logo_2x.jpg") -> { + val baseUrl = fullUrl.substringBefore("_glx_logo_2x.jpg") + "$baseUrl.jpg" + } + else -> fullUrl + } + + Timber.d("Game $gameId - using $imageType image: $fullUrl -> $foundUrl") + break // Exit loop once we find a valid URL + } + } + foundUrl + } else { + "" + } + } catch (e: Exception) { + Timber.w(e, "Game $gameId - error extracting image URL") + "" + } + + // Get icon URL specifically + val iconUrl = try { + val images = gameJson?.callAttr("get", "images") + val iconData = images?.callAttr("get", "icon")?.toString() + if (!iconData.isNullOrEmpty()) { + val fullIconUrl = if (iconData.startsWith("//")) { + "https:$iconData" + } else { + iconData + } + Timber.d("Game $gameId - icon URL: $fullIconUrl") + fullIconUrl + } else { + "" + } + } catch (e: Exception) { + Timber.w(e, "Game $gameId - error extracting icon URL") + "" + } + + // Developer and publisher info already extracted from GamesDB above + + // Get release date + val releaseDate = try { + gameJson?.callAttr("get", "release_date")?.toString() ?: "" + } catch (e: Exception) { + "" + } + + Timber.d("Successfully fetched details for game: $title") + + GOGGame( + id = gameId, + title = title, + slug = slug, + description = description, + imageUrl = imageUrl, + iconUrl = iconUrl, + developer = metadata.developer, + publisher = metadata.publisher, + releaseDate = releaseDate, + ) + } else { + Timber.w("Failed to fetch game details for $gameId: HTTP $statusCode") + null + } + } catch (e: Exception) { + Timber.e(e, "Exception fetching game details for $gameId") + null + } + } + + /** + * Enhanced download method with proper progress tracking (bypassing GOGDL completely) + */ + suspend fun downloadGame(gameId: String, installPath: String, authConfigPath: String): Result { + return try { + Timber.i("Starting GOGDL download with progress parsing for game $gameId") + + val installDir = File(installPath) + if (!installDir.exists()) { + installDir.mkdirs() + } + + // Create DownloadInfo for progress tracking + val downloadInfo = DownloadInfo(jobCount = 1) + + // Track this download in the active downloads map + getInstance()?.activeDownloads?.put(gameId, downloadInfo) + + // Start GOGDL download with progress parsing + val downloadJob = CoroutineScope(Dispatchers.IO).launch { + try { + // Create support directory for redistributables (like Heroic does) + val supportDir = File(installDir.parentFile, "gog-support") + supportDir.mkdirs() + + val result = executeCommandWithProgressParsing( + downloadInfo, + "--auth-config-path", authConfigPath, + "download", ContainerUtils.extractGameIdFromContainerId(gameId).toString(), + "--platform", "windows", + "--path", installPath, + "--support", supportDir.absolutePath, + "--skip-dlcs", + "--lang", "en-US", + "--max-workers", "1", + ) + + if (result.isSuccess) { + // Check if the download was actually cancelled + if (downloadInfo.isCancelled()) { + downloadInfo.setProgress(-1.0f) // Mark as cancelled + Timber.i("GOGDL download was cancelled by user") + } else { + downloadInfo.setProgress(1.0f) // Mark as complete + Timber.i("GOGDL download completed successfully") + } + } else { + downloadInfo.setProgress(-1.0f) // Mark as failed + Timber.e("GOGDL download failed: ${result.exceptionOrNull()?.message}") + } + } catch (e: CancellationException) { + Timber.i("GOGDL download cancelled by user") + downloadInfo.setProgress(-1.0f) // Mark as cancelled + } catch (e: Exception) { + Timber.e(e, "GOGDL download failed") + downloadInfo.setProgress(-1.0f) // Mark as failed + } finally { + // Clean up the download from active downloads + getInstance()?.activeDownloads?.remove(gameId) + Timber.d("Cleaned up download for game: $gameId") + } + } + + // Store the job in DownloadInfo so it can be cancelled + downloadInfo.setDownloadJob(downloadJob) + + Result.success(downloadInfo) + } catch (e: Exception) { + Timber.e(e, "Failed to start GOG game download") + Result.failure(e) + } + } + + private suspend fun executeCommandWithProgressParsing(downloadInfo: DownloadInfo, vararg args: String): Result { + return withContext(Dispatchers.IO) { + var logMonitorJob: Job? = null + try { + // Start log monitoring for GOGDL progress (works for both V1 and V2) + logMonitorJob = CoroutineScope(Dispatchers.IO).launch { + monitorGOGDLProgress(downloadInfo) + } + + // Store the progress monitor job in DownloadInfo so it can be cancelled + downloadInfo.setProgressMonitorJob(logMonitorJob) + + val python = Python.getInstance() + val sys = python.getModule("sys") + val originalArgv = sys.get("argv") + + try { + val gogdlCli = python.getModule("gogdl.cli") + + // Set up arguments for argparse + val argsList = listOf("gogdl") + args.toList() + Timber.d("Setting GOGDL arguments for argparse: ${args.joinToString(" ")}") + val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) + sys.put("argv", pythonList) + + // Check for cancellation before starting + ensureActive() + + // Set up cancellation mechanism for Python + // Extract game ID from the download command arguments + val gameIdFromArgs = args.find { it.matches(Regex("\\d+")) } ?: "unknown" + val builtins = python.getModule("builtins") + + // Set a global variable that Python can check + builtins.put("GOGDL_CANCEL_${gameIdFromArgs}", false) + Timber.i("Set up Python cancellation flag: GOGDL_CANCEL_${gameIdFromArgs}") + + // Execute the main function with periodic cancellation checks + val pythonExecutionJob = async(Dispatchers.IO) { + gogdlCli.callAttr("main") + } + + // Wait for either completion or cancellation + while (pythonExecutionJob.isActive) { + delay(100) // Check every 100ms + ensureActive() // Throw CancellationException if cancelled + } + + pythonExecutionJob.await() + Timber.d("GOGDL execution completed successfully") + Result.success("Download completed") + } 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) + } finally { + logMonitorJob?.cancel() + } + } + } + + /** + * Monitor GOGDL progress by parsing log output like Heroic Games Launcher does + * Works for both V1 and V2 games using the same progress format + */ + private suspend fun monitorGOGDLProgress(downloadInfo: DownloadInfo) { + var process: Process? = null + try { + // Clear any existing logcat buffer to ensure fresh start + try { + val clearProcess = ProcessBuilder("logcat", "-c").start() + clearProcess.waitFor() + Timber.d("Cleared logcat buffer for fresh progress monitoring") + } catch (e: Exception) { + Timber.w(e, "Failed to clear logcat buffer, continuing anyway") + } + + // Add delay to ensure Python process has started and old logs are cleared + delay(1000) + + // Use logcat to read python.stderr logs in real-time with timestamp filtering + // Only process logs that are newer than when we started + val startTime = System.currentTimeMillis() + process = ProcessBuilder("logcat", "-s", "python.stderr:W", "-T", "1") + .redirectErrorStream(true) + .start() + + val reader = process.inputStream.bufferedReader() + Timber.d("Progress monitoring logcat process started successfully with timestamp filtering") + + // Track progress state exactly like Heroic does + var currentPercent: Float? = null + var currentEta: String = "" + var currentBytes: String = "" + var currentDownSpeed: Float? = null + var currentDiskSpeed: Float? = null + + while (downloadInfo.getProgress() < 1.0f && downloadInfo.getProgress() >= 0.0f && !downloadInfo.isCancelled()) { + // Check for cancellation before reading each line + if (downloadInfo.isCancelled()) { + Timber.d("Progress monitoring stopping due to cancellation") + break + } + + val line = reader.readLine() + if (line != null) { + // Double-check cancellation after reading line + if (downloadInfo.isCancelled()) { + Timber.d("Progress monitoring stopping due to cancellation after line read") + break + } + // Parse like Heroic: only update if field is empty/undefined + + // parse log for percent (only if not already set) + if (currentPercent == null) { + val percentMatch = Regex("""Progress: (\d+\.\d+) """).find(line) + if (percentMatch != null) { + val percent = percentMatch.groupValues[1].toFloatOrNull() + if (percent != null && !percent.isNaN()) { + currentPercent = percent + } + } + } + + // parse log for eta (only if empty) + if (currentEta.isEmpty()) { + val etaMatch = Regex("""ETA: (\d\d:\d\d:\d\d)""").find(line) + if (etaMatch != null) { + currentEta = etaMatch.groupValues[1] + } + } + + // parse log for game download progress (only if empty) + if (currentBytes.isEmpty()) { + val bytesMatch = Regex("""Downloaded: (\S+) MiB""").find(line) + if (bytesMatch != null) { + currentBytes = "${bytesMatch.groupValues[1]}MB" + } + } + + // parse log for download speed (only if not set) + if (currentDownSpeed == null) { + val downSpeedMatch = Regex("""Download\t- (\S+) MiB""").find(line) + if (downSpeedMatch != null) { + val speed = downSpeedMatch.groupValues[1].toFloatOrNull() + if (speed != null && !speed.isNaN()) { + currentDownSpeed = speed + } + } + } + + // parse disk write speed (only if not set) + if (currentDiskSpeed == null) { + val diskSpeedMatch = Regex("""Disk\t- (\S+) MiB""").find(line) + if (diskSpeedMatch != null) { + val speed = diskSpeedMatch.groupValues[1].toFloatOrNull() + if (speed != null && !speed.isNaN()) { + currentDiskSpeed = speed + } + } + } + + // only send update if all values are present (exactly like Heroic) + if (currentPercent != null && currentEta.isNotEmpty() && + currentBytes.isNotEmpty() && currentDownSpeed != null && currentDiskSpeed != null) { + + // Update progress with the percentage + val progress = (currentPercent!! / 100.0f).coerceIn(0.0f, 1.0f) + downloadInfo.setProgress(progress) + + // Log exactly like Heroic does + Timber.i("Progress for game: ${currentPercent}%/${currentBytes}/${currentEta} Down: ${currentDownSpeed}MB/s / Disk: ${currentDiskSpeed}MB/s") + + // reset (exactly like Heroic does) + currentPercent = null + currentEta = "" + currentBytes = "" + currentDownSpeed = null + currentDiskSpeed = null + } + } else { + delay(100L) // Brief delay if no new log lines + } + } + + Timber.d("Progress monitoring loop ended - cancelled: ${downloadInfo.isCancelled()}, progress: ${downloadInfo.getProgress()}") + process?.destroyForcibly() // Use destroyForcibly for more aggressive termination + Timber.d("Logcat process destroyed forcibly") + } catch (e: CancellationException) { + Timber.d("GOGDL progress monitoring cancelled") + process?.destroyForcibly() + throw e + } catch (e: Exception) { + Timber.w(e, "Error monitoring GOGDL progress, falling back to estimation") + // Simple fallback - just wait and set progress to completion + var lastProgress = 0.0f + val startTime = System.currentTimeMillis() + + while (downloadInfo.getProgress() < 1.0f && downloadInfo.getProgress() >= 0.0f && !downloadInfo.isCancelled()) { + delay(2000L) + val elapsed = System.currentTimeMillis() - startTime + val estimatedProgress = when { + elapsed < 5000 -> 0.05f + elapsed < 15000 -> 0.20f + elapsed < 30000 -> 0.50f + elapsed < 60000 -> 0.80f + else -> 0.90f + }.coerceAtLeast(lastProgress) + + if (estimatedProgress > lastProgress) { + downloadInfo.setProgress(estimatedProgress) + lastProgress = estimatedProgress + } + } + } + } + + /** + * Parse GOGDL progress components from log line using Heroic Games Launcher approach + * Collects all progress data before updating (prevents partial updates) + */ + private fun parseGOGDLProgressComponents( + line: String, + onPercent: (Float) -> Unit, + onEta: (String) -> Unit, + onBytes: (String) -> Unit, + onDownSpeed: (Float) -> Unit, + onDiskSpeed: (Float) -> Unit + ) { + try { + // Parse progress percentage: "= Progress: 45.67 12345/67890, Running for: 00:01:23, ETA: 00:02:34" + val progressRegex = Regex("""= Progress: (\d+\.\d+) .+ETA: (\d\d:\d\d:\d\d)""") + val progressMatch = progressRegex.find(line) + + if (progressMatch != null) { + val percent = progressMatch.groupValues[1].toFloat() + val eta = progressMatch.groupValues[2] + onPercent(percent) + onEta(eta) + return + } + + // Parse download progress: "= Downloaded: 123.45 MiB, Written: 234.56 MiB" + val downloadedRegex = Regex("""= Downloaded: (\S+) MiB""") + val downloadedMatch = downloadedRegex.find(line) + + if (downloadedMatch != null) { + val downloadedMB = downloadedMatch.groupValues[1] + onBytes("${downloadedMB}MB") + return + } + + // Parse download speed: " + Download - 12.34 MiB/s (raw) / 23.45 MiB/s (decompressed)" + val downloadSpeedRegex = Regex(""" \+ Download\t- (\S+) MiB/s \(raw\)""") + val downloadSpeedMatch = downloadSpeedRegex.find(line) + + if (downloadSpeedMatch != null) { + val downloadSpeed = downloadSpeedMatch.groupValues[1].toFloat() + onDownSpeed(downloadSpeed) + return + } + + // Parse disk speed: " + Disk - 34.56 MiB/s (write) / 45.67 MiB/s (read)" + val diskSpeedRegex = Regex(""" \+ Disk\t- (\S+) MiB/s \(write\)""") + val diskSpeedMatch = diskSpeedRegex.find(line) + + if (diskSpeedMatch != null) { + val diskSpeed = diskSpeedMatch.groupValues[1].toFloat() + onDiskSpeed(diskSpeed) + return + } + + // Handle completion + if (line.contains("download completed") || line.contains("Download completed")) { + Timber.i("GOGDL: Download completed") + // Force 100% completion + onPercent(100.0f) + onEta("00:00:00") + onBytes("Complete") + onDownSpeed(0.0f) + onDiskSpeed(0.0f) + return + } + + } catch (e: Exception) { + Timber.w(e, "Error parsing GOGDL progress line: $line") + } + } + + /** + * Parse GOGDL progress from log line using Heroic Games Launcher patterns + * Works for both V1 and V2 games since they use the same ExecutingManager/ProgressBar + */ + private fun parseGOGDLProgressLine(line: String, downloadInfo: DownloadInfo): Boolean { + try { + // Parse progress percentage: "= Progress: 45.67 12345/67890, Running for: 00:01:23, ETA: 00:02:34" + val progressRegex = Regex("""= Progress: (\d+\.\d+) """) + val progressMatch = progressRegex.find(line) + + if (progressMatch != null) { + val percent = progressMatch.groupValues[1].toFloat() + val progress = (percent / 100.0f).coerceIn(0.0f, 1.0f) + downloadInfo.setProgress(progress) + return true + } + + // Parse download progress: "= Downloaded: 123.45 MiB, Written: 234.56 MiB" + val downloadedRegex = Regex("""= Downloaded: (\S+) MiB""") + val downloadedMatch = downloadedRegex.find(line) + + if (downloadedMatch != null) { + val downloadedMB = downloadedMatch.groupValues[1] + Timber.d("Downloaded: ${downloadedMB}MB") + return true + } + + // Parse download speed: " + Download - 12.34 MiB/s (raw) / 23.45 MiB/s (decompressed)" + val downloadSpeedRegex = Regex(""" \+ Download\t- (\S+) MiB/s \(raw\)""") + val downloadSpeedMatch = downloadSpeedRegex.find(line) + + if (downloadSpeedMatch != null) { + val downloadSpeed = downloadSpeedMatch.groupValues[1] + Timber.d("Download speed: ${downloadSpeed}MB/s") + return true + } + + // Parse disk speed: " + Disk - 34.56 MiB/s (write) / 45.67 MiB/s (read)" + val diskSpeedRegex = Regex(""" \+ Disk\t- (\S+) MiB/s \(write\)""") + val diskSpeedMatch = diskSpeedRegex.find(line) + + if (diskSpeedMatch != null) { + val diskSpeed = diskSpeedMatch.groupValues[1] + Timber.d("Disk speed: ${diskSpeed}MB/s") + return true + } + + // Log other important GOGDL messages + if (line.contains("Starting V1 download") || line.contains("Starting V2 download")) { + Timber.i("GOGDL: $line") + return true + } + + if (line.contains("download completed") || line.contains("Download completed")) { + Timber.i("GOGDL: Download completed") + downloadInfo.setProgress(1.0f) + return true + } + + return false + } catch (e: Exception) { + Timber.w(e, "Error parsing GOGDL progress line: $line") + return false + } + } + + /** + * Parse both V1Manager and V2Manager progress from log lines (Heroic approach) + */ + private fun parseGOGDLProgress(line: String, downloadInfo: DownloadInfo) { + try { + // Parse V1Manager progress: "[V1Manager] INFO: Completed 12/16: filename" + val v1ProgressRegex = Regex("""\[V1Manager\] INFO: Completed\s+(\d+)/(\d+):\s+(.+)""") + val v1Match = v1ProgressRegex.find(line) + + if (v1Match != null) { + val completed = v1Match.groupValues[1].toInt() + val total = v1Match.groupValues[2].toInt() + val filename = v1Match.groupValues[3] + + val progress = (completed.toFloat() / total.toFloat()).coerceIn(0.0f, 1.0f) + + downloadInfo.setProgress(progress) + Timber.i("V1 Progress: $completed/$total files (${(progress * 100).toInt()}%) - $filename") + return + } + + // Parse V2Manager progress: "[V2Manager] INFO: Downloading file: filename.exe" + val v2FileRegex = Regex("""\[V2Manager\] INFO: Downloading file:\s+(.+)""") + val v2FileMatch = v2FileRegex.find(line) + + if (v2FileMatch != null) { + val filename = v2FileMatch.groupValues[1] + // For V2, we don't have total file count, so use incremental progress + val currentProgress = downloadInfo.getProgress() + val increment = 0.05f // 5% per file + val newProgress = (currentProgress + increment).coerceAtMost(0.95f) + + downloadInfo.setProgress(newProgress) + Timber.i("V2 Progress: Downloading $filename (${(newProgress * 100).toInt()}%)") + return + } + + // Parse V2Manager chunk progress: "[V2Manager] INFO: Downloading chunk 3/5 for filename.exe" + val v2ChunkRegex = Regex("""\[V2Manager\] INFO: Downloading chunk\s+(\d+)/(\d+)\s+for\s+(.+)""") + val v2ChunkMatch = v2ChunkRegex.find(line) + + if (v2ChunkMatch != null) { + val currentChunk = v2ChunkMatch.groupValues[1].toInt() + val totalChunks = v2ChunkMatch.groupValues[2].toInt() + val filename = v2ChunkMatch.groupValues[3] + + // For chunk progress, add smaller increments + val currentProgress = downloadInfo.getProgress() + val chunkIncrement = 0.01f // 1% per chunk + val newProgress = (currentProgress + chunkIncrement).coerceAtMost(0.95f) + + downloadInfo.setProgress(newProgress) + Timber.d("V2 Chunk Progress: $currentChunk/$totalChunks for $filename (${(newProgress * 100).toInt()}%)") + return + } + + // Parse V2Manager depot info: "[V2Manager] INFO: Depot contains 25 files" + val v2DepotRegex = Regex("""\[V2Manager\] INFO: Depot contains\s+(\d+)\s+files""") + val v2DepotMatch = v2DepotRegex.find(line) + + if (v2DepotMatch != null) { + val totalFiles = v2DepotMatch.groupValues[1].toInt() + Timber.i("V2 Download: Depot contains $totalFiles files") + // Set initial progress + downloadInfo.setProgress(0.05f) + return + } + + // Check for completion (both V1 and V2) + if ((line.contains("All") && line.contains("files downloaded successfully")) || + line.contains("Download completed successfully") || + line.contains("Installation completed") + ) { + downloadInfo.setProgress(1.0f) + Timber.i("Download completed successfully") + return + } + + // Check for errors (both V1 and V2) + if (line.contains("ERROR") || line.contains("Failed")) { + Timber.w("Download error detected: $line") + return + } + } catch (e: Exception) { + Timber.w("Error parsing progress: ${e.message}") + } + } + + /** + * Calculate the total size of all files in a directory + */ + private fun calculateDirectorySize(directory: File): Long { + var size = 0L + try { + directory.walkTopDown().forEach { file -> + if (file.isFile) { + size += file.length() + } + } + } catch (e: Exception) { + Timber.w(e, "Error calculating directory size") + } + return size + } + + /** + * Sync GOG cloud saves for a game + */ + suspend fun syncCloudSaves(gameId: String, savePath: String, authConfigPath: String, timestamp: Float = 0.0f): Result { + return try { + Timber.i("Starting GOG cloud save sync for game $gameId") + + val result = executeCommand( + "--auth-config-path", authConfigPath, + "save-sync", savePath, + "--dirname", gameId, + "--timestamp", timestamp.toString(), + ) + + if (result.isSuccess) { + Timber.i("GOG cloud save sync completed successfully for game $gameId") + Result.success(Unit) + } else { + val error = result.exceptionOrNull() ?: Exception("Save sync failed") + Timber.e(error, "GOG cloud save sync failed for game $gameId") + Result.failure(error) + } + } catch (e: Exception) { + Timber.e(e, "GOG cloud save sync exception for game $gameId") + Result.failure(e) + } + } + + /** + * Check if user is authenticated by testing GOGDL command + */ + fun hasStoredCredentials(context: Context): Boolean { + val authFile = File(context.filesDir, "gog_auth.json") + return authFile.exists() + } + + /** + * 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 = "${context.filesDir}/gog_auth.json" + + if (!hasStoredCredentials(context)) { + return Result.failure(Exception("No stored credentials found")) + } + + // Use GOGDL to get credentials - this will handle token refresh automatically + val result = executeCommand("--auth-config-path", authConfigPath, "auth") + + if (result.isSuccess) { + val output = result.getOrNull() ?: "" + Timber.d("GOGDL credentials output: $output") + + 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: $username") + Result.success(credentials) + } catch (e: Exception) { + Timber.e(e, "Failed to parse GOGDL credentials response") + Result.failure(e) + } + } 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) + } + } + + /** + * 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 = "${context.filesDir}/gog_auth.json" + + 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 get credentials - this will handle token refresh automatically + val result = 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() ?: "" + Timber.d("GOGDL validation output: $output") + + 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: $output") + return Result.success(false) + } + } catch (e: Exception) { + Timber.e(e, "Failed to validate credentials") + return Result.failure(e) + } + } + + /** + * Get GOG library with progressive processing + * This processes games one by one as they're fetched, without making additional API calls + */ + private suspend fun getLibraryProgressively( + authConfigPath: String, + onGameFetched: suspend (GOGGame) -> Unit, + onTotalCount: (Int) -> Unit, + ): Result { + return try { + Timber.i("Getting GOG library progressively...") + + // Read auth credentials using extracted function + val credentialsResult = readAuthCredentials(authConfigPath) + if (credentialsResult.isFailure) { + return Result.failure(credentialsResult.exceptionOrNull()!!) + } + + val (accessToken, userId) = credentialsResult.getOrThrow() + + // Use Python requests to call GOG Galaxy API + val python = Python.getInstance() + val requests = python.getModule("requests") + + val url = "https://embed.gog.com/user/data/games" + + // Convert Kotlin Map to Python dictionary to avoid LinkedHashMap issues + val pyDict = python.builtins.callAttr("dict") + pyDict.callAttr("__setitem__", "Authorization", "Bearer $accessToken") + pyDict.callAttr("__setitem__", "User-Agent", "GOGGalaxyClient/2.0.45.61 (Windows_x86_64)") + + Timber.d("Making GOG API request to: $url") + Timber.d("Request headers: Authorization=Bearer ${accessToken.take(20)}..., User-Agent=GOGGalaxyClient/2.0.45.61") + + // Make the request with headers - pass as separate arguments + val response = requests.callAttr( + "get", url, + Kwarg("headers", pyDict), + Kwarg("timeout", 30), + ) + + val statusCode = response.get("status_code")?.toInt() ?: 0 + Timber.d("GOG API response status: $statusCode") + + if (statusCode == 200) { + val responseJson = response.callAttr("json") + Timber.d("GOG API response JSON: $responseJson") + + // Try different ways to access the owned array + val ownedGames = try { + responseJson?.callAttr("get", "owned") + } catch (e: Exception) { + Timber.w("Failed to get owned with callAttr: ${e.message}") + try { + responseJson?.get("owned") + } catch (e2: Exception) { + Timber.w("Failed to get owned with get: ${e2.message}") + null + } + } + + Timber.d("GOG API owned games: $ownedGames") + + // Count the owned game IDs + val gameCount = ownedGames?.callAttr("__len__")?.toInt() ?: 0 + Timber.i("GOG library retrieved: $gameCount game IDs found") + + // Notify total count first + onTotalCount(gameCount) + + // Convert Python list to Kotlin list of game IDs and process them progressively + var processedCount = 0 + if (ownedGames != null && gameCount > 0) { + for (i in 0 until gameCount) { + try { + val gameId = ownedGames.callAttr("__getitem__", i)?.toString() + if (gameId != null) { + // Fetch details for this specific game + val gameDetails = fetchGameDetails(gameId, accessToken) + if (gameDetails != null) { + onGameFetched(gameDetails) + processedCount++ + + // Small delay to allow UI updates + kotlinx.coroutines.delay(10) + } + } + } catch (e: Exception) { + Timber.w("Failed to process game at index $i: ${e.message}") + } + } + } + + Timber.i("Successfully processed $processedCount games progressively") + Result.success(processedCount) + } else { + val errorText = response.callAttr("text")?.toString() ?: "Unknown error" + Timber.e("GOG API error: HTTP $statusCode - $errorText") + Result.failure(Exception("Failed to get library: HTTP $statusCode")) + } + } catch (e: Exception) { + Timber.e(e, "GOG library exception") + Result.failure(e) + } + } + + /** + * Get user library progressively by calling GOG Galaxy API directly + * This inserts games one by one as they are fetched, providing real-time updates + */ + suspend fun getUserLibraryProgressively( + context: Context, + onGameFetched: suspend (GOGGame) -> Unit, + onTotalCount: (Int) -> Unit, + ): Result { + return try { + val authConfigPath = "${context.filesDir}/gog_auth.json" + + if (!hasStoredCredentials(context)) { + return Result.failure(Exception("No stored credentials found")) + } + + // Use the true progressive method that fetches games one by one + getLibraryProgressively(authConfigPath, onGameFetched, onTotalCount) + } catch (e: Exception) { + Timber.e(e, "GOG library exception") + Result.failure(e) + } + } + + fun clearStoredCredentials(context: Context): Boolean { + return try { + val authFile = File(context.filesDir, "gog_auth.json") + if (authFile.exists()) { + authFile.delete() + } else { + true + } + } catch (e: Exception) { + Timber.e(e, "Failed to clear GOG credentials") + false + } + } + + // Enhanced hasActiveOperations to track background sync + fun hasActiveOperations(): Boolean { + return syncInProgress || backgroundSyncJob?.isActive == true + } + + // Add methods to control sync state + private fun setSyncInProgress(inProgress: Boolean) { + syncInProgress = inProgress + } + + fun isSyncInProgress(): Boolean = syncInProgress + + fun getInstance(): GOGService? = instance + + /** + * Check if any download is currently active + */ + fun hasActiveDownload(): Boolean { + return getInstance()?.activeDownloads?.isNotEmpty() ?: false + } + + /** + * Get the currently downloading game ID (for error messages) + */ + fun getCurrentlyDownloadingGame(): String? { + return getInstance()?.activeDownloads?.keys?.firstOrNull() + } + + /** + * Get download info for a specific game + */ + fun getDownloadInfo(gameId: String): DownloadInfo? { + return getInstance()?.activeDownloads?.get(gameId) + } + + + /** + * Clean up active download when game is deleted + */ + fun cleanupDownload(gameId: String) { + getInstance()?.activeDownloads?.remove(gameId) + } + + /** + * Cancel an active download for a specific game + */ + 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") + + try { + // Signal Python to cancel the download + val gameIdNum = ContainerUtils.extractGameIdFromContainerId(gameId) + val python = Python.getInstance() + val builtins = python.getModule("builtins") + builtins.put("GOGDL_CANCEL_${gameIdNum}", true) + Timber.i("Set Python cancellation flag for game: $gameIdNum") + + // Verify the flag was set + val flagValue = builtins.get("GOGDL_CANCEL_${gameIdNum}") + Timber.i("Verified Python cancellation flag value: $flagValue") + + } catch (e: Exception) { + Timber.e(e, "Failed to set Python cancellation flag") + } + + // Cancel the Kotlin coroutine + downloadInfo.cancel() + Timber.d("Cancelled download job and progress monitor job for game: $gameId") + + // Clean up immediately + instance.activeDownloads.remove(gameId) + Timber.d("Removed game from active downloads: $gameId") + true + } else { + Timber.w("No active download found for game: $gameId") + false + } + } + + /** + * Get download and install size information using gogdl info command + * Uses the same CLI pattern as existing download methods + */ + suspend fun getGameSizeInfo(gameId: String): GameSizeInfo? = withContext(Dispatchers.IO) { + try { + val authConfigPath = "/data/data/app.gamenative/files/gog_config.json" + + Timber.d("Getting size info for GOG game: $gameId") + + // Use the same executeCommand pattern as existing methods + val result = executeCommand("--auth-config-path", authConfigPath, "info", gameId, "--platform", "windows") + + if (result.isSuccess) { + val output = result.getOrNull() ?: "" + Timber.d("Got gogdl info output: $output") + + if (output.isNotEmpty()) { + try { + // Parse JSON output from gogdl info command + val jsonResponse = JSONObject(output.trim()) + + // Debug: Log the full JSON structure + Timber.d("Full gogdl info JSON response: $output") + + // Extract size information from the JSON response + val sizeInfo = jsonResponse.optJSONObject("size") + Timber.d("Size info object: $sizeInfo") + + var maxDownloadSize = 0L + var maxDiskSize = 0L + + if (sizeInfo != null) { + // Iterate through all language keys to find the largest size + val keys = sizeInfo.keys() + while (keys.hasNext()) { + val key = keys.next() + val languageSize = sizeInfo.optJSONObject(key) + if (languageSize != null) { + val downloadSize = languageSize.optLong("download_size", 0L) + val diskSize = languageSize.optLong("disk_size", 0L) + + Timber.d("Language '$key' sizes - Download: $downloadSize bytes, Disk: $diskSize bytes") + + // Keep track of the largest sizes (usually the full game language pack) + if (downloadSize > maxDownloadSize) { + maxDownloadSize = downloadSize + } + if (diskSize > maxDiskSize) { + maxDiskSize = diskSize + } + } + } + } + + Timber.d("Final max sizes - Download: $maxDownloadSize bytes, Disk: $maxDiskSize bytes") + + if (maxDownloadSize > 0 || maxDiskSize > 0) { + Timber.d("Got size info for $gameId - Download: ${app.gamenative.utils.StorageUtils.formatBinarySize(maxDownloadSize)}, Disk: ${app.gamenative.utils.StorageUtils.formatBinarySize(maxDiskSize)}") + return@withContext GameSizeInfo(maxDownloadSize, maxDiskSize) + } + } catch (e: Exception) { + Timber.w(e, "Failed to parse gogdl info JSON output") + } + } + } else { + Timber.w("GOGDL info command failed: ${result.exceptionOrNull()?.message}") + } + + return@withContext null + } catch (e: Exception) { + Timber.w(e, "Failed to get size info for game $gameId") + return@withContext null + } + } + } + + // Add these for foreground service support + private lateinit var notificationHelper: NotificationHelper + + @Inject + lateinit var gogLibraryManager: GOGLibraryManager + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Track active downloads by game ID + private val activeDownloads = ConcurrentHashMap() + + 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 { + // Start as foreground service + val notification = notificationHelper.createForegroundNotification("GOG Service running...") + startForeground(2, notification) // Use different ID than SteamService (which uses 1) + + // Start background library sync automatically when service starts with tracking + backgroundSyncJob = scope.launch { + try { + setSyncInProgress(true) + Timber.d("[GOGService]: Starting background library sync") + + val syncResult = gogLibraryManager.startBackgroundSync(applicationContext) + if (syncResult.isFailure) { + Timber.w("[GOGService]: Failed to start background sync: ${syncResult.exceptionOrNull()?.message}") + } else { + Timber.i("[GOGService]: Background library sync started successfully") + } + } catch (e: Exception) { + Timber.e(e, "[GOGService]: Exception starting background sync") + } finally { + setSyncInProgress(false) + } + } + + 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/service/GameManager.kt b/app/src/main/java/app/gamenative/service/GameManager.kt new file mode 100644 index 000000000..8f5cd5431 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/GameManager.kt @@ -0,0 +1,139 @@ +package app.gamenative.service + +import android.content.Context +import android.net.Uri +import app.gamenative.data.DownloadInfo +import app.gamenative.data.Game +import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem +import app.gamenative.data.PostSyncInfo +import app.gamenative.data.SteamApp +import app.gamenative.ui.component.dialog.state.MessageDialogState +import com.winlator.container.Container +import com.winlator.core.envvars.EnvVars +import com.winlator.xenvironment.components.GuestProgramLauncherComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface GameManager { + /** + * Download a game + */ + fun downloadGame(context: Context, libraryItem: LibraryItem): Result + + /** + * Delete a game + */ + fun deleteGame(context: Context, libraryItem: LibraryItem): Result + + /** + * Check if a game is installed + */ + fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean + + /** + * Check if an update is pending for a game + */ + suspend fun isUpdatePending(libraryItem: LibraryItem): Boolean + + /** + * Get the download info for a game + */ + fun getDownloadInfo(libraryItem: LibraryItem): DownloadInfo? + + /** + * Check if a game has a partial download + */ + fun hasPartialDownload(libraryItem: LibraryItem): Boolean + + /** + * Get the game disk size for a game + */ + suspend fun getGameDiskSize(context: Context, libraryItem: LibraryItem): String + + /** + * Create a FAKE libraryItem object for a game + */ + fun createLibraryItem(appId: String, gameId: String, context: Context): LibraryItem + + /** + * Get the download size for a game + */ + suspend fun getDownloadSize(libraryItem: LibraryItem): String + + /** + * Check if a game is valid to download + */ + fun isValidToDownload(library: LibraryItem): Boolean + + /** + * Returns the app info for the given game (steam only, should be refactored) + */ + fun getAppInfo(libraryItem: LibraryItem): SteamApp? + + /** + * Returns the app dir path for the given game + */ + fun getAppDirPath(appId: String): String + + /** + * Get the platform-specific store URL for a game + */ + fun getStoreUrl(libraryItem: LibraryItem): Uri + + /** + * Launch a game with cloud save sync + */ + suspend fun launchGameWithSaveSync( + context: Context, + libraryItem: LibraryItem, + parentScope: CoroutineScope, + ignorePendingOperations: Boolean = false, + preferredSave: Int? = null, + ): PostSyncInfo + + /** + * Get the wine start command for platform-specific game launching + * This handles the platform-specific logic for launching games + */ + fun getWineStartCommand( + context: Context, + libraryItem: LibraryItem, + container: Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: EnvVars, + guestProgramLauncherComponent: GuestProgramLauncherComponent, + ): String + + /** + * Returns the release date for the given game + */ + fun getReleaseDate(libraryItem: LibraryItem): String + + /** + * Get the hero image for the given game + */ + fun getHeroImage(libraryItem: LibraryItem): String + + + /** + * Get the icon image for the given game + */ + fun getIconImage(libraryItem: LibraryItem): String + + /** + * Returns the install info dialog for the given game + */ + fun getInstallInfoDialog(context: Context, libraryItem: LibraryItem): MessageDialogState + + /** + * Run code before launching the given game + */ + fun runBeforeLaunch(context: Context, libraryItem: LibraryItem) + + /** + * Get all games from this manager's source with wrappers pre-applied + */ + fun getAllGames(): Flow> +} diff --git a/app/src/main/java/app/gamenative/service/GameManagerService.kt b/app/src/main/java/app/gamenative/service/GameManagerService.kt new file mode 100644 index 000000000..d9d59a368 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/GameManagerService.kt @@ -0,0 +1,247 @@ +package app.gamenative.service + +import android.content.Context +import android.net.Uri +import app.gamenative.data.DownloadInfo +import app.gamenative.data.Game +import app.gamenative.data.GameSource +import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem +import app.gamenative.data.PostSyncInfo +import app.gamenative.data.SteamApp +import app.gamenative.service.GOG.GOGGameManager +import app.gamenative.service.Steam.SteamGameManager +import app.gamenative.ui.component.dialog.state.MessageDialogState +import app.gamenative.utils.ContainerUtils +import com.winlator.container.Container +import com.winlator.core.envvars.EnvVars +import com.winlator.xenvironment.components.GuestProgramLauncherComponent +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import timber.log.Timber + +@Singleton +class GameManagerService @Inject constructor( + private val steamGameManager: SteamGameManager, + private val gogGameManager: GOGGameManager, + // Add new game sources here +) { + companion object { + private var instance: GameManagerService? = null + private var gameManagers: Map = mapOf() + + fun initialize(context: Context) { + if (instance == null) { + val serviceInstance = EntryPointAccessors.fromApplication( + context.applicationContext, + GameManagerServiceEntryPoint::class.java, + ).gameManagerService() + + instance = serviceInstance + + // Set up default game managers using the real steamGameManager + gameManagers = mapOf( + GameSource.STEAM to serviceInstance.steamGameManager, + GameSource.GOG to serviceInstance.gogGameManager, + // Add new game sources here + ) + } + } + + fun initializeForPreview(managers: Map) { + gameManagers = managers + } + + fun getManagerForGameSource(gameSource: GameSource): GameManager { + return gameManagers[gameSource] ?: throw IllegalArgumentException("No manager found for game source: $gameSource") + } + + /** + * Get the appropriate game manager for a given game + */ + fun getManagerForGame(game: LibraryItem): GameManager { + return getManagerForGameSource(game.gameSource) + } + + fun getStoreUrl(libraryItem: LibraryItem): Uri { + return getManagerForGame(libraryItem).getStoreUrl(libraryItem) + } + + fun getDownloadInfo(libraryItem: LibraryItem): DownloadInfo? { + return getManagerForGame(libraryItem).getDownloadInfo(libraryItem) + } + + fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean { + return getManagerForGame(libraryItem).isGameInstalled(context, libraryItem) + } + + suspend fun isUpdatePending(libraryItem: LibraryItem): Boolean { + return getManagerForGame(libraryItem).isUpdatePending(libraryItem) + } + + fun deleteGame(context: Context, libraryItem: LibraryItem): Boolean { + return getManagerForGame(libraryItem).deleteGame(context, libraryItem).isSuccess + } + + fun downloadGame(context: Context, libraryItem: LibraryItem): DownloadInfo? { + return getManagerForGame(libraryItem).downloadGame(context, libraryItem).getOrNull() + } + + fun downloadGameWithResult(context: Context, libraryItem: LibraryItem): Result { + return getManagerForGame(libraryItem).downloadGame(context, libraryItem) + } + + fun hasPartialDownload(libraryItem: LibraryItem): Boolean { + return getManagerForGame(libraryItem).hasPartialDownload(libraryItem) + } + + suspend fun getGameDiskSize(context: Context, libraryItem: LibraryItem): String { + return getManagerForGame(libraryItem).getGameDiskSize(context, libraryItem) + } + + fun getWineStartCommand( + context: Context, + libraryItem: LibraryItem, + container: Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: EnvVars, + guestProgramLauncherComponent: GuestProgramLauncherComponent, + ): String { + if (bootToContainer) { + return "winhandler.exe \"wfm.exe\"" + } + + val args = getManagerForGame(libraryItem).getWineStartCommand( + context, + libraryItem, + container, + bootToContainer, + appLaunchInfo, + envVars, + guestProgramLauncherComponent, + ) + + // Always use winhandler.exe wrapper for proper windowing and display + return "winhandler.exe $args" + } + + /** + * Launch a game with appropriate save sync based on LibraryItem + */ + suspend fun launchGameWithSaveSync( + context: Context, + libraryItem: LibraryItem, + parentScope: CoroutineScope, + ignorePendingOperations: Boolean = false, + preferredSave: Int? = null, + ): PostSyncInfo { + return getManagerForGame(libraryItem).launchGameWithSaveSync( + context = context, + libraryItem = libraryItem, + parentScope = parentScope, + ignorePendingOperations = ignorePendingOperations, + preferredSave = preferredSave, + ) + } + + /** + * We may need to quickly get the container name in places that aren't using LibraryItem yet + */ + fun getAppId(gameId: Int, gameSource: GameSource): String { + return gameSource.name + "_" + gameId + } + + /** + * Get the app directory path for a given app ID + */ + fun getAppDirPath(appId: String): String { + val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) + return getManagerForGameSource(gameSource).getAppDirPath(appId) + } + + /** + * Helper function to create a LibraryItem from an appId string + * This is a temporary solution until we have proper LibraryItem objects throughout the codebase + */ + fun createLibraryItemFromAppId(appId: String, context: Context): LibraryItem { + val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + + return getManagerForGameSource(gameSource).createLibraryItem(appId, gameId.toString(), context) + } + + suspend fun getDownloadSize(libraryItem: LibraryItem): String { + return getManagerForGame(libraryItem).getDownloadSize(libraryItem) + } + + fun isValidToDownload(libraryItem: LibraryItem): Boolean { + return getManagerForGame(libraryItem).isValidToDownload(libraryItem) + } + + fun getAppInfo(libraryItem: LibraryItem): SteamApp? { + return getManagerForGame(libraryItem).getAppInfo(libraryItem) + } + + fun getReleaseDate(libraryItem: LibraryItem): String { + return getManagerForGame(libraryItem).getReleaseDate(libraryItem) + } + + fun getHeroImage(libraryItem: LibraryItem): String { + return getManagerForGame(libraryItem).getHeroImage(libraryItem) + } + + fun getIconImage(libraryItem: LibraryItem): String { + return getManagerForGame(libraryItem).getIconImage(libraryItem) + } + + fun getInstallInfoDialog(context: Context, libraryItem: LibraryItem): MessageDialogState { + return getManagerForGame(libraryItem).getInstallInfoDialog(context, libraryItem) + } + + fun runBeforeLaunch(context: Context, libraryItem: LibraryItem) { + getManagerForGame(libraryItem).runBeforeLaunch(context, libraryItem) + } + + /** + * Provides a flow of all games from all sources combined + */ + fun getAllGames(): Flow> { + // Get all pre-wrapped game flows from each manager + val gameFlows = gameManagers.map { (_, manager) -> + manager.getAllGames() + }.toTypedArray() + + return combine(*gameFlows) { gameArrays -> + val games = mutableListOf() + + gameArrays.forEachIndexed { index, wrappedGames -> + // Only log when there's actually a meaningful change + if (wrappedGames.isNotEmpty()) { + val gameSource = gameManagers.keys.elementAt(index) + Timber.tag("GameManagerService").d("Collecting ${wrappedGames.size} games from $gameSource") + } + + // Games are already wrapped, just add them directly + games.addAll(wrappedGames) + } + + games + }.distinctUntilChanged() // Prevent duplicate emissions + } + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface GameManagerServiceEntryPoint { + fun gameManagerService(): GameManagerService +} diff --git a/app/src/main/java/app/gamenative/service/Steam/SteamGameManager.kt b/app/src/main/java/app/gamenative/service/Steam/SteamGameManager.kt new file mode 100644 index 000000000..bc2d55e48 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/Steam/SteamGameManager.kt @@ -0,0 +1,309 @@ +package app.gamenative.service.Steam + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import app.gamenative.Constants +import app.gamenative.R +import app.gamenative.data.DownloadInfo +import app.gamenative.data.Game +import app.gamenative.data.GameSource +import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem +import app.gamenative.data.PostSyncInfo +import app.gamenative.data.SteamApp +import app.gamenative.data.SteamGameWrapper +import app.gamenative.db.dao.SteamAppDao +import app.gamenative.enums.PathType +import app.gamenative.enums.SaveLocation +import app.gamenative.service.DownloadService +import app.gamenative.service.GameManager +import app.gamenative.service.SteamService +import app.gamenative.ui.component.dialog.state.MessageDialogState +import app.gamenative.ui.enums.DialogType +import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.SteamUtils +import app.gamenative.utils.StorageUtils +import com.winlator.container.Container +import com.winlator.core.envvars.EnvVars +import com.winlator.xenvironment.components.GuestProgramLauncherComponent +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import timber.log.Timber + +class SteamGameManager @Inject constructor( + private val steamAppDao: SteamAppDao, +) : GameManager { + override fun downloadGame(context: Context, libraryItem: LibraryItem): Result { + try { + val downloadInfo = SteamService.downloadApp(libraryItem.gameId) + if (downloadInfo != null) { + return Result.success(downloadInfo) + } else { + return Result.failure(Exception("Failed to start Steam game download")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to install Steam game $libraryItem.gameId") + return Result.failure(e) + } + } + + override fun deleteGame(context: Context, libraryItem: LibraryItem): Result { + try { + val success = SteamService.deleteApp(libraryItem.gameId) + if (success) { + return Result.success(Unit) + } else { + return Result.failure(Exception("Failed to delete Steam game files")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to delete Steam game ${libraryItem.gameId}") + return Result.failure(e) + } + } + + override fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean { + return try { + SteamService.isAppInstalled(libraryItem.gameId) + } catch (e: Exception) { + false + } + } + + override suspend fun isUpdatePending(libraryItem: LibraryItem): Boolean { + val appInfo = getAppInfo(libraryItem) + if (appInfo == null) { + return false + } + return SteamService.isUpdatePending(appInfo.id) + } + + override fun getDownloadInfo(libraryItem: LibraryItem): DownloadInfo? { + return try { + SteamService.getAppDownloadInfo(libraryItem.gameId) + } catch (e: Exception) { + null + } + } + + override fun hasPartialDownload(libraryItem: LibraryItem): Boolean { + return try { + SteamService.hasPartialDownload(libraryItem.gameId) + } catch (e: Exception) { + false + } + } + + override suspend fun launchGameWithSaveSync( + context: Context, + libraryItem: LibraryItem, + parentScope: CoroutineScope, + ignorePendingOperations: Boolean, + preferredSave: Int?, + ): PostSyncInfo = withContext(Dispatchers.IO) { + try { + val gameId = libraryItem.gameId + Timber.i("Starting Steam game launch with save sync for ${libraryItem.name} (appId: $gameId)") + + // Use existing Steam save sync logic + val prefixToPath: (String) -> String = { prefix -> + PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) + } + + // Convert Int? to SaveLocation + val saveLocation = when (preferredSave) { + 0 -> SaveLocation.Local + 1 -> SaveLocation.Remote + else -> SaveLocation.None + } + + val postSyncInfo = SteamService.beginLaunchApp( + appId = gameId, + prefixToPath = prefixToPath, + ignorePendingOperations = ignorePendingOperations, + preferredSave = saveLocation, + parentScope = parentScope, + ).await() + + Timber.i("Steam game save sync completed for ${libraryItem.name}") + postSyncInfo + } catch (e: Exception) { + Timber.e(e, "Steam game launch with save sync failed for ${libraryItem.gameId}") + PostSyncInfo(app.gamenative.enums.SyncResult.UnknownFail) + } + } + + override suspend fun getGameDiskSize(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { + var result = "..." + DownloadService.getSizeOnDiskDisplay(libraryItem.gameId.toInt()) { result = it } + result + } + + override fun getAppDirPath(appId: String): String { + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + return SteamService.getAppDirPath(gameId) + } + + override fun getStoreUrl(libraryItem: LibraryItem): Uri { + return "https://store.steampowered.com/app/${libraryItem.gameId}".toUri() + } + + override fun getWineStartCommand( + context: Context, + libraryItem: LibraryItem, + container: Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: EnvVars, + guestProgramLauncherComponent: GuestProgramLauncherComponent, + ): String { + val appId = libraryItem.appId // For backward compatibility + + if (appLaunchInfo == null) { + return "\"wfm.exe\"" + } + + // Check if we should launch through real Steam + if (container.isLaunchRealSteam()) { + // Launch Steam with the applaunch parameter to start the game + return "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" -silent -vgui -tcp " + + "-nobigpicture -nofriendsui -nochatui -nointro -applaunch $appId" + } + + // Original logic for direct game launch + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val appDirPath = SteamService.getAppDirPath(gameId) + var executablePath = "" + if (container.executablePath.isNotEmpty()) { + executablePath = container.executablePath + } else { + executablePath = SteamService.getInstalledExe(gameId) + container.executablePath = executablePath + container.saveData() + } + val executableDir = appDirPath + "/" + executablePath.substringBeforeLast("/", "") + guestProgramLauncherComponent.workingDir = File(executableDir) + Timber.i("Working directory is $executableDir") + + Timber.i("Final exe path is " + executablePath) + val drives = container.drives + val driveIndex = drives.indexOf(appDirPath) + // greater than 1 since there is the drive character and the colon before the app dir path + val drive = if (driveIndex > 1) { + drives[driveIndex - 2] + } else { + Timber.e("Could not locate game drive") + 'D' + } + envVars.put("WINEPATH", "$drive:/${appLaunchInfo?.workingDir}") + + return "\"$drive:/${executablePath}\"" + } + + override fun createLibraryItem(appId: String, gameId: String, context: Context): LibraryItem { + val gameIdInt = gameId.toInt() + val appInfo = SteamService.getAppInfoOf(gameIdInt) + + return LibraryItem( + appId = appId, + name = appInfo?.name ?: "Unknown Game", + iconHash = appInfo?.iconHash ?: "", + gameSource = GameSource.STEAM, + ) + } + + override suspend fun getDownloadSize(libraryItem: LibraryItem): String { + return withContext(Dispatchers.IO) { + DownloadService.getSizeFromStoreDisplay(libraryItem.gameId) + } + } + + override fun isValidToDownload(libraryItem: LibraryItem): Boolean { + val appInfo = getAppInfo(libraryItem) + return appInfo?.branches?.isNotEmpty() == true && appInfo?.depots?.isNotEmpty() == true + } + + override fun getAppInfo(libraryItem: LibraryItem): SteamApp? { + return SteamService.getAppInfoOf(libraryItem.gameId) + } + + override fun getReleaseDate(libraryItem: LibraryItem): String { + val appInfo = getAppInfo(libraryItem) + if (appInfo?.releaseDate == null) { + return "Unknown" + } + val date = Date(appInfo.releaseDate * 1000) + return SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(date) + } + + override fun getHeroImage(libraryItem: LibraryItem): String { + val appInfo = getAppInfo(libraryItem) + return appInfo?.getHeroUrl() ?: "" + } + + override fun getIconImage(libraryItem: LibraryItem): String { + return Constants.Library.ICON_URL + "${libraryItem.gameId}/${libraryItem.iconHash}.ico" + } + + override fun getInstallInfoDialog(context: Context, libraryItem: LibraryItem): MessageDialogState { + val depots = SteamService.getDownloadableDepots(libraryItem.gameId) + Timber.i("There are ${depots.size} depots belonging to ${libraryItem.gameId}") + // How much free space is on disk + val availableBytes = StorageUtils.getAvailableSpace(SteamService.defaultStoragePath) + val availableSpace = StorageUtils.formatBinarySize(availableBytes) + // TODO: un-hardcode "public" branch + val downloadSize = StorageUtils.formatBinarySize( + depots.values.sumOf { + it.manifests["public"]?.download ?: 0 + }, + ) + val installBytes = depots.values.sumOf { it.manifests["public"]?.size ?: 0 } + val installSize = StorageUtils.formatBinarySize(installBytes) + if (availableBytes < installBytes) { + return MessageDialogState( + visible = true, + type = DialogType.NOT_ENOUGH_SPACE, + title = context.getString(R.string.not_enough_space), + message = "The app being installed needs $installSize of space but " + + "there is only $availableSpace left on this device", + confirmBtnText = context.getString(R.string.acknowledge), + ) + } else { + return MessageDialogState( + visible = true, + type = DialogType.INSTALL_APP, + title = context.getString(R.string.download_prompt_title), + message = "The app being installed has the following space requirements. Would you like to proceed?" + + "\n\n\tDownload Size: $downloadSize" + + "\n\tSize on Disk: $installSize" + + "\n\tAvailable Space: $availableSpace", + confirmBtnText = context.getString(R.string.proceed), + dismissBtnText = context.getString(R.string.cancel), + ) + } + } + + override fun runBeforeLaunch(context: Context, libraryItem: LibraryItem) { + val container = ContainerUtils.getOrCreateContainer(context, libraryItem.appId) + if (container.isLaunchRealSteam()) { + SteamUtils.restoreSteamApi(context, libraryItem.gameId) + } else { + runBlocking { SteamUtils.replaceSteamApi(context, libraryItem.gameId) } + } + } + + override fun getAllGames(): Flow> { + return steamAppDao.getAllOwnedApps().map { steamApps -> + steamApps.map { steamApp -> SteamGameWrapper(steamApp) } + } + } +} diff --git a/app/src/main/java/app/gamenative/service/Steam/SteamGameWrapper.kt b/app/src/main/java/app/gamenative/service/Steam/SteamGameWrapper.kt new file mode 100644 index 000000000..0f1603d09 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/Steam/SteamGameWrapper.kt @@ -0,0 +1,41 @@ +package app.gamenative.data + +import app.gamenative.Constants +import app.gamenative.enums.AppType +import app.gamenative.service.DownloadService +import app.gamenative.service.SteamService + +/** + * Steam game implementation + */ +data class SteamGameWrapper( + private val steamApp: SteamApp, +) : Game { + override val id: String get() = steamApp.id.toString() + override val name: String get() = steamApp.name + override val source: GameSource get() = GameSource.STEAM + + override val isInstalled: Boolean get() { + val downloadDirectoryApps = DownloadService.getDownloadDirectoryApps() + return downloadDirectoryApps.contains(SteamService.getAppDirName(steamApp)) + } + + override val isShared: Boolean get() { + val thisSteamId: Int = SteamService.userSteamId?.accountID?.toInt() ?: 0 + return thisSteamId != 0 && !steamApp.ownerAccountId.contains(thisSteamId) + } + + override val iconUrl: String get() = + Constants.Library.ICON_URL + "${steamApp.id}/${steamApp.clientIconHash}.ico" + + override val appType: AppType get() = steamApp.type + + override fun toLibraryItem(index: Int): LibraryItem = LibraryItem( + index = index, + appId = "STEAM_${steamApp.id}", + name = steamApp.name, + iconHash = steamApp.clientIconHash, + isShared = isShared, + gameSource = GameSource.STEAM, + ) +} diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index a58ebec48..c50402557 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -5,9 +5,10 @@ import android.content.Context import android.content.Intent import android.net.ConnectivityManager import android.net.Network -import android.net.NetworkRequest import android.net.NetworkCapabilities +import android.net.NetworkRequest import android.os.IBinder +import android.os.SystemClock import androidx.room.withTransaction import app.gamenative.BuildConfig import app.gamenative.PluviaApp @@ -33,6 +34,7 @@ import app.gamenative.db.dao.SteamAppDao import app.gamenative.db.dao.SteamFriendDao import app.gamenative.db.dao.SteamLicenseDao import app.gamenative.enums.LoginResult +import app.gamenative.enums.Marker import app.gamenative.enums.OS import app.gamenative.enums.OSArch import app.gamenative.enums.SaveLocation @@ -41,6 +43,7 @@ import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent import app.gamenative.service.callback.EmoticonListCallback import app.gamenative.service.handler.PluviaHandler +import app.gamenative.utils.MarkerUtils import app.gamenative.utils.SteamUtils import app.gamenative.utils.generateSteamApp import com.google.android.play.core.ktx.bytesDownloaded @@ -106,6 +109,7 @@ import `in`.dragonbra.javasteam.util.log.LogListener import `in`.dragonbra.javasteam.util.log.LogManager import java.io.Closeable import java.io.File +import java.lang.NullPointerException import java.nio.file.Files import java.nio.file.Paths import java.util.Collections @@ -120,6 +124,7 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -128,6 +133,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.receiveAsFlow @@ -138,17 +144,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import okhttp3.ConnectionPool -import okhttp3.Dispatcher -import okhttp3.OkHttpClient import timber.log.Timber -import java.lang.NullPointerException -import java.util.concurrent.TimeUnit -import android.os.Environment -import android.os.SystemClock -import kotlinx.coroutines.ensureActive -import app.gamenative.enums.Marker -import app.gamenative.utils.MarkerUtils @AndroidEntryPoint class SteamService : Service(), IChallengeUrlChanged { @@ -226,6 +222,11 @@ class SteamService : Service(), IChallengeUrlChanged { private lateinit var networkCallback: ConnectivityManager.NetworkCallback private var isWifiConnected: Boolean = true + // Add these as class properties + private var picsGetProductInfoJob: Job? = null + private var picsChangesCheckerJob: Job? = null + private var friendCheckerJob: Job? = null + companion object { const val MAX_PICS_BUFFER = 256 @@ -268,30 +269,28 @@ class SteamService : Service(), IChallengeUrlChanged { private set val isLoggedIn: Boolean get() = instance?.steamClient?.steamID?.isValid == true + + fun hasStoredCredentials(): Boolean { + return PrefManager.username.isNotEmpty() && PrefManager.refreshToken.isNotEmpty() + } var isWaitingForQRAuth: Boolean = false private set private val serverListPath: String - get() = Paths.get(instance!!.cacheDir.path, "server_list.bin").pathString + get() = Paths.get(DownloadService.baseCacheDirPath, "server_list.bin").pathString private val depotManifestsPath: String - get() = Paths.get(instance!!.dataDir.path, "Steam", "depot_manifests.zip").pathString + get() = Paths.get(DownloadService.baseDataDirPath, "Steam", "depot_manifests.zip").pathString val internalAppInstallPath: String - get() { - if (instance != null) { - return Paths.get(instance!!.dataDir.path, "Steam", "steamapps", "common").pathString - } - return "" - } + get() = Paths.get(DownloadService.baseDataDirPath, "Steam", "steamapps", "common").pathString + val externalAppInstallPath: String - get() { - return Paths.get(PrefManager.externalStoragePath, "Steam", "steamapps", "common").pathString - } + get() = Paths.get(PrefManager.externalStoragePath, "Steam", "steamapps", "common").pathString private val internalAppStagingPath: String get() { - return Paths.get(instance!!.dataDir.path, "Steam", "steamapps", "staging").pathString + return Paths.get(DownloadService.baseDataDirPath, "Steam", "steamapps", "staging").pathString } private val externalAppStagingPath: String get() { @@ -306,7 +305,7 @@ class SteamService : Service(), IChallengeUrlChanged { PrefManager.externalStoragePath } else { if (instance != null) { - return instance!!.dataDir.path + return DownloadService.baseDataDirPath } return "" } @@ -338,13 +337,13 @@ class SteamService : Service(), IChallengeUrlChanged { get() = instance?.steamClient?.steamID val familyMembers: List - get() = instance!!.familyGroupMembers + get() = instance?.familyGroupMembers ?: emptyList() val isLoginInProgress: Boolean - get() = instance!!._loginResult == LoginResult.InProgress + get() = instance?._loginResult == LoginResult.InProgress - private const val MAX_PARALLEL_DEPOTS = 2 // instead of all 38 - private const val CHUNKS_PER_DEPOT = 16 + private const val MAX_PARALLEL_DEPOTS = 2 // instead of all 38 + private const val CHUNKS_PER_DEPOT = 16 // simple depot-level semaphore private val depotGate = Semaphore(MAX_PARALLEL_DEPOTS) @@ -397,24 +396,31 @@ class SteamService : Service(), IChallengeUrlChanged { } fun getDownloadableDepots(appId: Int): Map { - val appInfo = getAppInfoOf(appId) ?: return emptyMap() - val ownedDlc = getOwnedAppDlc(appId) + val appInfo = getAppInfoOf(appId) ?: return emptyMap() + val ownedDlc = getOwnedAppDlc(appId) return appInfo.depots .asSequence() .filter { (_, depot) -> - if (depot.manifests.isEmpty() && depot.encryptedManifests.isNotEmpty()) + if (depot.manifests.isEmpty() && depot.encryptedManifests.isNotEmpty()) { return@filter false + } // 1. Has something to download - if (depot.manifests.isEmpty() && !depot.sharedInstall) + if (depot.manifests.isEmpty() && !depot.sharedInstall) { return@filter false + } // 2. Supported OS - if (!(depot.osList.contains(OS.windows) || - (!depot.osList.contains(OS.linux) && !depot.osList.contains(OS.macos)))) + if (!( + depot.osList.contains(OS.windows) || + (!depot.osList.contains(OS.linux) && !depot.osList.contains(OS.macos)) + ) + ) { return@filter false + } // 3. 64-bit or indeterminate - if (!(depot.osArch == OSArch.Arch64 || depot.osArch == OSArch.Unknown || depot.osArch == OSArch.Arch32)) + if (!(depot.osArch == OSArch.Arch64 || depot.osArch == OSArch.Unknown || depot.osArch == OSArch.Arch32)) { return@filter false + } // 4. DLC you actually own depot.dlcAppId == INVALID_APP_ID || ownedDlc.containsKey(depot.dlcAppId) } @@ -431,7 +437,6 @@ class SteamService : Service(), IChallengeUrlChanged { } fun getAppDirPath(appId: Int): String { - val appName = getAppDirName(getAppInfoOf(appId)) // Internal first (legacy installs), external second @@ -452,14 +457,14 @@ class SteamService : Service(), IChallengeUrlChanged { // SteamKit-JVM (most forks) – flags is EnumSet is EnumSet<*> -> { flags.contains(EDepotFileFlag.Executable) || - flags.contains(EDepotFileFlag.CustomExecutable) + flags.contains(EDepotFileFlag.CustomExecutable) } // SteamKit-C# protobuf port – flags is UInt / Int / Long - is Int -> (flags and 0x20) != 0 || (flags and 0x80) != 0 + is Int -> (flags and 0x20) != 0 || (flags and 0x80) != 0 is Long -> ((flags and 0x20L) != 0L) || ((flags and 0x80L) != 0L) - else -> false + else -> false } /* -------------------------------------------------------------------------- */ @@ -467,18 +472,23 @@ class SteamService : Service(), IChallengeUrlChanged { /* -------------------------------------------------------------------------- */ // Unreal Engine "Shipping" binaries (e.g. Stray-Win64-Shipping.exe) - private val UE_SHIPPING = Regex(""".*-win(32|64)(-shipping)?\.exe$""", - RegexOption.IGNORE_CASE) + private val UE_SHIPPING = Regex( + """.*-win(32|64)(-shipping)?\.exe$""", + RegexOption.IGNORE_CASE, + ) // UE folder hint …/Binaries/Win32|64/… - private val UE_BINARIES = Regex(""".*/binaries/win(32|64)/.*\.exe$""", - RegexOption.IGNORE_CASE) + private val UE_BINARIES = Regex( + """.*/binaries/win(32|64)/.*\.exe$""", + RegexOption.IGNORE_CASE, + ) // Tools / crash-dumpers to push down private val NEGATIVE_KEYWORDS = listOf( "crash", "handler", "viewer", "compiler", "tool", - "setup", "unins", "eac", "launcher", "steam" + "setup", "unins", "eac", "launcher", "steam", ) + /* add near-name helper */ private fun fuzzyMatch(a: String, b: String): Boolean { /* strip digits & punctuation, compare first 5 letters */ @@ -497,27 +507,27 @@ class SteamService : Service(), IChallengeUrlChanged { private fun scoreExe( file: FileData, gameName: String, - hasExeFlag: Boolean + hasExeFlag: Boolean, ): Int { var s = 0 val path = file.fileName.lowercase() // 1️⃣ UE shipping or binaries folder bonus - if (UE_SHIPPING.matches(path)) s += 300 + if (UE_SHIPPING.matches(path)) s += 300 if (UE_BINARIES.containsMatchIn(path)) s += 250 // 2️⃣ root-folder exe bonus - if (!path.contains('/')) s += 200 + if (!path.contains('/')) s += 200 // 3️⃣ filename contains the game / installDir - if (path.contains(gameName) || fuzzyMatch(path, gameName)) s += 100 + if (path.contains(gameName) || fuzzyMatch(path, gameName)) s += 100 // 4️⃣ obvious tool / crash-dumper penalty if (NEGATIVE_KEYWORDS.any { it in path }) s -= 150 - if (GENERIC_NAME.matches(file.fileName)) s -= 200 // ← new + if (GENERIC_NAME.matches(file.fileName)) s -= 200 // ← new // 5️⃣ Executable | CustomExecutable flag - if (hasExeFlag) s += 50 + if (hasExeFlag) s += 50 return s } @@ -525,14 +535,14 @@ class SteamService : Service(), IChallengeUrlChanged { /** select the primary binary */ fun choosePrimaryExe( files: List?, - gameName: String + gameName: String, ): FileData? = files?.maxWithOrNull { a, b -> - val sa = scoreExe(a, gameName, isExecutable(a.flags)) // <- fixed + val sa = scoreExe(a, gameName, isExecutable(a.flags)) // <- fixed val sb = scoreExe(b, gameName, isExecutable(b.flags)) when { - sa != sb -> sa - sb // higher score wins - else -> (a.totalSize - b.totalSize).toInt() // tie-break on size + sa != sb -> sa - sb // higher score wins + else -> (a.totalSize - b.totalSize).toInt() // tie-break on size } } @@ -550,8 +560,11 @@ class SteamService : Service(), IChallengeUrlChanged { val installDir = appInfo.config.installDir.ifEmpty { appInfo.name } val depots = appInfo.depots.values.filter { d -> - !d.sharedInstall && (d.osList.isEmpty() || - d.osList.any { it.name.equals("windows", true) || it.name.equals("none", true) }) + !d.sharedInstall && + ( + d.osList.isEmpty() || + d.osList.any { it.name.equals("windows", true) || it.name.equals("none", true) } + ) } Timber.i("Depots considered: $depots") @@ -563,7 +576,7 @@ class SteamService : Service(), IChallengeUrlChanged { /* stub detector (same short rules) */ val generic = Regex("^[a-z]\\d{1,3}\\.exe$", RegexOption.IGNORE_CASE) - val bad = listOf("launcher","steam","crash","handler","setup","unins","eac") + val bad = listOf("launcher", "steam", "crash", "handler", "setup", "unins", "eac") fun FileData.isStub(): Boolean { val n = fileName.lowercase() val stub = generic.matches(n) || bad.any { it in n } || totalSize < 1_000_000 @@ -572,7 +585,7 @@ class SteamService : Service(), IChallengeUrlChanged { } /* ---------------------------------------------------------- */ - val flagged = mutableListOf>() // (file, depotSize) + val flagged = mutableListOf>() // (file, depotSize) var largestDepotSize = 0L val provider = ThreadSafeManifestProvider(File(depotManifestsPath).toPath()) @@ -589,7 +602,7 @@ class SteamService : Service(), IChallengeUrlChanged { f.fileName.lowercase() in launchTargets && !f.isStub() }?.let { Timber.i("Picked via launch entry: ${it.fileName}") - return it.fileName.replace('\\','/').toString() + return it.fileName.replace('\\', '/').toString() } /* collect for later */ @@ -602,7 +615,7 @@ class SteamService : Service(), IChallengeUrlChanged { /* 2️⃣ scorer (unchanged) */ choosePrimaryExe(flagged.map { it.first }, installDir.lowercase())?.let { Timber.i("Picked via scorer: ${it.fileName}") - return it.fileName.replace('\\','/').toString() + return it.fileName.replace('\\', '/').toString() } /* 3️⃣ fallback: biggest exe from the biggest depot */ @@ -611,14 +624,16 @@ class SteamService : Service(), IChallengeUrlChanged { .maxByOrNull { it.first.totalSize } ?.let { Timber.i("Picked via largest-depot fallback: ${it.first.fileName}") - return it.first.fileName.replace('\\','/').toString() + return it.first.fileName.replace('\\', '/').toString() } /* 4️⃣ last resort */ Timber.w("No executable found; falling back to install dir") - return (getAppInfoOf(appId)?.let { appInfo -> - getWindowsLaunchInfos(appId).firstOrNull() - })?.executable ?: "" + return ( + getAppInfoOf(appId)?.let { appInfo -> + getWindowsLaunchInfos(appId).firstOrNull() + } + )?.executable ?: "" } fun deleteApp(appId: Int): Boolean { @@ -739,7 +754,8 @@ class SteamService : Service(), IChallengeUrlChanged { depotIds.map { depotId -> async(Dispatchers.IO) { val result = try { - withTimeout(1_000) { // 5 s is enough for a normal reply + withTimeout(1_000) { + // 5 s is enough for a normal reply steamApps.getDepotDecryptionKey(depotId, appId) .await() .result @@ -762,51 +778,54 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.i("Starting download for $appId") val info = DownloadInfo(entitledDepotIds.size).also { di -> - di.setDownloadJob(instance!!.scope.launch { - coroutineScope { - entitledDepotIds.mapIndexed { idx, depotId -> - async { - depotGate.acquire() // ── enter gate - var success = false - try { - val MIN_INTERVAL_MS = 1000L - var lastEmit = 0L - Timber.i("Downloading game to " + defaultAppInstallPath) - success = retry(times = 3, backoffMs = 2_000) { - ContentDownloader(instance!!.steamClient!!) - .downloadApp( - appId = appId, - depotId = depotId, - installPath = defaultAppInstallPath, - stagingPath = defaultAppStagingPath, - branch = branch, - maxDownloads = CHUNKS_PER_DEPOT, - onDownloadProgress = { p -> - val now = SystemClock.elapsedRealtime() - if (now - lastEmit >= MIN_INTERVAL_MS || p >= 1f) { - lastEmit = now - di.setProgress(p, idx) - } - }, - parentScope = this, - ).await() - } - if (success) di.setProgress(1f, idx) - else { - Timber.w("Depot $depotId skipped after retries") - di.setWeight(idx, 0) - di.setProgress(1f, idx) + di.setDownloadJob( + instance!!.scope.launch { + coroutineScope { + entitledDepotIds.mapIndexed { idx, depotId -> + async { + depotGate.acquire() // ── enter gate + var success = false + try { + val MIN_INTERVAL_MS = 1000L + var lastEmit = 0L + Timber.i("Downloading game to " + defaultAppInstallPath) + success = retry(times = 3, backoffMs = 2_000) { + ContentDownloader(instance!!.steamClient!!) + .downloadApp( + appId = appId, + depotId = depotId, + installPath = defaultAppInstallPath, + stagingPath = defaultAppStagingPath, + branch = branch, + maxDownloads = CHUNKS_PER_DEPOT, + onDownloadProgress = { p -> + val now = SystemClock.elapsedRealtime() + if (now - lastEmit >= MIN_INTERVAL_MS || p >= 1f) { + lastEmit = now + di.setProgress(p, idx) + } + }, + parentScope = this, + ).await() + } + if (success) { + di.setProgress(1f, idx) + } else { + Timber.w("Depot $depotId skipped after retries") + di.setWeight(idx, 0) + di.setProgress(1f, idx) + } + } finally { + depotGate.release() } - } finally { - depotGate.release() } - } - }.awaitAll() - } - downloadJobs.remove(appId) - // Write download complete marker on disk - MarkerUtils.addMarker(getAppDirPath(appId), Marker.DOWNLOAD_COMPLETE_MARKER) - }) + }.awaitAll() + } + downloadJobs.remove(appId) + // Write download complete marker on disk + MarkerUtils.addMarker(getAppDirPath(appId), Marker.DOWNLOAD_COMPLETE_MARKER) + }, + ) } downloadJobs[appId] = info @@ -814,23 +833,22 @@ class SteamService : Service(), IChallengeUrlChanged { val sizes = entitledDepotIds.map { depotId -> val depot = getAppInfoOf(appId)!!.depots[depotId]!! - val mInfo = depot.manifests[branch] + val mInfo = depot.manifests[branch] ?: depot.encryptedManifests[branch] ?: return@map 1L - (mInfo.size ?: 1).toLong() // Steam's VDF exposes this + (mInfo.size ?: 1).toLong() // Steam's VDF exposes this } sizes.forEachIndexed { i, bytes -> info.setWeight(i, bytes) } info.addProgressListener { p -> val percent = (p * 100).toInt() - if (percent != lastPercent) { // only when it really changed + if (percent != lastPercent) { // only when it really changed lastPercent = percent } } return info } - private suspend fun retry( times: Int, backoffMs: Long = 0, @@ -843,7 +861,6 @@ class SteamService : Service(), IChallengeUrlChanged { return block() } - fun getWindowsLaunchInfos(appId: Int): List { return getAppInfoOf(appId)?.let { appInfo -> appInfo.config.launch.filter { launchInfo -> @@ -855,58 +872,60 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun notifyRunningProcesses(vararg gameProcesses: GameProcessInfo) = withContext(Dispatchers.IO) { instance?.let { steamInstance -> - val gamesPlayed = gameProcesses.mapNotNull { gameProcess -> - getAppInfoOf(gameProcess.appId)?.let { appInfo -> - getPkgInfoOf(gameProcess.appId)?.let { pkgInfo -> - appInfo.branches[gameProcess.branch]?.let { branch -> - val processId = gameProcess.processes - .firstOrNull { it.parentIsSteam } - ?.processId - ?: gameProcess.processes.firstOrNull()?.processId - ?: 0 - - val userAccountId = userSteamId!!.accountID.toInt() - GamePlayedInfo( - gameId = gameProcess.appId.toLong(), - processId = processId, - ownerId = if (pkgInfo.ownerAccountId.contains(userAccountId)) { - userAccountId - } else { - pkgInfo.ownerAccountId.first() - }, - // TODO: figure out what this is and un-hardcode - launchSource = 100, - gameBuildId = branch.buildId.toInt(), - processIdList = gameProcess.processes, - ) + if (isConnected) { + val gamesPlayed = gameProcesses.mapNotNull { gameProcess -> + getAppInfoOf(gameProcess.appId)?.let { appInfo -> + getPkgInfoOf(gameProcess.appId)?.let { pkgInfo -> + appInfo.branches[gameProcess.branch]?.let { branch -> + val processId = gameProcess.processes + .firstOrNull { it.parentIsSteam } + ?.processId + ?: gameProcess.processes.firstOrNull()?.processId + ?: 0 + + val userAccountId = userSteamId!!.accountID.toInt() + GamePlayedInfo( + gameId = gameProcess.appId.toLong(), + processId = processId, + ownerId = if (pkgInfo.ownerAccountId.contains(userAccountId)) { + userAccountId + } else { + pkgInfo.ownerAccountId.first() + }, + // TODO: figure out what this is and un-hardcode + launchSource = 100, + gameBuildId = branch.buildId.toInt(), + processIdList = gameProcess.processes, + ) + } } } } - } - Timber.i( - "GameProcessInfo:%s", - gamesPlayed.joinToString("\n") { game -> - """ + Timber.i( + "GameProcessInfo:%s", + gamesPlayed.joinToString("\n") { game -> + """ | processId: ${game.processId} | gameId: ${game.gameId} | processes: ${ - game.processIdList.joinToString("\n") { process -> - """ + game.processIdList.joinToString("\n") { process -> + """ | processId: ${process.processId} | processIdParent: ${process.processIdParent} | parentIsSteam: ${process.parentIsSteam} """.trimMargin() + } } - } """.trimMargin() - }, - ) + }, + ) - steamInstance._steamApps?.notifyGamesPlayed( - gamesPlayed = gamesPlayed, - clientOsType = EOSType.AndroidUnknown, - ) + steamInstance._steamApps?.notifyGamesPlayed( + gamesPlayed = gamesPlayed, + clientOsType = EOSType.AndroidUnknown, + ) + } } } @@ -926,6 +945,10 @@ class SteamService : Service(), IChallengeUrlChanged { var syncResult = PostSyncInfo(SyncResult.UnknownFail) + if (!isConnected) { + syncResult = PostSyncInfo(SyncResult.DownloadFail) + } + PrefManager.clientId?.let { clientId -> instance?.let { steamInstance -> getAppInfoOf(appId)?.let { appInfo -> @@ -1107,7 +1130,7 @@ class SteamService : Service(), IChallengeUrlChanged { appendLine("}") } - return vdf; + return vdf } private fun login( @@ -1367,6 +1390,11 @@ class SteamService : Service(), IChallengeUrlChanged { val event = SteamEvent.LoggedOut(username) PluviaApp.events.emit(event) + + // Cancel previous continuous jobs or else they will continue to run even after logout + instance?.picsGetProductInfoJob?.cancel() + instance?.picsChangesCheckerJob?.cancel() + instance?.friendCheckerJob?.cancel() } suspend fun getEmoticonList() = withContext(Dispatchers.IO) { @@ -1440,6 +1468,9 @@ class SteamService : Service(), IChallengeUrlChanged { appId: Int, branch: String = "public", ): Boolean = withContext(Dispatchers.IO) { + // Don't try if there's no internet + if (!isConnected) return@withContext false + val steamApps = instance?._steamApps ?: return@withContext false // ── 1. Fetch the latest app header from Steam (PICS). @@ -1453,15 +1484,15 @@ class SteamService : Service(), IChallengeUrlChanged { ?.apps ?.values ?.firstOrNull() - ?: return@withContext false // nothing returned ⇒ treat as up-to-date + ?: return@withContext false // nothing returned ⇒ treat as up-to-date val remoteSteamApp = remoteAppInfo.keyValues.generateSteamApp() - val localSteamApp = getAppInfoOf(appId) ?: return@withContext true // not cached yet + val localSteamApp = getAppInfoOf(appId) ?: return@withContext true // not cached yet // ── 2. Compare manifest IDs of the depots we actually install. getDownloadableDepots(appId).keys.any { depotId -> val remoteManifest = remoteSteamApp.depots[depotId]?.manifests?.get(branch) - val localManifest = localSteamApp .depots[depotId]?.manifests?.get(branch) + val localManifest = localSteamApp.depots[depotId]?.manifests?.get(branch) remoteManifest?.gid != localManifest?.gid } } @@ -1476,8 +1507,9 @@ class SteamService : Service(), IChallengeUrlChanged { val clazz = Class.forName("in.dragonbra.javasteam.util.log.LogManager") val field = clazz.getDeclaredField("LOGGERS").apply { isAccessible = true } field.set( - /* obj = */ null, - java.util.concurrent.ConcurrentHashMap() // replaces the HashMap + /* obj = */ + null, + java.util.concurrent.ConcurrentHashMap(), // replaces the HashMap ) } @@ -1648,12 +1680,16 @@ class SteamService : Service(), IChallengeUrlChanged { try { steamClient!!.servers.tryMark(steamClient!!.currentEndpoint, PROTOCOL_TYPES, ServerQuality.BAD) + } catch (e: NullPointerException) { + // I don't care } catch (e: Exception) { Timber.e(e, "Failed to mark endpoint as bad:") } try { steamClient!!.disconnect() + } catch (e: NullPointerException) { + // I don't care } catch (e: Exception) { Timber.e(e, "There was an issue when disconnecting:") } @@ -1770,6 +1806,11 @@ class SteamService : Service(), IChallengeUrlChanged { private fun onLoggedOn(callback: LoggedOnCallback) { Timber.i("Logged onto Steam: ${callback.result}") + if (userSteamId?.isValid == true && PrefManager.steamUserAccountId != userSteamId!!.accountID.toInt()) { + PrefManager.steamUserAccountId = userSteamId!!.accountID.toInt() + Timber.d("Saving logged in Steam accountID ${userSteamId!!.accountID.toInt()}") + } + when (callback.result) { EResult.TryAnotherCM -> { _loginResult = LoginResult.Failed @@ -1811,16 +1852,13 @@ class SteamService : Service(), IChallengeUrlChanged { } } - // continuously check for pics changes - continuousPICSChangesChecker() - - // request app pics data when needed - continuousPICSGetProductInfo() + picsChangesCheckerJob = continuousPICSChangesChecker() + picsGetProductInfoJob = continuousPICSGetProductInfo() if (false) { // No social features are implemented at present // continuously check for game names that friends are playing. - continuousFriendChecker() + friendCheckerJob = continuousFriendChecker() } // Tell steam we're online, this allows friends to update. @@ -2085,7 +2123,7 @@ class SteamService : Service(), IChallengeUrlChanged { * Checks every [PICS_CHANGE_CHECK_DELAY] seconds. * Results are returned in a [PICSChangesCallback] */ - private fun continuousPICSChangesChecker() = scope.launch { + private fun continuousPICSChangesChecker(): Job = scope.launch { while (isActive && isLoggedIn) { // Initial delay before each check delay(60.seconds) @@ -2093,6 +2131,7 @@ class SteamService : Service(), IChallengeUrlChanged { PICSChangesCheck() } } + private fun PICSChangesCheck() { scope.launch { ensureActive() @@ -2114,15 +2153,15 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.d( "picsGetChangesSince:" + - "\n\tlastChangeNumber: ${changesSince.lastChangeNumber}" + - "\n\tcurrentChangeNumber: ${changesSince.currentChangeNumber}" + - "\n\tisRequiresFullUpdate: ${changesSince.isRequiresFullUpdate}" + - "\n\tisRequiresFullAppUpdate: ${changesSince.isRequiresFullAppUpdate}" + - "\n\tisRequiresFullPackageUpdate: ${changesSince.isRequiresFullPackageUpdate}" + - "\n\tappChangesCount: ${changesSince.appChanges.size}" + - "\n\tpkgChangesCount: ${changesSince.packageChanges.size}", + "\n\tlastChangeNumber: ${changesSince.lastChangeNumber}" + + "\n\tcurrentChangeNumber: ${changesSince.currentChangeNumber}" + + "\n\tisRequiresFullUpdate: ${changesSince.isRequiresFullUpdate}" + + "\n\tisRequiresFullAppUpdate: ${changesSince.isRequiresFullAppUpdate}" + + "\n\tisRequiresFullPackageUpdate: ${changesSince.isRequiresFullPackageUpdate}" + + "\n\tappChangesCount: ${changesSince.appChanges.size}" + + "\n\tpkgChangesCount: ${changesSince.packageChanges.size}", - ) + ) // Process any app changes launch { @@ -2176,7 +2215,7 @@ class SteamService : Service(), IChallengeUrlChanged { /** * Continuously check for friends playing games and query for pics if its a game we don't have in the database. */ - private fun continuousFriendChecker() = scope.launch { + private fun continuousFriendChecker(): Job = scope.launch { val friendsToUpdate = mutableListOf() val gameRequest = mutableListOf() while (isActive && isLoggedIn) { @@ -2221,8 +2260,9 @@ class SteamService : Service(), IChallengeUrlChanged { /** * A buffered flow to parse so many PICS requests in a given moment. */ - private fun continuousPICSGetProductInfo() { - scope.launch { + private fun continuousPICSGetProductInfo(): Job = scope.launch { + // Launch both coroutines within this parent job + launch { appPicsChannel.receiveAsFlow() .filter { it.isNotEmpty() } .buffer(capacity = MAX_PICS_BUFFER, onBufferOverflow = BufferOverflow.SUSPEND) @@ -2230,6 +2270,7 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.d("Processing ${appRequests.size} app PICS requests") ensureActive() + if (!isLoggedIn) return@collect val steamApps = instance?._steamApps ?: return@collect val callback = steamApps.picsGetProductInfo( @@ -2240,8 +2281,8 @@ class SteamService : Service(), IChallengeUrlChanged { callback.results.forEachIndexed { index, picsCallback -> Timber.d( "onPicsProduct: ${index + 1} of ${callback.results.size}" + - "\n\tReceived PICS result of ${picsCallback.apps.size} app(s)." + - "\n\tReceived PICS result of ${picsCallback.packages.size} package(s).", + "\n\tReceived PICS result of ${picsCallback.apps.size} app(s)." + + "\n\tReceived PICS result of ${picsCallback.packages.size} package(s).", ) ensureActive() @@ -2279,7 +2320,7 @@ class SteamService : Service(), IChallengeUrlChanged { } } - scope.launch { + launch { packagePicsChannel.receiveAsFlow() .filter { it.isNotEmpty() } .buffer(capacity = MAX_PICS_BUFFER, onBufferOverflow = BufferOverflow.SUSPEND) @@ -2287,7 +2328,9 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.d("Processing ${packageRequests.size} package PICS requests") ensureActive() + if (!isLoggedIn) return@collect val steamApps = instance?._steamApps ?: return@collect + val callback = steamApps.picsGetProductInfo( apps = emptyList(), packages = packageRequests, @@ -2295,6 +2338,7 @@ class SteamService : Service(), IChallengeUrlChanged { callback.results.forEach { picsCallback -> // Don't race the queue. + if (!isLoggedIn) return@collect val queue = Collections.synchronizedList(mutableListOf()) db.withTransaction { diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 8be292bba..b693955ab 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -2,20 +2,29 @@ package app.gamenative.ui import android.content.Context import android.content.Intent +import android.widget.Toast import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import android.widget.Toast import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -36,14 +45,16 @@ import app.gamenative.Constants import app.gamenative.MainActivity import app.gamenative.PluviaApp import app.gamenative.PrefManager +import app.gamenative.R +import app.gamenative.data.LibraryItem import app.gamenative.enums.AppTheme import app.gamenative.enums.LoginResult -import app.gamenative.enums.PathType import app.gamenative.enums.SaveLocation import app.gamenative.enums.SyncResult import app.gamenative.events.AndroidEvent +import app.gamenative.service.GOG.GOGService +import app.gamenative.service.GameManagerService import app.gamenative.service.SteamService -import app.gamenative.ui.component.LoadingScreen import app.gamenative.ui.component.dialog.GameFeedbackDialog import app.gamenative.ui.component.dialog.LoadingDialog import app.gamenative.ui.component.dialog.MessageDialog @@ -55,6 +66,7 @@ import app.gamenative.ui.enums.Orientation import app.gamenative.ui.model.MainViewModel import app.gamenative.ui.screen.HomeScreen import app.gamenative.ui.screen.PluviaScreen +import app.gamenative.ui.screen.accounts.AccountManagementScreen import app.gamenative.ui.screen.chat.ChatScreen import app.gamenative.ui.screen.login.UserLoginScreen import app.gamenative.ui.screen.settings.SettingsScreen @@ -63,7 +75,6 @@ import app.gamenative.ui.theme.PluviaTheme import app.gamenative.utils.ContainerUtils import app.gamenative.utils.GameFeedbackUtils import app.gamenative.utils.IntentLaunchManager -import app.gamenative.R import com.google.android.play.core.splitcompat.SplitCompat import com.winlator.container.ContainerManager import com.winlator.xenvironment.ImageFsInstaller @@ -75,8 +86,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber -import kotlin.reflect.KFunction2 -import io.ktor.client.plugins.HttpTimeout @Composable fun PluviaMain( @@ -86,9 +95,63 @@ fun PluviaMain( ) { val context = LocalContext.current val uriHandler = LocalUriHandler.current + val scope = rememberCoroutineScope() val state by viewModel.state.collectAsStateWithLifecycle() + // Container migration state + var showMigrationDialog by rememberSaveable { mutableStateOf(false) } + var migrationProgress by rememberSaveable { mutableStateOf(0f) } + var currentMigrating by rememberSaveable { mutableStateOf("") } + var totalToMigrate by rememberSaveable { mutableIntStateOf(0) } + var migratedCount by rememberSaveable { mutableIntStateOf(0) } + + // Check for legacy containers on startup + LaunchedEffect(Unit) { + scope.launch { + // Check if there are legacy containers to migrate + val hasLegacyContainers = ContainerUtils.hasLegacyContainers(context) + if (hasLegacyContainers) { + showMigrationDialog = true + ContainerUtils.migrateLegacyContainers( + context = context, + onProgressUpdate = { current, migrated, total -> + currentMigrating = current + migratedCount = migrated + totalToMigrate = total + migrationProgress = if (total > 0) migrated.toFloat() / total else 1f + }, + onComplete = { count -> + showMigrationDialog = false + Timber.i("Container migration completed: $count containers migrated") + }, + ) + } + } + } + + // Migration Dialog + if (showMigrationDialog) { + AlertDialog( + onDismissRequest = { /* Cannot dismiss during migration */ }, + title = { Text("Migrating Containers") }, + text = { + Column { + Text("Updating container format for platform compatibility...") + if (currentMigrating.isNotEmpty()) { + Text("Current: $currentMigrating") + Text("Progress: $migratedCount / $totalToMigrate") + } + LinearProgressIndicator( + progress = migrationProgress, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + }, + confirmButton = { /* No buttons during migration */ }, + ) + } + var msgDialogState by rememberSaveable(stateSaver = MessageDialogState.Saver) { mutableStateOf(MessageDialogState(false)) } @@ -109,8 +172,9 @@ fun PluviaMain( Timber.i("[PluviaMain]: Processing pending launch request for app ${launchRequest.appId} (user is now logged in)") // Check if the game is installed - if (!SteamService.isAppInstalled(launchRequest.appId)) { - val appName = SteamService.getAppInfoOf(launchRequest.appId)?.name ?: "App ${launchRequest.appId}" + val gameId = ContainerUtils.extractGameIdFromContainerId(launchRequest.appId) + if (!SteamService.isAppInstalled(gameId)) { + val appName = SteamService.getAppInfoOf(gameId)?.name ?: "App ${launchRequest.appId}" Timber.w("[PluviaMain]: Game not installed: $appName (${launchRequest.appId})") // Show error message @@ -145,7 +209,7 @@ fun PluviaMain( viewModel.setBootToContainer(false) preLaunchApp( context = context, - appId = launchRequest.appId, + libraryItem = GameManagerService.createLibraryItemFromAppId(launchRequest.appId, context), useTemporaryOverride = true, setLoadingDialogVisible = viewModel::setLoadingDialogVisible, setLoadingProgress = viewModel::setLoadingDialogProgress, @@ -169,7 +233,7 @@ fun PluviaMain( viewModel.setBootToContainer(false) preLaunchApp( context = context, - appId = event.appId, + libraryItem = GameManagerService.createLibraryItemFromAppId(event.appId, context), useTemporaryOverride = true, setLoadingDialogVisible = viewModel::setLoadingDialogVisible, setLoadingProgress = viewModel::setLoadingDialogProgress, @@ -188,12 +252,7 @@ fun PluviaMain( } MainViewModel.MainUiEvent.OnLoggedOut -> { - // Pop stack and go back to login. - navController.popBackStack( - route = PluviaScreen.LoginUser.route, - inclusive = false, - saveState = false, - ) + // Do nothing - let users stay on current page after logout } is MainViewModel.MainUiEvent.OnLogonEnded -> { @@ -250,7 +309,7 @@ fun PluviaMain( is MainViewModel.MainUiEvent.ShowGameFeedbackDialog -> { gameFeedbackState = GameFeedbackDialogState( visible = true, - appId = event.appId + appId = event.appId, ) } @@ -303,14 +362,19 @@ fun PluviaMain( LaunchedEffect(Unit) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - if (!state.isSteamConnected && !isConnecting) { - Timber.d("[PluviaMain]: Steam not connected - attempt") + if (!state.isSteamConnected && !isConnecting && SteamService.hasStoredCredentials()) { + Timber.d("[PluviaMain]: Steam not connected but has stored credentials - attempting auto-connect") isConnecting = true context.startForegroundService(Intent(context, SteamService::class.java)) } if (SteamService.isLoggedIn && state.currentScreen == PluviaScreen.LoginUser) { navController.navigate(PluviaScreen.Home.route) } + + if (GOGService.hasStoredCredentials(context) && !GOGService.isRunning) { + Timber.d("[PluviaMain]: GOG credentials found - starting GOG service") + GOGService.start(context) + } } } @@ -322,7 +386,7 @@ fun PluviaMain( } // Listen for save container config prompt - var pendingSaveAppId by rememberSaveable { mutableStateOf(null) } + var pendingSaveAppId by rememberSaveable { mutableStateOf(null) } val onPromptSaveConfig: (AndroidEvent.PromptSaveContainerConfig) -> Unit = { event -> pendingSaveAppId = event.appId msgDialogState = MessageDialogState( @@ -339,7 +403,7 @@ fun PluviaMain( val onShowGameFeedback: (AndroidEvent.ShowGameFeedback) -> Unit = { event -> gameFeedbackState = GameFeedbackDialogState( visible = true, - appId = event.appId + appId = event.appId, ) } @@ -355,26 +419,6 @@ fun PluviaMain( } } - // Timeout if stuck in connecting state for 10 seconds so that its not in loading state forever - LaunchedEffect(isConnecting) { - if (isConnecting) { - Timber.d("Started connecting, will timeout in 10s") - delay(10000) - Timber.d("Timeout reached, isSteamConnected=${state.isSteamConnected}") - if (!state.isSteamConnected) { - isConnecting = false - } - } - } - - // Show loading or error UI as appropriate - when { - isConnecting -> { - LoadingScreen() - return - } - } - val onDismissRequest: (() -> Unit)? val onDismissClick: (() -> Unit)? val onConfirmClick: (() -> Unit)? @@ -409,7 +453,7 @@ fun PluviaMain( onConfirmClick = { preLaunchApp( context = context, - appId = state.launchedAppId, + libraryItem = GameManagerService.createLibraryItemFromAppId(state.launchedAppId, context), preferredSave = SaveLocation.Remote, setLoadingDialogVisible = viewModel::setLoadingDialogVisible, setLoadingProgress = viewModel::setLoadingDialogProgress, @@ -421,7 +465,7 @@ fun PluviaMain( onDismissClick = { preLaunchApp( context = context, - appId = state.launchedAppId, + libraryItem = GameManagerService.createLibraryItemFromAppId(state.launchedAppId, context), preferredSave = SaveLocation.Local, setLoadingDialogVisible = viewModel::setLoadingDialogVisible, setLoadingProgress = viewModel::setLoadingDialogProgress, @@ -436,13 +480,25 @@ fun PluviaMain( } DialogType.SYNC_FAIL -> { + onConfirmClick = { + preLaunchApp( + context = context, + libraryItem = GameManagerService.createLibraryItemFromAppId(state.launchedAppId, context), + preferredSave = SaveLocation.Local, + setLoadingDialogVisible = viewModel::setLoadingDialogVisible, + setLoadingProgress = viewModel::setLoadingDialogProgress, + setMessageDialogState = setMessageDialogState, + onSuccess = viewModel::launchApp, + ignoreCloudSaveIssues = true, + ) + msgDialogState = MessageDialogState(false) + } onDismissClick = { setMessageDialogState(MessageDialogState(false)) } onDismissRequest = { setMessageDialogState(MessageDialogState(false)) } - onConfirmClick = null } DialogType.PENDING_UPLOAD_IN_PROGRESS -> { @@ -460,7 +516,7 @@ fun PluviaMain( setMessageDialogState(MessageDialogState(false)) preLaunchApp( context = context, - appId = state.launchedAppId, + libraryItem = GameManagerService.createLibraryItemFromAppId(state.launchedAppId, context), ignorePendingOperations = true, setLoadingDialogVisible = viewModel::setLoadingDialogVisible, setLoadingProgress = viewModel::setLoadingDialogProgress, @@ -481,7 +537,7 @@ fun PluviaMain( setMessageDialogState(MessageDialogState(false)) preLaunchApp( context = context, - appId = state.launchedAppId, + libraryItem = GameManagerService.createLibraryItemFromAppId(state.launchedAppId, context), ignorePendingOperations = true, setLoadingDialogVisible = viewModel::setLoadingDialogVisible, setLoadingProgress = viewModel::setLoadingDialogProgress, @@ -630,7 +686,7 @@ fun PluviaMain( appId = appId, rating = feedbackState.rating, tags = feedbackState.selectedTags.toList(), - notes = feedbackState.feedbackText.takeIf { it.isNotBlank() } + notes = feedbackState.feedbackText.takeIf { it.isNotBlank() }, ) Timber.d("GameFeedback: Submission returned $result") @@ -660,7 +716,7 @@ fun PluviaMain( }, onDiscordSupport = { uriHandler.openUri("https://discord.gg/2hKv4VfZfE") - } + }, ) Box(modifier = Modifier.zIndex(10f)) { @@ -674,13 +730,25 @@ fun PluviaMain( NavHost( navController = navController, - startDestination = PluviaScreen.LoginUser.route, + startDestination = PluviaScreen.Home.route, ) { /** Login **/ /** Login **/ composable(route = PluviaScreen.LoginUser.route) { UserLoginScreen() } + + /** Account Management **/ + composable(route = PluviaScreen.AccountManagement.route) { + AccountManagementScreen( + onNavigateRoute = { + navController.navigate(it) + }, + onBack = { + navController.navigateUp() + }, + ) + } /** Library, Downloads, Friends **/ /** Library, Downloads, Friends **/ composable( @@ -688,12 +756,12 @@ fun PluviaMain( deepLinks = listOf(navDeepLink { uriPattern = "pluvia://home" }), ) { HomeScreen( - onClickPlay = { launchAppId, asContainer -> - viewModel.setLaunchedAppId(launchAppId) + onClickPlay = { libraryItem, asContainer -> + viewModel.setLaunchedAppId(libraryItem.appId) viewModel.setBootToContainer(asContainer) preLaunchApp( context = context, - appId = launchAppId, + libraryItem = libraryItem, setLoadingDialogVisible = viewModel::setLoadingDialogVisible, setLoadingProgress = viewModel::setLoadingDialogProgress, setMessageDialogState = { msgDialogState = it }, @@ -709,9 +777,6 @@ fun PluviaMain( onNavigateRoute = { navController.navigate(it) }, - onLogout = { - SteamService.logOut() - }, ) } @@ -741,8 +806,10 @@ fun PluviaMain( /** Game Screen **/ composable(route = PluviaScreen.XServer.route) { + val libraryItem = GameManagerService.createLibraryItemFromAppId(state.launchedAppId, context) + XServerScreen( - appId = state.launchedAppId, + libraryItem = libraryItem, bootToContainer = state.bootToContainer, navigateBack = { CoroutineScope(Dispatchers.Main).launch { @@ -750,10 +817,10 @@ fun PluviaMain( } }, onWindowMapped = { context, window -> - viewModel.onWindowMapped(context, window, state.launchedAppId) + viewModel.onWindowMapped(context, window, libraryItem) }, onExit = { - viewModel.exitSteamApp(context, state.launchedAppId) + viewModel.exitSteamApp(context, libraryItem) }, onGameLaunchError = { error -> viewModel.onGameLaunchError(error) @@ -779,19 +846,24 @@ fun PluviaMain( fun preLaunchApp( context: Context, - appId: Int, + libraryItem: LibraryItem, ignorePendingOperations: Boolean = false, preferredSave: SaveLocation = SaveLocation.None, useTemporaryOverride: Boolean = false, setLoadingDialogVisible: (Boolean) -> Unit, setLoadingProgress: (Float) -> Unit, setMessageDialogState: (MessageDialogState) -> Unit, - onSuccess: KFunction2, + onSuccess: (Context, LibraryItem) -> Unit, retryCount: Int = 0, + ignoreCloudSaveIssues: Boolean = false, ) { setLoadingDialogVisible(true) // TODO: add a way to cancel // TODO: add fail conditions + + val gameId = libraryItem.gameId + val appId = libraryItem.appId + CoroutineScope(Dispatchers.IO).launch { // set up Ubuntu file system SplitCompat.install(context) @@ -806,24 +878,20 @@ fun preLaunchApp( // TODO: combine somehow with container creation in HomeLibraryAppScreen val containerManager = ContainerManager(context) val container = if (useTemporaryOverride) { - ContainerUtils.getOrCreateContainerWithOverride(context, appId) + ContainerUtils.getOrCreateContainerWithOverride(context, libraryItem.appId) } else { - ContainerUtils.getOrCreateContainer(context, appId) + ContainerUtils.getOrCreateContainer(context, libraryItem.appId) } // must activate container before downloading save files containerManager.activateContainer(container) - // sync save files and check no pending remote operations are running - val prefixToPath: (String) -> String = { prefix -> - PathType.from(prefix).toAbsPath(context, appId, SteamService.userSteamId!!.accountID) - } - val postSyncInfo = SteamService.beginLaunchApp( - appId = appId, - prefixToPath = prefixToPath, - ignorePendingOperations = ignorePendingOperations, - preferredSave = preferredSave, + val postSyncInfo = GameManagerService.launchGameWithSaveSync( + context = context, + libraryItem = libraryItem, parentScope = this, - ).await() + ignorePendingOperations = ignorePendingOperations, + preferredSave = preferredSave.ordinal, + ) setLoadingDialogVisible(false) @@ -850,7 +918,7 @@ fun preLaunchApp( delay(2000) preLaunchApp( context = context, - appId = appId, + libraryItem = libraryItem, ignorePendingOperations = ignorePendingOperations, preferredSave = preferredSave, useTemporaryOverride = useTemporaryOverride, @@ -877,20 +945,25 @@ fun preLaunchApp( ) } } - SyncResult.UnknownFail, SyncResult.DownloadFail, SyncResult.UpdateFail, -> { - setMessageDialogState( - MessageDialogState( - visible = true, - type = DialogType.SYNC_FAIL, - title = context.getString(R.string.sync_error_title), - message = "Failed to sync save files: ${postSyncInfo.syncResult}. Please restart app.", - dismissBtnText = context.getString(R.string.ok), - ), - ) + if (ignoreCloudSaveIssues) { + // Carry on and launch + onSuccess(context, GameManagerService.createLibraryItemFromAppId(appId, context)) + } else { + setMessageDialogState( + MessageDialogState( + visible = true, + type = DialogType.SYNC_FAIL, + title = context.getString(R.string.sync_error_title), + message = "Failed to sync save files: ${postSyncInfo.syncResult}. Continuing can cause sync conflicts and lost data.\n\nYOU MAY LOSE SAVE DATA!", + dismissBtnText = "Cancel", + confirmBtnText = "Launch anyway", + ), + ) + } } SyncResult.PendingOperations -> { @@ -914,7 +987,7 @@ fun preLaunchApp( visible = true, type = DialogType.PENDING_UPLOAD_IN_PROGRESS, title = "Upload in Progress", - message = "You played ${SteamService.getAppInfoOf(appId)?.name} " + + message = "You played ${libraryItem.name} " + "on the device ${pro.machineName} " + "(${Date(pro.timeLastUpdated * 1000L)}) and the save of " + "that session is still uploading.\nTry again later.", @@ -930,7 +1003,7 @@ fun preLaunchApp( type = DialogType.PENDING_UPLOAD, title = "Pending Upload", message = "You played " + - "${SteamService.getAppInfoOf(appId)?.name} " + + "${libraryItem.name} " + "on the device ${pro.machineName} " + "(${Date(pro.timeLastUpdated * 1000L)}), " + "and that save is not yet in the cloud. " + @@ -951,7 +1024,7 @@ fun preLaunchApp( type = DialogType.APP_SESSION_ACTIVE, title = "App Running", message = "You are logged in on another device (${pro.machineName}) " + - "already playing ${SteamService.getAppInfoOf(appId)?.name} " + + "already playing ${libraryItem.name} " + "(${Date(pro.timeLastUpdated * 1000L)}), and that save " + "is not yet in the cloud. \nYou can still play this game, " + "but that will disconnect the other session from Steam " + @@ -1005,7 +1078,7 @@ fun preLaunchApp( SyncResult.UpToDate, SyncResult.Success, - -> onSuccess(context, appId) + -> onSuccess(context, libraryItem) } } } diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt index 63da83592..749515254 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ProfileDialog.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Help import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog @@ -48,7 +48,6 @@ fun ProfileDialog( state: EPersonaState, onStatusChange: (EPersonaState) -> Unit, onNavigateRoute: (String) -> Unit, - onLogout: () -> Unit, onDismiss: () -> Unit, ) { if (!openDialog) { @@ -106,6 +105,13 @@ fun ProfileDialog( /* Action Buttons */ Spacer(modifier = Modifier.height(16.dp)) + + FilledTonalButton(modifier = Modifier.fillMaxWidth(), onClick = { onNavigateRoute(PluviaScreen.AccountManagement.route) }) { + Icon(imageVector = Icons.Default.AccountCircle, contentDescription = null) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSize)) + Text(text = "Manage Accounts") + } + FilledTonalButton(modifier = Modifier.fillMaxWidth(), onClick = { onNavigateRoute(PluviaScreen.Settings.route) }) { Icon(imageVector = Icons.Default.Settings, contentDescription = null) Spacer(modifier = Modifier.size(ButtonDefaults.IconSize)) @@ -117,12 +123,6 @@ fun ProfileDialog( Spacer(modifier = Modifier.size(ButtonDefaults.IconSize)) Text(text = "Help & Support") } - - FilledTonalButton(modifier = Modifier.fillMaxWidth(), onClick = onLogout) { - Icon(imageVector = Icons.AutoMirrored.Filled.Logout, contentDescription = null) - Spacer(modifier = Modifier.size(ButtonDefaults.IconSize)) - Text(text = "Log Out") - } } }, confirmButton = { @@ -144,7 +144,6 @@ private fun Preview_ProfileDialog() { state = EPersonaState.Online, onStatusChange = {}, onNavigateRoute = {}, - onLogout = {}, onDismiss = {}, ) } diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/state/GameFeedbackDialogState.kt b/app/src/main/java/app/gamenative/ui/component/dialog/state/GameFeedbackDialogState.kt index 3f52489ff..1ff7c6504 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/state/GameFeedbackDialogState.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/state/GameFeedbackDialogState.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.saveable.mapSaver data class GameFeedbackDialogState( val visible: Boolean, - val appId: Int = -1, + val appId: String = "", val rating: Int = 0, // 0-5 stars, 0 means no selection val selectedTags: Set = emptySet(), val feedbackText: String = "", @@ -22,7 +22,7 @@ data class GameFeedbackDialogState( "does_not_open", "directx_error" ) - + val Saver = mapSaver( save = { state -> mapOf( @@ -38,7 +38,7 @@ data class GameFeedbackDialogState( restore = { savedMap -> GameFeedbackDialogState( visible = savedMap["visible"] as Boolean, - appId = savedMap["appId"] as Int, + appId = savedMap["appId"] as String, rating = savedMap["rating"] as Int, selectedTags = (savedMap["selectedTags"] as List).toSet(), feedbackText = savedMap["feedbackText"] as String, @@ -48,4 +48,4 @@ data class GameFeedbackDialogState( }, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt b/app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt index fb9a3f39b..a0df1f64d 100644 --- a/app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt +++ b/app/src/main/java/app/gamenative/ui/component/topbar/AccountButton.kt @@ -29,7 +29,6 @@ import timber.log.Timber @Composable fun AccountButton( onNavigateRoute: (String) -> Unit, - onLogout: () -> Unit, ) { val scope = rememberCoroutineScope() var persona by remember { mutableStateOf(null) } @@ -68,10 +67,6 @@ fun AccountButton( onNavigateRoute(it) showDialog = false }, - onLogout = { - onLogout() - showDialog = false - }, onDismiss = { showDialog = false }, @@ -98,7 +93,6 @@ private fun Preview_AccountButton() { actions = { AccountButton( onNavigateRoute = {}, - onLogout = {}, ) }, ) diff --git a/app/src/main/java/app/gamenative/ui/data/MainState.kt b/app/src/main/java/app/gamenative/ui/data/MainState.kt index f62fb3669..fc9ccf133 100644 --- a/app/src/main/java/app/gamenative/ui/data/MainState.kt +++ b/app/src/main/java/app/gamenative/ui/data/MainState.kt @@ -15,7 +15,7 @@ data class MainState( val annoyingDialogShown: Boolean = false, val hasCrashedLastStart: Boolean = false, val isSteamConnected: Boolean = false, - val launchedAppId: Int = 0, + val launchedAppId: String = "", val bootToContainer: Boolean = false, val showBootingSplash: Boolean = false, ) diff --git a/app/src/main/java/app/gamenative/ui/enums/AppFilter.kt b/app/src/main/java/app/gamenative/ui/enums/AppFilter.kt index e891f2acf..02eef7d55 100644 --- a/app/src/main/java/app/gamenative/ui/enums/AppFilter.kt +++ b/app/src/main/java/app/gamenative/ui/enums/AppFilter.kt @@ -5,7 +5,9 @@ import androidx.compose.material.icons.filled.AvTimer import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Computer import androidx.compose.material.icons.filled.Diversity3 +import androidx.compose.material.icons.filled.Games import androidx.compose.material.icons.filled.InstallMobile +import androidx.compose.material.icons.filled.LibraryBooks import androidx.compose.material.icons.filled.VideogameAsset import androidx.compose.ui.graphics.vector.ImageVector import app.gamenative.enums.AppType @@ -46,6 +48,16 @@ enum class AppFilter( displayText = "Family", icon = Icons.Default.Diversity3, ), + STEAM( + code = 0x40, + displayText = "Steam", + icon = Icons.Default.Games, + ), + GOG( + code = 0x80, + displayText = "GOG", + icon = Icons.Default.LibraryBooks, + ), // ALPHABETIC( // code = 0x20, // displayText = "Alphabetic", diff --git a/app/src/main/java/app/gamenative/ui/internal/FakeGameManager.kt b/app/src/main/java/app/gamenative/ui/internal/FakeGameManager.kt new file mode 100644 index 000000000..8e8a41383 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/internal/FakeGameManager.kt @@ -0,0 +1,111 @@ +package app.gamenative.service + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import app.gamenative.data.DownloadInfo +import app.gamenative.data.Game +import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem +import app.gamenative.data.PostSyncInfo +import app.gamenative.data.SteamApp +import app.gamenative.enums.SyncResult +import app.gamenative.ui.component.dialog.state.MessageDialogState +import com.winlator.container.Container +import com.winlator.core.envvars.EnvVars +import com.winlator.xenvironment.components.GuestProgramLauncherComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * Fake GameManager implementation for previews and testing + */ +object FakeGameManager : GameManager { + + override fun downloadGame(context: Context, libraryItem: LibraryItem): Result { + return Result.success(null) + } + + override fun deleteGame(context: Context, libraryItem: LibraryItem): Result { + return Result.success(Unit) + } + + override fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean { + return libraryItem.index % 3 == 0 + } + + override suspend fun isUpdatePending(libraryItem: LibraryItem): Boolean = false + + override fun getDownloadInfo(libraryItem: LibraryItem): DownloadInfo? { + return when (libraryItem.index % 5) { + 1 -> DownloadInfo().apply { setProgress(0.3f) } + 2 -> DownloadInfo().apply { setProgress(0.7f) } + else -> null + } + } + + override fun hasPartialDownload(libraryItem: LibraryItem): Boolean = false + + override suspend fun getGameDiskSize(context: Context, libraryItem: LibraryItem): String { + return when (libraryItem.index % 4) { + 0 -> "2.1 GB" + 1 -> "15.3 GB" + 2 -> "847 MB" + else -> "4.7 GB" + } + } + + override fun createLibraryItem(appId: String, gameId: String, context: Context): LibraryItem { + return LibraryItem( + index = 0, + appId = appId, + name = "Fake Game", + iconHash = "", + isShared = false, + ) + } + + override suspend fun getDownloadSize(libraryItem: LibraryItem): String = "1.5 GB" + override fun isValidToDownload(library: LibraryItem): Boolean = true + override fun getAppInfo(libraryItem: LibraryItem): SteamApp? = null + override fun getAppDirPath(appId: String): String = "/path/to/fake/app/dir" + override fun getStoreUrl(libraryItem: LibraryItem): Uri = "https://example.com".toUri() + + override suspend fun launchGameWithSaveSync( + context: Context, + libraryItem: LibraryItem, + parentScope: CoroutineScope, + ignorePendingOperations: Boolean, + preferredSave: Int?, + ): PostSyncInfo { + return PostSyncInfo(SyncResult.Success, 0) + } + + override fun getWineStartCommand( + context: Context, + libraryItem: LibraryItem, + container: Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: EnvVars, + guestProgramLauncherComponent: GuestProgramLauncherComponent, + ): String = "" + + override fun getReleaseDate(libraryItem: LibraryItem): String = "2024-01-01" + + override fun getHeroImage(libraryItem: LibraryItem): String = "" + override fun getIconImage(libraryItem: LibraryItem): String = "" + + override fun getInstallInfoDialog(context: Context, libraryItem: LibraryItem): MessageDialogState { + return MessageDialogState( + false, + ) + } + + override fun runBeforeLaunch(context: Context, libraryItem: LibraryItem) { + // No-op for fake implementation + } + + override fun getAllGames(): Flow> = flowOf(emptyList()) +} diff --git a/app/src/main/java/app/gamenative/ui/internal/FakeGameManagerService.kt b/app/src/main/java/app/gamenative/ui/internal/FakeGameManagerService.kt new file mode 100644 index 000000000..a74646502 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/internal/FakeGameManagerService.kt @@ -0,0 +1,19 @@ +package app.gamenative.ui.internal + +import androidx.compose.runtime.staticCompositionLocalOf +import app.gamenative.data.GameSource +import app.gamenative.service.FakeGameManager +import app.gamenative.service.GameManagerService + +class MockGameManagerServiceProvider { + fun ensureInitialized() { + // Initialize preview mode with the fake game manager + GameManagerService.initializeForPreview( + mapOf(GameSource.STEAM to FakeGameManager), + ) + } +} + +val LocalGameManagerService = staticCompositionLocalOf { + MockGameManagerServiceProvider() +} diff --git a/app/src/main/java/app/gamenative/ui/model/AccountManagementViewModel.kt b/app/src/main/java/app/gamenative/ui/model/AccountManagementViewModel.kt new file mode 100644 index 000000000..7611130b4 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/model/AccountManagementViewModel.kt @@ -0,0 +1,41 @@ +package app.gamenative.ui.model + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.gamenative.service.GOG.GOGLibraryManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.launch +import timber.log.Timber + +@HiltViewModel +class AccountManagementViewModel @Inject constructor( + private val gogLibraryManager: GOGLibraryManager, +) : ViewModel() { + fun syncGOGLibraryAsync(context: Context, clearExisting: Boolean = true, onResult: (Result) -> Unit) { + viewModelScope.launch { + try { + // Clear existing games and start background sync + if (clearExisting) { + gogLibraryManager.clearLibrary() + } + + // Start background sync and check if it was successful + val syncStartResult = gogLibraryManager.startBackgroundSync(context, clearExisting) + + if (syncStartResult.isSuccess) { + // Sync started successfully, return current game count + val gameCount = gogLibraryManager.getLocalGameCount() + onResult(Result.success(gameCount)) + } else { + // Sync failed to start, return the error + onResult(Result.failure(syncStartResult.exceptionOrNull() ?: Exception("Failed to start sync"))) + } + } catch (e: Exception) { + Timber.e(e, "Exception during GOG sync start") + onResult(Result.failure(e)) + } + } + } +} 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 499cb3fda..3c818b480 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -7,16 +7,16 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.gamenative.PrefManager -import app.gamenative.data.LibraryItem -import app.gamenative.data.SteamApp -import app.gamenative.db.dao.SteamAppDao -import app.gamenative.service.DownloadService -import app.gamenative.service.SteamService +import app.gamenative.data.Game +import app.gamenative.data.GameSource +import app.gamenative.service.GameManagerService import app.gamenative.ui.data.LibraryState import app.gamenative.ui.enums.AppFilter import dagger.hilt.android.lifecycle.HiltViewModel import java.util.EnumSet import javax.inject.Inject +import kotlin.math.max +import kotlin.math.min import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -24,14 +24,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber -import kotlin.math.max -import kotlin.math.min @HiltViewModel -class LibraryViewModel @Inject constructor( - private val steamAppDao: SteamAppDao, -) : ViewModel() { - +class LibraryViewModel @Inject constructor() : ViewModel() { private val _state = MutableStateFlow(LibraryState()) val state: StateFlow = _state.asStateFlow() @@ -39,23 +34,17 @@ class LibraryViewModel @Inject constructor( var listState: LazyListState by mutableStateOf(LazyListState(0, 0)) // How many items loaded on one page of results - private var paginationCurrentPage: Int = 0; - private var lastPageInCurrentFilter: Int = 0; + private var paginationCurrentPage: Int = 0 + private var lastPageInCurrentFilter: Int = 0 - // Complete and unfiltered app list - private var appList: List = emptyList() + // Complete and unfiltered games from all sources + private var allGames: List = emptyList() init { viewModelScope.launch(Dispatchers.IO) { - steamAppDao.getAllOwnedApps( - // ownerIds = SteamService.familyMembers.ifEmpty { listOf(SteamService.userSteamId!!.accountID.toInt()) }, - ).collect { apps -> - Timber.tag("LibraryViewModel").d("Collecting ${apps.size} apps") - - if (appList.size != apps.size) { - // Don't filter if it's no change - appList = apps - + GameManagerService.getAllGames().collect { games -> + if (allGames.size != games.size) { + allGames = games onFilterApps(paginationCurrentPage) } } @@ -111,25 +100,20 @@ class LibraryViewModel @Inject constructor( val currentState = _state.value val currentFilter = AppFilter.getAppType(currentState.appInfoSortType) - val downloadDirectoryApps = DownloadService.getDownloadDirectoryApps() - - var filteredList = appList + val filteredGames = allGames .asSequence() - .filter { item -> - SteamService.familyMembers.ifEmpty { - listOf(SteamService.userSteamId!!.accountID.toInt()) - }.map { - item.ownerAccountId.contains(it) - }.any() - } - .filter { item -> - currentFilter.any { item.type == it } + .filter { game -> + when { + currentState.appInfoSortType.contains(AppFilter.STEAM) -> game.source == GameSource.STEAM + currentState.appInfoSortType.contains(AppFilter.GOG) -> game.source == GameSource.GOG + else -> true + } } .filter { item -> if (currentState.appInfoSortType.contains(AppFilter.SHARED)) { true } else { - item.ownerAccountId.contains(SteamService.userSteamId!!.accountID.toInt()) + !item.isShared } } .filter { item -> @@ -141,49 +125,49 @@ class LibraryViewModel @Inject constructor( } .filter { item -> if (currentState.appInfoSortType.contains(AppFilter.INSTALLED)) { - downloadDirectoryApps.contains(SteamService.getAppDirName(item)) + item.isInstalled + } else { + true + } + } + .filter { item -> + if (currentFilter.isNotEmpty()) { + currentFilter.contains(item.appType) } else { true } } .sortedWith( - // Comes from DAO in alphabetical order - compareByDescending { downloadDirectoryApps.contains(SteamService.getAppDirName(it)) } - ); + compareByDescending { it.isInstalled } + .thenBy { it.name.lowercase() }, + ) + .toList() + + // Convert to LibraryItems + val libraryItems = filteredGames.mapIndexed { index, item -> + item.toLibraryItem(index) + } // Total count for the current filter - val totalFound = filteredList.count() + val totalFound = libraryItems.size // Determine how many pages and slice the list for incremental loading val pageSize = PrefManager.itemsPerPage // Update internal pagination state paginationCurrentPage = paginationPage - lastPageInCurrentFilter = (totalFound - 1) / pageSize + lastPageInCurrentFilter = if (totalFound > 0) (totalFound - 1) / pageSize else 0 // Calculate how many items to show: (pagesLoaded * pageSize) val endIndex = min((paginationPage + 1) * pageSize, totalFound) - val pagedSequence = filteredList.take(endIndex) - val thisSteamId: Int = SteamService.userSteamId?.accountID?.toInt() ?: 0 - // Map to UI model - val filteredListPage = pagedSequence - .mapIndexed { idx, item -> - LibraryItem( - index = idx, - appId = item.id, - name = item.name, - iconHash = item.clientIconHash, - isShared = (thisSteamId != 0 && !item.ownerAccountId.contains(thisSteamId)), - ) - } - .toList() + val pagedLibraryItems = libraryItems.take(endIndex) - Timber.tag("LibraryViewModel").d("Filtered list size: ${totalFound}") + Timber.tag("LibraryViewModel").d("Filtered list size: $totalFound") _state.update { it.copy( - appInfoList = filteredListPage, + appInfoList = pagedLibraryItems, currentPaginationPage = paginationPage + 1, // visual display is not 0 indexed lastPaginationPage = lastPageInCurrentFilter + 1, totalAppsInFilter = totalFound, - ) + ) } } } 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 a528eb400..c7c82377b 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -7,25 +7,29 @@ import androidx.lifecycle.viewModelScope import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.data.GameProcessInfo +import app.gamenative.data.LibraryItem import app.gamenative.di.IAppTheme import app.gamenative.enums.AppTheme import app.gamenative.enums.LoginResult import app.gamenative.enums.PathType import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent +import app.gamenative.service.GameManagerService import app.gamenative.service.SteamService import app.gamenative.ui.data.MainState -import app.gamenative.utils.IntentLaunchManager import app.gamenative.ui.screen.PluviaScreen -import app.gamenative.utils.SteamUtils +import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.IntentLaunchManager import com.materialkolor.PaletteStyle import com.winlator.xserver.Window import dagger.hilt.android.lifecycle.HiltViewModel import `in`.dragonbra.javasteam.steam.handlers.steamapps.AppProcessInfo -import kotlinx.coroutines.Dispatchers import java.nio.file.Paths import javax.inject.Inject import kotlin.io.path.name +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -35,9 +39,6 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber -import kotlinx.coroutines.Job -import app.gamenative.utils.ContainerUtils -import kotlinx.coroutines.async @HiltViewModel class MainViewModel @Inject constructor( @@ -48,10 +49,10 @@ class MainViewModel @Inject constructor( data object OnBackPressed : MainUiEvent() data object OnLoggedOut : MainUiEvent() data object LaunchApp : MainUiEvent() - data class ExternalGameLaunch(val appId: Int) : MainUiEvent() + data class ExternalGameLaunch(val appId: String) : MainUiEvent() data class OnLogonEnded(val result: LoginResult) : MainUiEvent() data object ShowDiscordSupportDialog : MainUiEvent() - data class ShowGameFeedbackDialog(val appId: Int) : MainUiEvent() + data class ShowGameFeedbackDialog(val appId: String) : MainUiEvent() data class ShowToast(val message: String) : MainUiEvent() } @@ -141,7 +142,7 @@ class MainViewModel @Inject constructor( it.copy( isSteamConnected = SteamService.isConnected, hasCrashedLastStart = PrefManager.recentlyCrashed, - launchedAppId = SteamService.INVALID_APP_ID, + launchedAppId = "", ) } } @@ -202,7 +203,7 @@ class MainViewModel @Inject constructor( _state.update { it.copy(resettedScreen = it.currentScreen) } } - fun setLaunchedAppId(value: Int) { + fun setLaunchedAppId(value: String) { _state.update { it.copy(launchedAppId = value) } } @@ -210,19 +211,16 @@ class MainViewModel @Inject constructor( _state.update { it.copy(bootToContainer = value) } } - fun launchApp(context: Context, appId: Int) { + fun launchApp(context: Context, libraryItem: LibraryItem) { // Show booting splash before launching the app viewModelScope.launch { setShowBootingSplash(true) PluviaApp.events.emit(AndroidEvent.SetAllowedOrientation(PrefManager.allowedOrientation)) + val gameId = libraryItem.gameId + val apiJob = viewModelScope.async(Dispatchers.IO) { - val container = ContainerUtils.getOrCreateContainer(context, appId) - if (container.isLaunchRealSteam()) { - SteamUtils.restoreSteamApi(context, appId) - } else { - SteamUtils.replaceSteamApi(context, appId) - } + GameManagerService.runBeforeLaunch(context, libraryItem) } // Small delay to ensure the splash screen is visible before proceeding @@ -234,31 +232,33 @@ class MainViewModel @Inject constructor( } } - fun exitSteamApp(context: Context, appId: Int) { + fun exitSteamApp(context: Context, libraryItem: LibraryItem) { viewModelScope.launch { // Check if we have a temporary override before doing anything - val hadTemporaryOverride = IntentLaunchManager.hasTemporaryOverride(appId) + val hadTemporaryOverride = IntentLaunchManager.hasTemporaryOverride(libraryItem.appId) + + val gameId = libraryItem.gameId SteamService.notifyRunningProcesses() - SteamService.closeApp(appId) { prefix -> - PathType.from(prefix).toAbsPath(context, appId, SteamService.userSteamId!!.accountID) + SteamService.closeApp(gameId) { prefix -> + PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) }.await() // Prompt user to save temporary container configuration if one was applied if (hadTemporaryOverride) { - PluviaApp.events.emit(AndroidEvent.PromptSaveContainerConfig(appId)) + PluviaApp.events.emit(AndroidEvent.PromptSaveContainerConfig(libraryItem.appId)) // Dialog handler in PluviaMain manages the save/discard logic } // After app closes, check if we need to show the feedback dialog try { - val container = ContainerUtils.getContainer(context, appId) + val container = ContainerUtils.getContainer(context, libraryItem.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)) + _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(libraryItem.appId)) } // Only show feedback if container config was changed before this game run @@ -267,7 +267,7 @@ class MainViewModel @Inject constructor( container.putExtra("config_changed", "false") container.saveData() // Show the feedback dialog - _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) + _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(libraryItem.appId)) } } catch (_: Exception) { // ignore container errors @@ -275,16 +275,18 @@ class MainViewModel @Inject constructor( } } - fun onWindowMapped(context: Context, window: Window, appId: Int) { + fun onWindowMapped(context: Context, window: Window, libraryItem: LibraryItem) { viewModelScope.launch { // Hide the booting splash when a window is mapped bootingSplashTimeoutJob?.cancel() bootingSplashTimeoutJob = null setShowBootingSplash(false) - SteamService.getAppInfoOf(appId)?.let { appInfo -> + val gameId = libraryItem.gameId + + SteamService.getAppInfoOf(gameId)?.let { appInfo -> // TODO: this should not be a search, the app should have been launched with a specific launch config that we then use to compare - val launchConfig = SteamService.getWindowsLaunchInfos(appId).firstOrNull { + val launchConfig = SteamService.getWindowsLaunchInfos(gameId).firstOrNull { val gameExe = Paths.get(it.executable.replace('\\', '/')).name.lowercase() val windowExe = window.className.lowercase() gameExe == windowExe @@ -310,11 +312,11 @@ class MainViewModel @Inject constructor( processes.add(process) } while (parentWindow != null) - GameProcessInfo(appId = appId, processes = processes).let { + GameProcessInfo(appId = libraryItem.gameId, processes = processes).let { // Only notify Steam if we're not using real Steam // When launchRealSteam is true, let the real Steam client handle the "game is running" notification val shouldLaunchRealSteam = try { - val container = ContainerUtils.getContainer(context, appId) + val container = ContainerUtils.getContainer(context, libraryItem.appId) container.isLaunchRealSteam() } catch (e: Exception) { // Container might not exist, default to notifying Steam diff --git a/app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt b/app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt index 5142d34c3..5ea142b69 100644 --- a/app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.gamenative.data.LibraryItem import app.gamenative.ui.enums.HomeDestination import app.gamenative.ui.model.HomeViewModel import app.gamenative.ui.screen.library.HomeLibraryScreen @@ -20,8 +21,7 @@ fun HomeScreen( viewModel: HomeViewModel = hiltViewModel(), onChat: (Long) -> Unit, onClickExit: () -> Unit, - onClickPlay: (Int, Boolean) -> Unit, - onLogout: () -> Unit, + onClickPlay: (LibraryItem, Boolean) -> Unit, onNavigateRoute: (String) -> Unit, ) { val homeState by viewModel.homeState.collectAsStateWithLifecycle() @@ -35,7 +35,6 @@ fun HomeScreen( HomeLibraryScreen( onClickPlay = onClickPlay, onNavigateRoute = onNavigateRoute, - onLogout = onLogout, ) } @@ -53,9 +52,8 @@ private fun Preview_HomeScreenContent() { HomeScreen( onChat = {}, onClickPlay = { _, _ -> }, - onLogout = {}, onNavigateRoute = {}, - onClickExit = {} + onClickExit = {}, ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/PluviaScreen.kt b/app/src/main/java/app/gamenative/ui/screen/PluviaScreen.kt index 5fad849b8..9de6afdce 100644 --- a/app/src/main/java/app/gamenative/ui/screen/PluviaScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/PluviaScreen.kt @@ -8,6 +8,7 @@ sealed class PluviaScreen(val route: String) { data object Home : PluviaScreen("home") data object XServer : PluviaScreen("xserver") data object Settings : PluviaScreen("settings") + data object AccountManagement : PluviaScreen("accounts") data object Chat : PluviaScreen("chat/{id}") { fun route(id: Long) = "chat/$id" const val ARG_ID = "id" diff --git a/app/src/main/java/app/gamenative/ui/screen/accounts/AccountManagementScreen.kt b/app/src/main/java/app/gamenative/ui/screen/accounts/AccountManagementScreen.kt new file mode 100644 index 000000000..8ded49715 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/accounts/AccountManagementScreen.kt @@ -0,0 +1,247 @@ +package app.gamenative.ui.screen.accounts + +import android.content.res.Configuration +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import app.gamenative.ui.component.topbar.BackButton +import app.gamenative.ui.model.AccountManagementViewModel +import app.gamenative.ui.theme.PluviaTheme +import com.alorma.compose.settings.ui.SettingsGroup +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountManagementScreen( + onNavigateRoute: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AccountManagementViewModel = hiltViewModel(), +) { + val snackBarHostState = remember { SnackbarHostState() } + val scrollState = rememberScrollState() + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, + topBar = { + CenterAlignedTopAppBar( + title = { Text(text = "Manage Accounts") }, + navigationIcon = { + BackButton(onClick = { onBack() }) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = modifier + .padding(paddingValues) + .displayCutoutPadding() + .fillMaxSize() + .verticalScroll(scrollState), + ) { + AccountsGroup(onNavigateRoute = onNavigateRoute, viewModel = viewModel) + } + } +} + +@Composable +private fun AccountsGroup( + onNavigateRoute: (String) -> Unit, + viewModel: AccountManagementViewModel, +) { + SettingsGroup(title = { Text(text = "Accounts") }) { + SteamAccountSection(onNavigateRoute = onNavigateRoute) + GOGAccountSection(viewModel = viewModel) + // Other account sections (GOG, Epic Games, etc.) + } +} + +// Keep the existing AccountSection for backward compatibility +@Composable +fun AccountSection( + title: String, + description: String, + icon: String, + isLoggedIn: Boolean, + username: String?, + onLogin: () -> Unit, + onLogout: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + error: String? = null, +) { + val primaryColor = MaterialTheme.colorScheme.primary + val tertiaryColor = MaterialTheme.colorScheme.tertiary + + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), + ), + border = BorderStroke(1.dp, primaryColor.copy(alpha = 0.2f)), + shape = RoundedCornerShape(16.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background( + brush = Brush.horizontalGradient( + colors = listOf(primaryColor, tertiaryColor, primaryColor), + ), + ), + ) + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CoilImage( + imageModel = { icon }, + imageOptions = ImageOptions( + contentScale = androidx.compose.ui.layout.ContentScale.Fit, + alignment = androidx.compose.ui.Alignment.Center, + ), + modifier = Modifier.size(32.dp), + failure = { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = if (isLoggedIn) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + }, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = if (isLoggedIn) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + + Text( + text = if (isLoggedIn && username != null) { + "Logged in as $username" + } else { + description + }, + style = MaterialTheme.typography.bodyMedium, + color = if (isLoggedIn) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Status indicator + Icon( + imageVector = if (isLoggedIn) Icons.Default.CheckCircle else Icons.Default.Circle, + contentDescription = if (isLoggedIn) "Connected" else "Not connected", + tint = if (isLoggedIn) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp), + ) + } + + // Error message + if (error != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Text( + text = error, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + + // Action button + if (isLoggedIn) { + OutlinedButton( + onClick = onLogout, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null) + } + Spacer(modifier = Modifier.width(8.dp)) + Text("Sign Out") + } + } else { + Button( + onClick = onLogin, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon(Icons.AutoMirrored.Filled.Login, contentDescription = null) + } + Spacer(modifier = Modifier.width(8.dp)) + Text(if (isLoading) "Signing In..." else "Sign In") + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Preview(device = "spec:width=1920px,height=1080px,dpi=440") // Odin2 Mini +@Composable +private fun AccountManagementScreenPreview() { + PluviaTheme { + AccountManagementScreen( + onNavigateRoute = {}, + onBack = {}, + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/accounts/GOGAccountSection.kt b/app/src/main/java/app/gamenative/ui/screen/accounts/GOGAccountSection.kt new file mode 100644 index 000000000..9f45593ba --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/accounts/GOGAccountSection.kt @@ -0,0 +1,154 @@ +package app.gamenative.ui.screen.accounts + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import app.gamenative.service.GOG.GOGService +import app.gamenative.ui.model.AccountManagementViewModel +import app.gamenative.ui.screen.auth.GOGOAuthActivity +import kotlinx.coroutines.launch +import timber.log.Timber + +@Composable +fun GOGAccountSection( + viewModel: AccountManagementViewModel, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // State for GOG + var isGOGLoggedIn by remember { mutableStateOf(false) } + var gogUsername by remember { mutableStateOf("") } + var gogAuthInProgress by remember { mutableStateOf(false) } + var gogError by remember { mutableStateOf(null) } + + // Check for existing GOG credentials on startup + LaunchedEffect(Unit) { + if (GOGService.hasStoredCredentials(context)) { + // Use GOGDL to validate credentials (this handles token refresh automatically) + val validationResult = GOGService.validateCredentials(context) + + if (validationResult.isSuccess && validationResult.getOrThrow()) { + // Credentials are valid, get user info + val credentialsResult = GOGService.getStoredCredentials(context) + if (credentialsResult.isSuccess) { + val credentials = credentialsResult.getOrThrow() + isGOGLoggedIn = true + gogUsername = credentials.username + gogError = null + } else { + gogError = "Failed to get user info: ${credentialsResult.exceptionOrNull()?.message}" + isGOGLoggedIn = false + gogUsername = "" + } + } else { + val errorMsg = if (validationResult.isFailure) { + "Validation failed: ${validationResult.exceptionOrNull()?.message}" + } else { + "Session expired or invalid credentials" + } + gogError = errorMsg + isGOGLoggedIn = false + gogUsername = "" + } + } + } + + // OAuth launcher for GOG authentication + val gogOAuthLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + when (result.resultCode) { + android.app.Activity.RESULT_OK -> { + val authCode = result.data?.getStringExtra(GOGOAuthActivity.EXTRA_AUTH_CODE) + if (authCode != null) { + // Got authorization code, now authenticate with GOGDL + scope.launch { + gogAuthInProgress = true + gogError = null + + try { + val authConfigPath = "${context.filesDir}/gog_auth.json" + val authResult = GOGService.authenticateWithCode(authConfigPath, authCode) + + if (authResult.isSuccess) { + val credentials = authResult.getOrThrow() + isGOGLoggedIn = true + gogUsername = credentials.username + gogError = null + + // Automatically start GOG library sync after successful login + Timber.i("GOG login successful, starting automatic library sync...") + viewModel.syncGOGLibraryAsync(context, clearExisting = true) { result -> + if (result.isSuccess) { + Timber.i("GOG library sync started successfully after login") + } else { + Timber.w("Failed to start GOG library sync after login: ${result.exceptionOrNull()?.message}") + } + } + } else { + gogError = authResult.exceptionOrNull()?.message ?: "Authentication failed" + } + } catch (e: Exception) { + gogError = e.message ?: "Authentication failed" + } finally { + gogAuthInProgress = false + } + } + } else { + gogError = "No authorization code received" + gogAuthInProgress = false + } + } + android.app.Activity.RESULT_CANCELED -> { + val error = result.data?.getStringExtra(GOGOAuthActivity.EXTRA_ERROR) + gogError = error ?: "Authentication cancelled" + gogAuthInProgress = false + } + } + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // GOG Account Section + AccountSection( + title = "GOG", + description = "Access your GOG library and DRM-free games", + icon = "https://www.gog.com/favicon.ico", + isLoggedIn = isGOGLoggedIn, + username = if (isGOGLoggedIn) gogUsername else null, + isLoading = gogAuthInProgress, + error = gogError, + onLogin = { + // Launch GOG OAuth activity + gogAuthInProgress = true + gogError = null + val intent = Intent(context, GOGOAuthActivity::class.java) + gogOAuthLauncher.launch(intent) + }, + onLogout = { + scope.launch { + try { + // Clear stored credentials using the service method + GOGService.clearStoredCredentials(context) + + isGOGLoggedIn = false + gogUsername = "" + gogError = null + } catch (e: Exception) { + gogError = "Logout error: ${e.message}" + } + } + }, + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/accounts/SteamAccountSection.kt b/app/src/main/java/app/gamenative/ui/screen/accounts/SteamAccountSection.kt new file mode 100644 index 000000000..2f9c84239 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/accounts/SteamAccountSection.kt @@ -0,0 +1,29 @@ +package app.gamenative.ui.screen.accounts + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import app.gamenative.service.SteamService +import app.gamenative.ui.screen.PluviaScreen +import kotlinx.coroutines.launch + +@Composable +fun SteamAccountSection( + onNavigateRoute: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val isSteamLoggedIn = remember { mutableStateOf(SteamService.isLoggedIn)} + + AccountSection( + title = "Steam", + description = "Access your Steam library and games", + icon = "https://store.steampowered.com/favicon.ico", + isLoggedIn = isSteamLoggedIn.value, + username = if (isSteamLoggedIn.value) "Steam User" else null, + onLogin = { onNavigateRoute(PluviaScreen.LoginUser.route) }, + onLogout = { + SteamService.logOut() + isSteamLoggedIn.value = false // Trigger a redraw + }, + modifier = modifier, + ) +} diff --git a/app/src/main/java/app/gamenative/ui/screen/auth/GOGOAuthActivity.kt b/app/src/main/java/app/gamenative/ui/screen/auth/GOGOAuthActivity.kt new file mode 100644 index 000000000..8203bb7ee --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/auth/GOGOAuthActivity.kt @@ -0,0 +1,66 @@ +package app.gamenative.ui.screen.auth + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import app.gamenative.ui.component.dialog.GOGWebViewDialog +import app.gamenative.ui.theme.PluviaTheme +import timber.log.Timber + +class GOGOAuthActivity : ComponentActivity() { + + companion object { + const val EXTRA_AUTH_CODE = "auth_code" + const val EXTRA_ERROR = "error" + const val GOG_CLIENT_ID = "46899977096215655" // TODO: we should use our own instead of Heroic's client id. + const val GOG_AUTH_URL = "https://auth.gog.com/auth?" + + "client_id=$GOG_CLIENT_ID" + + "&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient" + + "&response_type=code" + + "&layout=galaxy" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + PluviaTheme { + GOGWebViewDialog( + isVisible = true, + url = GOG_AUTH_URL, + onDismissRequest = { + setResult(Activity.RESULT_CANCELED) + finish() + }, + onUrlChange = { currentUrl: String -> + // Check if this is the GOG redirect URL with authorization code + if (currentUrl.contains("embed.gog.com/on_login_success")) { + val extractedCode = extractAuthCode(currentUrl) + if (extractedCode != null) { + Timber.d("Automatically extracted auth code from URL") + val resultIntent = Intent().apply { + putExtra(EXTRA_AUTH_CODE, extractedCode) + } + setResult(Activity.RESULT_OK, resultIntent) + finish() + } + } + }, + ) + } + } + } + + private fun extractAuthCode(url: String): String? { + return try { + val uri = Uri.parse(url) + uri.getQueryParameter("code") + } catch (e: Exception) { + Timber.e(e, "Failed to extract auth code from URL: $url") + null + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/auth/GOGWebViewDialog.kt b/app/src/main/java/app/gamenative/ui/screen/auth/GOGWebViewDialog.kt new file mode 100644 index 000000000..ee968719d --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/auth/GOGWebViewDialog.kt @@ -0,0 +1,181 @@ +package app.gamenative.ui.component.dialog + +import android.content.res.Configuration +import android.os.Bundle +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.gamenative.ui.theme.PluviaTheme +import timber.log.Timber + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GOGWebViewDialog( + isVisible: Boolean, + url: String, + onDismissRequest: () -> Unit, + onUrlChange: ((String) -> Unit)? = null, +) { + if (isVisible) { + var topBarTitle by rememberSaveable { mutableStateOf("GOG Authentication") } + val startingUrl by rememberSaveable(url) { mutableStateOf(url) } + var webView: WebView? = remember { null } + val webViewState = rememberSaveable { Bundle() } + + Dialog( + onDismissRequest = { + if (webView?.canGoBack() == true) { + webView!!.goBack() + } else { + webViewState.clear() + onDismissRequest() + } + }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + ), + content = { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = topBarTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton( + onClick = { + webViewState.clear() + onDismissRequest() + }, + content = { Icon(imageVector = Icons.Default.Close, null) }, + ) + }, + ) + }, + ) { paddingValues -> + AndroidView( + modifier = Modifier.padding(paddingValues), + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + + // GOG-specific WebView settings + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + setSupportZoom(true) + allowFileAccess = true + allowContentAccess = true + allowFileAccessFromFileURLs = true + allowUniversalAccessFromFileURLs = true + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + + // GOG-specific user agent (similar to Heroic) + userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/200.0" + } + + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + Timber.d("GOG WebView navigating to: $url") + url?.let { currentUrl -> + onUrlChange?.invoke(currentUrl) + } + return super.shouldOverrideUrlLoading(view, url) + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + Timber.d("GOG WebView page finished loading: $url") + } + + override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) { + super.onReceivedError(view, errorCode, description, failingUrl) + Timber.e("GOG WebView error: $errorCode - $description for URL: $failingUrl") + } + } + + webChromeClient = object : WebChromeClient() { + override fun onReceivedTitle(view: WebView?, title: String?) { + title?.let { pageTitle -> + topBarTitle = pageTitle + Timber.d("GOG WebView title: $pageTitle") + } + } + + override fun onProgressChanged(view: WebView?, newProgress: Int) { + super.onProgressChanged(view, newProgress) + Timber.d("GOG WebView progress: $newProgress%") + } + } + + if (webViewState.size() > 0) { + restoreState(webViewState) + } else { + Timber.d("Loading GOG WebView URL: $startingUrl") + loadUrl(startingUrl) + } + webView = this + } + }, + update = { + webView = it + }, + onRelease = { view -> + view.saveState(webViewState) + }, + ) + } + }, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Preview +@Composable +private fun Preview_GOGWebView() { + PluviaTheme { + GOGWebViewDialog( + isVisible = true, + url = "https://auth.gog.com/auth?client_id=46899977096215655&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=code&layout=galaxy", + onDismissRequest = { + println("GOG WebView dismissed!") + }, + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/downloads/HomeDownloadsScreen.kt b/app/src/main/java/app/gamenative/ui/screen/downloads/HomeDownloadsScreen.kt index 4bec2dc30..5d1ab7c50 100644 --- a/app/src/main/java/app/gamenative/ui/screen/downloads/HomeDownloadsScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/downloads/HomeDownloadsScreen.kt @@ -40,14 +40,12 @@ import app.gamenative.ui.theme.PluviaTheme @Composable fun HomeDownloadsScreen( onNavigateRoute: (String) -> Unit, - onLogout: () -> Unit, ) { val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher DownloadsScreenContent( onBack = { onBackPressedDispatcher?.onBackPressed() }, onNavigateRoute = onNavigateRoute, - onLogout = onLogout, ) } @@ -56,7 +54,6 @@ fun HomeDownloadsScreen( private fun DownloadsScreenContent( onBack: () -> Unit, onNavigateRoute: (String) -> Unit, - onLogout: () -> Unit, ) { val snackbarHost = remember { SnackbarHostState() } val navigator = rememberListDetailPaneScaffoldNavigator() @@ -79,7 +76,6 @@ private fun DownloadsScreenContent( }, onBack = onBack, onNavigateRoute = onNavigateRoute, - onLogout = onLogout, ) } }, @@ -103,7 +99,6 @@ private fun DownloadsScreenPane( onClick: () -> Unit, onBack: () -> Unit, onNavigateRoute: (String) -> Unit, - onLogout: () -> Unit, ) { Scaffold( snackbarHost = { SnackbarHost(snackbarHost) }, @@ -113,7 +108,6 @@ private fun DownloadsScreenPane( actions = { AccountButton( onNavigateRoute = onNavigateRoute, - onLogout = onLogout, ) }, navigationIcon = { BackButton(onClick = onBack) }, @@ -198,7 +192,6 @@ private fun Preview_DownloadsScreenContent() { DownloadsScreenContent( onBack = {}, onNavigateRoute = {}, - onLogout = {}, ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/friends/FriendsScreen.kt b/app/src/main/java/app/gamenative/ui/screen/friends/FriendsScreen.kt index f93102a2c..c84d3b21b 100644 --- a/app/src/main/java/app/gamenative/ui/screen/friends/FriendsScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/friends/FriendsScreen.kt @@ -247,7 +247,6 @@ private fun FriendsListPane( actions = { AccountButton( onNavigateRoute = onNavigateRoute, - onLogout = onLogout, ) }, navigationIcon = { BackButton(onClick = onBack) }, 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 c3fb85d29..d5b531ca1 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 @@ -1,13 +1,20 @@ package app.gamenative.ui.screen.library import android.Manifest +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Environment +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.* import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,12 +28,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -48,8 +57,10 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -69,10 +80,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.core.net.toUri -import app.gamenative.Constants +import app.gamenative.PrefManager import app.gamenative.R -import app.gamenative.data.SteamApp +import app.gamenative.data.GameSource +import app.gamenative.data.LibraryItem +import app.gamenative.enums.Marker +import app.gamenative.enums.PathType +import app.gamenative.enums.SaveLocation +import app.gamenative.enums.SyncResult +import app.gamenative.service.GameManagerService +import app.gamenative.service.GOG.GOGService import app.gamenative.service.SteamService +import app.gamenative.service.SteamService.Companion.getAppDirPath import app.gamenative.ui.component.LoadingScreen import app.gamenative.ui.component.dialog.ContainerConfigDialog import app.gamenative.ui.component.dialog.LoadingDialog @@ -82,51 +101,25 @@ import app.gamenative.ui.component.topbar.BackButton import app.gamenative.ui.data.AppMenuOption import app.gamenative.ui.enums.AppOptionMenuType import app.gamenative.ui.enums.DialogType -import app.gamenative.ui.internal.fakeAppInfo +import app.gamenative.ui.internal.LocalGameManagerService import app.gamenative.ui.theme.PluviaTheme import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.MarkerUtils import app.gamenative.utils.StorageUtils import com.google.android.play.core.splitcompat.SplitCompat +import com.posthog.PostHog import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage -import app.gamenative.utils.SteamUtils import com.winlator.container.ContainerData +import com.winlator.container.ContainerManager import com.winlator.xenvironment.ImageFsInstaller -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import java.nio.file.Paths +import kotlin.io.path.pathString +import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import timber.log.Timber -import app.gamenative.service.SteamService.Companion.getAppDirPath -import com.posthog.PostHog -import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.os.Environment -import androidx.compose.foundation.border -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.rememberCoroutineScope -import app.gamenative.PrefManager -import app.gamenative.service.DownloadService -import java.nio.file.Paths -import kotlin.io.path.pathString -import kotlin.math.roundToInt -import app.gamenative.enums.PathType -import com.winlator.container.ContainerManager -import app.gamenative.enums.SyncResult -import android.widget.Toast -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import app.gamenative.enums.Marker -import app.gamenative.enums.SaveLocation -import androidx.compose.animation.core.* -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.ui.graphics.compositeOver -import app.gamenative.utils.MarkerUtils // https://partner.steamgames.com/doc/store/assets/libraryassets#4 @@ -134,7 +127,7 @@ import app.gamenative.utils.MarkerUtils private fun SkeletonText( modifier: Modifier = Modifier, lines: Int = 1, - lineHeight: Int = 16 + lineHeight: Int = 16, ) { val infiniteTransition = rememberInfiniteTransition(label = "skeleton") val alpha by infiniteTransition.animateFloat( @@ -142,9 +135,9 @@ private fun SkeletonText( targetValue = 0.25f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1500, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse + repeatMode = RepeatMode.Reverse, ), - label = "alpha" + label = "alpha", ) val color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha) @@ -157,8 +150,8 @@ private fun SkeletonText( .height(lineHeight.dp) .background( color = color, - shape = RoundedCornerShape(4.dp) - ) + shape = RoundedCornerShape(4.dp), + ), ) if (index < lines - 1) { Spacer(modifier = Modifier.height(4.dp)) @@ -170,29 +163,27 @@ private fun SkeletonText( @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppScreen( - appId: Int, + libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit, onBack: () -> Unit, ) { val context = LocalContext.current val scope = rememberCoroutineScope() - - val appInfo by remember(appId) { - mutableStateOf(SteamService.getAppInfoOf(appId)!!) - } + val gameId = libraryItem.gameId + val appId = libraryItem.appId var downloadInfo by remember(appId) { - mutableStateOf(SteamService.getAppDownloadInfo(appId)) + mutableStateOf(GameManagerService.getDownloadInfo(libraryItem)) } var downloadProgress by remember(appId) { mutableFloatStateOf(downloadInfo?.getProgress() ?: 0f) } var isInstalled by remember(appId) { - mutableStateOf(SteamService.isAppInstalled(appId)) + mutableStateOf(GameManagerService.isGameInstalled(context, libraryItem)) } val isValidToDownload by remember(appId) { - mutableStateOf(appInfo.branches.isNotEmpty() && appInfo.depots.isNotEmpty()) + mutableStateOf(GameManagerService.isValidToDownload(libraryItem)) } val isDownloading: () -> Boolean = { downloadInfo != null && downloadProgress < 1f } @@ -219,10 +210,14 @@ fun AppScreen( DisposableEffect(downloadInfo) { val onDownloadProgress: (Float) -> Unit = { if (it >= 1f) { - isInstalled = SteamService.isAppInstalled(appId) + // Download completed - update markers + val appDirPath = GameManagerService.getAppDirPath(libraryItem.appId) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + + isInstalled = GameManagerService.isGameInstalled(context, libraryItem) downloadInfo = null isInstalled = true - MarkerUtils.addMarker(getAppDirPath(appId), Marker.DOWNLOAD_COMPLETE_MARKER) } downloadProgress = it } @@ -235,7 +230,7 @@ fun AppScreen( } LaunchedEffect(appId) { - Timber.d("Selected app $appId") + Timber.d("Selected app ${libraryItem.appId} (${libraryItem.gameSource})") } val oldGamesDirectory by remember { @@ -301,8 +296,6 @@ fun AppScreen( ) } - - val windowWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass /** Storage Permission **/ @@ -323,42 +316,7 @@ fun AppScreen( if (writePermissionGranted && readPermissionGranted) { hasStoragePermission = true - - val depots = SteamService.getDownloadableDepots(appId) - Timber.i("There are ${depots.size} depots belonging to $appId") - // How much free space is on disk - val availableBytes = StorageUtils.getAvailableSpace(SteamService.defaultStoragePath) - val availableSpace = StorageUtils.formatBinarySize(availableBytes) - // TODO: un-hardcode "public" branch - val downloadSize = StorageUtils.formatBinarySize( - depots.values.sumOf { - it.manifests["public"]?.download ?: 0 - }, - ) - val installBytes = depots.values.sumOf { it.manifests["public"]?.size ?: 0 } - val installSize = StorageUtils.formatBinarySize(installBytes) - if (availableBytes < installBytes) { - msgDialogState = MessageDialogState( - visible = true, - type = DialogType.NOT_ENOUGH_SPACE, - title = context.getString(R.string.not_enough_space), - message = "The app being installed needs $installSize of space but " + - "there is only $availableSpace left on this device", - confirmBtnText = context.getString(R.string.acknowledge), - ) - } else { - msgDialogState = MessageDialogState( - visible = true, - type = DialogType.INSTALL_APP, - title = context.getString(R.string.download_prompt_title), - message = "The app being installed has the following space requirements. Would you like to proceed?" + - "\n\n\tDownload Size: $downloadSize" + - "\n\tSize on Disk: $installSize" + - "\n\tAvailable Space: $availableSpace", - confirmBtnText = context.getString(R.string.proceed), - dismissBtnText = context.getString(R.string.cancel), - ) - } + msgDialogState = GameManagerService.getInstallInfoDialog(context, libraryItem) } else { // Snack bar this? Toast.makeText(context, "Storage permission required", Toast.LENGTH_SHORT).show() @@ -366,22 +324,28 @@ fun AppScreen( }, ) - val onDismissRequest: (() -> Unit)? val onDismissClick: (() -> Unit)? val onConfirmClick: (() -> Unit)? when (msgDialogState.type) { DialogType.CANCEL_APP_DOWNLOAD -> { onConfirmClick = { - PostHog.capture(event = "game_install_cancelled", + PostHog.capture( + event = "game_install_cancelled", properties = mapOf( - "game_name" to appInfo.name - )) - downloadInfo?.cancel() - SteamService.deleteApp(appId) + "game_name" to libraryItem.name, + ), + ) + // Cancel the download properly based on game source + if (libraryItem.gameSource == GameSource.GOG) { + GOGService.cancelDownload(libraryItem.appId) + } else { + downloadInfo?.cancel() + } + GameManagerService.deleteGame(context, libraryItem) downloadInfo = null downloadProgress = 0f - isInstalled = SteamService.isAppInstalled(appId) + isInstalled = GameManagerService.isGameInstalled(context, libraryItem) msgDialogState = MessageDialogState(false) } onDismissRequest = { msgDialogState = MessageDialogState(false) } @@ -397,13 +361,23 @@ fun AppScreen( DialogType.INSTALL_APP -> { onDismissRequest = { msgDialogState = MessageDialogState(false) } onConfirmClick = { - PostHog.capture(event = "game_install_started", + PostHog.capture( + event = "game_install_started", properties = mapOf( - "game_name" to appInfo.name - )) + "game_name" to libraryItem.name, + ), + ) CoroutineScope(Dispatchers.IO).launch { downloadProgress = 0f - downloadInfo = SteamService.downloadApp(appId) + val result = GameManagerService.downloadGameWithResult(context, libraryItem) + if (result.isSuccess) { + downloadInfo = result.getOrNull() + } else { + // Download failed - show error message + CoroutineScope(Dispatchers.Main).launch { + Toast.makeText(context, result.exceptionOrNull()?.message ?: "Download failed", Toast.LENGTH_LONG).show() + } + } msgDialogState = MessageDialogState(false) } } @@ -412,13 +386,12 @@ fun AppScreen( DialogType.DELETE_APP -> { onConfirmClick = { - // Delete the Steam app data - SteamService.deleteApp(appId) + GameManagerService.deleteGame(context, libraryItem) // Also delete the associated container so it will be recreated on next launch ContainerUtils.deleteContainer(context, appId) msgDialogState = MessageDialogState(false) - isInstalled = SteamService.isAppInstalled(appId) + isInstalled = GameManagerService.isGameInstalled(context, libraryItem) } onDismissRequest = { msgDialogState = MessageDialogState(false) } onDismissClick = { msgDialogState = MessageDialogState(false) } @@ -471,7 +444,7 @@ fun AppScreen( ContainerConfigDialog( visible = showConfigDialog, - title = "${appInfo.name} Config", + title = "${libraryItem.name} Config", initialConfig = containerData, onDismissRequest = { showConfigDialog = false }, onSave = { @@ -488,7 +461,7 @@ fun AppScreen( Scaffold { AppScreenContent( modifier = Modifier.padding(it), - appInfo = appInfo, + libraryItem = libraryItem, isInstalled = isInstalled, isValidToDownload = isValidToDownload, isDownloading = isDownloading(), @@ -504,33 +477,54 @@ fun AppScreen( confirmBtnText = context.getString(R.string.yes), dismissBtnText = context.getString(R.string.no), ) - } else if (SteamService.hasPartialDownload(appId)) { + } else if (GameManagerService.hasPartialDownload(libraryItem)) { // Resume incomplete download CoroutineScope(Dispatchers.IO).launch { - downloadInfo = SteamService.downloadApp(appId) + val result = GameManagerService.downloadGameWithResult(context, libraryItem) + if (result.isSuccess) { + downloadInfo = result.getOrNull() + } else { + CoroutineScope(Dispatchers.Main).launch { + Toast.makeText(context, result.exceptionOrNull()?.message ?: "Download failed", Toast.LENGTH_LONG).show() + } + } } } else if (!isInstalled) { permissionLauncher.launch( arrayOf( Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE, - ), - ) + ), + ) } else { // Already installed: launch app - PostHog.capture(event = "game_launched", + PostHog.capture( + event = "game_launched", properties = mapOf( - "game_name" to appInfo.name - )) + "game_name" to libraryItem.name, + ), + ) onClickPlay(false) } }, onPauseResumeClick = { if (isDownloading()) { - downloadInfo?.cancel() + // Cancel the download properly based on game source + if (libraryItem.gameSource == GameSource.GOG) { + GOGService.cancelDownload(libraryItem.appId) + } else { + downloadInfo?.cancel() + } downloadInfo = null } else { - downloadInfo = SteamService.downloadApp(appId) + val result = GameManagerService.downloadGameWithResult(context, libraryItem) + if (result.isSuccess) { + downloadInfo = result.getOrNull() + } else { + CoroutineScope(Dispatchers.Main).launch { + Toast.makeText(context, result.exceptionOrNull()?.message ?: "Download failed", Toast.LENGTH_LONG).show() + } + } } }, onDeleteDownloadClick = { @@ -540,10 +534,21 @@ fun AppScreen( title = context.getString(R.string.cancel_download_prompt_title), message = "Delete all downloaded data for this game?", confirmBtnText = context.getString(R.string.yes), - dismissBtnText = context.getString(R.string.no) + dismissBtnText = context.getString(R.string.no), ) }, - onUpdateClick = { CoroutineScope(Dispatchers.IO).launch { downloadInfo = SteamService.downloadApp(appId) } }, + onUpdateClick = { + CoroutineScope(Dispatchers.IO).launch { + val result = GameManagerService.downloadGameWithResult(context, libraryItem) + if (result.isSuccess) { + downloadInfo = result.getOrNull() + } else { + CoroutineScope(Dispatchers.Main).launch { + Toast.makeText(context, result.exceptionOrNull()?.message ?: "Download failed", Toast.LENGTH_LONG).show() + } + } + } + }, onBack = onBack, optionsMenu = arrayOf( AppMenuOption( @@ -552,7 +557,7 @@ fun AppScreen( // TODO add option to view web page externally or internally val browserIntent = Intent( Intent.ACTION_VIEW, - (Constants.Library.STORE_URL + appInfo.id).toUri(), + GameManagerService.getStoreUrl(libraryItem), ) context.startActivity(browserIntent) }, @@ -560,31 +565,37 @@ fun AppScreen( AppMenuOption( optionType = AppOptionMenuType.EditContainer, onClick = { - if (!SteamService.isImageFsInstalled(context)) { - if (!SteamService.isImageFsInstallable(context)) { - msgDialogState = MessageDialogState( - visible = true, - type = DialogType.INSTALL_IMAGEFS, - title = "Download & Install ImageFS", - message = "The Ubuntu image needs to be downloaded and installed before " + - "being able to edit the configuration. This operation might take " + - "a few minutes. Would you like to continue?", - confirmBtnText = "Proceed", - dismissBtnText = "Cancel", - ) + // For Steam games, check ImageFS requirements + if (libraryItem.gameSource == GameSource.STEAM) { + if (!SteamService.isImageFsInstalled(context)) { + if (!SteamService.isImageFsInstallable(context)) { + msgDialogState = MessageDialogState( + visible = true, + type = DialogType.INSTALL_IMAGEFS, + title = "Download & Install ImageFS", + message = "The Ubuntu image needs to be downloaded and installed before " + + "being able to edit the configuration. This operation might take " + + "a few minutes. Would you like to continue?", + confirmBtnText = "Proceed", + dismissBtnText = "Cancel", + ) + } else { + msgDialogState = MessageDialogState( + visible = true, + type = DialogType.INSTALL_IMAGEFS, + title = "Install ImageFS", + message = "The Ubuntu image needs to be installed before being able to edit " + + "the configuration. This operation might take a few minutes. " + + "Would you like to continue?", + confirmBtnText = "Proceed", + dismissBtnText = "Cancel", + ) + } } else { - msgDialogState = MessageDialogState( - visible = true, - type = DialogType.INSTALL_IMAGEFS, - title = "Install ImageFS", - message = "The Ubuntu image needs to be installed before being able to edit " + - "the configuration. This operation might take a few minutes. " + - "Would you like to continue?", - confirmBtnText = "Proceed", - dismissBtnText = "Cancel", - ) + showEditConfigDialog() } } else { + // For GOG games, directly show the config dialog (no ImageFS requirement) showEditConfigDialog() } }, @@ -595,10 +606,11 @@ fun AppScreen( AppMenuOption( AppOptionMenuType.RunContainer, onClick = { - PostHog.capture(event = "container_opened", + PostHog.capture( + event = "container_opened", properties = mapOf( - "game_name" to appInfo.name - ) + "game_name" to libraryItem.name, + ), ) onClickPlay(true) }, @@ -615,7 +627,14 @@ fun AppScreen( AppOptionMenuType.VerifyFiles, onClick = { CoroutineScope(Dispatchers.IO).launch { - downloadInfo = SteamService.downloadApp(appId) + val result = GameManagerService.downloadGameWithResult(context, libraryItem) + if (result.isSuccess) { + downloadInfo = result.getOrNull() + } else { + CoroutineScope(Dispatchers.Main).launch { + Toast.makeText(context, result.exceptionOrNull()?.message ?: "Download failed", Toast.LENGTH_LONG).show() + } + } } }, ), @@ -623,7 +642,14 @@ fun AppScreen( AppOptionMenuType.Update, onClick = { CoroutineScope(Dispatchers.IO).launch { - downloadInfo = SteamService.downloadApp(appId) + val result = GameManagerService.downloadGameWithResult(context, libraryItem) + if (result.isSuccess) { + downloadInfo = result.getOrNull() + } else { + CoroutineScope(Dispatchers.Main).launch { + Toast.makeText(context, result.exceptionOrNull()?.message ?: "Download failed", Toast.LENGTH_LONG).show() + } + } } }, ), @@ -675,10 +701,12 @@ fun AppScreen( AppMenuOption( AppOptionMenuType.ForceCloudSync, onClick = { - PostHog.capture(event = "cloud_sync_forced", + PostHog.capture( + event = "cloud_sync_forced", properties = mapOf( - "game_name" to appInfo.name - )) + "game_name" to libraryItem.name, + ), + ) CoroutineScope(Dispatchers.IO).launch { // Activate container before sync (required for proper path resolution) val containerManager = ContainerManager(context) @@ -686,11 +714,11 @@ fun AppScreen( containerManager.activateContainer(container) val prefixToPath: (String) -> String = { prefix -> - PathType.from(prefix).toAbsPath(context, appId, SteamService.userSteamId!!.accountID) + PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) } val syncResult = SteamService.forceSyncUserFiles( - appId = appId, - prefixToPath = prefixToPath + appId = gameId, + prefixToPath = prefixToPath, ).await() // Handle result on main thread @@ -713,23 +741,25 @@ fun AppScreen( AppMenuOption( AppOptionMenuType.ForceDownloadRemote, onClick = { - PostHog.capture(event = "force_download_remote", + PostHog.capture( + event = "force_download_remote", properties = mapOf( - "game_name" to appInfo.name - )) + "game_name" to libraryItem.name, + ), + ) CoroutineScope(Dispatchers.IO).launch { val containerManager = ContainerManager(context) val container = ContainerUtils.getOrCreateContainer(context, appId) containerManager.activateContainer(container) val prefixToPath: (String) -> String = { prefix -> - PathType.from(prefix).toAbsPath(context, appId, SteamService.userSteamId!!.accountID) + PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) } val syncResult = SteamService.forceSyncUserFiles( - appId = appId, + appId = gameId, prefixToPath = prefixToPath, preferredSave = SaveLocation.Remote, - overrideLocalChangeNumber = -1L + overrideLocalChangeNumber = -1L, ).await() scope.launch(Dispatchers.Main) { @@ -748,22 +778,24 @@ fun AppScreen( AppMenuOption( AppOptionMenuType.ForceUploadLocal, onClick = { - PostHog.capture(event = "force_upload_local", + PostHog.capture( + event = "force_upload_local", properties = mapOf( - "game_name" to appInfo.name - )) + "game_name" to libraryItem.name, + ), + ) CoroutineScope(Dispatchers.IO).launch { val containerManager = ContainerManager(context) val container = ContainerUtils.getOrCreateContainer(context, appId) containerManager.activateContainer(container) val prefixToPath: (String) -> String = { prefix -> - PathType.from(prefix).toAbsPath(context, appId, SteamService.userSteamId!!.accountID) + PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) } val syncResult = SteamService.forceSyncUserFiles( - appId = appId, + appId = gameId, prefixToPath = prefixToPath, - preferredSave = SaveLocation.Local + preferredSave = SaveLocation.Local, ).await() scope.launch(Dispatchers.Main) { @@ -783,7 +815,7 @@ fun AppScreen( } else { emptyArray() } - ), + ), ( AppMenuOption( optionType = AppOptionMenuType.GetSupport, @@ -795,7 +827,7 @@ fun AppScreen( context.startActivity(browserIntent) }, ) - ) + ), ), ) } @@ -804,7 +836,7 @@ fun AppScreen( @Composable private fun AppScreenContent( modifier: Modifier = Modifier, - appInfo: SteamApp, + libraryItem: LibraryItem, isInstalled: Boolean, isValidToDownload: Boolean, isDownloading: Boolean, @@ -827,36 +859,7 @@ private fun AppScreenContent( var optionsMenuVisible by remember { mutableStateOf(false) } - // Compute last played timestamp from local install folder - val lastPlayedText by remember(appInfo.id, isInstalled) { - mutableStateOf( - if (isInstalled) { - val path = SteamService.getAppDirPath(appInfo.id) - val file = java.io.File(path) - if (file.exists()) { - SteamUtils.fromSteamTime((file.lastModified() / 1000).toInt()) - } else { - "Never" - } - } else { - "Never" - } - ) - } - // Compute real playtime by fetching owned games - var playtimeText by remember { mutableStateOf("0 hrs") } - LaunchedEffect(appInfo.id) { - val steamID = SteamService.userSteamId?.accountID?.toLong() - if (steamID != null) { - val games = SteamService.getOwnedGames(steamID) - val game = games.firstOrNull { it.appId == appInfo.id } - playtimeText = if (game != null) { - SteamUtils.formatPlayTime(game.playtimeForever) + " hrs" - } else "0 hrs" - } - } - - LaunchedEffect(appInfo.id) { + LaunchedEffect(libraryItem.appId) { scrollState.animateScrollTo(0) } @@ -866,18 +869,14 @@ private fun AppScreenContent( // Fatass disk size call - needs to stop if we do something important like launch the app LaunchedEffect(appSizeDisplayed) { if (isInstalled) { - appSizeOnDisk = " ..." - - DownloadService.getSizeOnDiskDisplay(appInfo.id) { - appSizeOnDisk = "$it" - } + appSizeOnDisk = GameManagerService.getGameDiskSize(context, libraryItem) } } // Check if an update is pending - var isUpdatePending by remember(appInfo.id) { mutableStateOf(false) } - LaunchedEffect(appInfo.id) { - isUpdatePending = SteamService.isUpdatePending(appInfo.id) + var isUpdatePending by remember(libraryItem) { mutableStateOf(false) } + LaunchedEffect(libraryItem) { + isUpdatePending = GameManagerService.isUpdatePending(libraryItem) } Column( @@ -890,12 +889,12 @@ private fun AppScreenContent( Box( modifier = Modifier .fillMaxWidth() - .height(250.dp) + .height(250.dp), ) { // Hero background image CoilImage( modifier = Modifier.fillMaxSize(), - imageModel = { appInfo.getHeroUrl() }, + imageModel = { GameManagerService.getHeroImage(libraryItem) }, imageOptions = ImageOptions(contentScale = ContentScale.Crop), loading = { LoadingScreen() }, failure = { @@ -906,7 +905,7 @@ private fun AppScreenContent( // Gradient background as fallback Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) { } } }, @@ -921,10 +920,10 @@ private fun AppScreenContent( brush = Brush.verticalGradient( colors = listOf( Color.Transparent, - Color.Black.copy(alpha = 0.8f) - ) - ) - ) + Color.Black.copy(alpha = 0.8f), + ), + ), + ), ) // Back button (top left) @@ -933,8 +932,8 @@ private fun AppScreenContent( .padding(20.dp) .background( color = Color.Black.copy(alpha = 0.5f), - shape = RoundedCornerShape(12.dp) - ) + shape = RoundedCornerShape(12.dp), + ), ) { BackButton(onClick = onBack) } @@ -943,20 +942,20 @@ private fun AppScreenContent( Box( modifier = Modifier .align(Alignment.TopEnd) - .padding(20.dp) + .padding(20.dp), ) { IconButton( modifier = Modifier .background( color = Color.Black.copy(alpha = 0.5f), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(12.dp), ), onClick = { optionsMenuVisible = !optionsMenuVisible }, content = { Icon( Icons.Filled.MoreVert, contentDescription = "Settings", - tint = Color.White + tint = Color.White, ) }, ) @@ -981,27 +980,28 @@ private fun AppScreenContent( Column( modifier = Modifier .align(Alignment.BottomStart) - .padding(20.dp) + .padding(20.dp), ) { Text( - text = appInfo.name, + text = libraryItem.name, style = MaterialTheme.typography.headlineLarge.copy( fontWeight = FontWeight.Bold, shadow = Shadow( color = Color.Black.copy(alpha = 0.5f), offset = Offset(0f, 2f), - blurRadius = 10f - ) + blurRadius = 10f, + ), ), - color = Color.White + color = Color.White, ) + val developer = GameManagerService.getAppInfo(libraryItem)?.developer ?: "Unknown" + val releaseDate = GameManagerService.getReleaseDate(libraryItem) + Text( - text = "${appInfo.developer} • ${remember(appInfo.releaseDate) { - SimpleDateFormat("yyyy", Locale.getDefault()).format(Date(appInfo.releaseDate * 1000)) - }}", + text = "$developer • $releaseDate", style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.9f) + color = Color.White.copy(alpha = 0.9f), ) } } @@ -1010,16 +1010,17 @@ private fun AppScreenContent( Column( modifier = Modifier .fillMaxWidth() - .padding(24.dp) + .padding(24.dp), ) { // Action buttons Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { // Pause/Resume and Delete when downloading or paused // Determine if there's a partial download (in-session or from ungraceful close) - val isPartiallyDownloaded = (downloadProgress > 0f && downloadProgress < 1f) || SteamService.hasPartialDownload(appInfo.id) + val isPartiallyDownloaded = + (downloadProgress > 0f && downloadProgress < 1f) || GameManagerService.hasPartialDownload(libraryItem) // Disable resume when Wi-Fi only is enabled and there's no Wi-Fi val isResume = !isDownloading && isPartiallyDownloaded val pauseResumeEnabled = if (isResume) wifiAllowed else true @@ -1031,12 +1032,15 @@ private fun AppScreenContent( onClick = onPauseResumeClick, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(16.dp) + contentPadding = PaddingValues(16.dp), ) { Text( - text = if (isDownloading) stringResource(R.string.pause_download) - else stringResource(R.string.resume_download), - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) + text = if (isDownloading) { + stringResource(R.string.pause_download) + } else { + stringResource(R.string.resume_download) + }, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), ) } // Delete (Cancel) download data @@ -1046,7 +1050,7 @@ private fun AppScreenContent( shape = RoundedCornerShape(16.dp), border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(16.dp) + contentPadding = PaddingValues(16.dp), ) { Text(stringResource(R.string.delete_app), style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold)) } @@ -1065,7 +1069,7 @@ private fun AppScreenContent( }, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(16.dp) + contentPadding = PaddingValues(16.dp), ) { val text = when { isInstalled -> stringResource(R.string.run_app) @@ -1075,7 +1079,7 @@ private fun AppScreenContent( } Text( text = text, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), ) } // Uninstall if already installed @@ -1086,11 +1090,11 @@ private fun AppScreenContent( shape = RoundedCornerShape(16.dp), border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(16.dp) + contentPadding = PaddingValues(16.dp), ) { Text( text = stringResource(R.string.uninstall), - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), ) } } @@ -1122,21 +1126,21 @@ private fun AppScreenContent( } } Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "Installation Progress", - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, ) Text( text = "${(downloadProgress * 100f).toInt()}%", style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.tertiary + color = MaterialTheme.colorScheme.tertiary, ) } @@ -1149,7 +1153,7 @@ private fun AppScreenContent( .height(8.dp) .clip(RoundedCornerShape(4.dp)), color = MaterialTheme.colorScheme.tertiary, - trackColor = MaterialTheme.colorScheme.surfaceVariant + trackColor = MaterialTheme.colorScheme.surfaceVariant, ) Spacer(modifier = Modifier.height(8.dp)) @@ -1157,17 +1161,17 @@ private fun AppScreenContent( // This is placeholder text since we don't have exact size info in the state Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = "Downloading...", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = timeLeftText, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -1185,32 +1189,32 @@ private fun AppScreenContent( brush = Brush.linearGradient( colors = listOf( Color(0x1A06B6D4), - Color(0x1AA21CAF) + Color(0x1AA21CAF), ), start = Offset(0f, 0f), - end = Offset(1000f, 1000f) - ) + end = Offset(1000f, 1000f), + ), ) .border(1.dp, MaterialTheme.colorScheme.tertiary, RoundedCornerShape(16.dp)) - .padding(20.dp) + .padding(20.dp), ) { Column { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Box( modifier = Modifier .size(24.dp) .background(MaterialTheme.colorScheme.tertiary, CircleShape), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Text("↑", color = MaterialTheme.colorScheme.onTertiary, fontSize = 14.sp) } Text( "Update Available", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.tertiary + color = MaterialTheme.colorScheme.tertiary, ) } Spacer(modifier = Modifier.height(12.dp)) @@ -1220,9 +1224,9 @@ private fun AppScreenContent( shape = RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary + contentColor = MaterialTheme.colorScheme.onTertiary, ), - contentPadding = PaddingValues(12.dp) + contentPadding = PaddingValues(12.dp), ) { Text("Update Now", color = MaterialTheme.colorScheme.onTertiary) } @@ -1236,7 +1240,7 @@ private fun AppScreenContent( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.surfaceVariant) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.surfaceVariant), ) { Column(modifier = Modifier.fillMaxWidth()) { // Colored top border @@ -1248,17 +1252,17 @@ private fun AppScreenContent( brush = Brush.horizontalGradient( colors = listOf( MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary - ) - ) - ) + MaterialTheme.colorScheme.tertiary, + ), + ), + ), ) Column(modifier = Modifier.padding(24.dp)) { Text( text = "Game Information", style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(bottom = 16.dp), ) LazyVerticalGrid( @@ -1266,7 +1270,7 @@ private fun AppScreenContent( verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), // Setting a fixed height to avoid nested scrolling issues - modifier = Modifier.height(220.dp) + modifier = Modifier.height(220.dp), ) { // Status item item { @@ -1274,13 +1278,13 @@ private fun AppScreenContent( Text( text = "Status", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(4.dp)) Surface( shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f)) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f)), ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -1291,8 +1295,8 @@ private fun AppScreenContent( .size(8.dp) .background( color = MaterialTheme.colorScheme.tertiary, - shape = CircleShape - ) + shape = CircleShape, + ), ) Spacer(modifier = Modifier.width(8.dp)) Text( @@ -1302,7 +1306,7 @@ private fun AppScreenContent( else -> "Not Installed" }, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), - color = MaterialTheme.colorScheme.tertiary + color = MaterialTheme.colorScheme.tertiary, ) } } @@ -1315,23 +1319,30 @@ private fun AppScreenContent( Text( text = "Size", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(4.dp)) // Show skeleton while calculating disk size, otherwise show actual text if (isInstalled && (appSizeOnDisk.isEmpty() || appSizeOnDisk == " ...")) { SkeletonText(lines = 1, lineHeight = 20) } else { - if (!isInstalled){ + if (!isInstalled) { + // Use remember and LaunchedEffect to handle async size loading + var downloadSize by remember(libraryItem.gameId) { mutableStateOf("Loading...") } + + LaunchedEffect(libraryItem.gameId) { + // Now properly async - no more polling needed! + downloadSize = GameManagerService.getDownloadSize(libraryItem) + } + Text( - text = DownloadService.getSizeFromStoreDisplay(appInfo.id), - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + text = downloadSize, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), ) - } - else { + } else { Text( text = appSizeOnDisk, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), ) } } @@ -1340,13 +1351,12 @@ private fun AppScreenContent( // Location item if (isInstalled) { - item (span = { GridItemSpan(maxLineSpan) }) { - + item(span = { GridItemSpan(maxLineSpan) }) { Column { Text( text = "Location", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(4.dp)) Surface( @@ -1355,7 +1365,7 @@ private fun AppScreenContent( border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)), ) { Text( - text = getAppDirPath(appInfo.id), + text = GameManagerService.getAppDirPath(libraryItem.appId), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -1371,12 +1381,12 @@ private fun AppScreenContent( Text( text = "Developer", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = appInfo.developer, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + text = GameManagerService.getAppInfo(libraryItem)?.developer ?: "Unknown", + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), ) } } @@ -1387,15 +1397,12 @@ private fun AppScreenContent( Text( text = "Release Date", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = remember(appInfo.releaseDate) { - val date = Date(appInfo.releaseDate * 1000) - SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(date) - }, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + text = GameManagerService.getReleaseDate(libraryItem), + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), ) } } @@ -1459,7 +1466,6 @@ internal fun GameMigrationDialog( ) } - /*********** * PREVIEW * ***********/ @@ -1476,10 +1482,20 @@ private fun Preview_AppScreen() { val intent = Intent(context, SteamService::class.java) context.startForegroundService(intent) var isDownloading by remember { mutableStateOf(false) } + val gameManagerService = LocalGameManagerService.current + gameManagerService.ensureInitialized() + PluviaTheme { Surface { AppScreenContent( - appInfo = fakeAppInfo(1), + libraryItem = LibraryItem( + index = 0, + appId = "STEAM_1", + name = "Test Game", + iconHash = "", + isShared = false, + gameSource = GameSource.STEAM, + ), isInstalled = false, isValidToDownload = true, isDownloading = isDownloading, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index 1f7213988..c8bbb40c1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -5,54 +5,41 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.displayCutoutPadding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetState import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.AnimatedPane -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior -import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.gamenative.PrefManager +import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem -import app.gamenative.service.SteamService import app.gamenative.ui.data.LibraryState import app.gamenative.ui.enums.AppFilter -import app.gamenative.ui.enums.Orientation -import app.gamenative.events.AndroidEvent -import app.gamenative.PluviaApp import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.model.LibraryViewModel import app.gamenative.ui.screen.library.components.LibraryDetailPane import app.gamenative.ui.screen.library.components.LibraryListPane import app.gamenative.ui.theme.PluviaTheme -import java.util.EnumSet @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeLibraryScreen( viewModel: LibraryViewModel = hiltViewModel(), - onClickPlay: (Int, Boolean) -> Unit, + onClickPlay: (LibraryItem, Boolean) -> Unit, onNavigateRoute: (String) -> Unit, - onLogout: () -> Unit, ) { val state by viewModel.state.collectAsStateWithLifecycle() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -68,7 +55,6 @@ fun HomeLibraryScreen( onSearchQuery = viewModel::onSearchQuery, onClickPlay = onClickPlay, onNavigateRoute = onNavigateRoute, - onLogout = onLogout, ) } @@ -83,23 +69,24 @@ private fun LibraryScreenContent( onModalBottomSheet: (Boolean) -> Unit, onIsSearching: (Boolean) -> Unit, onSearchQuery: (String) -> Unit, - onClickPlay: (Int, Boolean) -> Unit, + onClickPlay: (LibraryItem, Boolean) -> Unit, onNavigateRoute: (String) -> Unit, - onLogout: () -> Unit, ) { - var selectedAppId by remember { mutableStateOf(null) } + var selectedGame by remember { mutableStateOf(null) } - BackHandler(selectedAppId != null) { selectedAppId = null } + BackHandler(selectedGame != null) { selectedGame = null } val safePaddingModifier = - if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { Modifier.displayCutoutPadding() - else + } else { Modifier + } Box( Modifier.background(MaterialTheme.colorScheme.background) - .then(safePaddingModifier)) { - if (selectedAppId == null) { + .then(safePaddingModifier), + ) { + if (selectedGame == null) { LibraryListPane( state = state, listState = listState, @@ -110,14 +97,13 @@ private fun LibraryScreenContent( onIsSearching = onIsSearching, onSearchQuery = onSearchQuery, onNavigateRoute = onNavigateRoute, - onLogout = onLogout, - onNavigate = { appId -> selectedAppId = appId } + onNavigate = { libraryItem -> selectedGame = libraryItem }, ) } else { LibraryDetailPane( - appId = selectedAppId ?: SteamService.INVALID_APP_ID, - onBack = { selectedAppId = null }, - onClickPlay = { onClickPlay(selectedAppId!!, it) }, + libraryItem = selectedGame!!, + onBack = { selectedGame = null }, + onClickPlay = { onClickPlay(selectedGame!!, it) }, ) } } @@ -149,7 +135,7 @@ private fun Preview_LibraryScreenContent() { val item = fakeAppInfo(idx) LibraryItem( index = idx, - appId = item.id, + appId = "${GameSource.STEAM.name}_${item.id}", name = item.name, iconHash = item.iconHash, ) @@ -173,7 +159,6 @@ private fun Preview_LibraryScreenContent() { }, onClickPlay = { _, _ -> }, onNavigateRoute = {}, - onLogout = {}, ) } } 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 636ffd0a8..281129750 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 @@ -6,7 +6,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -28,18 +27,19 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem -import app.gamenative.service.DownloadService -import app.gamenative.service.SteamService +import app.gamenative.service.GameManagerService +import app.gamenative.ui.internal.LocalGameManagerService import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.theme.PluviaTheme import app.gamenative.ui.util.ListItemImage @@ -47,15 +47,16 @@ import app.gamenative.ui.util.ListItemImage @Composable internal fun AppItem( modifier: Modifier = Modifier, - appInfo: LibraryItem, + appInfo: LibraryItem, // TODO: change this to libraryItem for clearity. onClick: () -> Unit, ) { + val context = LocalContext.current // Determine download and install state - val downloadInfo = remember(appInfo.appId) { SteamService.getAppDownloadInfo(appInfo.appId) } + val downloadInfo = remember(appInfo.appId) { GameManagerService.getDownloadInfo(appInfo) } val downloadProgress = remember(downloadInfo) { downloadInfo?.getProgress() ?: 0f } val isDownloading = downloadInfo != null && downloadProgress < 1f val isInstalled = remember(appInfo.appId) { - SteamService.isAppInstalled(appInfo.appId) + GameManagerService.isGameInstalled(context, appInfo) } var appSizeOnDisk by remember { mutableStateOf("") } @@ -63,7 +64,7 @@ internal fun AppItem( LaunchedEffect(Unit) { if (isInstalled) { appSizeOnDisk = "..." - DownloadService.getSizeOnDiskDisplay(appInfo.appId) { appSizeOnDisk = it } + appSizeOnDisk = GameManagerService.getGameDiskSize(context, appInfo) } } @@ -75,48 +76,48 @@ internal fun AppItem( .clickable { onClick() }, shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), ), border = androidx.compose.foundation.BorderStroke( width = 1.dp, - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) - ) + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + ), ) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { // Game icon Box( modifier = Modifier .size(60.dp) .clip(RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { ListItemImage( modifier = Modifier.size(56.dp), imageModifier = Modifier.clip(RoundedCornerShape(10.dp)), - image = { appInfo.clientIconUrl } + image = { appInfo.clientIconUrl }, ) } // Game info Column( - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( text = appInfo.name, style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ), - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Column( modifier = Modifier.padding(top = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.spacedBy(4.dp), ) { // Status indicator: Installing / Installed / Not installed val statusText = when { @@ -130,26 +131,26 @@ internal fun AppItem( } Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { // Status dot Box( modifier = Modifier .size(8.dp) - .background(color = statusColor, shape = CircleShape) + .background(color = statusColor, shape = CircleShape), ) // Status text Text( text = statusText, style = MaterialTheme.typography.bodyMedium, - color = statusColor + color = statusColor, ) // Download percentage when installing if (isDownloading) { Text( text = "${(downloadProgress * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium, - color = statusColor + color = statusColor, ) } } @@ -159,7 +160,7 @@ internal fun AppItem( Text( text = "$appSizeOnDisk", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -168,7 +169,7 @@ internal fun AppItem( Text( text = "Family Shared", style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), - color = MaterialTheme.colorScheme.tertiary + color = MaterialTheme.colorScheme.tertiary, ) } } @@ -178,16 +179,16 @@ internal fun AppItem( Button( onClick = onClick, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary + containerColor = MaterialTheme.colorScheme.primary, ), shape = RoundedCornerShape(12.dp), - modifier = Modifier.height(40.dp) + modifier = Modifier.height(40.dp), ) { Text( text = "Open", style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Bold - ) + fontWeight = FontWeight.Bold, + ), ) } } @@ -202,20 +203,24 @@ internal fun AppItem( @Preview(device = "spec:width=1920px,height=1080px,dpi=440") // Odin2 Mini @Composable private fun Preview_AppItem() { + val gameManagerService = LocalGameManagerService.current + gameManagerService.ensureInitialized() + PluviaTheme { Surface { LazyColumn( - modifier = Modifier.padding(16.dp) + modifier = Modifier.padding(16.dp), ) { items( items = List(5) { idx -> val item = fakeAppInfo(idx) LibraryItem( index = idx, - appId = item.id, + appId = "${GameSource.STEAM.name}_${item.id}", name = item.name, iconHash = item.iconHash, isShared = idx % 2 == 0, + gameSource = GameSource.STEAM, // Use FAKE game source ) }, itemContent = { 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 87f10827a..afb1f5422 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 @@ -39,8 +39,8 @@ fun LibraryBottomSheet( verticalArrangement = Arrangement.spacedBy(12.dp) ) { AppFilter.entries.forEach { appFilter -> - // TODO properly fix this (and the one below) - if (appFilter.code !in listOf(0x01, 0x20)) { + // App Type filters: exclude status and platform filters + if (appFilter.code !in listOf(0x01, 0x20, 0x40, 0x80)) { FlowFilterChip( onClick = { onFilterChanged(appFilter) }, label = { Text(text = appFilter.displayText) }, @@ -68,6 +68,23 @@ fun LibraryBottomSheet( } } + Spacer(modifier = Modifier.height(16.dp)) + + Text(text = "Platform", style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + FlowRow { + AppFilter.entries.forEach { appFilter -> + if (appFilter.code in listOf(0x40, 0x80)) { // Steam and GOG + FlowFilterChip( + onClick = { onFilterChanged(appFilter) }, + label = { Text(text = appFilter.displayText) }, + selected = selectedFilters.contains(appFilter), + leadingIcon = { Icon(imageVector = appFilter.icon, contentDescription = null) }, + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) // A little extra padding. } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt index 7f7a4d064..bd9bb0acc 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt @@ -9,9 +9,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import app.gamenative.PrefManager -import app.gamenative.service.SteamService +import app.gamenative.data.GameSource +import app.gamenative.data.LibraryItem import app.gamenative.ui.data.LibraryState import app.gamenative.ui.enums.AppFilter +import app.gamenative.ui.internal.LocalGameManagerService import app.gamenative.ui.screen.library.AppScreen import app.gamenative.ui.theme.PluviaTheme import java.util.EnumSet @@ -19,12 +21,12 @@ import java.util.EnumSet @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun LibraryDetailPane( - appId: Int, + libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit, onBack: () -> Unit, ) { Surface { - if (appId == SteamService.INVALID_APP_ID) { + if (libraryItem == null) { // Simply use the regular LibraryListPane with empty data val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState() @@ -32,7 +34,7 @@ internal fun LibraryDetailPane( LibraryState( appInfoList = emptyList(), // Use the same default filter as in PrefManager (GAME) - appInfoSortType = EnumSet.of(AppFilter.GAME) + appInfoSortType = EnumSet.of(AppFilter.GAME), ) } @@ -44,14 +46,13 @@ internal fun LibraryDetailPane( onPageChange = {}, onModalBottomSheet = {}, onIsSearching = {}, - onLogout = {}, - onNavigate = {}, onSearchQuery = {}, onNavigateRoute = {}, + onNavigate = {}, ) } else { AppScreen( - appId = appId, + libraryItem = libraryItem, onClickPlay = onClickPlay, onBack = onBack, ) @@ -68,9 +69,19 @@ internal fun LibraryDetailPane( @Composable private fun Preview_LibraryDetailPane() { PrefManager.init(LocalContext.current) + val gameManagerService = LocalGameManagerService.current + gameManagerService.ensureInitialized() + PluviaTheme { LibraryDetailPane( - appId = Int.MAX_VALUE, + libraryItem = LibraryItem( + index = 0, + appId = "${GameSource.STEAM.name}_123", + name = "Test Game", + iconHash = "", + isShared = false, + gameSource = GameSource.STEAM, + ), onClickPlay = { }, onBack = { }, ) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryList.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryList.kt deleted file mode 100644 index 901f53245..000000000 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryList.kt +++ /dev/null @@ -1,72 +0,0 @@ -package app.gamenative.ui.screen.library.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import app.gamenative.data.LibraryItem - -@Composable -internal fun LibraryList( - modifier: Modifier = Modifier, - contentPaddingValues: PaddingValues, - listState: LazyListState, - list: List, - onItemClick: (Int) -> Unit, -) { - if (list.isEmpty()) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Surface( - modifier = Modifier.padding(horizontal = 24.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - shadowElevation = 8.dp, - ) { - Text( - modifier = Modifier.padding(24.dp), - text = "No items listed with selection", - ) - } - } - } else { - LazyColumn( - modifier = modifier - .fillMaxSize() - .navigationBarsPadding(), - state = listState, - contentPadding = contentPaddingValues, - ) { - items(items = list, key = { it.index }) { item -> - AppItem( - modifier = Modifier.animateItem(), - appInfo = item, - onClick = { onItemClick(item.appId) }, - ) - - if (item.index < list.lastIndex) { - HorizontalDivider() - } - } - } - } -} - -/*********** - * PREVIEW * - ***********/ 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 f8de50c9b..e481ea189 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 @@ -9,12 +9,16 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -24,13 +28,16 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -39,24 +46,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import app.gamenative.PrefManager +import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem +import app.gamenative.service.DownloadService +import app.gamenative.ui.component.topbar.AccountButton import app.gamenative.ui.data.LibraryState import app.gamenative.ui.enums.AppFilter import app.gamenative.ui.internal.fakeAppInfo -import app.gamenative.service.DownloadService import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.component.topbar.AccountButton -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import app.gamenative.PrefManager import app.gamenative.utils.DeviceUtils -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -68,8 +69,7 @@ internal fun LibraryListPane( onModalBottomSheet: (Boolean) -> Unit, onPageChange: (Int) -> Unit, onIsSearching: (Boolean) -> Unit, - onLogout: () -> Unit, - onNavigate: (Int) -> Unit, + onNavigate: (LibraryItem) -> Unit, onSearchQuery: (String) -> Unit, onNavigateRoute: (String) -> Unit, ) { @@ -86,31 +86,32 @@ internal fun LibraryListPane( .filterNotNull() .distinctUntilChanged() .collect { lastVisibleIndex -> - if (lastVisibleIndex >= state.appInfoList.lastIndex - && state.appInfoList.size < state.totalAppsInFilter) { + if (lastVisibleIndex >= state.appInfoList.lastIndex && + state.appInfoList.size < state.totalAppsInFilter + ) { onPageChange(1) } } } Scaffold( - snackbarHost = { SnackbarHost(snackBarHost) } + snackbarHost = { SnackbarHost(snackBarHost) }, ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() - .padding(top = paddingValues.calculateTopPadding()) + .padding(top = paddingValues.calculateTopPadding()), ) { // Modern Header with gradient Box( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(16.dp), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween, ) { Column { Text( @@ -120,15 +121,15 @@ internal fun LibraryListPane( brush = Brush.horizontalGradient( colors = listOf( MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary - ) - ) - ) + MaterialTheme.colorScheme.tertiary, + ), + ), + ), ) Text( text = "${state.totalAppsInFilter} games • $installedCount installed", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -136,7 +137,7 @@ internal fun LibraryListPane( Box( modifier = Modifier .weight(1f) - .padding(horizontal = 30.dp) + .padding(horizontal = 30.dp), ) { LibrarySearchBar( state = state, @@ -151,22 +152,21 @@ internal fun LibraryListPane( modifier = Modifier .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) - .padding(8.dp) + .padding(8.dp), ) { AccountButton( onNavigateRoute = onNavigateRoute, - onLogout = onLogout ) } } } - if (! isViewWide) { + if (!isViewWide) { // Search bar Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp) + .padding(horizontal = 20.dp, vertical = 12.dp), ) { LibrarySearchBar( state = state, @@ -186,14 +186,14 @@ internal fun LibraryListPane( contentPadding = PaddingValues( start = 20.dp, end = 20.dp, - bottom = 72.dp + bottom = 72.dp, ), ) { items(items = state.appInfoList, key = { it.index }) { item -> AppItem( modifier = Modifier.animateItem(), appInfo = item, - onClick = { onNavigate(item.appId) } + onClick = { onNavigate(item) }, ) if (item.index < state.appInfoList.lastIndex) { HorizontalDivider() @@ -205,7 +205,7 @@ internal fun LibraryListPane( modifier = Modifier .fillMaxWidth() .padding(16.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } @@ -224,7 +224,7 @@ internal fun LibraryListPane( contentColor = MaterialTheme.colorScheme.onPrimary, modifier = Modifier .align(Alignment.BottomEnd) - .padding(24.dp) + .padding(24.dp), ) } @@ -264,7 +264,7 @@ private fun Preview_LibraryListPane() { val item = fakeAppInfo(idx) LibraryItem( index = idx, - appId = item.id, + appId = "${GameSource.STEAM.name}_${item.id}", name = item.name, iconHash = item.iconHash, isShared = idx % 2 == 0, @@ -289,7 +289,6 @@ private fun Preview_LibraryListPane() { onIsSearching = { }, onSearchQuery = { }, onNavigateRoute = { }, - onLogout = { }, onNavigate = { }, ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibrarySearchBar.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibrarySearchBar.kt index 27d463e31..32f42f01e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibrarySearchBar.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibrarySearchBar.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.gamenative.PrefManager import app.gamenative.data.LibraryItem +import app.gamenative.data.GameSource import app.gamenative.ui.data.LibraryState import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.theme.PluviaTheme @@ -136,7 +137,7 @@ private fun Preview_LibrarySearchBar() { val item = fakeAppInfo(idx) LibraryItem( index = idx, - appId = item.id, + appId = "${GameSource.STEAM.name}_${item.id}", name = item.name, iconHash = item.iconHash, ) 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 302f32e06..aef488751 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 @@ -26,27 +26,27 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import app.gamenative.PluviaApp import app.gamenative.PrefManager -import app.gamenative.R import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent +import app.gamenative.service.GameManagerService import app.gamenative.service.SteamService import app.gamenative.ui.data.XServerState import app.gamenative.utils.ContainerUtils import com.posthog.PostHog +import com.winlator.PrefManager as WinlatorPrefManager import com.winlator.alsaserver.ALSAClient import com.winlator.container.Container import com.winlator.container.ContainerManager import com.winlator.contentdialog.NavigationDialog import com.winlator.contents.ContentsManager import com.winlator.core.AppUtils -import com.winlator.core.AppUtils.showKeyboard import com.winlator.core.Callback import com.winlator.core.DXVKHelper import com.winlator.core.DefaultVersion @@ -64,8 +64,6 @@ import com.winlator.core.WineThemeManager import com.winlator.core.WineUtils import com.winlator.core.envvars.EnvVars import com.winlator.inputcontrols.ControlsProfile -import com.winlator.inputcontrols.ControlElement -import com.winlator.inputcontrols.Binding import com.winlator.inputcontrols.ExternalController import com.winlator.inputcontrols.InputControlsManager import com.winlator.inputcontrols.TouchMouse @@ -95,13 +93,6 @@ import com.winlator.xserver.ScreenInfo import com.winlator.xserver.Window import com.winlator.xserver.WindowManager import com.winlator.xserver.XServer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject -import timber.log.Timber import java.io.BufferedReader import java.io.File import java.io.IOException @@ -112,7 +103,12 @@ import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption.REPLACE_EXISTING import java.util.Arrays import kotlin.io.path.name -import com.winlator.PrefManager as WinlatorPrefManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber // TODO logs in composables are 'unstable' which can cause recomposition (performance issues) @@ -120,7 +116,7 @@ import com.winlator.PrefManager as WinlatorPrefManager @OptIn(ExperimentalComposeUiApi::class) fun XServerScreen( lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, - appId: Int, + libraryItem: LibraryItem, bootToContainer: Boolean, navigateBack: () -> Unit, onExit: () -> Unit, @@ -128,7 +124,8 @@ fun XServerScreen( onWindowUnmapped: ((Window) -> Unit)? = null, onGameLaunchError: ((String) -> Unit)? = null, ) { - Timber.i("Starting up XServerScreen") + Timber.i("Starting up XServerScreen for ${libraryItem.gameSource} game: ${libraryItem.name}") + val appId = libraryItem.appId // For backward compatibility with existing code val context = LocalContext.current val view = LocalView.current val imm = remember(context) { @@ -187,11 +184,12 @@ fun XServerScreen( var keyboard by remember { mutableStateOf(null) } // var pointerEventListener by remember { mutableStateOf?>(null) } - val appLaunchInfo = SteamService.getAppInfoOf(appId)?.let { appInfo -> - SteamService.getWindowsLaunchInfos(appId).firstOrNull() + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val appLaunchInfo = SteamService.getAppInfoOf(gameId)?.let { appInfo -> + SteamService.getWindowsLaunchInfos(gameId).firstOrNull() } - var currentAppInfo = SteamService.getAppInfoOf(appId) + var currentAppInfo = SteamService.getAppInfoOf(gameId) var xServerView: XServerView? by remember { val result = mutableStateOf(null) @@ -229,8 +227,11 @@ fun XServerScreen( when (itemId) { NavigationDialog.ACTION_KEYBOARD -> { val anchor = view // use the same composable root view - val c = if (Build.VERSION.SDK_INT >= 30) - anchor.windowInsetsController else null + val c = if (Build.VERSION.SDK_INT >= 30) { + anchor.windowInsetsController + } else { + null + } anchor.post { if (anchor.windowToken == null) return@post @@ -239,7 +240,7 @@ fun XServerScreen( imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) } if (Build.VERSION.SDK_INT > 29 && c != null) { - anchor.postDelayed({ show() }, 500) // Pixel/Android-12+ quirk + anchor.postDelayed({ show() }, 500) // Pixel/Android-12+ quirk } else { show() } @@ -247,9 +248,9 @@ fun XServerScreen( } NavigationDialog.ACTION_INPUT_CONTROLS -> { - if (areControlsVisible){ + if (areControlsVisible) { PostHog.capture(event = "onscreen_controller_disabled") - hideInputControls(); + hideInputControls() } else { PostHog.capture(event = "onscreen_controller_enabled") val profiles = PluviaApp.inputControlsManager?.getProfiles(false) ?: listOf() @@ -286,7 +287,6 @@ fun XServerScreen( } }, ).show() - } DisposableEffect(lifecycleOwner) { @@ -301,7 +301,11 @@ fun XServerScreen( var handled = false if (isGamepad) { - val emulate = try { ContainerUtils.getContainer(context, appId).isEmulateKeyboardMouse() } catch (_: Exception) { false } + val emulate = try { + ContainerUtils.getContainer(context, appId).isEmulateKeyboardMouse() + } catch (_: Exception) { + false + } if (emulate) { handled = PluviaApp.inputControlsView?.onKeyEvent(it.event) == true if (!handled) handled = xServerView!!.getxServer().winHandler.onKeyEvent(it.event) @@ -320,7 +324,11 @@ fun XServerScreen( var handled = false if (isGamepad) { - val emulate = try { ContainerUtils.getContainer(context, appId).isEmulateKeyboardMouse() } catch (_: Exception) { false } + val emulate = try { + ContainerUtils.getContainer(context, appId).isEmulateKeyboardMouse() + } catch (_: Exception) { + false + } if (emulate) { handled = PluviaApp.inputControlsView?.onGenericMotionEvent(it.event) == true if (!handled) handled = xServerView!!.getxServer().winHandler.onGenericMotionEvent(it.event) @@ -432,7 +440,8 @@ fun XServerScreen( val renderer = this.renderer renderer.isCursorVisible = false getxServer().renderer = renderer - PluviaApp.touchpadView = TouchpadView(context, getxServer(), PrefManager.getBoolean("capture_pointer_on_external_mouse", true)) + PluviaApp.touchpadView = + TouchpadView(context, getxServer(), PrefManager.getBoolean("capture_pointer_on_external_mouse", true)) frameLayout.addView(PluviaApp.touchpadView) PluviaApp.touchpadView?.setMoveCursorToTouchpoint(PrefManager.getBoolean("move_cursor_to_touchpoint", false)) getxServer().winHandler = WinHandler(getxServer(), this) @@ -486,11 +495,11 @@ fun XServerScreen( override fun onMapWindow(window: Window) { Timber.i( "onMapWindow:" + - "\n\twindowName: ${window.name}" + - "\n\twindowClassName: ${window.className}" + - "\n\tprocessId: ${window.processId}" + - "\n\thasParent: ${window.parent != null}" + - "\n\tchildrenSize: ${window.children.size}", + "\n\twindowName: ${window.name}" + + "\n\twindowClassName: ${window.className}" + + "\n\tprocessId: ${window.processId}" + + "\n\thasParent: ${window.parent != null}" + + "\n\tchildrenSize: ${window.children.size}", ) win32AppWorkarounds?.applyWindowWorkarounds(window) onWindowMapped?.invoke(context, window) @@ -499,11 +508,11 @@ fun XServerScreen( override fun onUnmapWindow(window: Window) { Timber.i( "onUnmapWindow:" + - "\n\twindowName: ${window.name}" + - "\n\twindowClassName: ${window.className}" + - "\n\tprocessId: ${window.processId}" + - "\n\thasParent: ${window.parent != null}" + - "\n\tchildrenSize: ${window.children.size}", + "\n\twindowName: ${window.name}" + + "\n\twindowClassName: ${window.className}" + + "\n\tprocessId: ${window.processId}" + + "\n\thasParent: ${window.parent != null}" + + "\n\tchildrenSize: ${window.children.size}", ) changeFrameRatingVisibility(window, null) onWindowUnmapped?.invoke(window) @@ -596,7 +605,7 @@ fun XServerScreen( changeWineAudioDriver(xServerState.value.audioDriver, container, ImageFs.find(context)) PluviaApp.xEnvironment = setupXEnvironment( context, - appId, + libraryItem, bootToContainer, xServerState, envVars, @@ -607,7 +616,7 @@ fun XServerScreen( ) } } - PluviaApp.xServerView = xServerView; + PluviaApp.xServerView = xServerView frameLayout.addView(xServerView) @@ -892,7 +901,7 @@ private fun assignTaskAffinity( val processAffinity = if (window.isWoW64()) taskAffinityMaskWoW64 else taskAffinityMask if (className.equals("steam.exe")) { - return; + return } if (processId > 0) { winHandler.setProcessAffinity(processId, processAffinity) @@ -957,7 +966,7 @@ private fun shiftXEnvironmentToContext( } private fun setupXEnvironment( context: Context, - appId: Int, + libraryItem: LibraryItem, bootToContainer: Boolean, xServerState: MutableState, // xServerViewModel: XServerViewModel, @@ -969,6 +978,7 @@ private fun setupXEnvironment( xServer: XServer, onGameLaunchError: ((String) -> Unit)? = null, ): XEnvironment { + val appId = libraryItem.appId // For backward compatibility val lc_all = container!!.lC_ALL val imageFs = ImageFs.find(context) Timber.i("ImageFs paths:") @@ -984,13 +994,13 @@ private fun setupXEnvironment( envVars.put("MESA_NO_ERROR", "1") envVars.put("WINEPREFIX", imageFs.wineprefix) envVars.put("WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER", "1") - if (container.isShowFPS){ + if (container.isShowFPS) { envVars.put("DXVK_HUD", "fps,frametimes") envVars.put("VK_INSTANCE_LAYERS", "VK_LAYER_MESA_overlay") envVars.put("MESA_OVERLAY_SHOW_FPS", 1) } - if (container.isSdlControllerAPI){ - if (container.inputType == PreferredInputApi.XINPUT.ordinal || container.inputType == PreferredInputApi.AUTO.ordinal){ + if (container.isSdlControllerAPI) { + if (container.inputType == PreferredInputApi.XINPUT.ordinal || container.inputType == PreferredInputApi.AUTO.ordinal) { envVars.put("SDL_XINPUT_ENABLED", "1") envVars.put("SDL_DIRECTINPUT_ENABLED", "0") envVars.put("SDL_JOYSTICK_HIDAPI", "1") @@ -1019,10 +1029,11 @@ private fun setupXEnvironment( // explicitly enable or disable Wine debug channels envVars.put( "WINEDEBUG", - if (enableWineDebug && wineDebugChannels.isNotEmpty()) + if (enableWineDebug && wineDebugChannels.isNotEmpty()) { "+" + wineDebugChannels.replace(",", ",+") - else - "-all", + } else { + "-all" + }, ) // capture debug output to file if either Wine or Box86/64 logging is enabled if (enableWineDebug || enableBox86Logs) { @@ -1045,8 +1056,7 @@ private fun setupXEnvironment( contentsManager, contentsManager.getProfileByEntryName(container.wineVersion), ) - } - else { + } else { Timber.i("Setting guestProgramLauncherComponent to GuestProgramLauncherComponent") GuestProgramLauncherComponent() } @@ -1057,7 +1067,7 @@ private fun setupXEnvironment( val wow64Mode = container.isWoW64Mode // String guestExecutable = wineInfo.getExecutable(this, wow64Mode)+" explorer /desktop=shell,"+xServer.screenInfo+" "+getWineStartCommand(); val guestExecutable = "wine explorer /desktop=shell," + xServer.screenInfo + " " + - getWineStartCommand(appId, container, bootToContainer, appLaunchInfo, envVars, guestProgramLauncherComponent) + + GameManagerService.getWineStartCommand(context, libraryItem, container, bootToContainer, appLaunchInfo, envVars, guestProgramLauncherComponent) + (if (container.execArgs.isNotEmpty()) " " + container.execArgs else "") guestProgramLauncherComponent.isWoW64Mode = wow64Mode guestProgramLauncherComponent.guestExecutable = guestExecutable @@ -1118,11 +1128,14 @@ private fun setupXEnvironment( UnixSocketConfig.createSocket(rootPath, UnixSocketConfig.VIRGL_SERVER_PATH), ), ) - } else if (xServerState.value.graphicsDriver == "vortek" || xServerState.value.graphicsDriver == "adreno" || xServerState.value.graphicsDriver == "sd-8-elite") { + } else if (xServerState.value.graphicsDriver == "vortek" || + xServerState.value.graphicsDriver == "adreno" || + xServerState.value.graphicsDriver == "sd-8-elite" + ) { Timber.i("Adding VortekRendererComponent to Environment") val gcfg = KeyValueSet(container.getGraphicsDriverConfig()) val graphicsDriver = xServerState.value.graphicsDriver - if (graphicsDriver == "adreno"){ + if (graphicsDriver == "adreno") { gcfg.put("adrenotoolsDriver", "vulkan.ad8190.so") container.setGraphicsDriverConfig(gcfg.toString()) } else if (graphicsDriver == "sd-8-elite") { @@ -1160,7 +1173,7 @@ private fun setupXEnvironment( Timber.i("CPU List: ${container.cpuList}") Timber.i("CPU List WoW64: ${container.cpuListWoW64}") Timber.i("Env Vars (Container Base): ${container.envVars}") // Log base container vars - Timber.i("Env Vars (Final Guest): ${envVars.toString()}") // Log the actual env vars being passed + Timber.i("Env Vars (Final Guest): $envVars") // Log the actual env vars being passed Timber.i("Guest Executable: ${guestProgramLauncherComponent.guestExecutable}") // Log the command Timber.i("---------------------------") } @@ -1186,65 +1199,15 @@ private fun setupXEnvironment( ) return environment } -private fun getWineStartCommand( - appId: Int, - container: Container, - bootToContainer: Boolean, - appLaunchInfo: LaunchInfo?, - envVars: EnvVars, - guestProgramLauncherComponent: GuestProgramLauncherComponent, -): String { - val tempDir = File(container.getRootDir(), ".wine/drive_c/windows/temp") - FileUtils.clear(tempDir) - - Timber.tag("XServerScreen").d("appLaunchInfo is $appLaunchInfo") - val args = if (bootToContainer || appLaunchInfo == null) { - "\"wfm.exe\"" - } else { - // Check if we should launch through real Steam - 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 $appId" - } else { - // Original logic for direct game launch - val appDirPath = SteamService.getAppDirPath(appId) - var executablePath = "" - if (container.executablePath.isNotEmpty()) { - executablePath = container.executablePath - } else { - executablePath = SteamService.getInstalledExe(appId) - container.executablePath = executablePath - container.saveData() - } - val executableDir = appDirPath + "/" + executablePath.substringBeforeLast("/", "") - guestProgramLauncherComponent.workingDir = File(executableDir); - Timber.i("Working directory is ${executableDir}") - - Timber.i("Final exe path is " + executablePath) - val drives = container.drives - val driveIndex = drives.indexOf(appDirPath) - // greater than 1 since there is the drive character and the colon before the app dir path - val drive = if (driveIndex > 1) { - drives[driveIndex - 2] - } else { - Timber.e("Could not locate game drive") - 'D' - } - envVars.put("WINEPATH", "$drive:/${appLaunchInfo.workingDir}") - "\"$drive:/${executablePath}\"" - } - } - return "winhandler.exe $args" -} private fun getSteamlessTarget( - appId: Int, + appId: String, container: Container, appLaunchInfo: LaunchInfo?, ): String { - val appDirPath = SteamService.getAppDirPath(appId) - val executablePath = SteamService.getInstalledExe(appId) + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val appDirPath = SteamService.getAppDirPath(gameId) + val executablePath = SteamService.getInstalledExe(gameId) val drives = container.drives val driveIndex = drives.indexOf(appDirPath) // greater than 1 since there is the drive character and the colon before the app dir path @@ -1254,7 +1217,7 @@ private fun getSteamlessTarget( Timber.e("Could not locate game drive") 'D' } - return "$drive:\\${executablePath}" + return "$drive:\\$executablePath" } private fun exit(winHandler: WinHandler?, environment: XEnvironment?, onExit: () -> Unit) { Timber.i("Exit called") @@ -1278,11 +1241,11 @@ private fun unpackExecutableFile( context: Context, needsUnpacking: Boolean, container: Container, - appId: Int, + appId: String, appLaunchInfo: LaunchInfo?, onError: ((String) -> Unit)? = null, ) { - if (!needsUnpacking){ + if (!needsUnpacking) { return } val shellCommandEnvVars = EnvVars() @@ -1297,19 +1260,21 @@ private fun unpackExecutableFile( shellCommandEnvVars.put( "PATH", winePath + ":" + - imageFs.getRootDir().getPath() + "/usr/bin:" + - imageFs.getRootDir().getPath() + "/usr/local/bin", + imageFs.getRootDir().getPath() + "/usr/bin:" + + imageFs.getRootDir().getPath() + "/usr/local/bin", ) shellCommandEnvVars.put("LD_LIBRARY_PATH", imageFs.getRootDir().getPath() + "/usr/lib") shellCommandEnvVars.put("BOX64_LD_LIBRARY_PATH", imageFs.getRootDir().getPath() + "/usr/lib/x86_64-linux-gnu") shellCommandEnvVars.put("ANDROID_SYSVSHM_SERVER", imageFs.getRootDir().getPath() + UnixSocketConfig.SYSVSHM_SERVER_PATH) shellCommandEnvVars.put("FONTCONFIG_PATH", imageFs.getRootDir().getPath() + "/usr/etc/fonts") - shellCommandEnvVars.put("WINEDLLOVERRIDES", "winex11.drv=b"); + shellCommandEnvVars.put("WINEDLLOVERRIDES", "winex11.drv=b") if ((File(imageFs.getGlibc64Dir(), "libandroid-sysvshm.so")).exists() || (File(imageFs.getGlibc32Dir(), "libandroid-sysvshm.so")).exists() - ) shellCommandEnvVars.put("LD_PRELOAD", "libredirect.so libandroid-sysvshm.so") + ) { + shellCommandEnvVars.put("LD_PRELOAD", "libredirect.so libandroid-sysvshm.so") + } if (!shellCommandEnvVars.has("WINEESYNC")) shellCommandEnvVars.put("WINEESYNC", "1") shellCommandEnvVars.put("WINEESYNC_WINLATOR", "1") val rootDir: File = imageFs.getRootDir() @@ -1319,7 +1284,7 @@ private fun unpackExecutableFile( try { // a:/.../GameDir/orig_dll_path.txt (same dir as the EXE inside A:) - val origTxtFile = File("${imageFs.wineprefix}/dosdevices/a:/orig_dll_path.txt") + val origTxtFile = File("${imageFs.wineprefix}/dosdevices/a:/orig_dll_path.txt") if (origTxtFile.exists()) { val relDllPath = origTxtFile.readText().trim() @@ -1341,10 +1306,10 @@ private fun unpackExecutableFile( shellCommandEnvVars.toStringArray(), imageFs.getRootDir(), ) - val genReader = BufferedReader(InputStreamReader(genProc.inputStream)) - val genErrReader = BufferedReader(InputStreamReader(genProc.errorStream)) - while (genReader.readLine().also { line = it } != null) genOutput.append(line).append('\n') - while (genErrReader.readLine().also { line = it } != null) genOutput.append(line).append('\n') + val genReader = BufferedReader(InputStreamReader(genProc.inputStream)) + val genErrReader = BufferedReader(InputStreamReader(genProc.errorStream)) + while (genReader.readLine().also { line = it } != null) genOutput.append(line).append('\n') + while (genErrReader.readLine().also { line = it } != null) genOutput.append(line).append('\n') genProc.waitFor() val origSteamInterfaces = File("${imageFs.wineprefix}/dosdevices/z:/steam_interfaces.txt") @@ -1627,12 +1592,13 @@ private fun extractDXWrapperFiles( "vkd3d" -> { Timber.i("Extracting VKD3D D3D12 DLLs for dxwrapper: $dxwrapper") // Determine graphics driver to choose DXVK version - val vortekLike = container.graphicsDriver == "vortek" || container.graphicsDriver == "adreno" || container.graphicsDriver == "sd-8-elite" + val vortekLike = + container.graphicsDriver == "vortek" || container.graphicsDriver == "adreno" || container.graphicsDriver == "sd-8-elite" val dxvkVersionForVkd3d = if (vortekLike) "1.10.3" else "2.3.1" Timber.i("Extracting VKD3D DX version for dxwrapper: $dxvkVersionForVkd3d") TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, context.assets, - "dxwrapper/dxvk-${dxvkVersionForVkd3d}.tzst", windowsDir, onExtractFileListener, + "dxwrapper/dxvk-$dxvkVersionForVkd3d.tzst", windowsDir, onExtractFileListener, ) // Determine VKD3D version from state config Timber.i("Extracting VKD3D D3D12 DLLs version: $dxwrapper") @@ -1798,11 +1764,11 @@ private fun extractGraphicsDriverFiles( var cacheId = graphicsDriver if (graphicsDriver == "turnip") { cacheId += "-" + turnipVersion + "-" + zinkVersion - if (turnipVersion == "25.2.0"){ + if (turnipVersion == "25.2.0") { if (GPUInformation.isAdreno710_720_732(context)) { - envVars.put("TU_DEBUG", "gmem"); + envVars.put("TU_DEBUG", "gmem") } else { - envVars.put("TU_DEBUG", "sysmem"); + envVars.put("TU_DEBUG", "sysmem") } } } else if (graphicsDriver == "virgl") { @@ -1858,13 +1824,13 @@ private fun extractGraphicsDriverFiles( TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, context.assets, - "graphics_driver/turnip-${turnipVersion}.tzst", + "graphics_driver/turnip-$turnipVersion.tzst", rootDir, ) TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, context.assets, - "graphics_driver/zink-${zinkVersion}.tzst", + "graphics_driver/zink-$zinkVersion.tzst", rootDir, ) } @@ -1878,7 +1844,7 @@ private fun extractGraphicsDriverFiles( if (changed) { TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, context.assets, - "graphics_driver/virgl-${virglVersion}.tzst", rootDir, + "graphics_driver/virgl-$virglVersion.tzst", rootDir, ) } } else if (graphicsDriver == "vortek") { @@ -1908,7 +1874,7 @@ private fun extractGraphicsDriverFiles( val identifier = readZipManifestNameFromAssets(context, assetZip) ?: assetZip.substringBeforeLast('.') // Only (re)extract if changed - val adrenoCacheId = "${graphicsDriver}-${identifier}" + val adrenoCacheId = "$graphicsDriver-$identifier" val needsExtract = changed || adrenoCacheId != container.getExtra("graphicsDriverAdreno") if (needsExtract) { @@ -1939,7 +1905,6 @@ private fun extractGraphicsDriverFiles( TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, context.assets, "graphics_driver/zink-22.2.5.tzst", rootDir) } } - } private fun readZipManifestNameFromAssets(context: Context, assetName: String): String? { @@ -1955,7 +1920,9 @@ private fun readLibraryNameFromExtractedDir(destinationDir: File): String? { val json = org.json.JSONObject(content) val libraryName = json.optString("libraryName", "").trim() if (libraryName.isNotEmpty()) libraryName else null - } else null + } else { + null + } } catch (_: Exception) { null } diff --git a/app/src/main/java/app/gamenative/ui/util/Images.kt b/app/src/main/java/app/gamenative/ui/util/Images.kt index 0987f0823..bbc453f2c 100644 --- a/app/src/main/java/app/gamenative/ui/util/Images.kt +++ b/app/src/main/java/app/gamenative/ui/util/Images.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -67,7 +68,7 @@ internal fun SteamIconImage( CircularProgressIndicator() }, failure = { - Icon(Icons.Filled.QuestionMark, null) + Icon(Icons.Default.AccountCircle, null) }, previewPlaceholder = painterResource(R.drawable.ic_logo_color), ) diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index bda12fde6..f6e0bb8fd 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -1,8 +1,10 @@ package app.gamenative.utils import android.content.Context -import app.gamenative.enums.Marker import app.gamenative.PrefManager +import app.gamenative.data.GameSource +import app.gamenative.enums.Marker +import app.gamenative.service.GameManagerService import app.gamenative.service.SteamService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -17,12 +19,13 @@ import com.winlator.core.WineThemeManager import kotlinx.coroutines.CompletableDeferred import com.winlator.inputcontrols.ControlsProfile import com.winlator.inputcontrols.InputControlsManager +import com.winlator.winhandler.WinHandler.PreferredInputApi +import com.winlator.xenvironment.ImageFs import java.io.File import kotlin.Boolean import org.json.JSONArray import org.json.JSONObject import timber.log.Timber -import com.winlator.winhandler.WinHandler.PreferredInputApi object ContainerUtils { data class GpuInfo( @@ -176,14 +179,26 @@ object ContainerUtils { box86Preset = container.box86Preset, box64Preset = container.box64Preset, desktopTheme = container.desktopTheme, - language = try { container.language } catch (e: Exception) { container.getExtra("language", "english") }, + language = try { + container.language + } catch (e: Exception) { + container.getExtra("language", "english") + }, sdlControllerAPI = container.isSdlControllerAPI, enableXInput = enableX, enableDInput = enableD, dinputMapperType = mapperType, disableMouseInput = disableMouse, - emulateKeyboardMouse = try { container.isEmulateKeyboardMouse() } catch (e: Exception) { false }, - controllerEmulationBindings = try { container.getControllerEmulationBindings()?.toString() ?: "" } catch (e: Exception) { "" }, + emulateKeyboardMouse = try { + container.isEmulateKeyboardMouse() + } catch (e: Exception) { + false + }, + controllerEmulationBindings = try { + container.getControllerEmulationBindings()?.toString() ?: "" + } catch (e: Exception) { + "" + }, csmt = csmt, videoPciDeviceID = videoPciDeviceID, offScreenRenderingMode = offScreenRenderingMode, @@ -193,14 +208,9 @@ object ContainerUtils { ) } - fun applyToContainer(context: Context, appId: Int, containerData: ContainerData) { - val containerManager = ContainerManager(context) - if (containerManager.hasContainer(appId)) { - val container = containerManager.getContainerById(appId) - applyToContainer(context, container, containerData) - } else { - throw Exception("Container does not exist for $appId") - } + fun applyToContainer(context: Context, appId: String, containerData: ContainerData) { + val container = getContainer(context, appId) + applyToContainer(context, container, containerData) } fun applyToContainer(context: Context, container: Container, containerData: ContainerData) { @@ -268,13 +278,18 @@ object ContainerUtils { container.setControllerEmulationBindings(org.json.JSONObject(bindingsStr)) } } catch (_: Exception) {} - try { container.language = containerData.language } catch (e: Exception) { container.putExtra("language", containerData.language) } + try { + container.language = containerData.language + } catch (e: Exception) { + container.putExtra("language", containerData.language) + } // Set container LC_ALL according to selected language val lcAll = mapLanguageToLocale(containerData.language) container.setLC_ALL(lcAll) // If language changed, remove the STEAM_DLL_REPLACED marker so settings regenerate if (previousLanguage.lowercase() != containerData.language.lowercase()) { - val appDirPath = SteamService.getAppDirPath(container.id) + val steamAppId = extractGameIdFromContainerId(container.id) + val appDirPath = SteamService.getAppDirPath(steamAppId) MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) Timber.i("Language changed from '$previousLanguage' to '${containerData.language}'. Cleared STEAM_DLL_REPLACED marker for container ${container.id}.") } @@ -342,23 +357,19 @@ object ContainerUtils { } } - fun getContainerId(appId: Int): Int { - // TODO: set up containers for each appId+depotId combo (intent extra "container_id") + fun getContainerId(appId: String): String { return appId } - fun hasContainer(context: Context, appId: Int): Boolean { - val containerId = getContainerId(appId) + fun hasContainer(context: Context, appId: String): Boolean { val containerManager = ContainerManager(context) - return containerManager.hasContainer(containerId) + return containerManager.hasContainer(appId) } - fun getContainer(context: Context, appId: Int): Container { - val containerId = getContainerId(appId) - + fun getContainer(context: Context, appId: String): Container { val containerManager = ContainerManager(context) - return if (containerManager.hasContainer(containerId)) { - containerManager.getContainerById(containerId) + return if (containerManager.hasContainer(appId)) { + containerManager.getContainerById(appId) } else { throw Exception("Container does not exist for game $appId") } @@ -366,14 +377,16 @@ object ContainerUtils { private fun createNewContainer( context: Context, - appId: Int, - containerId: Int, + appId: String, + containerId: String, containerManager: ContainerManager, customConfig: ContainerData? = null, ): Container { // Set up container drives to include app val defaultDrives = PrefManager.drives - val appDirPath = SteamService.getAppDirPath(appId) + val gameId = extractGameIdFromContainerId(appId) + val appDirPath = GameManagerService.getAppDirPath(appId) + val drive: Char = Container.getNextAvailableDriveLetter(defaultDrives) val drives = "$defaultDrives$drive:$appDirPath" Timber.d("Prepared container drives: $drives") @@ -485,23 +498,21 @@ object ContainerUtils { return container } - fun getOrCreateContainer(context: Context, appId: Int): Container { - val containerId = getContainerId(appId) + fun getOrCreateContainer(context: Context, appId: String): Container { val containerManager = ContainerManager(context) - return if (containerManager.hasContainer(containerId)) { - containerManager.getContainerById(containerId) + return if (containerManager.hasContainer(appId)) { + containerManager.getContainerById(appId) } else { - createNewContainer(context, appId, containerId, containerManager) + createNewContainer(context, appId, appId, containerManager) } } - fun getOrCreateContainerWithOverride(context: Context, appId: Int): Container { - val containerId = getContainerId(appId) + fun getOrCreateContainerWithOverride(context: Context, appId: String): Container { val containerManager = ContainerManager(context) - return if (containerManager.hasContainer(containerId)) { - val container = containerManager.getContainerById(containerId) + return if (containerManager.hasContainer(appId)) { + val container = containerManager.getContainerById(appId) // Apply temporary override if present (without saving to disk) if (IntentLaunchManager.hasTemporaryOverride(appId)) { @@ -537,7 +548,7 @@ object ContainerUtils { null } - createNewContainer(context, appId, containerId, containerManager, overrideConfig) + createNewContainer(context, appId, appId, containerManager, overrideConfig) } } @@ -559,7 +570,11 @@ object ContainerUtils { val profileJSONObject = org.json.JSONObject(FileUtils.readString(baseFile)) val elementsJSONArray = profileJSONObject.getJSONArray("elements") - val emuJson = try { container.controllerEmulationBindings } catch (_: Exception) { null } + val emuJson = try { + container.controllerEmulationBindings + } catch (_: Exception) { + null + } fun optBinding(key: String, fallback: String): String { return emuJson?.optString(key, fallback) ?: fallback @@ -688,16 +703,171 @@ object ContainerUtils { /** * Deletes the container associated with the given appId, if it exists. */ - fun deleteContainer(context: Context, appId: Int) { - val containerId = getContainerId(appId) + fun deleteContainer(context: Context, appId: String) { val manager = ContainerManager(context) - if (manager.hasContainer(containerId)) { + if (manager.hasContainer(appId)) { // Remove the container directory asynchronously manager.removeContainerAsync( - manager.getContainerById(containerId), + manager.getContainerById(appId), ) { - Timber.i("Deleted container for appId=$appId (containerId=$containerId)") + Timber.i("Deleted container for appId=$appId") } } } + + /** + * Extracts the game ID from a container ID string + * Splits on the first underscore and takes the numeric part, handling duplicate suffixes like (1), (2) + */ + fun extractGameIdFromContainerId(containerId: String): Int { + val afterUnderscore = containerId.split("_", limit = 2)[1] + // Remove duplicate suffix like (1), (2) if present + return if (afterUnderscore.contains("(")) { + afterUnderscore.substringBefore("(").toInt() + } else { + afterUnderscore.toInt() + } + } + + /** + * Extracts the game source from a container ID string + */ + fun extractGameSourceFromContainerId(containerId: String): GameSource { + return GameSource.values().find { gameSource -> + containerId.startsWith("${gameSource.name}_") + } ?: GameSource.STEAM // default fallback + } + + /** + * Migrates legacy numeric container directories to platform-prefixed format. + * Legacy: xuser-12345/ -> New: xuser-STEAM_12345/ + */ + suspend fun migrateLegacyContainers( + context: Context, + onProgressUpdate: (currentContainer: String, migratedContainers: Int, totalContainers: Int) -> Unit, + onComplete: (migratedCount: Int) -> Unit, + ) = withContext(Dispatchers.IO) { + try { + val imageFs = ImageFs.find(context) + val homeDir = File(imageFs.rootDir, "home") + + // Find all legacy numeric container directories + val legacyContainers = homeDir.listFiles()?.filter { file -> + file.isDirectory() && + // Skip active symlink + file.name != ImageFs.USER && + // Must have xuser- prefix + file.name.startsWith("${ImageFs.USER}-") && + // Numeric ID after prefix + file.name.removePrefix("${ImageFs.USER}-").matches(Regex("\\d+")) && + // Has container config + File(file, ".container").exists() + } ?: emptyList() + + val totalContainers = legacyContainers.size + var migratedContainers = 0 + + if (totalContainers == 0) { + withContext(Dispatchers.Main) { + onComplete(0) + } + return@withContext + } + + Timber.i("Found $totalContainers legacy containers to migrate") + + for (legacyDir in legacyContainers) { + val legacyId = legacyDir.name.removePrefix("${ImageFs.USER}-") // Remove xuser- prefix + val newContainerId = "STEAM_$legacyId" + val newDir = File(homeDir, "${ImageFs.USER}-$newContainerId") // WITH xuser- prefix + + withContext(Dispatchers.Main) { + onProgressUpdate(legacyId, migratedContainers, totalContainers) + } + + try { + // Handle naming conflicts + var finalContainerId = newContainerId + var finalNewDir = newDir + var counter = 1 + + while (finalNewDir.exists()) { + finalContainerId = "STEAM_$legacyId($counter)" + finalNewDir = File(homeDir, "${ImageFs.USER}-$finalContainerId") // WITH xuser- prefix + counter++ + } + + // Rename directory + if (legacyDir.renameTo(finalNewDir)) { + // Update container config + updateContainerConfig(finalNewDir, finalContainerId) + + // Update active symlink if this was the active container + val activeSymlink = File(homeDir, ImageFs.USER) + if (activeSymlink.exists() && activeSymlink.canonicalPath.endsWith(legacyId)) { + activeSymlink.delete() + FileUtils.symlink("./${ImageFs.USER}-$finalContainerId", activeSymlink.path) + Timber.i("Updated active symlink to point to $finalContainerId") + } + + migratedContainers++ + Timber.i("Migrated container $legacyId -> $finalContainerId") + } else { + Timber.e("Failed to rename container directory: $legacyId") + } + } catch (e: Exception) { + Timber.e(e, "Error migrating container $legacyId") + } + } + + withContext(Dispatchers.Main) { + onComplete(migratedContainers) + } + } catch (e: Exception) { + Timber.e(e, "Error during container migration") + withContext(Dispatchers.Main) { + onComplete(0) + } + } + } + + /** + * Checks if there are any legacy containers that need migration + */ + suspend fun hasLegacyContainers(context: Context): Boolean = withContext(Dispatchers.IO) { + try { + val imageFs = ImageFs.find(context) + val homeDir = File(imageFs.rootDir, "home") + + val legacyContainers = homeDir.listFiles()?.filter { file -> + file.isDirectory() && + // Skip active symlink + file.name != ImageFs.USER && + // Must have xuser- prefix + file.name.startsWith("${ImageFs.USER}-") && + // Numeric ID after prefix + file.name.removePrefix("${ImageFs.USER}-").matches(Regex("\\d+")) && + // Has container config + File(file, ".container").exists() + } ?: emptyList() + + return@withContext legacyContainers.isNotEmpty() + } catch (e: Exception) { + Timber.e(e, "Error checking for legacy containers") + return@withContext false + } + } + + private fun updateContainerConfig(containerDir: File, newContainerId: String) { + try { + val configFile = File(containerDir, ".container") + val configContent = FileUtils.readString(configFile) + val data = JSONObject(configContent) + data.put("id", newContainerId) + FileUtils.writeString(configFile, data.toString()) + Timber.i("Updated container config ID to $newContainerId") + } catch (e: Exception) { + Timber.e(e, "Failed to update container config for $newContainerId") + } + } } diff --git a/app/src/main/java/app/gamenative/utils/GameFeedbackUtils.kt b/app/src/main/java/app/gamenative/utils/GameFeedbackUtils.kt index d61f7226b..c1d263d9f 100644 --- a/app/src/main/java/app/gamenative/utils/GameFeedbackUtils.kt +++ b/app/src/main/java/app/gamenative/utils/GameFeedbackUtils.kt @@ -3,6 +3,7 @@ package app.gamenative.utils import android.content.Context import android.os.Build import app.gamenative.BuildConfig +import app.gamenative.service.GameManagerService import app.gamenative.service.SteamService import com.winlator.container.Container import com.winlator.core.FileUtils @@ -44,7 +45,7 @@ object GameFeedbackUtils { suspend fun submitGameFeedback( context: Context, supabase: SupabaseClient, - appId: Int, + appId: String, rating: Int, tags: List, notes: String?, @@ -56,8 +57,7 @@ object GameFeedbackUtils { Timber.d("config string is: " + FileUtils.readString(container.getConfigFile()).replace("\\u0000", "").replace("\u0000", "")) Timber.d("configJson: $configJson") // Get the game name from container or use a fallback - val appInfo = SteamService.getAppInfoOf(appId)!! - val gameName = appInfo.name + val gameName = GameManagerService.createLibraryItemFromAppId(appId, context).name Timber.d("GameFeedbackUtils: Game name: $gameName") // Get device model diff --git a/app/src/main/java/app/gamenative/utils/IntentLaunchManager.kt b/app/src/main/java/app/gamenative/utils/IntentLaunchManager.kt index f89d4ef33..8b17868fc 100644 --- a/app/src/main/java/app/gamenative/utils/IntentLaunchManager.kt +++ b/app/src/main/java/app/gamenative/utils/IntentLaunchManager.kt @@ -2,6 +2,7 @@ package app.gamenative.utils import android.content.Context import android.content.Intent +import app.gamenative.data.GameSource import com.winlator.container.Container import com.winlator.container.ContainerData import com.winlator.core.DXVKHelper @@ -19,7 +20,7 @@ object IntentLaunchManager { private const val MAX_CONFIG_JSON_SIZE = 50000 // 50KB limit to prevent memory exhaustion data class LaunchRequest( - val appId: Int, + val appId: String, val containerConfig: ContainerData? = null, ) @@ -31,14 +32,17 @@ object IntentLaunchManager { return null } - val appId = intent.getIntExtra(EXTRA_APP_ID, -1) - Timber.d("[IntentLaunchManager]: Extracted app_id: $appId from intent extras") + val gameId = intent.getIntExtra(EXTRA_APP_ID, -1) + Timber.d("[IntentLaunchManager]: Extracted app_id: $gameId from intent extras") - if (appId <= 0) { - Timber.w("[IntentLaunchManager]: Invalid or missing app_id in launch intent: $appId") + if (gameId <= 0) { + Timber.w("[IntentLaunchManager]: Invalid or missing app_id in launch intent: $gameId") return null } + val appId = "${GameSource.STEAM.name}_$gameId" + Timber.d("[IntentLaunchManager]: Converted to appId: $appId") + val containerConfigJson = intent.getStringExtra(EXTRA_CONTAINER_CONFIG) val containerConfig = if (containerConfigJson != null) { try { @@ -54,7 +58,7 @@ object IntentLaunchManager { return LaunchRequest(appId, containerConfig) } - fun applyTemporaryConfigOverride(context: Context, appId: Int, configOverride: ContainerData) { + fun applyTemporaryConfigOverride(context: Context, appId: String, configOverride: ContainerData) { try { TemporaryConfigStore.setOverride(appId, configOverride) @@ -82,7 +86,7 @@ object IntentLaunchManager { } } - fun getEffectiveContainerConfig(context: Context, appId: Int): ContainerData? { + fun getEffectiveContainerConfig(context: Context, appId: String): ContainerData? { return try { val baseConfig = if (ContainerUtils.hasContainer(context, appId)) { val container = ContainerUtils.getContainer(context, appId) @@ -104,7 +108,7 @@ object IntentLaunchManager { } } - fun clearTemporaryOverride(appId: Int) { + fun clearTemporaryOverride(appId: String) { TemporaryConfigStore.clearOverride(appId) Timber.d("[IntentLaunchManager]: Cleared temporary config override for app $appId") } @@ -114,7 +118,7 @@ object IntentLaunchManager { Timber.d("[IntentLaunchManager]: Cleared all temporary config overrides") } - fun restoreOriginalConfiguration(context: Context, appId: Int) { + fun restoreOriginalConfiguration(context: Context, appId: String) { try { val originalConfig = TemporaryConfigStore.getOriginalConfig(appId) if (originalConfig != null && ContainerUtils.hasContainer(context, appId)) { @@ -127,19 +131,19 @@ object IntentLaunchManager { } } - fun hasTemporaryOverride(appId: Int): Boolean { + fun hasTemporaryOverride(appId: String): Boolean { return TemporaryConfigStore.hasOverride(appId) } - fun getTemporaryOverride(appId: Int): ContainerData? { + fun getTemporaryOverride(appId: String): ContainerData? { return TemporaryConfigStore.getOverride(appId) } - fun getOriginalConfig(appId: Int): ContainerData? { + fun getOriginalConfig(appId: String): ContainerData? { return TemporaryConfigStore.getOriginalConfig(appId) } - fun setOriginalConfig(appId: Int, config: ContainerData) { + fun setOriginalConfig(appId: String, config: ContainerData) { TemporaryConfigStore.setOriginalConfig(appId, config) } @@ -309,32 +313,32 @@ object IntentLaunchManager { } private object TemporaryConfigStore { - private val overrides = mutableMapOf() - private val originalConfigs = mutableMapOf() + private val overrides = mutableMapOf() + private val originalConfigs = mutableMapOf() private val lock = Any() - fun setOverride(appId: Int, config: ContainerData) = synchronized(lock) { + fun setOverride(appId: String, config: ContainerData) = synchronized(lock) { overrides[appId] = config } - fun getOverride(appId: Int): ContainerData? = synchronized(lock) { + fun getOverride(appId: String): ContainerData? = synchronized(lock) { overrides[appId] } - fun clearOverride(appId: Int) = synchronized(lock) { + fun clearOverride(appId: String) = synchronized(lock) { overrides.remove(appId) originalConfigs.remove(appId) } - fun hasOverride(appId: Int): Boolean = synchronized(lock) { + fun hasOverride(appId: String): Boolean = synchronized(lock) { overrides.containsKey(appId) } - fun setOriginalConfig(appId: Int, config: ContainerData) = synchronized(lock) { + fun setOriginalConfig(appId: String, config: ContainerData) = synchronized(lock) { originalConfigs[appId] = config } - fun getOriginalConfig(appId: Int): ContainerData? = synchronized(lock) { + fun getOriginalConfig(appId: String): ContainerData? = synchronized(lock) { originalConfigs[appId] } diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 532d34b54..7bbd1a57c 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -5,7 +5,9 @@ import android.content.Context import android.provider.Settings import app.gamenative.PrefManager import app.gamenative.data.DepotInfo +import app.gamenative.data.GameSource import app.gamenative.enums.Marker +import app.gamenative.service.GameManagerService import app.gamenative.service.SteamService import com.winlator.core.WineRegistryEditor import com.winlator.xenvironment.ImageFs @@ -310,7 +312,7 @@ object SteamUtils { val executablePath = SteamService.getInstalledExe(appId) // Convert to Wine path format - val container = ContainerUtils.getContainer(context, appId) + val container = ContainerUtils.getContainer(context, "STEAM_$appId") val drives = container.drives val driveIndex = drives.indexOf(appDirPath) val drive = if (driveIndex > 1) { @@ -639,7 +641,7 @@ object SteamUtils { val accountName = PrefManager.username val accountSteamId = SteamService.userSteamId?.convertToUInt64()?.toString() ?: "0" val language = runCatching { - val container = ContainerUtils.getOrCreateContainer(context, appId) + val container = ContainerUtils.getOrCreateContainer(context, GameManagerService.getAppId(appId, GameSource.STEAM)) (container.getExtra("language", null) ?: container.javaClass.getMethod("getLanguage").invoke(container) as? String) ?: "english" @@ -780,10 +782,12 @@ object SteamUtils { } } - fun fetchDirect3DMajor(appId: Int, callback: (Int) -> Unit) { - // Build a single Cargo query: SELECT API.direct3d_versions WHERE steam_appid="" - Timber.i("[DX Fetch] Starting fetchDirect3DMajor for appId=%d", appId) - val where = URLEncoder.encode("Infobox_game.Steam_AppID HOLDS \"$appId\"", "UTF-8") + fun fetchDirect3DMajor(appId: String, callback: (Int) -> Unit) { + val id = ContainerUtils.extractGameIdFromContainerId(appId) + + // Build a single Cargo query: SELECT API.direct3d_versions WHERE steam_appid="" + Timber.i("[DX Fetch] Starting fetchDirect3DMajor for appId=%d", id) + val where = URLEncoder.encode("Infobox_game.Steam_AppID HOLDS \"$id\"", "UTF-8") val url = "https://pcgamingwiki.com/w/api.php" + "?action=cargoquery" + diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index e70a89f8e..529f1fac3 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -40,7 +40,7 @@ public enum XrControllerMapping { public static final String STEAM_TYPE_LIGHT = "light"; public static final String STEAM_TYPE_ULTRALIGHT = "ultralight"; public static final byte MAX_DRIVE_LETTERS = 8; - public final int id; + public final String id; private String name; private String screenSize = DEFAULT_SCREEN_SIZE; private String envVars = DEFAULT_ENV_VARS; @@ -139,7 +139,7 @@ public void setExecutablePath(String executablePath) { this.executablePath = executablePath != null ? executablePath : ""; } - public Container(int id) { + public Container(String id) { this.id = id; this.name = "Container-"+id; } diff --git a/app/src/main/java/com/winlator/container/ContainerManager.java b/app/src/main/java/com/winlator/container/ContainerManager.java index 0c4701676..837b6f44d 100644 --- a/app/src/main/java/com/winlator/container/ContainerManager.java +++ b/app/src/main/java/com/winlator/container/ContainerManager.java @@ -32,7 +32,6 @@ public class ContainerManager { private final ArrayList containers = new ArrayList<>(); - private int maxContainerId = 0; private final File homeDir; private final Context context; @@ -61,7 +60,6 @@ public ArrayList getContainers() { private void loadContainers() { containers.clear(); - maxContainerId = 0; try { File[] files = homeDir.listFiles(); @@ -69,12 +67,12 @@ private void loadContainers() { for (File file : files) { if (file.isDirectory()) { if (file.getName().startsWith(ImageFs.USER+"-")) { - Container container = new Container(Integer.parseInt(file.getName().replace(ImageFs.USER+"-", ""))); + String containerId = file.getName().replace(ImageFs.USER+"-", ""); + Container container = new Container(containerId); container.setRootDir(new File(homeDir, ImageFs.USER+"-"+container.id)); JSONObject data = new JSONObject(FileUtils.readString(container.getConfigFile())); container.loadData(data); containers.add(container); - maxContainerId = Math.max(maxContainerId, container.id); } } } @@ -92,25 +90,17 @@ public void activateContainer(Container container) { FileUtils.symlink("./"+ImageFs.USER+"-"+container.id, file.getPath()); } - public void createContainerAsync(final JSONObject data, Callback callback) { - int id = maxContainerId + 1; + public void createContainerAsync(String containerId, final JSONObject data, Callback callback) { final Handler handler = new Handler(); Executors.newSingleThreadExecutor().execute(() -> { - final Container container = createContainer(id, data); + final Container container = createContainer(containerId, data); handler.post(() -> callback.call(container)); }); } - public Future createContainerFuture(final JSONObject data) { - int id = maxContainerId + 1; - return Executors.newSingleThreadExecutor().submit(() -> createContainer(id, data)); + public Future createContainerFuture(String containerId, final JSONObject data) { + return Executors.newSingleThreadExecutor().submit(() -> createContainer(containerId, data)); } - public Future createContainerFuture(int id, final JSONObject data) { - return Executors.newSingleThreadExecutor().submit(() -> createContainer(id, data)); - } - public Future createDefaultContainerFuture(WineInfo wineInfo) { - return createDefaultContainerFuture(wineInfo, getNextContainerId()); - } - public Future createDefaultContainerFuture(WineInfo wineInfo, int containerId) { + public Future createDefaultContainerFuture(WineInfo wineInfo, String containerId) { String name = "container_" + containerId; Log.d("XServerScreen", "Creating container $name"); String screenSize = Container.DEFAULT_SCREEN_SIZE; @@ -173,7 +163,7 @@ public void removeContainerAsync(Container container, Runnable callback) { }); } - public Container createContainer(int containerId, JSONObject data) { + public Container createContainer(String containerId, JSONObject data) { try { data.put("id", containerId); @@ -193,7 +183,6 @@ public Container createContainer(int containerId, JSONObject data) { } container.saveData(); - maxContainerId++; containers.add(container); return container; } @@ -204,9 +193,11 @@ public Container createContainer(int containerId, JSONObject data) { } private void duplicateContainer(Container srcContainer) { - int id = maxContainerId + 1; + // Generate a unique ID by appending (1), (2), etc. to the original ID + String baseId = srcContainer.id; + String newId = generateUniqueContainerId(baseId); - File dstDir = new File(homeDir, ImageFs.USER+"-"+id); + File dstDir = new File(homeDir, ImageFs.USER+"-"+newId); if (!dstDir.mkdirs()) return; if (!FileUtils.copy(srcContainer.getRootDir(), dstDir, (file) -> FileUtils.chmod(file, 0771))) { @@ -214,7 +205,7 @@ private void duplicateContainer(Container srcContainer) { return; } - Container dstContainer = new Container(id); + Container dstContainer = new Container(newId); dstContainer.setRootDir(dstDir); dstContainer.setName(srcContainer.getName()+" ("+context.getString(R.string.copy)+")"); dstContainer.setScreenSize(srcContainer.getScreenSize()); @@ -239,10 +230,26 @@ private void duplicateContainer(Container srcContainer) { dstContainer.setWineVersion(srcContainer.getWineVersion()); dstContainer.saveData(); - maxContainerId++; containers.add(dstContainer); } + private String generateUniqueContainerId(String baseId) { + // If the base ID doesn't exist, use it as-is + if (!hasContainer(baseId)) { + return baseId; + } + + // Try baseId(1), baseId(2), etc. until we find a unique one + int counter = 1; + String candidateId; + do { + candidateId = baseId + "(" + counter + ")"; + counter++; + } while (hasContainer(candidateId)); + + return candidateId; + } + private void removeContainer(Container container) { if (FileUtils.delete(container.getRootDir())) containers.remove(container); } @@ -263,16 +270,13 @@ public ArrayList loadShortcuts() { return shortcuts; } - public int getNextContainerId() { - return maxContainerId + 1; - } - - public boolean hasContainer(int id) { - for (Container container : containers) if (container.id == id) return true; + public boolean hasContainer(String id) { + for (Container container : containers) if (container.id.equals(id)) return true; return false; } - public Container getContainerById(int id) { - for (Container container : containers) if (container.id == id) return container; + + public Container getContainerById(String id) { + for (Container container : containers) if (container.id.equals(id)) return container; return null; } diff --git a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java index 2bf8582de..3ba5a7774 100644 --- a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java +++ b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java @@ -6,6 +6,7 @@ import app.gamenative.enums.Marker; import app.gamenative.service.SteamService; +import app.gamenative.utils.ContainerUtils; import app.gamenative.utils.MarkerUtils; // import com.winlator.MainActivity; @@ -226,7 +227,8 @@ private static void clearSteamDllMarkers(Context context, ContainerManager conta try { for (Container container : containerManager.getContainers()) { try { - String mappedPath = SteamService.Companion.getAppDirPath(container.id); + int gameId = ContainerUtils.INSTANCE.extractGameIdFromContainerId(container.id); + String mappedPath = SteamService.Companion.getAppDirPath(gameId); MarkerUtils.INSTANCE.removeMarker(mappedPath, Marker.STEAM_DLL_REPLACED); MarkerUtils.INSTANCE.removeMarker(mappedPath, Marker.STEAM_DLL_RESTORED); Log.i("ImageFsInstaller", "Cleared markers for container: " + container.getName() + " (ID: " + container.id + ")"); 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..d45413b9f --- /dev/null +++ b/app/src/main/python/gogdl/api.py @@ -0,0 +1,118 @@ +import logging +import time +import requests +import json +from multiprocessing import cpu_count +from gogdl.dl import dl_utils +from gogdl import constants +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)' + } + credentials = self.auth_manager.get_credentials() + if credentials: + token = credentials["access_token"] + self.session.headers["Authorization"] = f"Bearer {token}" + 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 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): + url = f'{constants.GOG_API}/user/data/games' + response = self.session.get(url) + if response.ok: + return response.json() + else: + self.logger.error(f"Request failed {response}") + + 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 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): + """Get secure download links from GOG API""" + 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}") + time.sleep(0.2) + return self.get_secure_link(product_id, path, generation, root) + + js = response.json() + return js.get('urls', []) + + except Exception as e: + self.logger.error(f"Failed to get secure link: {e}") + time.sleep(0.2) + return self.get_secure_link(product_id, path, generation, root) \ No newline at end of file diff --git a/app/src/main/python/gogdl/args.py b/app/src/main/python/gogdl/args.py new file mode 100644 index 000000000..dca4cf519 --- /dev/null +++ b/app/src/main/python/gogdl/args.py @@ -0,0 +1,85 @@ +""" +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') + + 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..63cfc4d55 --- /dev/null +++ b/app/src/main/python/gogdl/cli.py @@ -0,0 +1,177 @@ +#!/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 json +import logging + + +def display_version(): + print(f"{gogdl_version}") + + +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 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..26c97708e --- /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, arguments, unknown_arguments, api_handler, max_workers=2): + super().__init__(arguments, unknown_arguments, api_handler, max_workers) + 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..f65849799 --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/manager.py @@ -0,0 +1,207 @@ +""" +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 with threading + manager = linux.LinuxManager( + self.arguments, + self.unknown_arguments, + self.api_handler, + max_workers=self.allowed_threads + ) + 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..9cd9df2e9 --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/linux.py @@ -0,0 +1,388 @@ +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): + 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 + self.url = response.headers.get('Location') or self.url + return self.get_bytes_from_file(from_b, size, add_archive_index, raw_response) + if not self.file_size: + self.file_size = int(response.headers.get("Content-Range").split("/")[-1]) + 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..6cd0470e7 --- /dev/null +++ b/app/src/main/python/gogdl/dl/progressbar.py @@ -0,0 +1,125 @@ +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: + pass + + 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=1) + self.downloaded_since_last_update += dl + self.decompressed_since_last_update += dec + except queue.Empty: + pass + try: + wr, r = self.write_queue.get(timeout=1) + self.written_since_last_update += wr + self.read_since_last_update += r + except queue.Empty: + pass + + self.print_progressbar() + def print_progressbar(self): + percentage = (self.written_total / self.total) * 100 + 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) # Cap to 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)" + ) + + 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..b633c0864 --- /dev/null +++ b/app/src/main/python/gogdl/imports.py @@ -0,0 +1,130 @@ +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 = {} + if platform != "linux": + if not info_file: + 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 + 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)" + }, + ) + 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"] + 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 + 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..9f2994247 --- /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) + self.md5 = hashlib.md5( + gzip.compress(open(self.absolute_path, "rb").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..f2bb9b691 --- /dev/null +++ b/app/src/main/python/gogdl/xdelta/objects.py @@ -0,0 +1,139 @@ +from dataclasses import dataclass +from io import IOBase, BytesIO +from typing import Optional + +@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 = CodeTable.near_modes + s_same = CodeTable.same_modes + next_slot = 0 + near_array = [0 for _ in range(s_near)] + same_array = [0 for _ in range(s_same * 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() + +