diff --git a/.github/workflows/pluvia-pr-check.yml b/.github/workflows/pluvia-pr-check.yml index c7a45e74bf..f561ef3336 100644 --- a/.github/workflows/pluvia-pr-check.yml +++ b/.github/workflows/pluvia-pr-check.yml @@ -23,6 +23,7 @@ jobs: java-version: '17' distribution: 'temurin' - name: Inject credentials + if: github.event.pull_request.head.repo.full_name == github.repository run: | cat < local.properties POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }} @@ -30,6 +31,15 @@ jobs: SUPABASE_URL=${{ secrets.SUPABASE_URL }} SUPABASE_KEY=${{ secrets.SUPABASE_KEY }} EOF + - name: Inject dummy credentials + if: github.event.pull_request.head.repo.full_name != github.repository + run: | + cat < local.properties + POSTHOG_API_KEY=dummy + POSTHOG_HOST=https://us.i.posthog.com + SUPABASE_URL=https://dummy.supabase.co + SUPABASE_KEY=dummy + EOF - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@v4 - name: Setup Gradle diff --git a/README.md b/README.md index 5575e441c5..eb9c3f53b0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This is a fork of [Pluvia](https://github.com/oxters168/Pluvia), a Steam client ## How to Use (Note that GameNative is still in its early stages, and all games may not work, or may require tweaking to get working well) -1. Download the latest release [here](https://github.com/utkarshdalal/GameNative/releases/download/v0.6.0/gamenative-v0.6.0.apk) +1. Download the latest release [here](https://github.com/utkarshdalal/GameNative/releases/download/v0.7.0/gamenative-v0.7.0.apk) 2. Install the APK on your Android device 3. Login to your Steam account 4. Install your game diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9a2356c4f1..c5c5237ae4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.kotlinter) alias(libs.plugins.ksp) alias(libs.plugins.secrets.gradle) + alias(libs.plugins.room) } val keystorePropertiesFile = rootProject.file("app/keystores/keystore.properties") @@ -27,6 +28,10 @@ val posthogHost: String = project.findProperty("POSTHOG_HOST") as String? ?: Sys val supabaseUrl: String = project.findProperty("SUPABASE_URL") as String? ?: System.getenv("SUPABASE_URL") ?: "https://your-project.supabase.co" val supabaseKey: String = project.findProperty("SUPABASE_KEY") as String? ?: System.getenv("SUPABASE_KEY") ?: "" +room { + schemaDirectory("$projectDir/schemas") +} + android { namespace = "app.gamenative" compileSdk = 35 @@ -51,8 +56,8 @@ android { minSdk = 26 targetSdk = 28 - versionCode = 7 - versionName = "0.6.0" + versionCode = 9 + versionName = "0.7.0" buildConfigField("boolean", "GOLD", "false") fun secret(name: String) = @@ -81,8 +86,13 @@ android { "en", // English (default) "da", // Danish "pt-rBR", // Portuguese (Brazilian) - "zh-rTW", // Traditional Chinese - "zh-rCN", // Simplified Chinese + "zh-rTW", // Traditional Chinese + "zh-rCN", // Simplified Chinese + "fr", // French + "de", // German + "uk", // Ukrainian + "it", // Italian + "ro", // Română // TODO: Add more languages here using the ISO 639-1 locale code with regional qualifiers (e.g., "pt-rPT" for European Portuguese) ) @@ -146,16 +156,12 @@ android { buildConfig = true } - ksp { - arg("room.schemaLocation", "$projectDir/schemas") - arg("room.incremental", "true") - } - packaging { resources { excludes += "/DebugProbesKt.bin" excludes += "/junit/runner/smalllogo.gif" excludes += "/junit/runner/logo.gif" + excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF" } jniLibs { // 'extractNativeLibs' was not enough to keep the jniLibs and @@ -175,12 +181,12 @@ android { } // build extras needed in libwinlator_bionic.so -// externalNativeBuild { -// cmake { -// path = file("src/main/cpp/extras/CMakeLists.txt") // the file shown above -// version = "3.22.1" -// } -// } + // externalNativeBuild { + // cmake { + // path = file("src/main/cpp/extras/CMakeLists.txt") // the file shown above + // version = "3.22.1" + // } + // } // cmake on release builds a proot that fails to process ld-2.31.so // externalNativeBuild { @@ -200,18 +206,23 @@ android { dependencies { implementation(libs.material) + + // Chrome Custom Tabs for GOG OAuth + implementation("androidx.browser:browser:1.8.0") + // JavaSteam val localBuild = false // Change to 'true' needed when building JavaSteam manually if (localBuild) { - implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0-SNAPSHOT.jar")) - implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0-SNAPSHOT.jar")) - implementation(libs.bundles.steamkit.dev) + implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0-6-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0-6-SNAPSHOT.jar")) + implementation(libs.bundles.javasteam.dev) } else { - implementation(libs.steamkit) { + implementation(libs.javasteam) { + isChanging = version?.contains("SNAPSHOT") ?: false + } + implementation(libs.javasteam.depotdownloader) { isChanging = version?.contains("SNAPSHOT") ?: false } - implementation("io.github.utkarshdalal:javasteam-depotdownloader:1.8.0-SNAPSHOT") -// implementation("in.dragonbra:javasteam-depotdownloader:1.8.0-SNAPSHOT") } implementation(libs.spongycastle) @@ -265,8 +276,11 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockk) testImplementation(libs.androidx.ui.test.junit4) testImplementation(libs.zstd.jni) + testImplementation(libs.orgJson) + testImplementation(libs.mockwebserver) // Add PostHog Android SDK dependency implementation("com.posthog:posthog-android:3.8.0") @@ -279,4 +293,6 @@ dependencies { implementation("io.github.jan-tennert.supabase:realtime-kt") implementation("io.ktor:ktor-client-android:3.1.3") + + implementation("com.auth0.android:jwtdecode:2.0.2") } diff --git a/app/schemas/app.gamenative.db.PluviaDatabase/10.json b/app/schemas/app.gamenative.db.PluviaDatabase/10.json new file mode 100644 index 0000000000..e075de6909 --- /dev/null +++ b/app/schemas/app.gamenative.db.PluviaDatabase/10.json @@ -0,0 +1,796 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "e857f30812e2e97aac7e3f026771f71e", + "entities": [ + { + "tableName": "app_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_downloaded` INTEGER NOT NULL, `downloaded_depots` TEXT NOT NULL, `dlc_depots` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "is_downloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedDepots", + "columnName": "downloaded_depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dlcDepots", + "columnName": "dlc_depots", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `license_json` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseJson", + "columnName": "license_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_change_numbers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `changeNumber` INTEGER, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "changeNumber", + "columnName": "changeNumber", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "encrypted_app_ticket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` INTEGER NOT NULL, `result` INTEGER NOT NULL, `ticket_version_no` INTEGER NOT NULL, `crc_encrypted_ticket` INTEGER NOT NULL, `cb_encrypted_user_data` INTEGER NOT NULL, `cb_encrypted_app_ownership_ticket` INTEGER NOT NULL, `encrypted_ticket` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`app_id`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ticketVersionNo", + "columnName": "ticket_version_no", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "crcEncryptedTicket", + "columnName": "crc_encrypted_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedUserData", + "columnName": "cb_encrypted_user_data", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedAppOwnershipTicket", + "columnName": "cb_encrypted_app_ownership_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedTicket", + "columnName": "encrypted_ticket", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "app_id" + ] + } + }, + { + "tableName": "app_file_change_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `userFileInfo` TEXT NOT NULL, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "userFileInfo", + "columnName": "userFileInfo", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "steam_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `package_id` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `license_flags` INTEGER NOT NULL, `received_pics` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `depots` TEXT NOT NULL, `branches` TEXT NOT NULL, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `os_list` INTEGER NOT NULL, `release_state` INTEGER NOT NULL, `release_date` INTEGER NOT NULL, `metacritic_score` INTEGER NOT NULL, `metacritic_full_url` TEXT NOT NULL, `logo_hash` TEXT NOT NULL, `logo_small_hash` TEXT NOT NULL, `icon_hash` TEXT NOT NULL, `client_icon_hash` TEXT NOT NULL, `client_tga_hash` TEXT NOT NULL, `small_capsule` TEXT NOT NULL, `header_image` TEXT NOT NULL, `library_assets` TEXT NOT NULL, `primary_genre` INTEGER NOT NULL, `review_score` INTEGER NOT NULL, `review_percentage` INTEGER NOT NULL, `controller_support` INTEGER NOT NULL, `demo_of_app_id` INTEGER NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `homepage_url` TEXT NOT NULL, `game_manual_url` TEXT NOT NULL, `load_all_before_launch` INTEGER NOT NULL, `dlc_app_ids` TEXT NOT NULL, `is_free_app` INTEGER NOT NULL, `dlc_for_app_id` INTEGER NOT NULL, `must_own_app_to_purchase` INTEGER NOT NULL, `dlc_available_on_store` INTEGER NOT NULL, `optional_dlc` INTEGER NOT NULL, `game_dir` TEXT NOT NULL, `install_script` TEXT NOT NULL, `no_servers` INTEGER NOT NULL, `order` INTEGER NOT NULL, `primary_cache` INTEGER NOT NULL, `valid_os_list` INTEGER NOT NULL, `third_party_cd_key` INTEGER NOT NULL, `visible_only_when_installed` INTEGER NOT NULL, `visible_only_when_subscribed` INTEGER NOT NULL, `launch_eula_url` TEXT NOT NULL, `require_default_install_folder` INTEGER NOT NULL, `content_type` INTEGER NOT NULL, `install_dir` TEXT NOT NULL, `use_launch_cmd_line` INTEGER NOT NULL, `launch_without_workshop_updates` INTEGER NOT NULL, `use_mms` INTEGER NOT NULL, `install_script_signature` TEXT NOT NULL, `install_script_override` INTEGER NOT NULL, `config` TEXT NOT NULL, `ufs` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receivedPICS", + "columnName": "received_pics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "depots", + "columnName": "depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branches", + "columnName": "branches", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "osList", + "columnName": "os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseState", + "columnName": "release_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticScore", + "columnName": "metacritic_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticFullUrl", + "columnName": "metacritic_full_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoHash", + "columnName": "logo_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoSmallHash", + "columnName": "logo_small_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconHash", + "columnName": "icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIconHash", + "columnName": "client_icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientTgaHash", + "columnName": "client_tga_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallCapsule", + "columnName": "small_capsule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerImage", + "columnName": "header_image", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryAssets", + "columnName": "library_assets", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryGenre", + "columnName": "primary_genre", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewScore", + "columnName": "review_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewPercentage", + "columnName": "review_percentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "controllerSupport", + "columnName": "controller_support", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "demoOfAppId", + "columnName": "demo_of_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homepageUrl", + "columnName": "homepage_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gameManualUrl", + "columnName": "game_manual_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loadAllBeforeLaunch", + "columnName": "load_all_before_launch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAppIds", + "columnName": "dlc_app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFreeApp", + "columnName": "is_free_app", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcForAppId", + "columnName": "dlc_for_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mustOwnAppToPurchase", + "columnName": "must_own_app_to_purchase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAvailableOnStore", + "columnName": "dlc_available_on_store", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "optionalDlc", + "columnName": "optional_dlc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gameDir", + "columnName": "game_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScript", + "columnName": "install_script", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noServers", + "columnName": "no_servers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryCache", + "columnName": "primary_cache", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "validOSList", + "columnName": "valid_os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thirdPartyCdKey", + "columnName": "third_party_cd_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenInstalled", + "columnName": "visible_only_when_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenSubscribed", + "columnName": "visible_only_when_subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchEulaUrl", + "columnName": "launch_eula_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requireDefaultInstallFolder", + "columnName": "require_default_install_folder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installDir", + "columnName": "install_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useLaunchCmdLine", + "columnName": "use_launch_cmd_line", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchWithoutWorkshopUpdates", + "columnName": "launch_without_workshop_updates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useMms", + "columnName": "use_mms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installScriptSignature", + "columnName": "install_script_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScriptOverride", + "columnName": "install_script_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ufs", + "columnName": "ufs", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "steam_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageId` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `time_created` INTEGER NOT NULL, `time_next_process` INTEGER NOT NULL, `minute_limit` INTEGER NOT NULL, `minutes_used` INTEGER NOT NULL, `payment_method` INTEGER NOT NULL, `license_flags` INTEGER NOT NULL, `purchase_code` TEXT NOT NULL, `license_type` INTEGER NOT NULL, `territory_code` INTEGER NOT NULL, `access_token` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `master_package_id` INTEGER NOT NULL, `app_ids` TEXT NOT NULL, `depot_ids` TEXT NOT NULL, PRIMARY KEY(`packageId`))", + "fields": [ + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeCreated", + "columnName": "time_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeNextProcess", + "columnName": "time_next_process", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minuteLimit", + "columnName": "minute_limit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesUsed", + "columnName": "minutes_used", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentMethod", + "columnName": "payment_method", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseCode", + "columnName": "purchase_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseType", + "columnName": "license_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "territoryCode", + "columnName": "territory_code", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "access_token", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "masterPackageID", + "columnName": "master_package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appIds", + "columnName": "app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "depotIds", + "columnName": "depot_ids", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageId" + ] + } + }, + { + "tableName": "gog_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `slug` TEXT NOT NULL, `download_size` INTEGER NOT NULL, `install_size` INTEGER NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `image_url` TEXT NOT NULL, `icon_url` TEXT NOT NULL, `description` TEXT NOT NULL, `release_date` TEXT NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `genres` TEXT NOT NULL, `languages` TEXT NOT NULL, `last_played` INTEGER NOT NULL, `play_time` INTEGER NOT NULL, `type` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "slug", + "columnName": "slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadSize", + "columnName": "download_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installSize", + "columnName": "install_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "is_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installPath", + "columnName": "install_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "languages", + "columnName": "languages", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "play_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "downloading_app_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER NOT NULL, `dlcAppIds` TEXT NOT NULL, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAppIds", + "columnName": "dlcAppIds", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e857f30812e2e97aac7e3f026771f71e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/app.gamenative.db.PluviaDatabase/11.json b/app/schemas/app.gamenative.db.PluviaDatabase/11.json new file mode 100644 index 0000000000..b6471c91e2 --- /dev/null +++ b/app/schemas/app.gamenative.db.PluviaDatabase/11.json @@ -0,0 +1,803 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "1f7ba808fad169ca465f142c315b8bdf", + "entities": [ + { + "tableName": "app_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_downloaded` INTEGER NOT NULL, `downloaded_depots` TEXT NOT NULL, `dlc_depots` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "is_downloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedDepots", + "columnName": "downloaded_depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dlcDepots", + "columnName": "dlc_depots", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `license_json` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseJson", + "columnName": "license_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_change_numbers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `changeNumber` INTEGER, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "changeNumber", + "columnName": "changeNumber", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "encrypted_app_ticket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` INTEGER NOT NULL, `result` INTEGER NOT NULL, `ticket_version_no` INTEGER NOT NULL, `crc_encrypted_ticket` INTEGER NOT NULL, `cb_encrypted_user_data` INTEGER NOT NULL, `cb_encrypted_app_ownership_ticket` INTEGER NOT NULL, `encrypted_ticket` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`app_id`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ticketVersionNo", + "columnName": "ticket_version_no", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "crcEncryptedTicket", + "columnName": "crc_encrypted_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedUserData", + "columnName": "cb_encrypted_user_data", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedAppOwnershipTicket", + "columnName": "cb_encrypted_app_ownership_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedTicket", + "columnName": "encrypted_ticket", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "app_id" + ] + } + }, + { + "tableName": "app_file_change_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `userFileInfo` TEXT NOT NULL, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "userFileInfo", + "columnName": "userFileInfo", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "steam_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `package_id` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `license_flags` INTEGER NOT NULL, `received_pics` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `depots` TEXT NOT NULL, `branches` TEXT NOT NULL, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `os_list` INTEGER NOT NULL, `release_state` INTEGER NOT NULL, `release_date` INTEGER NOT NULL, `metacritic_score` INTEGER NOT NULL, `metacritic_full_url` TEXT NOT NULL, `logo_hash` TEXT NOT NULL, `logo_small_hash` TEXT NOT NULL, `icon_hash` TEXT NOT NULL, `client_icon_hash` TEXT NOT NULL, `client_tga_hash` TEXT NOT NULL, `small_capsule` TEXT NOT NULL, `header_image` TEXT NOT NULL, `library_assets` TEXT NOT NULL, `primary_genre` INTEGER NOT NULL, `review_score` INTEGER NOT NULL, `review_percentage` INTEGER NOT NULL, `controller_support` INTEGER NOT NULL, `demo_of_app_id` INTEGER NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `homepage_url` TEXT NOT NULL, `game_manual_url` TEXT NOT NULL, `load_all_before_launch` INTEGER NOT NULL, `dlc_app_ids` TEXT NOT NULL, `is_free_app` INTEGER NOT NULL, `dlc_for_app_id` INTEGER NOT NULL, `must_own_app_to_purchase` INTEGER NOT NULL, `dlc_available_on_store` INTEGER NOT NULL, `optional_dlc` INTEGER NOT NULL, `game_dir` TEXT NOT NULL, `install_script` TEXT NOT NULL, `no_servers` INTEGER NOT NULL, `order` INTEGER NOT NULL, `primary_cache` INTEGER NOT NULL, `valid_os_list` INTEGER NOT NULL, `third_party_cd_key` INTEGER NOT NULL, `visible_only_when_installed` INTEGER NOT NULL, `visible_only_when_subscribed` INTEGER NOT NULL, `launch_eula_url` TEXT NOT NULL, `require_default_install_folder` INTEGER NOT NULL, `content_type` INTEGER NOT NULL, `install_dir` TEXT NOT NULL, `use_launch_cmd_line` INTEGER NOT NULL, `launch_without_workshop_updates` INTEGER NOT NULL, `use_mms` INTEGER NOT NULL, `install_script_signature` TEXT NOT NULL, `install_script_override` INTEGER NOT NULL, `config` TEXT NOT NULL, `ufs` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receivedPICS", + "columnName": "received_pics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "depots", + "columnName": "depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branches", + "columnName": "branches", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "osList", + "columnName": "os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseState", + "columnName": "release_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticScore", + "columnName": "metacritic_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticFullUrl", + "columnName": "metacritic_full_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoHash", + "columnName": "logo_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoSmallHash", + "columnName": "logo_small_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconHash", + "columnName": "icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIconHash", + "columnName": "client_icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientTgaHash", + "columnName": "client_tga_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallCapsule", + "columnName": "small_capsule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerImage", + "columnName": "header_image", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryAssets", + "columnName": "library_assets", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryGenre", + "columnName": "primary_genre", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewScore", + "columnName": "review_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewPercentage", + "columnName": "review_percentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "controllerSupport", + "columnName": "controller_support", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "demoOfAppId", + "columnName": "demo_of_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homepageUrl", + "columnName": "homepage_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gameManualUrl", + "columnName": "game_manual_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loadAllBeforeLaunch", + "columnName": "load_all_before_launch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAppIds", + "columnName": "dlc_app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFreeApp", + "columnName": "is_free_app", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcForAppId", + "columnName": "dlc_for_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mustOwnAppToPurchase", + "columnName": "must_own_app_to_purchase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAvailableOnStore", + "columnName": "dlc_available_on_store", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "optionalDlc", + "columnName": "optional_dlc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gameDir", + "columnName": "game_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScript", + "columnName": "install_script", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noServers", + "columnName": "no_servers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryCache", + "columnName": "primary_cache", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "validOSList", + "columnName": "valid_os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thirdPartyCdKey", + "columnName": "third_party_cd_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenInstalled", + "columnName": "visible_only_when_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenSubscribed", + "columnName": "visible_only_when_subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchEulaUrl", + "columnName": "launch_eula_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requireDefaultInstallFolder", + "columnName": "require_default_install_folder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installDir", + "columnName": "install_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useLaunchCmdLine", + "columnName": "use_launch_cmd_line", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchWithoutWorkshopUpdates", + "columnName": "launch_without_workshop_updates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useMms", + "columnName": "use_mms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installScriptSignature", + "columnName": "install_script_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScriptOverride", + "columnName": "install_script_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ufs", + "columnName": "ufs", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "steam_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageId` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `time_created` INTEGER NOT NULL, `time_next_process` INTEGER NOT NULL, `minute_limit` INTEGER NOT NULL, `minutes_used` INTEGER NOT NULL, `payment_method` INTEGER NOT NULL, `license_flags` INTEGER NOT NULL, `purchase_code` TEXT NOT NULL, `license_type` INTEGER NOT NULL, `territory_code` INTEGER NOT NULL, `access_token` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `master_package_id` INTEGER NOT NULL, `app_ids` TEXT NOT NULL, `depot_ids` TEXT NOT NULL, PRIMARY KEY(`packageId`))", + "fields": [ + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeCreated", + "columnName": "time_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeNextProcess", + "columnName": "time_next_process", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minuteLimit", + "columnName": "minute_limit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesUsed", + "columnName": "minutes_used", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentMethod", + "columnName": "payment_method", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseCode", + "columnName": "purchase_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseType", + "columnName": "license_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "territoryCode", + "columnName": "territory_code", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "access_token", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "masterPackageID", + "columnName": "master_package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appIds", + "columnName": "app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "depotIds", + "columnName": "depot_ids", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageId" + ] + } + }, + { + "tableName": "gog_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `slug` TEXT NOT NULL, `download_size` INTEGER NOT NULL, `install_size` INTEGER NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `image_url` TEXT NOT NULL, `icon_url` TEXT NOT NULL, `description` TEXT NOT NULL, `release_date` TEXT NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `genres` TEXT NOT NULL, `languages` TEXT NOT NULL, `last_played` INTEGER NOT NULL, `play_time` INTEGER NOT NULL, `type` INTEGER NOT NULL, `exclude` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "slug", + "columnName": "slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadSize", + "columnName": "download_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installSize", + "columnName": "install_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "is_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installPath", + "columnName": "install_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "languages", + "columnName": "languages", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "play_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exclude", + "columnName": "exclude", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "downloading_app_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER NOT NULL, `dlcAppIds` TEXT NOT NULL, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAppIds", + "columnName": "dlcAppIds", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1f7ba808fad169ca465f142c315b8bdf')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/app.gamenative.db.PluviaDatabase/8.json b/app/schemas/app.gamenative.db.PluviaDatabase/8.json new file mode 100644 index 0000000000..8e62ad4c64 --- /dev/null +++ b/app/schemas/app.gamenative.db.PluviaDatabase/8.json @@ -0,0 +1,652 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "87cc091d2b797f84d619578feb06701d", + "entities": [ + { + "tableName": "app_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_downloaded` INTEGER NOT NULL, `downloaded_depots` TEXT NOT NULL, `dlc_depots` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "is_downloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedDepots", + "columnName": "downloaded_depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dlcDepots", + "columnName": "dlc_depots", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `license_json` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseJson", + "columnName": "license_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_change_numbers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `changeNumber` INTEGER, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "changeNumber", + "columnName": "changeNumber", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "encrypted_app_ticket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` INTEGER NOT NULL, `result` INTEGER NOT NULL, `ticket_version_no` INTEGER NOT NULL, `crc_encrypted_ticket` INTEGER NOT NULL, `cb_encrypted_user_data` INTEGER NOT NULL, `cb_encrypted_app_ownership_ticket` INTEGER NOT NULL, `encrypted_ticket` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`app_id`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ticketVersionNo", + "columnName": "ticket_version_no", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "crcEncryptedTicket", + "columnName": "crc_encrypted_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedUserData", + "columnName": "cb_encrypted_user_data", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedAppOwnershipTicket", + "columnName": "cb_encrypted_app_ownership_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedTicket", + "columnName": "encrypted_ticket", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "app_id" + ] + } + }, + { + "tableName": "app_file_change_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `userFileInfo` TEXT NOT NULL, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "userFileInfo", + "columnName": "userFileInfo", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "steam_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `package_id` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `license_flags` INTEGER NOT NULL, `received_pics` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `depots` TEXT NOT NULL, `branches` TEXT NOT NULL, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `os_list` INTEGER NOT NULL, `release_state` INTEGER NOT NULL, `release_date` INTEGER NOT NULL, `metacritic_score` INTEGER NOT NULL, `metacritic_full_url` TEXT NOT NULL, `logo_hash` TEXT NOT NULL, `logo_small_hash` TEXT NOT NULL, `icon_hash` TEXT NOT NULL, `client_icon_hash` TEXT NOT NULL, `client_tga_hash` TEXT NOT NULL, `small_capsule` TEXT NOT NULL, `header_image` TEXT NOT NULL, `library_assets` TEXT NOT NULL, `primary_genre` INTEGER NOT NULL, `review_score` INTEGER NOT NULL, `review_percentage` INTEGER NOT NULL, `controller_support` INTEGER NOT NULL, `demo_of_app_id` INTEGER NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `homepage_url` TEXT NOT NULL, `game_manual_url` TEXT NOT NULL, `load_all_before_launch` INTEGER NOT NULL, `dlc_app_ids` TEXT NOT NULL, `is_free_app` INTEGER NOT NULL, `dlc_for_app_id` INTEGER NOT NULL, `must_own_app_to_purchase` INTEGER NOT NULL, `dlc_available_on_store` INTEGER NOT NULL, `optional_dlc` INTEGER NOT NULL, `game_dir` TEXT NOT NULL, `install_script` TEXT NOT NULL, `no_servers` INTEGER NOT NULL, `order` INTEGER NOT NULL, `primary_cache` INTEGER NOT NULL, `valid_os_list` INTEGER NOT NULL, `third_party_cd_key` INTEGER NOT NULL, `visible_only_when_installed` INTEGER NOT NULL, `visible_only_when_subscribed` INTEGER NOT NULL, `launch_eula_url` TEXT NOT NULL, `require_default_install_folder` INTEGER NOT NULL, `content_type` INTEGER NOT NULL, `install_dir` TEXT NOT NULL, `use_launch_cmd_line` INTEGER NOT NULL, `launch_without_workshop_updates` INTEGER NOT NULL, `use_mms` INTEGER NOT NULL, `install_script_signature` TEXT NOT NULL, `install_script_override` INTEGER NOT NULL, `config` TEXT NOT NULL, `ufs` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receivedPICS", + "columnName": "received_pics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "depots", + "columnName": "depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branches", + "columnName": "branches", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "osList", + "columnName": "os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseState", + "columnName": "release_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticScore", + "columnName": "metacritic_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticFullUrl", + "columnName": "metacritic_full_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoHash", + "columnName": "logo_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoSmallHash", + "columnName": "logo_small_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconHash", + "columnName": "icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIconHash", + "columnName": "client_icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientTgaHash", + "columnName": "client_tga_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallCapsule", + "columnName": "small_capsule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerImage", + "columnName": "header_image", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryAssets", + "columnName": "library_assets", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryGenre", + "columnName": "primary_genre", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewScore", + "columnName": "review_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewPercentage", + "columnName": "review_percentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "controllerSupport", + "columnName": "controller_support", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "demoOfAppId", + "columnName": "demo_of_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homepageUrl", + "columnName": "homepage_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gameManualUrl", + "columnName": "game_manual_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loadAllBeforeLaunch", + "columnName": "load_all_before_launch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAppIds", + "columnName": "dlc_app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFreeApp", + "columnName": "is_free_app", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcForAppId", + "columnName": "dlc_for_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mustOwnAppToPurchase", + "columnName": "must_own_app_to_purchase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAvailableOnStore", + "columnName": "dlc_available_on_store", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "optionalDlc", + "columnName": "optional_dlc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gameDir", + "columnName": "game_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScript", + "columnName": "install_script", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noServers", + "columnName": "no_servers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryCache", + "columnName": "primary_cache", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "validOSList", + "columnName": "valid_os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thirdPartyCdKey", + "columnName": "third_party_cd_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenInstalled", + "columnName": "visible_only_when_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenSubscribed", + "columnName": "visible_only_when_subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchEulaUrl", + "columnName": "launch_eula_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requireDefaultInstallFolder", + "columnName": "require_default_install_folder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installDir", + "columnName": "install_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useLaunchCmdLine", + "columnName": "use_launch_cmd_line", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchWithoutWorkshopUpdates", + "columnName": "launch_without_workshop_updates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useMms", + "columnName": "use_mms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installScriptSignature", + "columnName": "install_script_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScriptOverride", + "columnName": "install_script_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ufs", + "columnName": "ufs", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "steam_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageId` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `time_created` INTEGER NOT NULL, `time_next_process` INTEGER NOT NULL, `minute_limit` INTEGER NOT NULL, `minutes_used` INTEGER NOT NULL, `payment_method` INTEGER NOT NULL, `license_flags` INTEGER NOT NULL, `purchase_code` TEXT NOT NULL, `license_type` INTEGER NOT NULL, `territory_code` INTEGER NOT NULL, `access_token` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `master_package_id` INTEGER NOT NULL, `app_ids` TEXT NOT NULL, `depot_ids` TEXT NOT NULL, PRIMARY KEY(`packageId`))", + "fields": [ + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeCreated", + "columnName": "time_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeNextProcess", + "columnName": "time_next_process", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minuteLimit", + "columnName": "minute_limit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesUsed", + "columnName": "minutes_used", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentMethod", + "columnName": "payment_method", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseCode", + "columnName": "purchase_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseType", + "columnName": "license_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "territoryCode", + "columnName": "territory_code", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "access_token", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "masterPackageID", + "columnName": "master_package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appIds", + "columnName": "app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "depotIds", + "columnName": "depot_ids", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '87cc091d2b797f84d619578feb06701d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/app.gamenative.db.PluviaDatabase/9.json b/app/schemas/app.gamenative.db.PluviaDatabase/9.json new file mode 100644 index 0000000000..33f2769f98 --- /dev/null +++ b/app/schemas/app.gamenative.db.PluviaDatabase/9.json @@ -0,0 +1,772 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "12c9ce07ac85aa0a7b83c81705f4596d", + "entities": [ + { + "tableName": "app_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_downloaded` INTEGER NOT NULL, `downloaded_depots` TEXT NOT NULL, `dlc_depots` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "is_downloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedDepots", + "columnName": "downloaded_depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dlcDepots", + "columnName": "dlc_depots", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `license_json` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseJson", + "columnName": "license_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_change_numbers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `changeNumber` INTEGER, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "changeNumber", + "columnName": "changeNumber", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "encrypted_app_ticket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` INTEGER NOT NULL, `result` INTEGER NOT NULL, `ticket_version_no` INTEGER NOT NULL, `crc_encrypted_ticket` INTEGER NOT NULL, `cb_encrypted_user_data` INTEGER NOT NULL, `cb_encrypted_app_ownership_ticket` INTEGER NOT NULL, `encrypted_ticket` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`app_id`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ticketVersionNo", + "columnName": "ticket_version_no", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "crcEncryptedTicket", + "columnName": "crc_encrypted_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedUserData", + "columnName": "cb_encrypted_user_data", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedAppOwnershipTicket", + "columnName": "cb_encrypted_app_ownership_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedTicket", + "columnName": "encrypted_ticket", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "app_id" + ] + } + }, + { + "tableName": "app_file_change_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `userFileInfo` TEXT NOT NULL, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "userFileInfo", + "columnName": "userFileInfo", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "steam_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `package_id` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `license_flags` INTEGER NOT NULL, `received_pics` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `depots` TEXT NOT NULL, `branches` TEXT NOT NULL, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `os_list` INTEGER NOT NULL, `release_state` INTEGER NOT NULL, `release_date` INTEGER NOT NULL, `metacritic_score` INTEGER NOT NULL, `metacritic_full_url` TEXT NOT NULL, `logo_hash` TEXT NOT NULL, `logo_small_hash` TEXT NOT NULL, `icon_hash` TEXT NOT NULL, `client_icon_hash` TEXT NOT NULL, `client_tga_hash` TEXT NOT NULL, `small_capsule` TEXT NOT NULL, `header_image` TEXT NOT NULL, `library_assets` TEXT NOT NULL, `primary_genre` INTEGER NOT NULL, `review_score` INTEGER NOT NULL, `review_percentage` INTEGER NOT NULL, `controller_support` INTEGER NOT NULL, `demo_of_app_id` INTEGER NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `homepage_url` TEXT NOT NULL, `game_manual_url` TEXT NOT NULL, `load_all_before_launch` INTEGER NOT NULL, `dlc_app_ids` TEXT NOT NULL, `is_free_app` INTEGER NOT NULL, `dlc_for_app_id` INTEGER NOT NULL, `must_own_app_to_purchase` INTEGER NOT NULL, `dlc_available_on_store` INTEGER NOT NULL, `optional_dlc` INTEGER NOT NULL, `game_dir` TEXT NOT NULL, `install_script` TEXT NOT NULL, `no_servers` INTEGER NOT NULL, `order` INTEGER NOT NULL, `primary_cache` INTEGER NOT NULL, `valid_os_list` INTEGER NOT NULL, `third_party_cd_key` INTEGER NOT NULL, `visible_only_when_installed` INTEGER NOT NULL, `visible_only_when_subscribed` INTEGER NOT NULL, `launch_eula_url` TEXT NOT NULL, `require_default_install_folder` INTEGER NOT NULL, `content_type` INTEGER NOT NULL, `install_dir` TEXT NOT NULL, `use_launch_cmd_line` INTEGER NOT NULL, `launch_without_workshop_updates` INTEGER NOT NULL, `use_mms` INTEGER NOT NULL, `install_script_signature` TEXT NOT NULL, `install_script_override` INTEGER NOT NULL, `config` TEXT NOT NULL, `ufs` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receivedPICS", + "columnName": "received_pics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "depots", + "columnName": "depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branches", + "columnName": "branches", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "osList", + "columnName": "os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseState", + "columnName": "release_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticScore", + "columnName": "metacritic_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticFullUrl", + "columnName": "metacritic_full_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoHash", + "columnName": "logo_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoSmallHash", + "columnName": "logo_small_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconHash", + "columnName": "icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIconHash", + "columnName": "client_icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientTgaHash", + "columnName": "client_tga_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallCapsule", + "columnName": "small_capsule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerImage", + "columnName": "header_image", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryAssets", + "columnName": "library_assets", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryGenre", + "columnName": "primary_genre", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewScore", + "columnName": "review_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewPercentage", + "columnName": "review_percentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "controllerSupport", + "columnName": "controller_support", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "demoOfAppId", + "columnName": "demo_of_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homepageUrl", + "columnName": "homepage_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gameManualUrl", + "columnName": "game_manual_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loadAllBeforeLaunch", + "columnName": "load_all_before_launch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAppIds", + "columnName": "dlc_app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFreeApp", + "columnName": "is_free_app", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcForAppId", + "columnName": "dlc_for_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mustOwnAppToPurchase", + "columnName": "must_own_app_to_purchase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAvailableOnStore", + "columnName": "dlc_available_on_store", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "optionalDlc", + "columnName": "optional_dlc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gameDir", + "columnName": "game_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScript", + "columnName": "install_script", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noServers", + "columnName": "no_servers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryCache", + "columnName": "primary_cache", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "validOSList", + "columnName": "valid_os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thirdPartyCdKey", + "columnName": "third_party_cd_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenInstalled", + "columnName": "visible_only_when_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenSubscribed", + "columnName": "visible_only_when_subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchEulaUrl", + "columnName": "launch_eula_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requireDefaultInstallFolder", + "columnName": "require_default_install_folder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installDir", + "columnName": "install_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useLaunchCmdLine", + "columnName": "use_launch_cmd_line", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchWithoutWorkshopUpdates", + "columnName": "launch_without_workshop_updates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useMms", + "columnName": "use_mms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installScriptSignature", + "columnName": "install_script_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScriptOverride", + "columnName": "install_script_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ufs", + "columnName": "ufs", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "steam_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageId` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `time_created` INTEGER NOT NULL, `time_next_process` INTEGER NOT NULL, `minute_limit` INTEGER NOT NULL, `minutes_used` INTEGER NOT NULL, `payment_method` INTEGER NOT NULL, `license_flags` INTEGER NOT NULL, `purchase_code` TEXT NOT NULL, `license_type` INTEGER NOT NULL, `territory_code` INTEGER NOT NULL, `access_token` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `master_package_id` INTEGER NOT NULL, `app_ids` TEXT NOT NULL, `depot_ids` TEXT NOT NULL, PRIMARY KEY(`packageId`))", + "fields": [ + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeCreated", + "columnName": "time_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeNextProcess", + "columnName": "time_next_process", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minuteLimit", + "columnName": "minute_limit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesUsed", + "columnName": "minutes_used", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentMethod", + "columnName": "payment_method", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseCode", + "columnName": "purchase_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseType", + "columnName": "license_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "territoryCode", + "columnName": "territory_code", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "access_token", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "masterPackageID", + "columnName": "master_package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appIds", + "columnName": "app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "depotIds", + "columnName": "depot_ids", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageId" + ] + } + }, + { + "tableName": "gog_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `slug` TEXT NOT NULL, `download_size` INTEGER NOT NULL, `install_size` INTEGER NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `image_url` TEXT NOT NULL, `icon_url` TEXT NOT NULL, `description` TEXT NOT NULL, `release_date` TEXT NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `genres` TEXT NOT NULL, `languages` TEXT NOT NULL, `last_played` INTEGER NOT NULL, `play_time` INTEGER NOT NULL, `type` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "slug", + "columnName": "slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadSize", + "columnName": "download_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installSize", + "columnName": "install_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "is_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installPath", + "columnName": "install_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "languages", + "columnName": "languages", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "play_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '12c9ce07ac85aa0a7b83c81705f4596d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index af29c3906f..61e7268ce0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + android:theme="@style/Theme.Pluvia" + android:allowAudioPlaybackCapture="true" + tools:targetApi="29"> - + - + + + @@ -681,6 +715,13 @@ object PrefManager { setPref(SHOW_CUSTOM_GAMES_IN_LIBRARY, value) } + private val SHOW_GOG_IN_LIBRARY = booleanPreferencesKey("show_gog_in_library") + var showGOGInLibrary: Boolean + get() = getPref(SHOW_GOG_IN_LIBRARY, true) + set(value) { + setPref(SHOW_GOG_IN_LIBRARY, value) + } + // Game counts for skeleton loaders private val CUSTOM_GAMES_COUNT = intPreferencesKey("custom_games_count") var customGamesCount: Int @@ -696,6 +737,20 @@ object PrefManager { setPref(STEAM_GAMES_COUNT, value) } + private val GOG_GAMES_COUNT = intPreferencesKey("gog_games_count") + var gogGamesCount: Int + get() = getPref(GOG_GAMES_COUNT, 0) + set(value) { + setPref(GOG_GAMES_COUNT, value) + } + + private val GOG_INSTALLED_GAMES_COUNT = intPreferencesKey("gog_installed_games_count") + var gogInstalledGamesCount: Int + get() = getPref(GOG_INSTALLED_GAMES_COUNT, 0) + set(value) { + setPref(GOG_INSTALLED_GAMES_COUNT, value) + } + // Show dialog when adding custom game folder private val SHOW_ADD_CUSTOM_GAME_DIALOG = booleanPreferencesKey("show_add_custom_game_dialog") var showAddCustomGameDialog: Boolean @@ -742,11 +797,30 @@ object PrefManager { setPref(EXTERNAL_STORAGE_PATH, value) } + // Custom Games root (additional paths). Default path is provided by the app at runtime and isn't stored here. + private val CUSTOM_GAME_PATHS = stringPreferencesKey("custom_game_paths") + var customGamePaths: Set + get() { + val value = getPref(CUSTOM_GAME_PATHS, "[]") + return try { + Json.decodeFromString>(value) + } catch (e: Exception) { + emptySet() + } + } + set(value) { + setPref(CUSTOM_GAME_PATHS, Json.encodeToString(value)) + } + private val CUSTOM_GAME_MANUAL_FOLDERS = stringPreferencesKey("custom_game_manual_folders") var customGameManualFolders: Set get() { val value = getPref(CUSTOM_GAME_MANUAL_FOLDERS, "[]") - return try { Json.decodeFromString>(value) } catch (e: Exception) { emptySet() } + return try { + Json.decodeFromString>(value) + } catch (e: Exception) { + emptySet() + } } set(value) { setPref(CUSTOM_GAME_MANUAL_FOLDERS, Json.encodeToString(value)) @@ -780,4 +854,12 @@ object PrefManager { var appLanguage: String get() = getPref(APP_LANGUAGE, "") set(value) = setPref(APP_LANGUAGE, value) + + // Game compatibility cache (JSON string) + private val GAME_COMPATIBILITY_CACHE = stringPreferencesKey("game_compatibility_cache") + var gameCompatibilityCache: String + get() = getPref(GAME_COMPATIBILITY_CACHE, "{}") + set(value) { + setPref(GAME_COMPATIBILITY_CACHE, value) + } } diff --git a/app/src/main/java/app/gamenative/data/DownloadInfo.kt b/app/src/main/java/app/gamenative/data/DownloadInfo.kt index 9fd19b41e4..00f494eff7 100644 --- a/app/src/main/java/app/gamenative/data/DownloadInfo.kt +++ b/app/src/main/java/app/gamenative/data/DownloadInfo.kt @@ -6,9 +6,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import timber.log.Timber import java.io.File +import java.util.concurrent.CopyOnWriteArrayList data class DownloadInfo( val jobCount: Int = 1, + val gameId: Int, + var downloadingAppIds: CopyOnWriteArrayList, ) { private var downloadJob: Job? = null private val downloadProgressListeners = mutableListOf<((Float) -> Unit)>() @@ -24,20 +27,28 @@ data class DownloadInfo( private data class SpeedSample(val timeMs: Long, val bytes: Long) - private val speedSamples = ArrayDeque() + private val speedSamples = CopyOnWriteArrayList() private var emaSpeedBytesPerSec: Double = 0.0 private var hasEmaSpeed: Boolean = false private var isActive: Boolean = true private val statusMessage = MutableStateFlow(null) fun cancel() { + cancel("Cancelled by user") + } + + fun failedToDownload() { + cancel("Failed to download") + } + + fun cancel(message: String) { // Persist the most recent progress so a resume can pick up where it left off. persistProgressSnapshot() // Mark as inactive and clear speed tracking so a future resume // does not use stale samples. setActive(false) resetSpeedTracking() - downloadJob?.cancel(CancellationException("Cancelled by user")) + downloadJob?.cancel(CancellationException(message)) } fun setDownloadJob(job: Job) { @@ -50,7 +61,7 @@ data class DownloadInfo( val bytesProgress = (bytesDownloaded.toFloat() / totalExpectedBytes.toFloat()).coerceIn(0f, 1f) return bytesProgress } - + // Fallback to depot-based progress only if we don't have byte tracking var total = 0f for (i in progresses.indices) { @@ -117,14 +128,14 @@ data class DownloadInfo( fun getStatusMessageFlow(): StateFlow = statusMessage private fun addSpeedSample(timestampMs: Long) { - speedSamples.addLast(SpeedSample(timestampMs, bytesDownloaded)) + speedSamples.add(SpeedSample(timestampMs, bytesDownloaded)) trimOldSamples(timestampMs) } private fun trimOldSamples(nowMs: Long, windowMs: Long = 30_000L) { val cutoff = nowMs - windowMs while (speedSamples.isNotEmpty() && speedSamples.first().timeMs < cutoff) { - speedSamples.removeFirst() + speedSamples.removeAt(0) } } diff --git a/app/src/main/java/app/gamenative/data/DownloadingAppInfo.kt b/app/src/main/java/app/gamenative/data/DownloadingAppInfo.kt new file mode 100644 index 0000000000..9bfd4d93c9 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/DownloadingAppInfo.kt @@ -0,0 +1,14 @@ +package app.gamenative.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity("downloading_app_info") +data class DownloadingAppInfo ( + @PrimaryKey + val appId: Int, + + @ColumnInfo("dlcAppIds") + val dlcAppIds: List = emptyList() +){} diff --git a/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt b/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt new file mode 100644 index 0000000000..ec8aac633d --- /dev/null +++ b/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt @@ -0,0 +1,26 @@ +package app.gamenative.data + +/** + * Save location template from GOG API (before path resolution) + * @param name The name/identifier of the location (e.g., "__default", "saves", "configs") + * @param location The path template with GOG variables (e.g., "/saves") + */ +data class GOGCloudSavesLocationTemplate( + val name: String, + val location: String +) + +/** + * Resolved GOG cloud save location (after path resolution) + * @param name The name/identifier of the save location + * @param location The absolute path to the save directory on the device + * @param clientId The game's GOG client ID used for cloud storage API + * @param clientSecret The game's GOG client secret for authentication + */ +data class GOGCloudSavesLocation( + val name: String, + val location: String, + val clientId: String, + val clientSecret: String = "" // Default empty for backward compatibility +) + diff --git a/app/src/main/java/app/gamenative/data/GOGGame.kt b/app/src/main/java/app/gamenative/data/GOGGame.kt new file mode 100644 index 0000000000..1bfdf52f22 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/GOGGame.kt @@ -0,0 +1,92 @@ +package app.gamenative.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import app.gamenative.enums.AppType + +/** + * GOG Game entity for Room database + * Represents a game from the GOG platform + */ +@Entity(tableName = "gog_games") +data class GOGGame( + @PrimaryKey + @ColumnInfo("id") + val id: String, + + @ColumnInfo("title") + val title: String = "", + + @ColumnInfo("slug") + val slug: String = "", + + @ColumnInfo("download_size") + val downloadSize: Long = 0, + + @ColumnInfo("install_size") + val installSize: Long = 0, + + @ColumnInfo("is_installed") + val isInstalled: Boolean = false, + + @ColumnInfo("install_path") + val installPath: String = "", + + @ColumnInfo("image_url") + val imageUrl: String = "", + + @ColumnInfo("icon_url") + val iconUrl: String = "", + + @ColumnInfo("description") + val description: String = "", + + @ColumnInfo("release_date") + val releaseDate: String = "", + + @ColumnInfo("developer") + val developer: String = "", + + @ColumnInfo("publisher") + val publisher: String = "", + + @ColumnInfo("genres") + val genres: List = emptyList(), + + @ColumnInfo("languages") + val languages: List = emptyList(), + + @ColumnInfo("last_played") + val lastPlayed: Long = 0, + + @ColumnInfo("play_time") + val playTime: Long = 0, + + @ColumnInfo("type") + val type: AppType = AppType.game, + + @ColumnInfo(name = "exclude", defaultValue = "0") + val exclude: Boolean = false, +) { + companion object { + const val GOG_IMAGE_BASE_URL = "https://images.gog.com/images" + } +} + +data class GOGCredentials( + val accessToken: String, + val refreshToken: String, + val userId: String, + val username: String, +) + +data class GOGDownloadInfo( + val gameId: String, + val totalSize: Long, + val downloadedSize: Long = 0, + val progress: Float = 0f, + val isActive: Boolean = false, + val isPaused: Boolean = false, + val error: String? = null, +) diff --git a/app/src/main/java/app/gamenative/data/LibraryItem.kt b/app/src/main/java/app/gamenative/data/LibraryItem.kt index 1ee8398300..f3f7c0d79d 100644 --- a/app/src/main/java/app/gamenative/data/LibraryItem.kt +++ b/app/src/main/java/app/gamenative/data/LibraryItem.kt @@ -6,6 +6,7 @@ import app.gamenative.utils.CustomGameScanner enum class GameSource { STEAM, CUSTOM_GAME, + GOG, // Add other platforms here.. } @@ -46,12 +47,22 @@ data class LibraryItem( "" } } + GameSource.GOG -> { + // GoG Images are typically the full URL, but have fallback just in case. + if (iconHash.isEmpty()) { + "" + } else if (iconHash.startsWith("http")) { + iconHash + } else { + "${GOGGame.GOG_IMAGE_BASE_URL}/$iconHash" + } + } } /** * Helper property to get the game ID as an integer - * Extracts the numeric part by removing the gameSource prefix + * For all game sources, extract the numeric part after the prefix */ val gameId: Int - get() = appId.removePrefix("${gameSource.name}_").toInt() + get() = appId.removePrefix("${gameSource.name}_").toIntOrNull() ?: 0 } diff --git a/app/src/main/java/app/gamenative/data/SaveFilePattern.kt b/app/src/main/java/app/gamenative/data/SaveFilePattern.kt index 28726dab87..7be6ac05a7 100644 --- a/app/src/main/java/app/gamenative/data/SaveFilePattern.kt +++ b/app/src/main/java/app/gamenative/data/SaveFilePattern.kt @@ -9,6 +9,7 @@ data class SaveFilePattern( val root: PathType, val path: String, val pattern: String, + val recursive: Int = 0, ) { val prefix: String get() = "%${root.name}%$path" diff --git a/app/src/main/java/app/gamenative/data/SteamFriend.kt b/app/src/main/java/app/gamenative/data/SteamFriend.kt index efc6cfc839..f315f77aa3 100644 --- a/app/src/main/java/app/gamenative/data/SteamFriend.kt +++ b/app/src/main/java/app/gamenative/data/SteamFriend.kt @@ -1,138 +1,20 @@ package app.gamenative.data -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bedtime -import androidx.compose.material.icons.filled.PersonAddAlt1 -import androidx.compose.material.icons.filled.Smartphone -import androidx.compose.material.icons.filled.SportsEsports -import androidx.compose.material.icons.filled.Web -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import app.gamenative.ui.component.icons.VR -import app.gamenative.ui.theme.DarkColors -import `in`.dragonbra.javasteam.enums.EClientPersonaStateFlag -import `in`.dragonbra.javasteam.enums.EFriendRelationship import `in`.dragonbra.javasteam.enums.EPersonaState -import `in`.dragonbra.javasteam.enums.EPersonaStateFlag -import `in`.dragonbra.javasteam.types.GameID -import `in`.dragonbra.javasteam.types.SteamID -import java.util.Date -import java.util.EnumSet -@Entity("steam_friend") +/** + * This class serves to update your steam's profile icon on the main library screen and settings dialog. + */ data class SteamFriend( - @PrimaryKey val id: Long, - @ColumnInfo(name = "relation") - val relation: EFriendRelationship = EFriendRelationship.None, - @ColumnInfo("status_flags") - val statusFlags: EnumSet = EClientPersonaStateFlag.from(0), - @ColumnInfo("state") - val state: EPersonaState = EPersonaState.Offline, - @ColumnInfo("state_flags") - val stateFlags: EnumSet = EPersonaStateFlag.from(0), - @ColumnInfo("game_app_id") + val avatarHash: String = "", val gameAppID: Int = 0, - @ColumnInfo("game_id") - val gameID: GameID = GameID(), - @ColumnInfo("game_name") val gameName: String = "", - @ColumnInfo("game_server_ip") - val gameServerIP: Int = 0, - @ColumnInfo("game_server_port") - val gameServerPort: Int = 0, - @ColumnInfo("query_port") - val queryPort: Int = 0, - @ColumnInfo("source_steam_id") - val sourceSteamID: SteamID = SteamID(), - @ColumnInfo("game_data_blob") - val gameDataBlob: String = "", - @ColumnInfo("name") val name: String = "", - @ColumnInfo("nickname") - val nickname: String = "", - @ColumnInfo("avatar_hash") - val avatarHash: String = "", - @ColumnInfo("last_log_off") - val lastLogOff: Date = Date(0), - @ColumnInfo("last_log_on") - val lastLogOn: Date = Date(0), - @ColumnInfo("clan_rank") - val clanRank: Int = 0, - @ColumnInfo("clan_tag") - val clanTag: String = "", - @ColumnInfo("online_session_instances") - val onlineSessionInstances: Int = 0, - - // Chat message - @ColumnInfo("chat_entry_type") - val isTyping: Boolean = false, - @ColumnInfo("unread_messages") - val unreadMessageCount: Int = 0, + val state: EPersonaState = EPersonaState.Offline, ) { val isOnline: Boolean get() = (state.code() in 1..6) - val isOffline: Boolean - get() = state == EPersonaState.Offline - - val nameOrNickname: String - get() = nickname.ifEmpty { name.ifEmpty { "" } } - val isPlayingGame: Boolean - get() = if (isOnline) gameAppID > 0 || gameName.isEmpty().not() else false - - val isPlayingGameName: String - get() = if (isPlayingGame) { - gameName.ifEmpty { "Playing game id: $gameAppID" } - } else { - if (isBlocked) { - relation.name - } else { - state.name - } - } - - val isAwayOrSnooze: Boolean - get() = state.let { - it == EPersonaState.Away || it == EPersonaState.Snooze || it == EPersonaState.Busy - } - - val isInGameAwayOrSnooze: Boolean - get() = isPlayingGame && isAwayOrSnooze - - val isRequestRecipient: Boolean - get() = relation == EFriendRelationship.RequestRecipient - - val isBlocked: Boolean - get() = relation == EFriendRelationship.Blocked || - relation == EFriendRelationship.Ignored || - relation == EFriendRelationship.IgnoredFriend - - val isFriend: Boolean - get() = relation == EFriendRelationship.Friend - - val statusColor: Color - get() = when { - isBlocked -> DarkColors.friendBlocked - isOffline -> DarkColors.friendOffline - isInGameAwayOrSnooze -> DarkColors.friendInGameAwayOrSnooze - isAwayOrSnooze -> DarkColors.friendAwayOrSnooze - isPlayingGame -> DarkColors.friendInGame - isOnline -> DarkColors.friendOnline - else -> DarkColors.friendOffline - } - - val statusIcon: ImageVector? - get() = when { - isRequestRecipient -> Icons.Default.PersonAddAlt1 - isAwayOrSnooze -> Icons.Default.Bedtime - stateFlags.contains(EPersonaStateFlag.ClientTypeVR) -> Icons.Default.VR - stateFlags.contains(EPersonaStateFlag.ClientTypeTenfoot) -> Icons.Default.SportsEsports - stateFlags.contains(EPersonaStateFlag.ClientTypeMobile) -> Icons.Default.Smartphone - stateFlags.contains(EPersonaStateFlag.ClientTypeWeb) -> Icons.Default.Web - else -> null - } + get() = if (isOnline) gameAppID > 0 || gameName.isNotBlank() else false } diff --git a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt index 6a03d3c1e8..40fc41c64d 100644 --- a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt +++ b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt @@ -1,52 +1,58 @@ package app.gamenative.db +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import app.gamenative.data.ChangeNumbers -import app.gamenative.data.Emoticon import app.gamenative.data.AppInfo import app.gamenative.data.FileChangeLists -import app.gamenative.data.FriendMessage import app.gamenative.data.SteamApp -import app.gamenative.data.SteamFriend import app.gamenative.data.SteamLicense import app.gamenative.data.CachedLicense +import app.gamenative.data.DownloadingAppInfo import app.gamenative.data.EncryptedAppTicket +import app.gamenative.data.GOGGame import app.gamenative.db.converters.AppConverter import app.gamenative.db.converters.ByteArrayConverter import app.gamenative.db.converters.FriendConverter import app.gamenative.db.converters.LicenseConverter import app.gamenative.db.converters.PathTypeConverter import app.gamenative.db.converters.UserFileInfoListConverter +import app.gamenative.db.converters.GOGConverter import app.gamenative.db.dao.ChangeNumbersDao -import app.gamenative.db.dao.EmoticonDao import app.gamenative.db.dao.FileChangeListsDao -import app.gamenative.db.dao.FriendMessagesDao import app.gamenative.db.dao.SteamAppDao -import app.gamenative.db.dao.SteamFriendDao import app.gamenative.db.dao.SteamLicenseDao import app.gamenative.db.dao.AppInfoDao import app.gamenative.db.dao.CachedLicenseDao +import app.gamenative.db.dao.DownloadingAppInfoDao import app.gamenative.db.dao.EncryptedAppTicketDao +import app.gamenative.db.dao.GOGGameDao const val DATABASE_NAME = "pluvia.db" @Database( entities = [ - SteamApp::class, - SteamLicense::class, - SteamFriend::class, - ChangeNumbers::class, - FileChangeLists::class, - FriendMessage::class, - Emoticon::class, AppInfo::class, CachedLicense::class, + ChangeNumbers::class, EncryptedAppTicket::class, + FileChangeLists::class, + SteamApp::class, + SteamLicense::class, + GOGGame::class, + DownloadingAppInfo::class ], - version = 7, - exportSchema = false, // Should export once stable. + version = 11, + // For db migration, visit https://developer.android.com/training/data-storage/room/migrating-db-versions for more information + exportSchema = true, // It is better to handle db changes carefully, as GN is getting much more users. + autoMigrations = [ + // For every version change, if it is automatic, please add a new migration here. + AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10), + AutoMigration(from = 10, to = 11) + ] ) @TypeConverters( AppConverter::class, @@ -55,6 +61,7 @@ const val DATABASE_NAME = "pluvia.db" LicenseConverter::class, PathTypeConverter::class, UserFileInfoListConverter::class, + GOGConverter::class, ) abstract class PluviaDatabase : RoomDatabase() { @@ -62,19 +69,17 @@ abstract class PluviaDatabase : RoomDatabase() { abstract fun steamAppDao(): SteamAppDao - abstract fun steamFriendDao(): SteamFriendDao - abstract fun appChangeNumbersDao(): ChangeNumbersDao abstract fun appFileChangeListsDao(): FileChangeListsDao - abstract fun friendMessagesDao(): FriendMessagesDao - - abstract fun emoticonDao(): EmoticonDao - abstract fun appInfoDao(): AppInfoDao abstract fun cachedLicenseDao(): CachedLicenseDao abstract fun encryptedAppTicketDao(): EncryptedAppTicketDao + + abstract fun gogGameDao(): GOGGameDao + + abstract fun downloadingAppInfoDao(): DownloadingAppInfoDao } 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 0000000000..21f9755811 --- /dev/null +++ b/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt @@ -0,0 +1,25 @@ +package app.gamenative.db.converters + +import androidx.room.TypeConverter +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Room TypeConverter for GOG-specific data types + */ +class GOGConverter { + + @TypeConverter + fun fromStringList(value: List): String { + return Json.encodeToString(value) + } + + @TypeConverter + fun toStringList(value: String): List { + + if (value.isEmpty()) { + return emptyList() + } + return Json.decodeFromString>(value) + } +} diff --git a/app/src/main/java/app/gamenative/db/dao/AppInfoDao.kt b/app/src/main/java/app/gamenative/db/dao/AppInfoDao.kt index b80a2fbb30..306fcfcb9c 100644 --- a/app/src/main/java/app/gamenative/db/dao/AppInfoDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/AppInfoDao.kt @@ -21,7 +21,7 @@ interface AppInfoDao { suspend fun update(appInfo: AppInfo) @Query("SELECT * FROM app_info WHERE id = :appId") - suspend fun getInstalledDepots(appId: Int): AppInfo? + suspend fun getInstalledApp(appId: Int): AppInfo? @Query("SELECT * FROM app_info WHERE id = :appId") suspend fun get(appId: Int): AppInfo? diff --git a/app/src/main/java/app/gamenative/db/dao/DownloadingAppInfoDao.kt b/app/src/main/java/app/gamenative/db/dao/DownloadingAppInfoDao.kt new file mode 100644 index 0000000000..67c3a949dc --- /dev/null +++ b/app/src/main/java/app/gamenative/db/dao/DownloadingAppInfoDao.kt @@ -0,0 +1,22 @@ +package app.gamenative.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import app.gamenative.data.DownloadingAppInfo + +@Dao +interface DownloadingAppInfoDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(appInfo: DownloadingAppInfo) + + @Query("SELECT * FROM downloading_app_info WHERE appId = :appId") + suspend fun getDownloadingApp(appId: Int): DownloadingAppInfo? + + @Query("DELETE from downloading_app_info WHERE appId = :appId") + suspend fun deleteApp(appId: Int) + + @Query("DELETE from downloading_app_info") + suspend fun deleteAll() +} diff --git a/app/src/main/java/app/gamenative/db/dao/EmoticonDao.kt b/app/src/main/java/app/gamenative/db/dao/EmoticonDao.kt deleted file mode 100644 index bfe4ef4cba..0000000000 --- a/app/src/main/java/app/gamenative/db/dao/EmoticonDao.kt +++ /dev/null @@ -1,37 +0,0 @@ -package app.gamenative.db.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import app.gamenative.data.Emoticon -import kotlinx.coroutines.flow.Flow - -@Dao -interface EmoticonDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(emoticons: List) - - @Query("SELECT * FROM emoticon ORDER BY isSticker DESC, appID DESC, name DESC") - fun getAll(): Flow> - - @Query("SELECT * FROM emoticon ORDER BY isSticker DESC, appID DESC, name DESC") - fun getAllAsList(): List - - @Query("DELETE FROM emoticon") - suspend fun deleteAll() - - @Transaction - suspend fun replaceAll(emoticons: List) { - deleteAll() - insertAll(emoticons) - } - - @Query("SELECT COUNT(*) FROM emoticon") - fun getCount(): Flow - - @Query("SELECT * FROM emoticon WHERE isSticker = :isSticker ORDER BY name ASC") - fun getByType(isSticker: Boolean): Flow> -} diff --git a/app/src/main/java/app/gamenative/db/dao/FriendMessagesDao.kt b/app/src/main/java/app/gamenative/db/dao/FriendMessagesDao.kt deleted file mode 100644 index 5414362957..0000000000 --- a/app/src/main/java/app/gamenative/db/dao/FriendMessagesDao.kt +++ /dev/null @@ -1,77 +0,0 @@ -package app.gamenative.db.dao - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import app.gamenative.data.FriendMessage -import `in`.dragonbra.javasteam.types.SteamID -import kotlinx.coroutines.flow.Flow - -@Dao -interface FriendMessagesDao { - @Insert - suspend fun insertMessage(message: FriendMessage) - - @Insert - suspend fun insertMessages(messages: List) - - @Delete - suspend fun deleteMessage(message: FriendMessage) - - @Query("DELETE FROM chat_message") - suspend fun deleteAllMessages() - - @Query("DELETE FROM chat_message WHERE steam_id_friend = :steamId") - suspend fun deleteAllMessagesForFriend(steamId: SteamID) - - @Query("SELECT * FROM chat_message WHERE steam_id_friend = :steamId ORDER BY timestamp DESC") - fun getAllMessagesForFriend(steamId: Long): Flow> - - @Update - suspend fun updateMessage(message: FriendMessage) - - @Query("SELECT COUNT(*) FROM chat_message WHERE steam_id_friend = :steamId") - fun getMessageCountForFriend(steamId: SteamID): Flow - - @Query("SELECT EXISTS(SELECT 1 FROM chat_message WHERE steam_id_friend = :steamId AND timestamp = :timestamp AND message = :message)") - suspend fun messageExists(steamId: Long, timestamp: Int, message: String): Boolean - - @Transaction - suspend fun insertMessageIfNotExists(message: FriendMessage): Boolean { - val exists = messageExists( - steamId = message.steamIDFriend, - timestamp = message.timestamp, - message = message.message, - ) - - if (!exists) { - insertMessage(message) - return true - } - - return false - } - - @Transaction - suspend fun insertMessagesIfNotExist(messages: List): List { - val insertedMessages = mutableListOf() - - messages.forEach { message -> - val exists = messageExists( - steamId = message.steamIDFriend, - timestamp = message.timestamp, - message = message.message, - ) - - if (!exists) { - insertMessage(message) - insertedMessages.add(message) - } - } - - return insertedMessages - } -} 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 0000000000..8e653be7c1 --- /dev/null +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -0,0 +1,88 @@ +package app.gamenative.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import app.gamenative.data.GOGGame +import kotlinx.coroutines.flow.Flow + +/** + * DAO for GOG games in the Room database + */ +@Dao +interface GOGGameDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(game: GOGGame) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(games: List) + + @Update + suspend fun update(game: GOGGame) + + @Delete + suspend fun delete(game: GOGGame) + + @Query("DELETE FROM gog_games WHERE id = :gameId") + suspend fun deleteById(gameId: String) + + @Query("SELECT * FROM gog_games WHERE id = :gameId") + suspend fun getById(gameId: String): GOGGame? + + @Query("SELECT * FROM gog_games WHERE exclude = false ORDER BY title ASC") + fun getAll(): Flow> + + @Query("SELECT * FROM gog_games WHERE exclude = false ORDER BY title ASC") + suspend fun getAllAsList(): List + + @Query("SELECT * FROM gog_games WHERE is_installed = :isInstalled AND exclude = false ORDER BY title ASC") + fun getByInstallStatus(isInstalled: Boolean): Flow> + + @Query("SELECT * FROM gog_games WHERE exclude = false AND 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 WHERE exclude = false") + fun getCount(): Flow + + @Query("SELECT id FROM gog_games") + suspend fun getAllGameIdsIncludingExcluded(): List + + @Transaction + suspend fun replaceAll(games: List) { + deleteAll() + insertAll(games) + } + + /** + * Upsert GOG games while preserving install status and paths + * This is useful when refreshing the library from GOG API + */ + @Transaction + suspend fun upsertPreservingInstallStatus(games: List) { + games.forEach { newGame -> + val existingGame = getById(newGame.id) + if (existingGame != null) { + // Preserve installation status, path, and size from existing game + val gameToInsert = newGame.copy( + isInstalled = existingGame.isInstalled, + installPath = existingGame.installPath, + installSize = existingGame.installSize, + lastPlayed = existingGame.lastPlayed, + playTime = existingGame.playTime, + ) + insert(gameToInsert) + } else { + // New game, insert as-is + insert(newGame) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt b/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt index 016f7d0a9a..96c0215bc8 100644 --- a/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt @@ -44,6 +44,24 @@ interface SteamAppDao { @Query("SELECT * FROM steam_app WHERE id = :appId") suspend fun findApp(appId: Int): SteamApp? + @Query("SELECT * FROM steam_app AS app WHERE dlc_for_app_id = :appId AND depots <> '{}' AND " + + " EXISTS (" + + " SELECT * FROM steam_license AS license " + + " WHERE license.license_type <> 0 AND " + + " REPLACE(REPLACE(license.app_ids, '[', ','), ']', ',') LIKE ('%,' || app.id || ',%') " + + ")" + ) + suspend fun findDownloadableDLCApps(appId: Int): List? + + @Query("SELECT * FROM steam_app AS app WHERE dlc_for_app_id = :appId AND depots = '{}' AND " + + " EXISTS (" + + " SELECT * FROM steam_license AS license " + + " WHERE license.license_type <> 0 AND " + + " REPLACE(REPLACE(license.app_ids, '[', ','), ']', ',') LIKE ('%,' || app.id || ',%') " + + ")" + ) + suspend fun findHiddenDLCApps(appId: Int): List? + @Query("DELETE from steam_app") suspend fun deleteAll() diff --git a/app/src/main/java/app/gamenative/db/dao/SteamFriendDao.kt b/app/src/main/java/app/gamenative/db/dao/SteamFriendDao.kt deleted file mode 100644 index 3561e67001..0000000000 --- a/app/src/main/java/app/gamenative/db/dao/SteamFriendDao.kt +++ /dev/null @@ -1,61 +0,0 @@ -package app.gamenative.db.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import app.gamenative.data.SteamFriend -import `in`.dragonbra.javasteam.steam.handlers.steamfriends.PlayerNickname -import kotlinx.coroutines.flow.Flow - -@Dao -interface SteamFriendDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(friend: SteamFriend) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(friends: List) - - @Update - suspend fun update(friend: SteamFriend) - - @Update - suspend fun updateAll(friend: List) - - @Transaction - suspend fun updateNicknames(nickname: List) { - nickname.forEach { - updateNicknameInternal(it.steamID.convertToUInt64(), it.nickname) - } - } - - @Query("UPDATE steam_friend SET nickname = :newNickname WHERE id = :friendId") - suspend fun updateNicknameInternal(friendId: Long, newNickname: String) - - @Query("UPDATE steam_friend SET nickname = ''") - suspend fun clearAllNicknames() - - @Query("SELECT * FROM steam_friend ORDER BY name ASC") - fun getAllFriendsFlow(): Flow> - - @Query("SELECT * FROM steam_friend WHERE game_app_id > 0") - suspend fun findFriendsInGame(): List - - @Query("SELECT * FROM steam_friend WHERE id = :id") - fun findFriendFlow(id: Long): Flow - - @Query("SELECT * FROM steam_friend WHERE id = :id") - suspend fun findFriend(id: Long): SteamFriend? - - @Query("SELECT * FROM steam_friend WHERE name LIKE '%' || :name || '%' OR nickname LIKE '%' || :name || '%'") - fun findFriendFlow(name: String): Flow> - - @Query("DELETE FROM steam_friend WHERE id = :friendId") - suspend fun remove(friendId: Long) - - @Query("DELETE from steam_friend") - suspend fun deleteAll() -} diff --git a/app/src/main/java/app/gamenative/db/migration/RoomMigration.kt b/app/src/main/java/app/gamenative/db/migration/RoomMigration.kt new file mode 100644 index 0000000000..f640ed3ea1 --- /dev/null +++ b/app/src/main/java/app/gamenative/db/migration/RoomMigration.kt @@ -0,0 +1,16 @@ +package app.gamenative.db.migration + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +private const val DROP_TABLE = "DROP TABLE IF EXISTS " // Trailing Space + +internal val ROOM_MIGRATION_V7_to_V8 = object : Migration(7, 8) { + override fun migrate(connection: SQLiteConnection) { + // Dec 5, 2025: Friends and Chat features removed + connection.execSQL(DROP_TABLE + "chat_message") + connection.execSQL(DROP_TABLE + "emoticon") + connection.execSQL(DROP_TABLE + "steam_friend") + } +} diff --git a/app/src/main/java/app/gamenative/di/DatabaseModule.kt b/app/src/main/java/app/gamenative/di/DatabaseModule.kt index 32bc64ae0d..071bf3d636 100644 --- a/app/src/main/java/app/gamenative/di/DatabaseModule.kt +++ b/app/src/main/java/app/gamenative/di/DatabaseModule.kt @@ -6,7 +6,9 @@ import app.gamenative.db.DATABASE_NAME import app.gamenative.db.PluviaDatabase import app.gamenative.db.dao.AppInfoDao import app.gamenative.db.dao.CachedLicenseDao +import app.gamenative.db.dao.DownloadingAppInfoDao import app.gamenative.db.dao.EncryptedAppTicketDao +import app.gamenative.db.migration.ROOM_MIGRATION_V7_to_V8 import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,7 +26,8 @@ class DatabaseModule { // The db will be considered unstable during development. // Once stable we should add a (room) db migration return Room.databaseBuilder(context, PluviaDatabase::class.java, DATABASE_NAME) - .fallbackToDestructiveMigration() // TODO remove before prod + .addMigrations(ROOM_MIGRATION_V7_to_V8) + .fallbackToDestructiveMigration(true) .build() } @@ -36,10 +39,6 @@ class DatabaseModule { @Singleton fun provideSteamAppDao(db: PluviaDatabase) = db.steamAppDao() - @Provides - @Singleton - fun provideSteamFriendDao(db: PluviaDatabase) = db.steamFriendDao() - @Provides @Singleton fun provideAppChangeNumbersDao(db: PluviaDatabase) = db.appChangeNumbersDao() @@ -50,21 +49,21 @@ class DatabaseModule { @Provides @Singleton - fun provideFriendMessagesDao(db: PluviaDatabase) = db.friendMessagesDao() + fun provideAppInfoDao(db: PluviaDatabase): AppInfoDao = db.appInfoDao() @Provides @Singleton - fun provideEmoticonDao(db: PluviaDatabase) = db.emoticonDao() + fun provideCachedLicenseDao(db: PluviaDatabase): CachedLicenseDao = db.cachedLicenseDao() @Provides @Singleton - fun provideAppInfoDao(db: PluviaDatabase): AppInfoDao = db.appInfoDao() + fun provideEncryptedAppTicketDao(db: PluviaDatabase): EncryptedAppTicketDao = db.encryptedAppTicketDao() @Provides @Singleton - fun provideCachedLicenseDao(db: PluviaDatabase): CachedLicenseDao = db.cachedLicenseDao() + fun provideGOGGameDao(db: PluviaDatabase) = db.gogGameDao() @Provides @Singleton - fun provideEncryptedAppTicketDao(db: PluviaDatabase): EncryptedAppTicketDao = db.encryptedAppTicketDao() + fun provideDownloadingAppInfoDao(db: PluviaDatabase): DownloadingAppInfoDao = db.downloadingAppInfoDao() } diff --git a/app/src/main/java/app/gamenative/enums/Marker.kt b/app/src/main/java/app/gamenative/enums/Marker.kt index bdf6b39f75..ba1bbbf18c 100644 --- a/app/src/main/java/app/gamenative/enums/Marker.kt +++ b/app/src/main/java/app/gamenative/enums/Marker.kt @@ -2,6 +2,7 @@ package app.gamenative.enums enum class Marker(val fileName: String ) { DOWNLOAD_COMPLETE_MARKER(".download_complete"), + DOWNLOAD_IN_PROGRESS_MARKER(".download_in_progress"), STEAM_DLL_REPLACED(".steam_dll_replaced"), STEAM_DLL_RESTORED(".steam_dll_restored"), STEAM_COLDCLIENT_USED(".steam_coldclient_used"), diff --git a/app/src/main/java/app/gamenative/enums/PathType.kt b/app/src/main/java/app/gamenative/enums/PathType.kt index a019a27bfb..9b73145735 100644 --- a/app/src/main/java/app/gamenative/enums/PathType.kt +++ b/app/src/main/java/app/gamenative/enums/PathType.kt @@ -102,6 +102,160 @@ enum class PathType { companion object { val DEFAULT = SteamUserData + + /** + * Resolve GOG path variables () to Windows environment variables + * Converts GOG-specific variables like to actual paths or Windows env vars + * @param location Path template with GOG variables (e.g., "/saves") + * @param installPath Game install path (for variable) + * @return Path with GOG variables resolved (may still contain Windows env vars like %LOCALAPPDATA%) + */ + fun resolveGOGPathVariables(location: String, installPath: String): String { + var resolved = location + + // Map of GOG variables to their values + val variableMap = mapOf( + "INSTALL" to installPath, + "SAVED_GAMES" to "%USERPROFILE%/Saved Games", + "APPLICATION_DATA_LOCAL" to "%LOCALAPPDATA%", + "APPLICATION_DATA_LOCAL_LOW" to "%APPDATA%\\..\\LocalLow", + "APPLICATION_DATA_ROAMING" to "%APPDATA%", + "DOCUMENTS" to "%USERPROFILE%\\Documents" + ) + + // Find and replace patterns + val pattern = Regex("<\\?(\\w+)\\?>") + val matches = pattern.findAll(resolved) + + for (match in matches) { + val variableName = match.groupValues[1] + val replacement = variableMap[variableName] + if (replacement != null) { + resolved = resolved.replace(match.value, replacement) + Timber.d("Resolved GOG variable to $replacement") + } else { + Timber.w("Unknown GOG path variable: , leaving as-is") + } + } + + return resolved + } + + /** + * Convert a GOG Windows path with environment variables to an absolute device path + * Used for GOG cloud saves which provide Windows paths that need to be mapped to Wine prefix + * @param context Android context + * @param gogWindowsPath GOG-provided Windows path that may contain env vars like %LOCALAPPDATA%, %APPDATA%, %USERPROFILE% + * @return Absolute Unix path in Wine prefix + */ + fun toAbsPathForGOG(context: Context, gogWindowsPath: String, appId: String? = null): String { + val imageFs = ImageFs.find(context) + // For GOG games, use the container-specific wine prefix if appId is provided + val (winePrefix, useContainerRoot) = if (appId != null) { + val container = app.gamenative.utils.ContainerUtils.getOrCreateContainer(context, appId) + val containerRoot = container.rootDir.absolutePath + Timber.d("[PathType] Using container-specific root for $appId: $containerRoot") + Pair(containerRoot, true) + } else { + Pair(imageFs.rootDir.absolutePath, false) + } + val user = ImageFs.USER + + var mappedPath = gogWindowsPath + + // Map Windows environment variables to their Wine prefix equivalents + // When using container root, paths are relative to containerRoot/.wine/ + // When using imageFs, paths are relative to imageFs/home/xuser/.wine/ + val winePrefixPath = if (useContainerRoot) { + // Container root is already the container dir, wine is at .wine/ + ".wine" + } else { + // ImageFs needs home/xuser/.wine + ImageFs.WINEPREFIX + } + + // Handle %USERPROFILE% first to avoid partial replacements + if (mappedPath.contains("%USERPROFILE%/Saved Games") || mappedPath.contains("%USERPROFILE%\\Saved Games")) { + val savedGamesPath = Paths.get( + winePrefix, winePrefixPath, + "drive_c/users/", user, "Saved Games/" + ).toString() + mappedPath = mappedPath.replace("%USERPROFILE%/Saved Games", savedGamesPath) + .replace("%USERPROFILE%\\Saved Games", savedGamesPath) + } + + if (mappedPath.contains("%USERPROFILE%/Documents") || mappedPath.contains("%USERPROFILE%\\Documents")) { + val documentsPath = Paths.get( + winePrefix, winePrefixPath, + "drive_c/users/", user, "Documents/" + ).toString() + mappedPath = mappedPath.replace("%USERPROFILE%/Documents", documentsPath) + .replace("%USERPROFILE%\\Documents", documentsPath) + } + + // Map standard Windows environment variables + mappedPath = mappedPath.replace("%LOCALAPPDATA%", + Paths.get(winePrefix, winePrefixPath, "drive_c/users/", user, "AppData/Local/").toString()) + mappedPath = mappedPath.replace("%APPDATA%", + Paths.get(winePrefix, winePrefixPath, "drive_c/users/", user, "AppData/Roaming/").toString()) + mappedPath = mappedPath.replace("%USERPROFILE%", + Paths.get(winePrefix, winePrefixPath, "drive_c/users/", user, "").toString()) + + // Normalize path separators + mappedPath = mappedPath.replace("\\", "/") + + // Check if path is already absolute (after env var replacement) + val isAlreadyAbsolute = mappedPath.startsWith(winePrefix) + + // Normalize path to resolve ../ and ./ components + // Split by /, process each component, and rebuild + val pathParts = mappedPath.split("/").toMutableList() + val normalizedParts = mutableListOf() + for (part in pathParts) { + when { + part == ".." && normalizedParts.isNotEmpty() && normalizedParts.last() != ".." -> { + // Go up one directory + normalizedParts.removeAt(normalizedParts.lastIndex) + } + part != "." && part.isNotEmpty() -> { + // Add non-empty, non-current-dir parts + normalizedParts.add(part) + } + // Skip "." and empty parts + } + } + mappedPath = normalizedParts.joinToString("/") + + // Build absolute path - but skip if already absolute after env var replacement + val absolutePath = when { + isAlreadyAbsolute -> { + // Path was already made absolute by env var replacement, use as-is + mappedPath + } + mappedPath.startsWith("drive_c/") || mappedPath.startsWith("/drive_c/") -> { + val cleanPath = mappedPath.removePrefix("/") + Paths.get(winePrefix, winePrefixPath, cleanPath).toString() + } + mappedPath.startsWith(winePrefix) -> { + // Already absolute + mappedPath + } + else -> { + // Relative path, assume it's in drive_c + Paths.get(winePrefix, winePrefixPath, "drive_c", mappedPath).toString() + } + } + + // Ensure path ends with / for directories + val finalPath = if (!absolutePath.endsWith("/") && !absolutePath.endsWith("\\")) { + "$absolutePath/" + } else { + absolutePath + } + + return finalPath + } + fun from(keyValue: String?): PathType { return when (keyValue?.lowercase()) { "%${GameInstall.name.lowercase()}%", diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt index 25b64e5e5b..62f6e239d9 100644 --- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt +++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt @@ -23,5 +23,6 @@ interface AndroidEvent : Event { data class DownloadStatusChanged(val appId: Int, val isDownloading: Boolean) : AndroidEvent data class LibraryInstallStatusChanged(val appId: Int) : AndroidEvent data class CustomGameImagesFetched(val appId: String) : AndroidEvent + data class GOGAuthCodeReceived(val authCode: String) : AndroidEvent // data class SetAppBarVisibility(val visible: Boolean) : AndroidEvent } diff --git a/app/src/main/java/app/gamenative/events/SteamEvent.kt b/app/src/main/java/app/gamenative/events/SteamEvent.kt index bcdcae1de2..1634ac4bda 100644 --- a/app/src/main/java/app/gamenative/events/SteamEvent.kt +++ b/app/src/main/java/app/gamenative/events/SteamEvent.kt @@ -18,8 +18,4 @@ sealed interface SteamEvent : Event { data object ForceCloseApp : SteamEvent data object Disconnected : SteamEvent data object RemotelyDisconnected : SteamEvent - - // This isn't a SteamEvent, but since its the only one now, it can stay - data class OnProfileInfo(val info: ProfileInfoCallback) : SteamEvent - data class OnAliasHistory(val names: List) : SteamEvent } diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index 8b82943cc7..fa7ed6d477 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -2,6 +2,7 @@ package app.gamenative.service import androidx.room.withTransaction import app.gamenative.data.PostSyncInfo +import app.gamenative.data.SaveFilePattern import app.gamenative.data.SteamApp import app.gamenative.data.UserFileInfo import app.gamenative.data.UserFilesDownloadResult @@ -41,6 +42,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import timber.log.Timber +import java.io.OutputStream import java.net.SocketTimeoutException /** @@ -53,6 +55,22 @@ object SteamAutoCloud { private fun findPlaceholderWithin(aString: String): Sequence = Regex("%\\w+%").findAll(aString) + private inline fun InputStream.copyTo( + out: OutputStream, + bufferSize: Int = 8 * 1024, + progress: (Long) -> Unit, + ) { + val buf = ByteArray(bufferSize) + var bytesRead: Int + var total = 0L + while (read(buf).also { bytesRead = it } >= 0) { + if (bytesRead == 0) continue + out.write(buf, 0, bytesRead) + total += bytesRead + progress(total) + } + } + fun syncUserFiles( appInfo: SteamApp, clientId: Long, @@ -62,6 +80,7 @@ object SteamAutoCloud { parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO), prefixToPath: (String) -> String, overrideLocalChangeNumber: Long? = null, + onProgress: ((message: String, progress: Float) -> Unit)? = null, ): Deferred = parentScope.async { val postSyncInfo: PostSyncInfo? @@ -188,7 +207,9 @@ object SteamAutoCloud { val savePatterns = appInfo.ufs.saveFilePatterns.filter { userFile -> userFile.root.isWindows } if (savePatterns.isNotEmpty()) { - savePatterns.associate { userFile -> + val result = mutableMapOf>() + + savePatterns.forEach { userFile -> val basePath = Paths.get(prefixToPath(userFile.root.toString()), userFile.substitutedPath) Timber.i("Looking for saves in $basePath with pattern ${userFile.pattern} (prefix ${userFile.prefix})") @@ -209,8 +230,11 @@ object SteamAutoCloud { Timber.i("Found ${files.size} file(s) in $basePath for pattern ${userFile.pattern}") - Paths.get(userFile.prefix).pathString to files + val prefixKey = Paths.get(userFile.prefix).pathString + result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) } + + result } else { // Fallback: no UFS patterns; scan SteamUserData root recursively (depth 5) val rootType = PathType.SteamUserData @@ -270,8 +294,9 @@ object SteamAutoCloud { parentScope.async { var filesDownloaded = 0 var bytesDownloaded = 0L + val totalFiles = fileList.files.size - fileList.files.forEach { file -> + fileList.files.forEachIndexed { index, file -> val prefixedPath = getFilePrefixPath(file, fileList) val actualFilePath = getFullFilePath(file, fileList) @@ -280,6 +305,7 @@ object SteamAutoCloud { val fileDownloadInfo = steamCloud.clientFileDownload(appInfo.id, prefixedPath).await() if (fileDownloadInfo.urlHost.isNotEmpty()) { + onProgress?.invoke("Downloading ${file.filename}", -1f) val httpUrl = with(fileDownloadInfo) { buildUrl(useHttps, urlHost, urlPath) } @@ -307,17 +333,31 @@ object SteamAutoCloud { if (!response.isSuccessful) { Timber.w("File download of $prefixedPath was unsuccessful") response.close() - return@forEach + return@forEachIndexed } try { + val totalFileSize = fileDownloadInfo.rawFileSize.toLong() + var totalBytesRead = 0L + var lastReportedProgress = -1f + val progressThreshold = 0.01f // Update every 1% + val copyToFile: (InputStream) -> Unit = { input -> Files.createDirectories(actualFilePath.parent) FileOutputStream(actualFilePath.toString()).use { fs -> - val bytesRead = input.copyTo(fs) + input.copyTo(fs, 8 * 1024) { bytesRead -> + totalBytesRead = bytesRead + if (totalFileSize > 0) { + val currentProgress = (totalBytesRead.toFloat() / totalFileSize).coerceIn(0f, 1f) + if (currentProgress - lastReportedProgress >= progressThreshold || currentProgress >= 1f) { + onProgress?.invoke("Downloading ${file.filename}", currentProgress) + lastReportedProgress = currentProgress + } + } + } - if (bytesRead != fileDownloadInfo.rawFileSize.toLong()) { + if (totalBytesRead != totalFileSize) { Timber.w("Bytes read from stream of $prefixedPath does not match expected size") } } @@ -363,6 +403,10 @@ object SteamAutoCloud { } } + if (totalFiles > 0) { + onProgress?.invoke("Download complete", 1.0f) + } + UserFilesDownloadResult(filesDownloaded, bytesDownloaded) } } @@ -380,6 +424,8 @@ object SteamAutoCloud { // Filter out entries whose files no longer exist at upload time .filter { Files.exists(it.second.getAbsPath(prefixToPath)) } + val totalFiles = filesToUpload.size + Timber.i( "Beginning app upload batch with ${filesToDelete.size} file(s) to delete " + "and ${filesToUpload.size} file(s) to upload", @@ -397,7 +443,7 @@ object SteamAutoCloud { var uploadBatchSuccess = true - filesToUpload.map { it.second }.forEach { file -> + filesToUpload.map { it.second }.forEachIndexed { index, file -> val absFilePath = file.getAbsPath(prefixToPath) val fileSize = try { @@ -405,14 +451,21 @@ object SteamAutoCloud { } catch (e: Exception) { Timber.w("Skipping upload of ${file.prefixPath}: ${e.javaClass.simpleName}: ${e.message}") uploadBatchSuccess = false - return@forEach + return@forEachIndexed } Timber.i("Beginning upload of ${file.prefixPath} whose timestamp is ${file.timestamp}") + // Report start of upload + onProgress?.invoke("Uploading ${file.filename}", 0f) + val uploadInfo = steamCloud.beginFileUpload( appId = appInfo.id, - filename = file.prefixPath, + filename = if (appInfo.ufs.saveFilePatterns.isEmpty()) { + file.path + file.filename + } else { + file.prefixPath + }, fileSize = fileSize, rawFileSize = fileSize, fileSha = file.sha, @@ -422,6 +475,9 @@ object SteamAutoCloud { ).await() var uploadFileSuccess = true + var bytesUploadedForFile = 0L + var lastReportedProgress = -1f + val progressThreshold = 0.01f // Update every 1% change RandomAccessFile(absFilePath.pathString, "r").use { fs -> uploadInfo.blockRequests.forEach { blockRequest -> @@ -499,6 +555,17 @@ object SteamAutoCloud { uploadFileSuccess = false uploadBatchSuccess = false + } else { + // Update progress after successful block upload + bytesUploadedForFile += blockRequest.blockLength + if (fileSize > 0) { + val currentProgress = (bytesUploadedForFile.toFloat() / fileSize).coerceIn(0f, 1f) + // Only update if progress changed by at least 1% or we're at 100% + if (currentProgress - lastReportedProgress >= progressThreshold || currentProgress >= 1f) { + onProgress?.invoke("Uploading ${file.filename}", currentProgress) + lastReportedProgress = currentProgress + } + } } } } @@ -513,7 +580,11 @@ object SteamAutoCloud { transferSucceeded = uploadFileSuccess, appId = appInfo.id, fileSha = file.sha, - filename = file.prefixPath, + filename = if (appInfo.ufs.saveFilePatterns.isEmpty()) { + file.path + file.filename + } else { + file.prefixPath + }, ).await() Timber.i("File ${file.prefixPath} commit success: $commitSuccess") @@ -525,6 +596,10 @@ object SteamAutoCloud { batchEResult = if (uploadBatchSuccess) EResult.OK else EResult.Fail, ).await() + if (totalFiles > 0) { + onProgress?.invoke("Upload complete", 1.0f) + } + UserFilesUploadResult(uploadBatchSuccess, uploadBatchResponse.appChangeNumber, filesUploaded, bytesUploaded) } } diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index b776b7f135..51d008c5a8 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -8,15 +8,12 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.IBinder -import android.util.Base64 import android.widget.Toast import androidx.room.withTransaction import app.gamenative.BuildConfig import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.R -import app.gamenative.data.AppInfo -import app.gamenative.data.CachedLicense import app.gamenative.data.DepotInfo import app.gamenative.data.DownloadInfo import app.gamenative.data.Emoticon @@ -30,15 +27,10 @@ import app.gamenative.data.SteamFriend import app.gamenative.data.SteamLicense import app.gamenative.data.UserFileInfo import app.gamenative.db.PluviaDatabase -import app.gamenative.db.dao.AppInfoDao import app.gamenative.db.dao.CachedLicenseDao import app.gamenative.db.dao.ChangeNumbersDao -import app.gamenative.db.dao.EmoticonDao -import app.gamenative.db.dao.EncryptedAppTicketDao import app.gamenative.db.dao.FileChangeListsDao -import app.gamenative.db.dao.FriendMessagesDao 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 @@ -48,21 +40,21 @@ import app.gamenative.enums.SaveLocation import app.gamenative.enums.SyncResult 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.LicenseSerializer -import app.gamenative.utils.MarkerUtils import app.gamenative.utils.SteamUtils import app.gamenative.utils.generateSteamApp -import com.winlator.container.Container +import com.google.android.play.core.ktx.bytesDownloaded +import com.google.android.play.core.ktx.requestCancelInstall +import com.google.android.play.core.ktx.requestInstall +import com.google.android.play.core.ktx.requestSessionState +import com.google.android.play.core.ktx.status +import com.google.android.play.core.ktx.totalBytesToDownload +import com.google.android.play.core.splitinstall.SplitInstallManagerFactory +import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus import com.winlator.xenvironment.ImageFs import dagger.hilt.android.AndroidEntryPoint import `in`.dragonbra.javasteam.depotdownloader.DepotDownloader import `in`.dragonbra.javasteam.depotdownloader.IDownloadListener -import `in`.dragonbra.javasteam.depotdownloader.data.AppItem -import `in`.dragonbra.javasteam.depotdownloader.data.DownloadItem import `in`.dragonbra.javasteam.enums.EDepotFileFlag -import `in`.dragonbra.javasteam.enums.EFriendRelationship import `in`.dragonbra.javasteam.enums.ELicenseFlags import `in`.dragonbra.javasteam.enums.EOSType import `in`.dragonbra.javasteam.enums.EPersonaState @@ -80,17 +72,12 @@ import `in`.dragonbra.javasteam.steam.authentication.QrAuthSession import `in`.dragonbra.javasteam.steam.discovery.FileServerListProvider import `in`.dragonbra.javasteam.steam.discovery.ServerQuality import `in`.dragonbra.javasteam.steam.handlers.steamapps.GamePlayedInfo -import `in`.dragonbra.javasteam.steam.handlers.steamapps.License import `in`.dragonbra.javasteam.steam.handlers.steamapps.PICSRequest import `in`.dragonbra.javasteam.steam.handlers.steamapps.SteamApps import `in`.dragonbra.javasteam.steam.handlers.steamapps.callback.LicenseListCallback import `in`.dragonbra.javasteam.steam.handlers.steamcloud.SteamCloud import `in`.dragonbra.javasteam.steam.handlers.steamfriends.SteamFriends -import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.AliasHistoryCallback -import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.FriendsListCallback -import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.NicknameListCallback import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.PersonaStateCallback -import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfoCallback import `in`.dragonbra.javasteam.steam.handlers.steamgameserver.SteamGameServer import `in`.dragonbra.javasteam.steam.handlers.steammasterserver.SteamMasterServer import `in`.dragonbra.javasteam.steam.handlers.steamscreenshots.SteamScreenshots @@ -100,33 +87,25 @@ import `in`.dragonbra.javasteam.steam.handlers.steamuser.LogOnDetails import `in`.dragonbra.javasteam.steam.handlers.steamuser.SteamUser import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.LoggedOffCallback import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.LoggedOnCallback -import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.PlayingSessionStateCallback import `in`.dragonbra.javasteam.steam.handlers.steamuserstats.SteamUserStats import `in`.dragonbra.javasteam.steam.handlers.steamworkshop.SteamWorkshop -import `in`.dragonbra.javasteam.steam.steamclient.AsyncJobFailedException import `in`.dragonbra.javasteam.steam.steamclient.SteamClient import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackManager import `in`.dragonbra.javasteam.steam.steamclient.callbacks.ConnectedCallback import `in`.dragonbra.javasteam.steam.steamclient.callbacks.DisconnectedCallback import `in`.dragonbra.javasteam.steam.steamclient.configuration.SteamConfiguration -import `in`.dragonbra.javasteam.types.DepotManifest import `in`.dragonbra.javasteam.types.FileData import `in`.dragonbra.javasteam.types.SteamID -import `in`.dragonbra.javasteam.util.NetHelpers import `in`.dragonbra.javasteam.util.log.LogListener import `in`.dragonbra.javasteam.util.log.LogManager import java.io.Closeable import java.io.File -import java.io.InputStream -import java.io.OutputStream -import java.lang.NullPointerException import java.nio.file.Files import java.nio.file.Paths import java.util.Collections import java.util.EnumSet import java.util.concurrent.CancellationException import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.io.path.pathString import kotlin.time.Duration.Companion.seconds @@ -135,14 +114,12 @@ 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.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.filter @@ -153,13 +130,52 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import timber.log.Timber +import java.lang.NullPointerException +import app.gamenative.data.AppInfo +import app.gamenative.db.dao.AppInfoDao +import kotlinx.coroutines.ensureActive +import app.gamenative.utils.LicenseSerializer +import app.gamenative.data.CachedLicense +import com.winlator.container.Container +import `in`.dragonbra.javasteam.depotdownloader.data.AppItem +import `in`.dragonbra.javasteam.depotdownloader.data.DownloadItem +import `in`.dragonbra.javasteam.steam.handlers.steamapps.License +import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.PlayingSessionStateCallback +import `in`.dragonbra.javasteam.steam.steamclient.AsyncJobFailedException +import `in`.dragonbra.javasteam.types.DepotManifest +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import okhttp3.OkHttpClient import okhttp3.Request -import timber.log.Timber +import android.util.Base64 +import app.gamenative.data.DownloadingAppInfo +import app.gamenative.db.dao.DownloadingAppInfoDao +import app.gamenative.db.dao.EncryptedAppTicketDao +import app.gamenative.utils.MarkerUtils +import kotlinx.coroutines.flow.update +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit @AndroidEntryPoint class SteamService : Service(), IChallengeUrlChanged { + // To view log messages in android logcat properly + private val logger = object : LogListener { + override fun onLog(clazz: Class<*>, message: String?, throwable: Throwable?) { + val logMessage = message ?: "No message given" + Timber.i(throwable, "[${clazz.simpleName}] -> $logMessage") + } + + override fun onError(clazz: Class<*>, message: String?, throwable: Throwable?) { + val logMessage = message ?: "No message given" + Timber.e(throwable, "[${clazz.simpleName}] -> $logMessage") + } + } + @Inject lateinit var db: PluviaDatabase @@ -169,15 +185,6 @@ class SteamService : Service(), IChallengeUrlChanged { @Inject lateinit var appDao: SteamAppDao - @Inject - lateinit var friendDao: SteamFriendDao - - @Inject - lateinit var messagesDao: FriendMessagesDao - - @Inject - lateinit var emoticonDao: EmoticonDao - @Inject lateinit var changeNumbersDao: ChangeNumbersDao @@ -193,6 +200,9 @@ class SteamService : Service(), IChallengeUrlChanged { @Inject lateinit var encryptedAppTicketDao: EncryptedAppTicketDao + @Inject + lateinit var downloadingAppInfoDao: DownloadingAppInfoDao + private lateinit var notificationHelper: NotificationHelper internal var callbackManager: CallbackManager? = null @@ -243,7 +253,8 @@ class SteamService : Service(), IChallengeUrlChanged { private lateinit var connectivityManager: ConnectivityManager private lateinit var networkCallback: ConnectivityManager.NetworkCallback - @Volatile private var isWifiConnected: Boolean = true + @Volatile + private var isWifiConnected: Boolean = true // Add these as class properties private var picsGetProductInfoJob: Job? = null @@ -253,6 +264,12 @@ class SteamService : Service(), IChallengeUrlChanged { private val _isPlayingBlocked = MutableStateFlow(false) val isPlayingBlocked = _isPlayingBlocked.asStateFlow() + // Cache in-memory the local persona state. + private val _localPersona = MutableStateFlow( + SteamFriend(name = PrefManager.steamUserName, avatarHash = PrefManager.steamUserAvatarHash), + ) + val localPersona = _localPersona.asStateFlow() + companion object { const val MAX_PICS_BUFFER = 256 @@ -294,6 +311,11 @@ class SteamService : Service(), IChallengeUrlChanged { /** Returns true if there is an incomplete download on disk (no complete marker). */ fun hasPartialDownload(appId: Int): Boolean { + val downloadingApp = getDownloadingAppInfoOf(appId) + if (downloadingApp != null) { + return true + } + val dirPath = getAppDirPath(appId) return File(dirPath).exists() && !MarkerUtils.hasMarker(dirPath, Marker.DOWNLOAD_COMPLETE_MARKER) } @@ -395,8 +417,7 @@ class SteamService : Service(), IChallengeUrlChanged { } suspend fun getSelfCurrentlyPlayingAppId(): Int? = withContext(Dispatchers.IO) { - val selfId = userSteamId?.convertToUInt64() ?: return@withContext null - val self = instance?.friendDao?.findFriend(selfId) ?: return@withContext null + val self = instance?.localPersona?.value ?: return@withContext null if (self.isPlayingGame) self.gameAppID else null } @@ -418,10 +439,6 @@ class SteamService : Service(), IChallengeUrlChanged { } } - suspend fun getPersonaStateOf(steamId: SteamID): SteamFriend? = withContext(Dispatchers.IO) { - instance!!.db.steamFriendDao().findFriend(steamId.convertToUInt64()) - } - /** * Get licenses from database for use with DepotDownloader */ @@ -444,12 +461,28 @@ class SteamService : Service(), IChallengeUrlChanged { return runBlocking(Dispatchers.IO) { instance?.appDao?.findApp(appId) } } + fun getDownloadingAppInfoOf(appId: Int): DownloadingAppInfo? { + return runBlocking(Dispatchers.IO) { instance?.downloadingAppInfoDao?.getDownloadingApp(appId) } + } + + fun getDownloadableDlcAppsOf(appId: Int): List? { + return runBlocking(Dispatchers.IO) { instance?.appDao?.findDownloadableDLCApps(appId) } + } + + fun getHiddenDlcAppsOf(appId: Int): List? { + return runBlocking(Dispatchers.IO) { instance?.appDao?.findHiddenDLCApps(appId) } + } + + fun getInstalledApp(appId: Int): AppInfo? { + return runBlocking(Dispatchers.IO) { instance?.appInfoDao?.getInstalledApp(appId) } + } + fun getInstalledDepotsOf(appId: Int): List? { - return runBlocking(Dispatchers.IO) { instance?.appInfoDao?.getInstalledDepots(appId)?.downloadedDepots } + return getInstalledApp(appId)?.downloadedDepots } - fun getDlcDepotsOf(appId: Int): List? { - return runBlocking(Dispatchers.IO) { instance?.appInfoDao?.getInstalledDepots(appId)?.dlcDepots } + fun getInstalledDlcDepotsOf(appId: Int): List? { + return getInstalledApp(appId)?.dlcDepots } fun getAppDownloadInfo(appId: Int): DownloadInfo? { @@ -476,9 +509,6 @@ class SteamService : Service(), IChallengeUrlChanged { /* Base-game depots always download */ depot.dlcAppId == INVALID_APP_ID -> true - /* Optional DLC depots are skipped */ - depot.optionalDlcId == depot.dlcAppId -> false - /* ① licence cache */ instance?.licenseDao?.findLicense(depot.dlcAppId) != null -> true @@ -494,6 +524,30 @@ class SteamService : Service(), IChallengeUrlChanged { }.toMap() } + fun getMainAppDlcIdsWithoutProperDepotDlcIds(appId: Int): MutableList { + val mainAppDlcIds = mutableListOf() + val hiddenDlcAppIds = getHiddenDlcAppsOf(appId).orEmpty().map { it.id } + + val appInfo = getAppInfoOf(appId) + if (appInfo != null) { + // for each of the dlcAppId found in main depots, filter the count = 1, add that dlcAppId to dlcAppIds + val checkingAppDlcIds = appInfo.depots.filter { it.value.dlcAppId != INVALID_APP_ID }.map { it.value.dlcAppId }.distinct() + checkingAppDlcIds.forEach { checkingDlcId -> + val checkMap = appInfo.depots.filter { it.value.dlcAppId == checkingDlcId } + if (checkMap.size == 1) { + val depotInfo = checkMap[checkMap.keys.first()]!! + if (depotInfo.osList.contains(OS.none) && + depotInfo.manifests.isEmpty() && + hiddenDlcAppIds.isNotEmpty() && hiddenDlcAppIds.contains(checkingDlcId)) { + mainAppDlcIds.add(checkingDlcId) + } + } + } + } + + return mainAppDlcIds + } + /** * Refresh the owned games list by querying Steam, diffing with the local DB, and * queueing PICS requests for anything new so metadata gets populated. @@ -531,6 +585,58 @@ class SteamService : Service(), IChallengeUrlChanged { }.getOrDefault(0) } + /** + * Common Filter for downloadable depots + */ + fun filterForDownloadableDepots(depot: DepotInfo, has64Bit: Boolean, preferredLanguage: String, ownedDlc: Map?): Boolean { + if (depot.manifests.isEmpty() && depot.encryptedManifests.isNotEmpty()) + return false + // 1. Has something to download + if (depot.manifests.isEmpty() && !depot.sharedInstall) + return false + // 2. Supported OS + if (!(depot.osList.contains(OS.windows) || + (!depot.osList.contains(OS.linux) && !depot.osList.contains(OS.macos))) + ) + return false + // 3. 64-bit or indeterminate + // Arch selection: allow 64-bit and Unknown always. + // Allow 32-bit only when no 64-bit depot exists. + val archOk = when (depot.osArch) { + OSArch.Arch64, OSArch.Unknown -> true + OSArch.Arch32 -> !has64Bit + else -> false + } + if (!archOk) return false + // 4. DLC you actually own + if (depot.dlcAppId != INVALID_APP_ID && ownedDlc != null && !ownedDlc.containsKey(depot.depotId)) + return false + // 5. Language filter - if depot has language, it must match preferred language + if (depot.language.isNotEmpty() && depot.language != preferredLanguage) + return false + + return true + } + + fun getMainAppDepots(appId: Int): Map { + val appInfo = getAppInfoOf(appId) ?: return emptyMap() + val ownedDlc = runBlocking { getOwnedAppDlc(appId) } + val preferredLanguage = PrefManager.containerLanguage + + // If the game ships any 64-bit depot, prefer those and ignore x86 ones + val has64Bit = appInfo.depots.values.any { it.osArch == OSArch.Arch64 } + + return appInfo.depots.asSequence() + .filter { (depotId, depot) -> + return@filter filterForDownloadableDepots(depot, has64Bit, preferredLanguage, ownedDlc) + } + .associate { it.toPair() } + } + + /** + * Get downloadable depots for a given app, including all DLCs + * @return Map of app ID to depot ID to depot info + */ fun getDownloadableDepots(appId: Int): Map { val appInfo = getAppInfoOf(appId) ?: return emptyMap() val ownedDlc = runBlocking { getOwnedAppDlc(appId) } @@ -539,45 +645,40 @@ class SteamService : Service(), IChallengeUrlChanged { // If the game ships any 64-bit depot, prefer those and ignore x86 ones val has64Bit = appInfo.depots.values.any { it.osArch == OSArch.Arch64 } - return appInfo.depots + val map = appInfo.depots .asSequence() - .filter { (_, depot) -> - if (depot.manifests.isEmpty() && depot.encryptedManifests.isNotEmpty()) { - return@filter false - } - // 1. Has something to download - 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)) - ) - ) { - return@filter false - } - // 3. 64-bit or indeterminate - // Arch selection: allow 64-bit and Unknown always. - // Allow 32-bit only when no 64-bit depot exists. - val archOk = when (depot.osArch) { - OSArch.Arch64, OSArch.Unknown -> true - OSArch.Arch32 -> !has64Bit - else -> false - } - if (!archOk) return@filter false - // 4. DLC you actually own - if (depot.dlcAppId != INVALID_APP_ID && !ownedDlc.containsKey(depot.depotId)) { - return@filter false + .filter { (depotId, depot) -> + return@filter filterForDownloadableDepots(depot, has64Bit, preferredLanguage, ownedDlc) + } + .associate { it.toPair() } + .toMutableMap() + + val indirectDlcApps = getDownloadableDlcAppsOf(appId).orEmpty() + indirectDlcApps.forEach { dlcApp -> + dlcApp.depots + .asSequence() + .filter { (depotId, depot) -> + return@filter filterForDownloadableDepots(depot, has64Bit, preferredLanguage, null) } - // 5. Language filter - if depot has language, it must match preferred language - if (depot.language.isNotEmpty() && depot.language != preferredLanguage) { - return@filter false + .associate { it.toPair() } + .forEach { (depotId, depot) -> + // Add DLC Depots with custom object + map[depotId] = DepotInfo( + depotId = depot.depotId, + dlcAppId = dlcApp.id, // Set to DLC App ID + optionalDlcId = depot.optionalDlcId, + depotFromApp = depot.depotFromApp, + sharedInstall = depot.sharedInstall, + osList = depot.osList, + osArch = depot.osArch, + language = depot.language, + manifests = depot.manifests, + encryptedManifests = depot.encryptedManifests, + ) } + } - true - } - .associate { it.toPair() } + return map } fun getAppDirName(app: SteamApp?): String { @@ -590,6 +691,7 @@ class SteamService : Service(), IChallengeUrlChanged { } fun getAppDirPath(gameId: Int): String { + val info = getAppInfoOf(gameId) val appName = getAppDirName(info) val oldName = info?.name.orEmpty() @@ -621,7 +723,6 @@ class SteamService : Service(), IChallengeUrlChanged { // SteamKit-C# protobuf port – flags is UInt / Int / Long is Int -> (flags and 0x20) != 0 || (flags and 0x80) != 0 - is Long -> ((flags and 0x20L) != 0L) || ((flags and 0x80L) != 0L) else -> false @@ -684,7 +785,7 @@ class SteamService : Service(), IChallengeUrlChanged { // 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 @@ -713,10 +814,8 @@ class SteamService : Service(), IChallengeUrlChanged { 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 } } @@ -830,6 +929,14 @@ class SteamService : Service(), IChallengeUrlChanged { appInfoDao.deleteApp(appId) changeNumbersDao.deleteByAppId(appId) fileChangeListsDao.deleteByAppId(appId) + downloadingAppInfoDao.deleteApp(appId) + + val indirectDlcAppIds = getDownloadableDlcAppsOf(appId).orEmpty().map { it.id } + indirectDlcAppIds.forEach { dlcAppId -> + appInfoDao.deleteApp(dlcAppId) + changeNumbersDao.deleteByAppId(dlcAppId) + fileChangeListsDao.deleteByAppId(dlcAppId) + } } } } @@ -840,14 +947,39 @@ class SteamService : Service(), IChallengeUrlChanged { } fun downloadApp(appId: Int): DownloadInfo? { + val currentDownloadInfo = downloadJobs[appId] + if (currentDownloadInfo != null) { + return downloadApp(appId, currentDownloadInfo.downloadingAppIds, isUpdateOrVerify = false) + } else { + // If downloading app info exists + val downloadingAppInfo = getDownloadingAppInfoOf(appId) + if (downloadingAppInfo != null) { + return downloadApp(appId, downloadingAppInfo.dlcAppIds.orEmpty(), isUpdateOrVerify = false) + } else { + // Otherwise it is verifying files + val dlcAppIds = getInstalledDlcDepotsOf(appId).orEmpty().toMutableList() + + getDownloadableDlcAppsOf(appId)?.forEach { dlcApp -> + val installedDlcApp = getInstalledApp(dlcApp.id) + if (installedDlcApp != null) { + dlcAppIds.add(installedDlcApp.id) + } + } + + return downloadApp(appId, dlcAppIds, isUpdateOrVerify = true) + } + } + } + + fun downloadApp(appId: Int, dlcAppIds: List, isUpdateOrVerify: Boolean): DownloadInfo? { // Enforce Wi-Fi-only downloads if (PrefManager.downloadOnWifiOnly && instance?.isWifiConnected == false) { instance?.notificationHelper?.notify("Not connected to Wi‑Fi/LAN") return null } return getAppInfoOf(appId)?.let { appInfo -> - Timber.i("App contains ${appInfo.depots.size} depot(s): ${appInfo.depots.keys}") - downloadApp(appId, getDownloadableDepots(appId).keys.toList(), "public") + val depots = getDownloadableDepots(appId) + downloadApp(appId, depots, dlcAppIds, "public", isUpdateOrVerify) } } @@ -858,11 +990,11 @@ class SteamService : Service(), IChallengeUrlChanged { fun isImageFsInstallable(context: Context, variant: String): Boolean { val imageFs = ImageFs.find(context) if (variant.equals(Container.BIONIC)) { - return File(imageFs.filesDir, "imagefs_bionic.txz").exists() || - context.assets.list("")?.contains("imagefs_bionic.txz") == true + return File(imageFs.filesDir, "imagefs_bionic.txz").exists() || context.assets.list("") + ?.contains("imagefs_bionic.txz") == true } else { - return File(imageFs.filesDir, "imagefs_gamenative.txz").exists() || - context.assets.list("")?.contains("imagefs_gamenative.txz") == true + return File(imageFs.filesDir, "imagefs_gamenative.txz").exists() || context.assets.list("") + ?.contains("imagefs_gamenative.txz") == true } } @@ -962,8 +1094,13 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.d("Downloading imagefs_bionic to " + dest.toString()) fetchFileWithFallback("imagefs_bionic.txz", dest, context, onDownloadProgress) } else { - Timber.d("Downloading imagefs_gamenative to " + File(instance!!.filesDir, "imagefs_gamenative.txz")) - fetchFileWithFallback("imagefs_gamenative.txz", File(instance!!.filesDir, "imagefs_gamenative.txz"), context, onDownloadProgress) + Timber.d("Downloading imagefs_gamenative to " + File(instance!!.filesDir, "imagefs_gamenative.txz")); + fetchFileWithFallback( + "imagefs_gamenative.txz", + File(instance!!.filesDir, "imagefs_gamenative.txz"), + context, + onDownloadProgress, + ) } } @@ -1003,36 +1140,99 @@ class SteamService : Service(), IChallengeUrlChanged { fun downloadApp( appId: Int, - depotIds: List, + downloadableDepots: Map, + userSelectedDlcAppIds: List, branch: String, + isUpdateOrVerify: Boolean, ): DownloadInfo? { - Timber.d("Attempting to download " + appId + " with depotIds " + depotIds) + val appDirPath = getAppDirPath(appId) + // Enforce Wi-Fi-only downloads if (PrefManager.downloadOnWifiOnly && instance?.isWifiConnected == false) { instance?.notificationHelper?.notify("Not connected to Wi‑Fi/LAN") return null } if (downloadJobs.contains(appId)) return getAppDownloadInfo(appId) - Timber.d("depotIds is empty? " + depotIds.isEmpty()) - if (depotIds.isEmpty()) return null + Timber.d("depots is empty? " + downloadableDepots.isEmpty()) + if (downloadableDepots.isEmpty()) return null - val entitledDepotIds = depotIds.sorted() + val indirectDlcAppIds = getDownloadableDlcAppsOf(appId).orEmpty().map { it.id } - Timber.i("entitledDepotIds is empty? " + entitledDepotIds.isEmpty()) + // Depots from Main game + val mainDepots = getMainAppDepots(appId) + var mainAppDepots = mainDepots.filter { (_, depot) -> + depot.dlcAppId == INVALID_APP_ID + } + mainDepots.filter { (_, depot) -> + userSelectedDlcAppIds.contains(depot.dlcAppId) && depot.manifests.isNotEmpty() + } - if (entitledDepotIds.isEmpty()) return null + // Depots from DLC App + val dlcAppDepots = downloadableDepots.filter { (_, depot) -> + !mainAppDepots.map { it.key }.contains(depot.depotId) && + userSelectedDlcAppIds.contains(depot.dlcAppId) && indirectDlcAppIds.contains(depot.dlcAppId) && depot.manifests.isNotEmpty() + } - Timber.i("Starting download for $appId") + // Remove depots that are already downloaded (not for update/verify) + val appInfo = getInstalledApp(appId) + if (appInfo != null && !isUpdateOrVerify) { + mainAppDepots = mainAppDepots.filter { it.key !in appInfo.downloadedDepots } + } - // Create mapping from depotId to index for progress tracking - val depotIdToIndex = entitledDepotIds.mapIndexed { index, depotId -> depotId to index }.toMap() + // Combine main app and DLC depots + val selectedDepots = mainAppDepots + dlcAppDepots - val appDirPath = getAppDirPath(appId) - val info = DownloadInfo(entitledDepotIds.size).also { di -> + val downloadingAppIds = CopyOnWriteArrayList() + val calculatedDlcAppIds = CopyOnWriteArrayList() + + userSelectedDlcAppIds.forEach { dlcAppId -> + if (dlcAppDepots.filter { (_, depot) -> depot.dlcAppId == dlcAppId }.isNotEmpty()) { + downloadingAppIds.add(dlcAppId) + calculatedDlcAppIds.add(dlcAppId) + } + } + + // Add main app ID if there are main app depots + if (mainAppDepots.isNotEmpty()) { + downloadingAppIds.add(appId) + } + + // There are some apps, the dlc depots does not have dlcAppId in the data, need to set it back + val mainAppDlcIds = getMainAppDlcIdsWithoutProperDepotDlcIds(appId) + + // If there are no DLC depots, download the main app only + if (dlcAppDepots.isEmpty()) { + // Because all dlcIDs are coming from main depots, need to add the dlcID to main app in order to save it to db after finish download + mainAppDlcIds.addAll(mainAppDepots.filter { it.value.dlcAppId != INVALID_APP_ID }.map { it.value.dlcAppId }.distinct()) + + // Refresh id List, so only main app is downloaded + calculatedDlcAppIds.clear() + downloadingAppIds.clear() + downloadingAppIds.add(appId) + } + + Timber.i("selectedDepots is empty? " + selectedDepots.isEmpty()) + + if (selectedDepots.isEmpty()) return null + + Timber.i("Starting download for $appId") + Timber.i("App contains ${mainAppDepots.size} depot(s): ${mainAppDepots.keys}") + Timber.i("DLC contains ${dlcAppDepots.size} depot(s): ${dlcAppDepots.keys}") + Timber.i("downloadingAppIds: $downloadingAppIds") + + // Save downloading app info + runBlocking { + instance?.downloadingAppInfoDao?.insert( + DownloadingAppInfo( + appId, + dlcAppIds = userSelectedDlcAppIds + ), + ) + } + + val info = DownloadInfo(selectedDepots.size, appId, downloadingAppIds).also { di -> di.setPersistencePath(appDirPath) // Set weights for each depot based on manifest sizes - val sizes = entitledDepotIds.map { depotId -> - val depot = getAppInfoOf(appId)!!.depots[depotId]!! + val sizes = selectedDepots.map { (_, depot) -> val mInfo = depot.manifests[branch] ?: depot.encryptedManifests[branch] ?: return@map 1L @@ -1060,46 +1260,127 @@ class SteamService : Service(), IChallengeUrlChanged { return@launch } + // Some notes here: + // Write should always be 1 in mobile device, as normally it does not use a SSD for storage + // And to have maximum throughput, set downloadRatio = decompressRatio = 1.0 x CPU Cores + var downloadRatio = 0.0 + var decompressRatio = 0.0 + + when (PrefManager.downloadSpeed) { + 8 -> { + downloadRatio = 0.3 + decompressRatio = 0.3 + } + 16 -> { + downloadRatio = 0.5 + decompressRatio = 0.5 + } + 24 -> { + downloadRatio = 0.8 + decompressRatio = 0.8 + } + 32 -> { + downloadRatio = 1.0 + decompressRatio = 1.0 + } + } + + val cpuCores = Runtime.getRuntime().availableProcessors() + val maxDownloads = (cpuCores * downloadRatio).toInt().coerceAtLeast(1) + val maxDecompress = (cpuCores * decompressRatio).toInt().coerceAtLeast(1) + val maxFileWrites = 1 + + Timber.i("CPU Cores: $cpuCores,") + Timber.i("maxDownloads: $maxDownloads") + Timber.i("maxDecompress: $maxDecompress") + Timber.i("maxFileWrites: $maxFileWrites") + // Create DepotDownloader instance val depotDownloader = DepotDownloader( instance!!.steamClient!!, licenses, debug = false, androidEmulation = true, + maxDownloads = maxDownloads, + maxDecompress = maxDecompress, + maxFileWrites = maxFileWrites, + parentJob = coroutineContext[Job], + autoStartDownload = false, ) - // Create listener - val listener = AppDownloadListener(di, depotIdToIndex, appId, entitledDepotIds, appDirPath) + // Create listeners for DLC apps + val depotIdToIndex = selectedDepots.keys.mapIndexed { index, depotId -> depotId to index }.toMap() + val listener = AppDownloadListener(di, depotIdToIndex) depotDownloader.addListener(listener) - // Create AppItem with only mandatory appId - val appItem = AppItem( - appId, - installDirectory = getAppDirPath(appId), - depot = entitledDepotIds, - ) + if (mainAppDepots.isNotEmpty()) { + // Create mapping from depotId to index for progress tracking + val mainAppDepotIds = mainAppDepots.keys.sorted() + + // Create AppItem with only mandatory appId + val mainAppItem = AppItem( + appId, + installDirectory = getAppDirPath(appId), + depot = mainAppDepotIds, + ) + + // Add item to downloader + depotDownloader.add(mainAppItem) + } - // Add item to downloader - depotDownloader.add(appItem) + // Create AppItem for each DLC app + calculatedDlcAppIds.forEach { dlcAppId -> + val dlcDepots = selectedDepots.filter { it.value.dlcAppId == dlcAppId } + val dlcDepotIds = dlcDepots.keys.sorted() + + val dlcAppItem = AppItem( + dlcAppId, + installDirectory = getAppDirPath(appId), + depot = dlcDepotIds + ) + + depotDownloader.add(dlcAppItem) + } // Signal that no more items will be added depotDownloader.finishAdding() + // Start Download + depotDownloader.startDownloading() + Timber.i("Downloading game to " + defaultAppInstallPath) // Wait for completion depotDownloader.getCompletion().await() - // Remove listener - depotDownloader.removeListener(listener) - // Close the downloader depotDownloader.close() + + // Complete app download + if (mainAppDepots.isNotEmpty()) { + val mainAppDepotIds = mainAppDepots.keys.sorted() + completeAppDownload(di, appId, mainAppDepotIds, mainAppDlcIds, appDirPath) + } + + // Complete dlc app download + calculatedDlcAppIds.forEach { dlcAppId -> + val dlcDepots = selectedDepots.filter { it.value.dlcAppId == dlcAppId } + val dlcDepotIds = dlcDepots.keys.sorted() + completeAppDownload(di, dlcAppId, dlcDepotIds, emptyList(), appDirPath) + } + + // Remove the job here + removeDownloadJob(appId) + + // Remove the downloading app info + runBlocking { + instance?.downloadingAppInfoDao?.deleteApp(appId) + } } catch (e: Exception) { Timber.e(e, "Download failed for app $appId") di.persistProgressSnapshot() // Mark all depots as failed - entitledDepotIds.forEachIndexed { idx, _ -> + selectedDepots.keys.sorted().forEachIndexed { idx, _ -> di.setWeight(idx, 0) di.setProgress(1f, idx) } @@ -1108,6 +1389,7 @@ class SteamService : Service(), IChallengeUrlChanged { } downloadJob.invokeOnCompletion { throwable -> if (throwable is kotlinx.coroutines.CancellationException) { + Timber.d(throwable, "Download canceled for app $appId") removeDownloadJob(appId) } } @@ -1119,15 +1401,66 @@ class SteamService : Service(), IChallengeUrlChanged { return info } + private suspend fun completeAppDownload( + downloadInfo: DownloadInfo, + downloadingAppId: Int, + entitledDepotIds: List, + selectedDlcAppIds: List, + appDirPath: String, + ) { + Timber.i("Item $downloadingAppId download completed, saving database") + + // Update database + val appInfo = instance?.appInfoDao?.getInstalledApp(downloadingAppId) + + // Update Saved AppInfo + if (appInfo != null) { + val updatedDownloadedDepots = (appInfo.downloadedDepots + entitledDepotIds).distinct() + val updatedDlcDepots = (appInfo.dlcDepots + selectedDlcAppIds).distinct() + + instance?.appInfoDao?.update( + AppInfo( + downloadingAppId, + isDownloaded = true, + downloadedDepots = updatedDownloadedDepots.sorted(), + dlcDepots = updatedDlcDepots.sorted(), + ), + ) + } else { + instance?.appInfoDao?.insert( + AppInfo( + downloadingAppId, + isDownloaded = true, + downloadedDepots = entitledDepotIds.sorted(), + dlcDepots = selectedDlcAppIds.sorted(), + ), + ) + } + + // Remove completed appId from downloadInfo.dlcAppIds + downloadInfo.downloadingAppIds.removeIf { it == downloadingAppId } + + // All downloading appIds are removed + if (downloadInfo.downloadingAppIds.isEmpty()) { + // Handle completion: add markers + withContext(Dispatchers.IO) { + MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) + MarkerUtils.removeMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) + } + PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(downloadInfo.gameId)) + + // Clear persisted bytes file on successful completion + downloadInfo.clearPersistedBytesDownloaded(appDirPath) + } + } + /** * Listener for download progress and completion events from DepotDownloader */ private class AppDownloadListener( private val downloadInfo: DownloadInfo, private val depotIdToIndex: Map, - private val appId: Int, - private val entitledDepotIds: List, - private val appDirPath: String, ) : IDownloadListener { // Track cumulative uncompressed bytes per depot to calculate deltas // (uncompressedBytes from onChunkCompleted is cumulative per depot) @@ -1142,32 +1475,18 @@ class SteamService : Service(), IChallengeUrlChanged { override fun onDownloadCompleted(item: DownloadItem) { Timber.i("Item ${item.appId} download completed") - // Handle completion: add marker, update database - val ownedDlc = runBlocking { getOwnedAppDlc(appId) } - MarkerUtils.addMarker(getAppDirPath(appId), Marker.DOWNLOAD_COMPLETE_MARKER) - PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(appId)) - runBlocking { - instance?.appInfoDao?.insert( - AppInfo( - appId, - isDownloaded = true, - downloadedDepots = entitledDepotIds, - dlcDepots = ownedDlc.values.map { it.dlcAppId }.distinct(), - ), - ) - } - MarkerUtils.removeMarker(getAppDirPath(appId), Marker.STEAM_DLL_REPLACED) - MarkerUtils.removeMarker(getAppDirPath(appId), Marker.STEAM_COLDCLIENT_USED) - - // Clear persisted bytes file on successful completion - downloadInfo.clearPersistedBytesDownloaded(appDirPath) - - removeDownloadJob(appId) } override fun onDownloadFailed(item: DownloadItem, error: Throwable) { Timber.e(error, "Item ${item.appId} failed to download") - removeDownloadJob(appId) + downloadInfo.failedToDownload() + + // Remove the downloading app info + runBlocking { + instance?.downloadingAppInfoDao?.deleteApp(downloadInfo.gameId) + } + + removeDownloadJob(downloadInfo.gameId) instance?.let { service -> service.scope.launch(Dispatchers.Main) { Toast.makeText( @@ -1205,6 +1524,9 @@ class SteamService : Service(), IChallengeUrlChanged { depotIdToIndex[depotId]?.let { index -> downloadInfo.setProgress(depotPercentComplete, index) } + + // Persist progress snapshot + downloadInfo.persistProgressSnapshot() } override fun onDepotCompleted(depotId: Int, compressedBytes: Long, uncompressedBytes: Long) { @@ -1222,9 +1544,13 @@ class SteamService : Service(), IChallengeUrlChanged { depotIdToIndex[depotId]?.let { index -> downloadInfo.setProgress(1f, index) } + + // Persist progress snapshot + downloadInfo.persistProgressSnapshot() } } + fun getWindowsLaunchInfos(appId: Int): List { return getAppInfoOf(appId)?.let { appInfo -> appInfo.config.launch.filter { launchInfo -> @@ -1300,6 +1626,7 @@ class SteamService : Service(), IChallengeUrlChanged { preferredSave: SaveLocation = SaveLocation.None, prefixToPath: (String) -> String, isOffline: Boolean = false, + onProgress: ((message: String, progress: Float) -> Unit)? = null, ): Deferred = parentScope.async { if (isOffline || !isConnected) { return@async PostSyncInfo(SyncResult.UpToDate) @@ -1325,6 +1652,7 @@ class SteamService : Service(), IChallengeUrlChanged { preferredSave = preferredSave, parentScope = parentScope, prefixToPath = prefixToPath, + onProgress = onProgress, ).await() postSyncInfo?.let { info -> @@ -1494,7 +1822,6 @@ class SteamService : Service(), IChallengeUrlChanged { appendLine(" \"MostRecent\" \"1\"") appendLine(" \"Timestamp\" \"$epoch\"") appendLine(" }") - appendLine(" \"currentuser\" \"$steamId64\"") appendLine("}") } @@ -1580,6 +1907,7 @@ class SteamService : Service(), IChallengeUrlChanged { this.persistentSession = rememberSession this.authenticator = authenticator this.deviceFriendlyName = SteamUtils.getMachineName(instance!!) + this.clientOSType = EOSType.WinUnknown } val event = SteamEvent.LogonStarted(username) @@ -1636,7 +1964,9 @@ class SteamService : Service(), IChallengeUrlChanged { isWaitingForQRAuth = true val authDetails = AuthSessionDetails().apply { - deviceFriendlyName = SteamUtils.getMachineName(service) + this.deviceFriendlyName = SteamUtils.getMachineName(instance!!) + this.clientOSType = EOSType.WinUnknown + this.persistentSession = true } val authSession = steamClient.authentication.beginAuthSessionViaQR(authDetails).await() @@ -1747,14 +2077,11 @@ class SteamService : Service(), IChallengeUrlChanged { with(instance!!) { scope.launch { db.withTransaction { - db.emoticonDao().deleteAll() - db.friendMessagesDao().deleteAllMessages() - appDao.deleteAll() changeNumbersDao.deleteAll() fileChangeListsDao.deleteAll() - friendDao.deleteAll() licenseDao.deleteAll() encryptedAppTicketDao.deleteAll() + downloadingAppInfoDao.deleteAll() } } } @@ -1774,65 +2101,10 @@ class SteamService : Service(), IChallengeUrlChanged { instance?.friendCheckerJob?.cancel() } - suspend fun getEmoticonList() = withContext(Dispatchers.IO) { - instance?.steamClient!!.getHandler()!!.getEmoticonList() - } - - suspend fun fetchEmoticons(): List = withContext(Dispatchers.IO) { - instance?.emoticonDao!!.getAllAsList() - } - - suspend fun getProfileInfo(friendID: SteamID): ProfileInfoCallback = withContext(Dispatchers.IO) { - instance?._steamFriends!!.requestProfileInfo(friendID).await() - } - suspend fun getOwnedGames(friendID: Long): List = withContext(Dispatchers.IO) { instance?._unifiedFriends!!.getOwnedGames(friendID) } - suspend fun getRecentMessages(friendID: Long) = withContext(Dispatchers.IO) { - instance?._unifiedFriends!!.getRecentMessages(friendID) - } - - suspend fun ackMessage(friendID: Long) = withContext(Dispatchers.IO) { - instance?._unifiedFriends!!.ackMessage(friendID) - } - - suspend fun requestAliasHistory(friendID: Long) = withContext(Dispatchers.IO) { - instance?.steamClient!!.getHandler()?.requestAliasHistory(SteamID(friendID)) - } - - suspend fun sendTypingMessage(friendID: Long) = withContext(Dispatchers.IO) { - instance?._unifiedFriends!!.setIsTyping(friendID) - } - - suspend fun sendMessage(friendID: Long, message: String) = withContext(Dispatchers.IO) { - instance?._unifiedFriends!!.sendMessage(friendID, message) - } - - suspend fun blockFriend(friendID: Long) = withContext(Dispatchers.IO) { - val friend = SteamID(friendID) - val result = instance?._steamFriends!!.ignoreFriend(friend).await() - - if (result.result == EResult.OK) { - val blockedFriend = instance!!.friendDao.findFriend(friendID) - blockedFriend?.let { - instance?.friendDao!!.update(it.copy(relation = EFriendRelationship.Blocked)) - } - } - } - - suspend fun removeFriend(friendID: Long) = withContext(Dispatchers.IO) { - val friend = SteamID(friendID) - instance?._steamFriends!!.removeFriend(friend) - instance?.friendDao!!.remove(friendID) - } - - suspend fun setNickName(friendID: Long, value: String) = withContext(Dispatchers.IO) { - val friend = SteamID(friendID) - instance?._steamFriends!!.setFriendNickname(friend, value) - } - // Add helper to detect if any downloads or cloud sync are in progress fun hasActiveOperations(): Boolean { return syncInProgress || downloadJobs.values.any { it.getProgress() < 1f } @@ -1952,8 +2224,8 @@ class SteamService : Service(), IChallengeUrlChanged { val clazz = Class.forName("in.dragonbra.javasteam.util.log.LogManager") val field = clazz.getDeclaredField("LOGGERS").apply { isAccessible = true } field.set( - null, - ConcurrentHashMap(), // replaces the HashMap + /* obj = */ null, + java.util.concurrent.ConcurrentHashMap(), // replaces the HashMap ) } @@ -1967,7 +2239,7 @@ class SteamService : Service(), IChallengeUrlChanged { val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) isWifiConnected = capabilities?.run { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) } == true // Register callback for Wi-Fi connectivity networkCallback = object : ConnectivityManager.NetworkCallback() { @@ -1975,6 +2247,7 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.d("Wifi available") isWifiConnected = true } + override fun onCapabilitiesChanged( network: Network, caps: NetworkCapabilities, @@ -1982,6 +2255,7 @@ class SteamService : Service(), IChallengeUrlChanged { isWifiConnected = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) } + override fun onLost(network: Network) { Timber.d("Wifi lost") isWifiConnected = false @@ -2004,17 +2278,6 @@ class SteamService : Service(), IChallengeUrlChanged { connectivityManager.registerNetworkCallback(networkRequest, networkCallback) // To view log messages in android logcat properly - val logger = object : LogListener { - override fun onLog(clazz: Class<*>, message: String?, throwable: Throwable?) { - val logMessage = message ?: "No message given" - Timber.i(throwable, "[${clazz.simpleName}] -> $logMessage") - } - - override fun onError(clazz: Class<*>, message: String?, throwable: Throwable?) { - val logMessage = message ?: "No message given" - Timber.e(throwable, "[${clazz.simpleName}] -> $logMessage") - } - } LogManager.addListener(logger) } @@ -2050,8 +2313,6 @@ class SteamService : Service(), IChallengeUrlChanged { // create our steam client instance steamClient = SteamClient(configuration).apply { - addHandler(PluviaHandler()) - // remove callbacks we're not using. removeHandler(SteamGameServer::class.java) removeHandler(SteamMasterServer::class.java) @@ -2081,10 +2342,6 @@ class SteamService : Service(), IChallengeUrlChanged { add(subscribe(LoggedOffCallback::class.java, ::onLoggedOff)) add(subscribe(PersonaStateCallback::class.java, ::onPersonaStateReceived)) add(subscribe(LicenseListCallback::class.java, ::onLicenseList)) - add(subscribe(NicknameListCallback::class.java, ::onNicknameList)) - add(subscribe(FriendsListCallback::class.java, ::onFriendsList)) - add(subscribe(EmoticonListCallback::class.java, ::onEmoticonList)) - add(subscribe(AliasHistoryCallback::class.java, ::onAliasHistory)) add(subscribe(PlayingSessionStateCallback::class.java, ::onPlayingSessionState)) } } @@ -2190,7 +2447,6 @@ class SteamService : Service(), IChallengeUrlChanged { isConnected = false isLoggingOut = false isWaitingForQRAuth = false - isGameRunning = false steamClient = null _steamUser = null @@ -2210,6 +2466,8 @@ class SteamService : Service(), IChallengeUrlChanged { PluviaApp.events.off(onEndProcess) PluviaApp.events.clearAllListenersOf>() + + LogManager.removeListener(logger) } private fun reconnect() { @@ -2276,9 +2534,16 @@ 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()}") + if (userSteamId?.isValid == true) { + if (PrefManager.steamUserAccountId != userSteamId!!.accountID.toInt()) { + PrefManager.steamUserAccountId = userSteamId!!.accountID.toInt() + Timber.d("Saving logged in Steam accountID ${userSteamId!!.accountID.toInt()}") + } + val steamId64 = userSteamId!!.convertToUInt64() + if (PrefManager.steamUserSteamId64 != steamId64) { + PrefManager.steamUserSteamId64 = steamId64 + Timber.d("Saving logged in Steam ID64 $steamId64") + } } when (callback.result) { @@ -2325,12 +2590,6 @@ class SteamService : Service(), IChallengeUrlChanged { picsChangesCheckerJob = continuousPICSChangesChecker() picsGetProductInfoJob = continuousPICSGetProductInfo() - if (false) { - // No social features are implemented at present - // continuously check for game names that friends are playing. - friendCheckerJob = continuousFriendChecker() - } - // Tell steam we're online, this allows friends to update. _steamFriends?.setPersonaState(PrefManager.personaState) @@ -2373,87 +2632,11 @@ class SteamService : Service(), IChallengeUrlChanged { } } - private fun onNicknameList(callback: NicknameListCallback) { - Timber.d("Nickname list called: ${callback.nicknames.size}") - scope.launch { - db.withTransaction { - friendDao.clearAllNicknames() - friendDao.updateNicknames(callback.nicknames) - } - } - } - private fun onPlayingSessionState(callback: PlayingSessionStateCallback) { Timber.d("onPlayingSessionState called with isPlayingBlocked = " + callback.isPlayingBlocked) _isPlayingBlocked.value = callback.isPlayingBlocked } - private fun onFriendsList(callback: FriendsListCallback) { - Timber.d("onFriendsList ${callback.friendList.size}") - scope.launch { - db.withTransaction { - val friendsToInsert = mutableListOf() - val friendsToUpdate = mutableListOf() - callback.friendList - .filter { it.steamID.isIndividualAccount } - .forEach { filteredFriend -> - val friendId = filteredFriend.steamID.convertToUInt64() - val friend = friendDao.findFriend(friendId) - - if (friend == null) { - SteamFriend(id = friendId, relation = filteredFriend.relationship).also(friendsToInsert::add) - // Not in the DB, create them. - val friendToAdd = SteamFriend( - id = filteredFriend.steamID.convertToUInt64(), - relation = filteredFriend.relationship, - ) - - friendDao.insert(friendToAdd) - } else { - friend.copy(relation = filteredFriend.relationship).also(friendsToUpdate::add) - // In the DB, update them. - val dbFriend = friend.copy(relation = filteredFriend.relationship) - friendDao.update(dbFriend) - } - } - if (friendsToInsert.isNotEmpty()) { - friendDao.insertAll(friendsToInsert) - } - if (friendsToUpdate.isNotEmpty()) { - friendDao.updateAll(friendsToUpdate) - } - - // Add logged in account if we don't exist yet. - val selfId = userSteamId!!.convertToUInt64() - val self = friendDao.findFriend(selfId) - - if (self == null) { - val sid = SteamFriend(id = selfId) - friendDao.insert(sid) - } - } - - // NOTE: Our UI could load too quickly on fresh database, our icon will be "?" - // unless relaunched or we nav to a new screen. - _unifiedFriends?.refreshPersonaStates() - } - } - - private fun onEmoticonList(callback: EmoticonListCallback) { - Timber.i("Getting emotes and stickers, size: ${callback.emoteList.size}") - scope.launch { - db.withTransaction { - emoticonDao.replaceAll(callback.emoteList) - } - } - } - - private fun onAliasHistory(callback: AliasHistoryCallback) { - val names = callback.responses.flatMap { map -> map.names }.map { map -> map.name } - val event = SteamEvent.OnAliasHistory(names) - PluviaApp.events.emit(event) - } - @OptIn(ExperimentalStdlibApi::class) private fun onPersonaStateReceived(callback: PersonaStateCallback) { // Ignore accounts that arent individuals @@ -2470,43 +2653,30 @@ class SteamService : Service(), IChallengeUrlChanged { scope.launch { db.withTransaction { - val id = callback.friendId.convertToUInt64() - val friend = friendDao.findFriend(id) - - if (friend == null) { - Timber.w("onPersonaStateReceived: failed to find friend to update: $id") - return@withTransaction - } - - friendDao.update( - friend.copy( - statusFlags = callback.statusFlags, - state = callback.personaState, - stateFlags = callback.personaStateFlags, - gameAppID = callback.gamePlayedAppId, - gameID = callback.gameId, - gameName = appDao.findApp(callback.gamePlayedAppId)?.name ?: callback.gameName, - gameServerIP = NetHelpers.getIPAddress(callback.gameServerIp), - gameServerPort = callback.gameServerPort, - queryPort = callback.queryPort, - sourceSteamID = callback.steamIdSource, - gameDataBlob = callback.gameDataBlob.decodeToString(), - name = callback.playerName, - avatarHash = callback.avatarHash.toHexString(), - lastLogOff = callback.lastLogoff, - lastLogOn = callback.lastLogon, - clanRank = callback.clanRank, - clanTag = callback.clanTag, - onlineSessionInstances = callback.onlineSessionInstances, - ), - ) - // Send off an event if we change states. if (callback.friendId == steamClient!!.steamID) { - friendDao.findFriend(id)?.let { account -> - val event = SteamEvent.PersonaStateReceived(account) - PluviaApp.events.emit(event) + Timber.d("Local persona state received: ${callback.playerName}") + + val avatarHash = callback.avatarHash.toHexString() + val playerName = callback.playerName + + // Update local state flow + _localPersona.update { + it.copy( + avatarHash = avatarHash, + name = playerName, + state = callback.personaState, + gameAppID = callback.gamePlayedAppId, + gameName = appDao.findApp(callback.gamePlayedAppId)?.name ?: callback.gameName, + ) } + + // Cache local persona + PrefManager.steamUserAvatarHash = avatarHash + PrefManager.steamUserName = playerName + + val event = SteamEvent.PersonaStateReceived(localPersona.value) + PluviaApp.events.emit(event) } } } @@ -2698,51 +2868,6 @@ 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(): Job = scope.launch { - val friendsToUpdate = mutableListOf() - val gameRequest = mutableListOf() - while (isActive && isLoggedIn) { - // Initial delay before each check - delay(20.seconds) - - friendsToUpdate.clear() - gameRequest.clear() - - val friendsInGame = friendDao.findFriendsInGame() - - Timber.d("Found ${friendsInGame.size} friends in game") - - friendsInGame.forEach { friend -> - val app = appDao.findApp(friend.gameAppID) - if (app != null) { - if (friend.gameName != app.name) { - Timber.d("Updating ${friend.name} with game ${app.name}") - friendsToUpdate.add(friend.copy(gameName = app.name)) - } - } else { - // Didn't find the app, we'll get it next time. - gameRequest.add(PICSRequest(id = friend.gameAppID)) - } - } - - if (friendsToUpdate.isNotEmpty()) { - db.withTransaction { - friendDao.updateAll(friendsToUpdate) - } - } - - gameRequest - .chunked(MAX_PICS_BUFFER) - .forEach { chunk -> - Timber.d("continuousFriendChecker: Queueing ${chunk.size} app(s) for PICS") - appPicsChannel.send(chunk) - } - } - } - /** * A buffered flow to parse so many PICS requests in a given moment. */ @@ -2879,7 +3004,7 @@ class SteamService : Service(), IChallengeUrlChanged { /** * Get encrypted app ticket for an app, with 30-minute caching. - * Returns the encrypted ticket bytes, or null if unavailable. + * Returns the serialized protobuf bytes, or null if unavailable. */ suspend fun getEncryptedAppTicket(appId: Int): ByteArray? { return try { @@ -2889,7 +3014,7 @@ class SteamService : Service(), IChallengeUrlChanged { val thirtyMinutes = 30 * 60 * 1000L if (cachedTicket != null && (now - cachedTicket.timestamp) < thirtyMinutes) { - Timber.d("Using cached encrypted app ticket for app $appId") + Timber.d("Using cached encrypted app ticket protobuf for app $appId") return cachedTicket.encryptedTicket } @@ -2918,13 +3043,13 @@ class SteamService : Service(), IChallengeUrlChanged { crcEncryptedTicket = ticketProto.crcEncryptedticket.toInt(), cbEncryptedUserData = ticketProto.cbEncrypteduserdata.toInt(), cbEncryptedAppOwnershipTicket = ticketProto.cbEncryptedAppownershipticket.toInt(), - encryptedTicket = ticketProto.encryptedTicket.toByteArray(), + encryptedTicket = ticketProto.toByteArray(), timestamp = now, ) // Store in database encryptedAppTicketDao.insert(ticket) - Timber.d("Stored new encrypted app ticket for app $appId") + Timber.d("Stored new encrypted app ticket protobuf for app $appId") ticket.encryptedTicket } catch (e: Exception) { diff --git a/app/src/main/java/app/gamenative/service/SteamUnifiedFriends.kt b/app/src/main/java/app/gamenative/service/SteamUnifiedFriends.kt index 1c6c1fcc30..aa821fe8fd 100644 --- a/app/src/main/java/app/gamenative/service/SteamUnifiedFriends.kt +++ b/app/src/main/java/app/gamenative/service/SteamUnifiedFriends.kt @@ -1,279 +1,28 @@ package app.gamenative.service -import androidx.room.withTransaction -import app.gamenative.data.FriendMessage import app.gamenative.data.OwnedGames -import `in`.dragonbra.javasteam.enums.EAccountType -import `in`.dragonbra.javasteam.enums.EChatEntryType import `in`.dragonbra.javasteam.enums.EResult -import `in`.dragonbra.javasteam.enums.EUniverse -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesChatSteamclient -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesFriendmessagesSteamclient import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesPlayerSteamclient -import `in`.dragonbra.javasteam.rpc.service.Chat -import `in`.dragonbra.javasteam.rpc.service.FriendMessages -import `in`.dragonbra.javasteam.rpc.service.FriendMessagesClient import `in`.dragonbra.javasteam.rpc.service.Player -import `in`.dragonbra.javasteam.rpc.service.PlayerClient import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.SteamUnifiedMessages -import `in`.dragonbra.javasteam.steam.handlers.steamunifiedmessages.callback.ServiceMethodNotification -import `in`.dragonbra.javasteam.types.SteamID -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import timber.log.Timber -// References: -// https://github.com/marwaniaaj/RichLinksJetpackCompose/tree/main -// https://blog.stackademic.com/rick-link-representation-in-jetpack-compose-d33956e8719e -// https://github.com/lukasroberts/AndroidLinkView -// https://github.com/android/compose-samples/tree/main/Jetchat -// https://github.com/LossyDragon/Vapulla - -// TODO -// Implement Reactions -// OfflineMessageNotificationCallback ? -// FriendMsgEchoCallback ? -// EmoticonListCallback ? - -typealias AckMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_AckMessage_Notification.Builder -typealias IncomingMessageNotification = SteammessagesFriendmessagesSteamclient.CFriendMessages_IncomingMessage_Notification.Builder -typealias FriendNicknameChanged = SteammessagesPlayerSteamclient.CPlayer_FriendNicknameChanged_Notification.Builder - -class SteamUnifiedFriends( - private val service: SteamService, -) : AutoCloseable { +// TODO this class has a single method, it could be merged into SteamService.kt +class SteamUnifiedFriends(service: SteamService) : AutoCloseable { private var unifiedMessages: SteamUnifiedMessages? = null - private var chat: Chat? = null - private var player: Player? = null - private var friendMessages: FriendMessages? = null - init { unifiedMessages = service.steamClient!!.getHandler() - chat = unifiedMessages!!.createService(Chat::class.java) - player = unifiedMessages!!.createService(Player::class.java) - - friendMessages = unifiedMessages!!.createService(FriendMessages::class.java) - - with(service.callbackManager!!) { - with(service.callbackSubscriptions) { - add(subscribeServiceNotification(::onIncomingMessage)) - add(subscribeServiceNotification(::onAckMessage)) - add(subscribeServiceNotification(::onNickNameChanged)) - } - } } override fun close() { unifiedMessages = null - chat = null player = null - friendMessages = null - } - - /** - * Request a fresh state of Friend's PersonaStates - */ - fun refreshPersonaStates() { - val request = SteammessagesChatSteamclient.CChat_RequestFriendPersonaStates_Request.newBuilder().build() - chat?.requestFriendPersonaStates(request) - } - - /** - * Gets the last 50 messages from the specified friend. Steam may not provide all 50. - */ - suspend fun getRecentMessages(friendID: Long) { - Timber.i("Getting Recent messages for: $friendID") - - val userSteamID = SteamService.userSteamId!! - - val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_GetRecentMessages_Request.newBuilder().apply { - steamid1 = userSteamID.convertToUInt64() // You - steamid2 = friendID // Friend - // The rest here and below is what steam has looking at NHA2 - count = 50 - rtime32StartTime = 0 - bbcodeFormat = true - startOrdinal = 0 - timeLast = Int.MAX_VALUE - ordinalLast = 0 - }.build() - - val response = friendMessages!!.getRecentMessages(request).await() - - if (response.result != EResult.OK) { - Timber.w("Failed to get message history for friend: $friendID, ${response.result}") - return - } - - val regex = "\\[U:\\d+:(\\d+)]".toRegex() - val userSteamId3 = regex.find(userSteamID.render())!!.groupValues[1].toInt() - val messages = response.body.messagesList.map { message -> - FriendMessage( - steamIDFriend = friendID, - fromLocal = userSteamId3 == message.accountid, - message = message.message, - timestamp = message.timestamp, - ) - } - - service.db.withTransaction { - service.messagesDao.insertMessagesIfNotExist(messages) - } - - Timber.i("More available: ${response.body.moreAvailable}") - } - - /** - * Sends a 'is typing' message to the specified friend. - */ - suspend fun setIsTyping(friendID: Long) { - Timber.i("Sending 'is typing' to $friendID") - val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_SendMessage_Request.newBuilder().apply { - steamid = friendID - chatEntryType = EChatEntryType.Typing.code() - }.build() - - val response = friendMessages!!.sendMessage(request).await() - - if (response.result != EResult.OK) { - Timber.w("Failed to send typing message to friend: $friendID, ${response.result}") - return - } - - // TODO: This, I believe returns a result with supplemental data to append to the database. - // response.body.serverTimestamp - } - - /** - * Sends a chat message to the specified friend. - */ - suspend fun sendMessage(friendID: Long, chatMessage: String) { - Timber.i("Sending chat message to $friendID") - val trimmedMessage = chatMessage.trim() - - if (trimmedMessage.isEmpty()) { - Timber.w("Trying to send an empty message.") - return - } - - val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_SendMessage_Request.newBuilder().apply { - chatEntryType = EChatEntryType.ChatMsg.code() - message = trimmedMessage - steamid = friendID - containsBbcode = true - echoToSender = false - lowPriority = false - }.build() - - val response = friendMessages!!.sendMessage(request).await() - - if (response.result != EResult.OK) { - Timber.w("Failed to send chat message to friend: $friendID, ${response.result}") - return - } - - service.db.withTransaction { - service.messagesDao.insertMessageIfNotExists( - FriendMessage( - steamIDFriend = friendID, - fromLocal = true, - message = response.body.modifiedMessage.ifEmpty { trimmedMessage }, - timestamp = response.body.serverTimestamp, - ), - ) - } - - // Once chat notifications are implemented, we should clear it here as well. - } - - /** - * Acknowledge the message, this will mark other clients that we have read the message. - */ - fun ackMessage(friendID: Long) { - Timber.d("Ack-ing message for friend: $friendID") - val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_AckMessage_Notification.newBuilder().apply { - steamidPartner = friendID - timestamp = System.currentTimeMillis().div(1000).toInt() - }.build() - - // This does not return anything. - friendMessages!!.ackMessage(request) - } - - /** - * TODO - */ - suspend fun getActiveMessageSessions() { - Timber.i("Get Active message sessions") - - val request = SteammessagesFriendmessagesSteamclient.CFriendsMessages_GetActiveMessageSessions_Request.newBuilder().apply { - lastmessageSince = 0 - onlySessionsWithMessages = true - }.build() - - val response = friendMessages!!.getActiveMessageSessions(request).await() - - if (response.result != EResult.OK) { - Timber.w("Failed to get active message sessions, ${response.result}") - return - } - - // response.body.timestamp - - response.body.messageSessionsList.forEach { session -> - // session.accountidFriend - // session.lastMessage - // session.lastView - // session.unreadMessageCount - } - } - - /** - * TODO - */ - // suspend fun getPerFriendPreferences() - - /** - * TODO - */ - suspend fun updateMessageReaction( - friendID: SteamID, - serverTimestamp: Int, - reactionType: SteammessagesFriendmessagesSteamclient.EMessageReactionType, - reaction: String, - isAdd: Boolean, - ) { - Timber.i( - "Update message reaction: ${friendID.convertToUInt64()}, timestamp: $serverTimestamp, " + - "type: $reactionType, reaction: $reaction, isAdd: $isAdd ", - ) - - val request = SteammessagesFriendmessagesSteamclient.CFriendMessages_UpdateMessageReaction_Request.newBuilder().apply { - this.steamid = friendID.convertToUInt64() - this.serverTimestamp = serverTimestamp - this.ordinal = 0 - this.reactionType = reactionType - this.reaction = reaction - this.isAdd = isAdd - }.build() - - val response = friendMessages!!.updateMessageReaction(request).await() - - if (response.result != EResult.OK) { - Timber.w("Failed to get message reaction, ${response.result}") - return - } - - response.body.reactorsList.forEach { reactor -> - // Last part of steamID3 - } } /** @@ -303,7 +52,7 @@ class SteamUnifiedFriends( playtimeForever = game.playtimeForever, imgIconUrl = game.imgIconUrl, sortAs = game.sortAs, - rtimeLastPlayed = game.rtimeLastPlayed + rtimeLastPlayed = game.rtimeLastPlayed, ) } @@ -313,85 +62,4 @@ class SteamUnifiedFriends( return list } - - /** - * Another steam client (logged into the same account) has opened up chat to acknowledge the message(s). - */ - private fun onAckMessage(notification: ServiceMethodNotification) { - val friendID = notification.body.steamidPartner - Timber.i("Ack-ing Message for $friendID") - CoroutineScope(Dispatchers.IO).launch { - service.db.withTransaction { - val friend = service.friendDao.findFriend(friendID) - friend?.let { service.friendDao.update(friend.copy(unreadMessageCount = 0)) } - } - } - } - - /** - * Someone has changed their nickname. - */ - private fun onNickNameChanged(notification: ServiceMethodNotification) { - CoroutineScope(Dispatchers.IO).launch { - Timber.i("Nickname Changed for ${notification.body.accountid} -> ${notification.body.nickname}") - val friendID = SteamID(notification.body.accountid.toLong(), EUniverse.Public, EAccountType.Individual) - - service.db.withTransaction { - service.friendDao.findFriend(friendID.convertToUInt64())?.let { - service.friendDao.update(it.copy(nickname = notification.body.nickname)) - } - } - } - } - - /** - * We're receiving information that someone is either typing a message or sent a message. - */ - private fun onIncomingMessage(notification: ServiceMethodNotification) { - val steamIDFriend = notification.body.steamidFriend - Timber.i("Incoming Message form $steamIDFriend") - - when (notification.body.chatEntryType) { - EChatEntryType.Typing.code() -> { - CoroutineScope(Dispatchers.IO).launch { - service.db.withTransaction { - val friend = service.friendDao.findFriend(steamIDFriend) - - if (friend == null) { - Timber.w("Unable to find friend $steamIDFriend") - return@withTransaction - } - - service.friendDao.update(friend.copy(isTyping = true)) - } - } - } - - EChatEntryType.ChatMsg.code() -> { - CoroutineScope(Dispatchers.IO).launch { - service.db.withTransaction { - val friend = service.friendDao.findFriend(steamIDFriend) - - if (friend == null) { - Timber.w("Unable to find friend $steamIDFriend") - return@withTransaction - } - - service.friendDao.update(friend.copy(isTyping = false)) - - val chatMsg = FriendMessage( - steamIDFriend = steamIDFriend, - fromLocal = false, - message = notification.body.message, - timestamp = notification.body.rtime32ServerTimestamp, - ) - - service.messagesDao.insertMessage(chatMsg) - } - } - } - - else -> Timber.w("Unknown incoming message, ${EChatEntryType.from(notification.body.chatEntryType)}") - } - } } diff --git a/app/src/main/java/app/gamenative/service/callback/EmoticonListCallback.kt b/app/src/main/java/app/gamenative/service/callback/EmoticonListCallback.kt deleted file mode 100644 index f4fd68599e..0000000000 --- a/app/src/main/java/app/gamenative/service/callback/EmoticonListCallback.kt +++ /dev/null @@ -1,30 +0,0 @@ -package app.gamenative.service.callback - -import app.gamenative.data.Emoticon -import `in`.dragonbra.javasteam.base.ClientMsgProtobuf -import `in`.dragonbra.javasteam.base.IPacketMsg -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverFriends.CMsgClientEmoticonList -import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg - -class EmoticonListCallback(packetMsg: IPacketMsg) : CallbackMsg() { - - val emoteList: List - - init { - val resp = ClientMsgProtobuf( - CMsgClientEmoticonList::class.java, - packetMsg, - ) - jobID = resp.targetJobID - - emoteList = buildList { - addAll( - resp.body.emoticonsList.map { - val fixedName = it.name.substring(1, it.name.length - 1) - Emoticon(name = fixedName, appID = it.appid, isSticker = false) - }, - ) - addAll(resp.body.stickersList.map { Emoticon(name = it.name, appID = it.appid, isSticker = true) }) - } - } -} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt new file mode 100644 index 0000000000..cb5cbd8cdb --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGApiClient.kt @@ -0,0 +1,488 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.data.GOGGame +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * Parsed/Formartted details returned by GOGApiClient. + */ +data class ParsedGogGame( + val id: String, + val title: String, + val slug: String, + val imageUrl: String, + val iconUrl: String, + val developer: String, + val publisher: String, + val genres: List, + val languages: List, + val description: String, + val releaseDate: String, + val downloadSize: Long, + val isSecret: Boolean, + val isDlc: Boolean +) + +/** + * Raw API Response details from gameDetails endpoint (Used for reference) + */ +data class RawGogApiResponse( + val id: String?, + val title: String?, + val slug: String?, + val images: Images?, + val developers: List?, + val publisher: Any?, // Can be object with name or plain string + val genres: List?, + val languages: Map?, // Language code -> Language name + val description: Description?, + val release_date: String?, + val downloads: Downloads? +) { + data class Images( + val logo2x: String?, + val logo: String?, + val icon: String? + ) + + data class Developer( + val name: String? + ) + + data class Genre( + val name: String? + ) + + data class Description( + val lead: String? + ) + + data class Downloads( + val installers: List? + ) + + data class Installer( + val id: String?, + val name: String?, + val os: String?, + val language: String?, + val total_size: Long? + ) +} + +/** + * Direct HTTP client for GOG API operations. + * Uses GOGAuthManager for authentication tokens. + */ +object GOGApiClient { + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + /** + * Fetch list of game IDs owned by the user + * + * - Gets credentials from AuthManager + * - Calls GOG_EMBED/user/data/games endpoint to get Ids + * - Returns list of owned game IDs + * + * @param context Application context for auth access + * @return Result containing list of game IDs or error + */ + suspend fun getGameIds(context: Context): Result> = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG").d("Fetching GOG game IDs...") + + // Get credentials from AuthManager + val credentialsResult = GOGAuthManager.getStoredCredentials(context) + if (credentialsResult.isFailure) { + val error = credentialsResult.exceptionOrNull() + Timber.tag("GOG").e(error, "Cannot list games: not authenticated") + return@withContext Result.failure(Exception("Not authenticated. Please log in first.")) + } + + val credentials = credentialsResult.getOrNull() + if (credentials == null || credentials.accessToken.isEmpty()) { + Timber.tag("GOG").e("No valid access token found") + return@withContext Result.failure(Exception("No valid credentials found")) + } + + + val url = "${GOGConstants.GOG_EMBED_URL}/user/data/games" + val request = Request.Builder() // Returns an "owned" key with an array of ints. + .url(url) + .addHeader("Authorization", "Bearer ${credentials.accessToken}") + .addHeader("User-Agent", "GameNative/1.0") + .get() + .build() + + Timber.tag("GOG").d("Requesting game IDs from: $url") + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Timber.e("Failed to fetch game IDs: HTTP ${response.code} - $errorBody") + return@withContext Result.failure( + Exception("Failed to fetch game IDs: HTTP ${response.code}") + ) + } + + val responseBody = response.body?.string() ?: "" + if (responseBody.isBlank()) { + Timber.w("Empty response when fetching game IDs") + return@withContext Result.failure(Exception("Empty response from GOG")) + } + + // Parse JSON response + val userData = JSONObject(responseBody) + val ownedGames = userData.optJSONArray("owned") ?: JSONArray() + + val gameIds = List(ownedGames.length()) { + ownedGames.get(it).toString() + } + + Timber.tag("GOG").i("Successfully fetched ${gameIds.size} game IDs") + Timber.tag("GOG").d("First 10 game IDs: ${gameIds.take(10).joinToString()}") + return@withContext Result.success(gameIds) + } + } catch (e: Exception) { + Timber.e(e, "Exception fetching game IDs: ${e.message}") + return@withContext Result.failure(e) + } + } + + /** + * Fetch detailed information for a specific game by ID + * + * - Gets credentials from AuthManager + * - Calls GOG_API/products/{id} endpoint to get gameInfo + * - Returns game details as ParsedGogGame + * + * @param context Application context for auth access + * @param gameId The GOG game ID + * @param expanded List of fields to expand (defaults to downloads, description, screenshots) + * @return Result containing ParsedGogGame with transformed details or error + */ + suspend fun getGameById( + context: Context, + gameId: String, + expanded: List = listOf("downloads", "description", "screenshots") + ): Result = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG").d("Fetching game details for gameId: $gameId") + + // Get credentials from AuthManager + val credentialsResult = GOGAuthManager.getStoredCredentials(context) + if (credentialsResult.isFailure) { + val error = credentialsResult.exceptionOrNull() + Timber.e(error, "Cannot fetch game details: not authenticated") + return@withContext Result.failure(Exception("Not authenticated")) + } + + val credentials = credentialsResult.getOrNull() + if (credentials == null || credentials.accessToken.isEmpty()) { + Timber.e("No valid access token found") + return@withContext Result.failure(Exception("No valid credentials found")) + } + + // Build URL with expanded fields + val expandedParam = if (expanded.isNotEmpty()) { + "?expand=${expanded.joinToString(",")}" + } else { + "" + } + val url = "${GOGConstants.GOG_BASE_API_URL}/products/$gameId$expandedParam" + + val request = Request.Builder() + .url(url) + .addHeader("Authorization", "Bearer ${credentials.accessToken}") + .addHeader("User-Agent", "GameNative/1.0") + .get() + .build() + + Timber.tag("GOG").d("Requesting game details from: $url") + + // Execute request + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Timber.tag("GOG").e("Failed to fetch game details for $gameId: HTTP ${response.code} - $errorBody") + return@withContext Result.failure( + Exception("Failed to fetch game details: HTTP ${response.code}") + ) + } + + val responseBody = response.body?.string() ?: "" + if (responseBody.isBlank()) { + Timber.tag("GOG").w("Empty response when fetching game details for $gameId") + return@withContext Result.failure(Exception("Empty response from GOG")) + } + + // Parse raw GOG API response + val rawApiResponse = JSONObject(responseBody) + + // Transform to simplified, flattened structure + val transformedResponse = transformGameDetails(rawApiResponse, gameId) + + return@withContext Result.success(transformedResponse) + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Exception fetching game details for $gameId: ${e.message}") + return@withContext Result.failure(e) + } + } + + /** + * Fetch client secret from GOG build metadata API + * @param gameId GOG game ID + * @param installPath Game install path (for platform detection, defaults to "windows") + * @return Client secret string, or null if not found + */ + suspend fun getClientSecret(context: Context, gameId: String, installPath: String?): String? = withContext(Dispatchers.IO) { + try { + val platform = "windows" // For now, assume Windows (proton) + val buildsUrl = "https://content-system.gog.com/products/$gameId/os/$platform/builds?generation=2" + + Timber.tag("GOG").d("[Cloud Saves] Fetching build metadata from: $buildsUrl") + + // Get credentials for API authentication + val credentials = GOGAuthManager.getStoredCredentials(context).getOrNull() + if (credentials == null) { + Timber.tag("GOG").w("[Cloud Saves] No credentials available for build metadata fetch") + return@withContext null + } + + val request = Request.Builder() + .url(buildsUrl) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + // Fetch the builds list and extract manifest link + val manifestLink = httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.tag("GOG").w("[Cloud Saves] Build metadata fetch failed: ${response.code}") + return@withContext null + } + + val jsonStr = response.body?.string() ?: "" + val buildsJson = JSONObject(jsonStr) + + // Get first build + val items = buildsJson.optJSONArray("items") + if (items == null || items.length() == 0) { + Timber.tag("GOG").w("[Cloud Saves] No builds found for game $gameId") + return@withContext null + } + + val firstBuild = items.getJSONObject(0) + val link = firstBuild.optString("link", "") + if (link.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No manifest link in first build") + return@withContext null + } + + Timber.tag("GOG").d("[Cloud Saves] Fetching build manifest from: $link") + link + } + + // Fetch the build manifest + val manifestRequest = Request.Builder() + .url(manifestLink) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + httpClient.newCall(manifestRequest).execute().use { manifestResponse -> + if (!manifestResponse.isSuccessful) { + Timber.tag("GOG").w("[Cloud Saves] Manifest fetch failed: ${manifestResponse.code}") + return@withContext null + } + + // Log response headers to debug compression + val contentEncoding = manifestResponse.header("Content-Encoding") + val contentType = manifestResponse.header("Content-Type") + Timber.tag("GOG").d("[Cloud Saves] Response headers - Content-Encoding: $contentEncoding, Content-Type: $contentType") + + // Read the response bytes (can only read body once) + val manifestBytes = manifestResponse.body?.bytes() ?: return@withContext null + + // Check compression type by magic bytes + val isGzipped = manifestBytes.size >= 2 && + manifestBytes[0] == 0x1f.toByte() && + manifestBytes[1] == 0x8b.toByte() + + val isZlib = manifestBytes.size >= 2 && + manifestBytes[0] == 0x78.toByte() && + (manifestBytes[1] == 0x9c.toByte() || + manifestBytes[1] == 0xda.toByte() || + manifestBytes[1] == 0x01.toByte()) + + Timber.tag("GOG").d("[Cloud Saves] Manifest bytes: ${manifestBytes.size}, isGzipped: $isGzipped, isZlib: $isZlib") + + // Decompress based on detected format + val manifestStr = when { + isGzipped -> { + try { + Timber.tag("GOG").d("[Cloud Saves] Decompressing gzip manifest") + val gzipStream = java.util.zip.GZIPInputStream(java.io.ByteArrayInputStream(manifestBytes)) + gzipStream.bufferedReader().use { it.readText() } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Gzip decompression failed") + return@withContext null + } + } + isZlib -> { + try { + Timber.tag("GOG").d("[Cloud Saves] Decompressing zlib manifest") + val inflaterStream = java.util.zip.InflaterInputStream(java.io.ByteArrayInputStream(manifestBytes)) + inflaterStream.bufferedReader().use { it.readText() } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Zlib decompression failed") + return@withContext null + } + } + else -> { + // Not compressed, read as plain text + Timber.tag("GOG").d("[Cloud Saves] Not compressed, reading as UTF-8") + String(manifestBytes, Charsets.UTF_8) + } + } + + if (manifestStr.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] Empty manifest response") + return@withContext null + } + + Timber.tag("GOG").d("[Cloud Saves] Parsing manifest JSON (${manifestStr.take(100)}...)") + val manifestJson = JSONObject(manifestStr) + + // Extract clientSecret from manifest + val clientSecret = manifestJson.optString("clientSecret", "") + if (clientSecret.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No clientSecret in manifest for game $gameId") + return@withContext null + } + + Timber.tag("GOG").d("[Cloud Saves] Successfully retrieved clientSecret for game $gameId") + return@withContext clientSecret + } + + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to get clientSecret for game $gameId") + return@withContext null + } + } + + /** + * Transform raw GOG API response into better format. Based on GOGDL implementation + * + * @param rawResponse Raw JSON from GOG API + * @param gameId The game ID + * @return ParsedGogGame with simplified structure + */ + private fun transformGameDetails(rawResponse: JSONObject, gameId: String): ParsedGogGame { + // Extract image URLs and add https: protocol if missing + val images = rawResponse.optJSONObject("images") + var logo2x = images?.optString("logo2x", "") ?: "" + var logo = images?.optString("logo", "") ?: "" + var icon = images?.optString("icon", "") ?: "" + + if (logo2x.startsWith("//")) logo2x = "https:$logo2x" + if (logo.startsWith("//")) logo = "https:$logo" + if (icon.startsWith("//")) icon = "https:$icon" + + val imageUrl = logo2x.ifEmpty { logo } + + // Extract developer (first from array) + val developers = rawResponse.optJSONArray("developers") + val developer = if (developers != null && developers.length() > 0) { + developers.optJSONObject(0)?.optString("name", "") ?: "" + } else { + "" + } + + // Extract publisher (can be object or string) + val publisherObj = rawResponse.opt("publisher") + val publisher = when (publisherObj) { + is JSONObject -> publisherObj.optString("name", "") + is String -> publisherObj + else -> "" + } + + // Extract genres (array of objects with name field) + val genresArray = rawResponse.optJSONArray("genres") + val genres = mutableListOf() + if (genresArray != null) { + for (i in 0 until genresArray.length()) { + val genreObj = genresArray.opt(i) + val genreName = when (genreObj) { + is JSONObject -> genreObj.optString("name", "") + is String -> genreObj + else -> "" + } + if (genreName.isNotEmpty()) { + genres.add(genreName) + } + } + } + + // Extract language codes (keys from object) + val languages = mutableListOf() + val langObj = rawResponse.optJSONObject("languages") + if (langObj != null) { + val keys = langObj.keys() + while (keys.hasNext()) { + languages.add(keys.next()) + } + } + + // Extract description from nested structure + val descriptionObj = rawResponse.opt("description") + val description = when (descriptionObj) { + is JSONObject -> descriptionObj.optString("lead", "") + is String -> descriptionObj + else -> "" + } + + // Extract download size from first installer + val downloads = rawResponse.optJSONObject("downloads") + // Used in GOG Galaxy to hide specific entitlements + val isSecret = rawResponse.optBoolean("is_secret", false) + val gameType = rawResponse.optString("game_type", "dlc") + val isDlc = gameType == "dlc" + + val installers = downloads?.optJSONArray("installers") + val downloadSize = if (installers != null && installers.length() > 0) { + installers.optJSONObject(0)?.optLong("total_size", 0L) ?: 0L + } else { + 0L + } + + // Return data class matching GOGDL format + return ParsedGogGame( + id = gameId, + title = rawResponse.optString("title", "Unknown"), + slug = rawResponse.optString("slug", ""), + imageUrl = imageUrl, + iconUrl = icon, + developer = developer, + publisher = publisher, + genres = genres, + languages = languages, + description = description, + releaseDate = rawResponse.optString("release_date", ""), + downloadSize = downloadSize, + isSecret = isSecret, + isDlc = isDlc + ) + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt new file mode 100644 index 0000000000..f414ddf4e8 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -0,0 +1,442 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.data.GOGCredentials +import app.gamenative.utils.Net +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.json.JSONObject +import timber.log.Timber +import java.io.File + +/** + * Manages GOG authentication and account operations. + * + * - OAuth2 authentication flow + * - Credential storage and validation + * - Token refresh + * - Account logout + */ +object GOGAuthManager { + + private val httpClient = Net.http + + // Internal for testing - allows tests to override token URL + @JvmField + internal var tokenUrl: String = "https://auth.gog.com/token" + + fun getAuthConfigPath(context: Context): String { + return "${context.filesDir}/gog_auth.json" + } + + fun hasStoredCredentials(context: Context): Boolean { + val authFile = File(getAuthConfigPath(context)) + return authFile.exists() + } + + /** + * Authenticate with GOG using authorization code + * Users must visit GOG login page, authenticate, and copy the authorization code + */ + suspend fun authenticateWithCode(context: Context, authorizationCode: String): Result { + return try { + Timber.tag("GOG").i("Starting GOG authentication with authorization code...") + + // Extract the actual authorization code from URL if needed + val actualCode = extractCodeFromInput(authorizationCode) + if (actualCode.isEmpty()) { + return Result.failure(Exception("Invalid authorization URL: no code parameter found")) + } + + val authConfigPath = getAuthConfigPath(context) + + // Create auth config directory + val authFile = File(authConfigPath) + val authDir = authFile.parentFile + if (authDir != null && !authDir.exists()) { + authDir.mkdirs() + Timber.tag("GOG").d("Created auth config directory: ${authDir.absolutePath}") + } + + // Exchange authorization code for tokens + Timber.tag("GOG").d("Exchanging authorization code for tokens...") + + val tokenUrlWithParams = tokenUrl.toHttpUrl().newBuilder() + .addQueryParameter("client_id", GOGConstants.GOG_CLIENT_ID) + .addQueryParameter("client_secret", GOGConstants.GOG_CLIENT_SECRET) + .addQueryParameter("grant_type", "authorization_code") + .addQueryParameter("code", actualCode) + .addQueryParameter("redirect_uri", GOGConstants.GOG_REDIRECT_URI) + .build() + + val request = okhttp3.Request.Builder() + .url(tokenUrlWithParams) + .get() + .build() + + Timber.tag("GOG").d("Sending authentication request...") + val tokenJson = withContext(Dispatchers.IO) { + httpClient.newCall(request).execute() + }.use { response -> + Timber.tag("GOG").d("Received response: HTTP ${response.code}") + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Timber.tag("GOG").e("Failed to authenticate: HTTP ${response.code} - $errorBody") + return Result.failure(Exception("Authentication failed: HTTP ${response.code} - $errorBody")) + } + + val responseBody = response.body?.string() ?: return Result.failure(Exception("Empty response")) + Timber.tag("GOG").d("Response body received, length: ${responseBody.length}") + JSONObject(responseBody) + } + + // Check for error in response + if (tokenJson.has("error")) { + val errorMsg = tokenJson.optString("error_description", "Authentication failed") + Timber.tag("GOG").e("GOG authentication failed: $errorMsg") + return Result.failure(Exception("Authentication failed: $errorMsg")) + } + + // Extract credentials from response + val accessToken = tokenJson.optString("access_token", "") + val refreshToken = tokenJson.optString("refresh_token", "") + val userId = tokenJson.optString("user_id", "") + val expiresIn = tokenJson.optInt("expires_in", 3600) + + if (accessToken.isEmpty() || userId.isEmpty()) { + Timber.tag("GOG").e("GOG authentication incomplete: missing access_token or user_id") + return Result.failure(Exception("Authentication incomplete: missing required data")) + } + + // Create credentials object + val credentials = GOGCredentials( + accessToken = accessToken, + refreshToken = refreshToken, + userId = userId, + username = "GOG User" + ) + + // Store credentials to file + val authData = JSONObject().apply { + put(GOGConstants.GOG_CLIENT_ID, JSONObject().apply { + put("access_token", accessToken) + put("refresh_token", refreshToken) + put("user_id", userId) + put("expires_in", expiresIn) + put("loginTime", System.currentTimeMillis() / 1000.0) + }) + } + + withContext(Dispatchers.IO) { + authFile.writeText(authData.toString(2)) + } + Timber.tag("GOG").i("GOG authentication successful for user: $userId") + + Result.success(credentials) + } catch (e: Exception) { + val errorMessage = e.message ?: e.javaClass.simpleName + Timber.tag("GOG").e(e, "GOG authentication exception: $errorMessage") + Timber.tag("GOG").e("Stack trace: ${e.stackTraceToString()}") + Result.failure(Exception("Authentication exception: $errorMessage", e)) + } + } + + /** + * Get user credentials from storage, automatically refreshing if expired + */ + suspend fun getStoredCredentials(context: Context): Result { + return try { + val authConfigPath = getAuthConfigPath(context) + + if (!hasStoredCredentials(context)) { + return Result.failure(Exception("No stored credentials found")) + } + + // Read credentials from file (IO dispatcher) + val authFile = File(authConfigPath) + val authContent = withContext(Dispatchers.IO) { authFile.readText() } + val authJson = JSONObject(authContent) + + // Get Galaxy app credentials + if (!authJson.has(GOGConstants.GOG_CLIENT_ID)) { + return Result.failure(Exception("No Galaxy credentials found")) + } + + val credentialsJson = authJson.getJSONObject(GOGConstants.GOG_CLIENT_ID) + + // Check if expired + val loginTime = credentialsJson.optDouble("loginTime", 0.0) + val expiresIn = credentialsJson.optInt("expires_in", 0) + val isExpired = System.currentTimeMillis() / 1000.0 >= loginTime + expiresIn + + if (isExpired) { + Timber.tag("GOG").d("Credentials expired, refreshing...") + // Refresh the token + val refreshResult = refreshCredentials(context, GOGConstants.GOG_CLIENT_ID, GOGConstants.GOG_CLIENT_SECRET) + if (refreshResult.isFailure) { + return Result.failure(refreshResult.exceptionOrNull() ?: Exception("Failed to refresh credentials")) + } + // Re-read the refreshed credentials + return getStoredCredentials(context) + } + + // Return valid credentials + val credentials = GOGCredentials( + accessToken = credentialsJson.getString("access_token"), + refreshToken = credentialsJson.optString("refresh_token", ""), + userId = credentialsJson.getString("user_id"), + username = credentialsJson.optString("username", "GOG User") + ) + + Timber.tag("GOG").d("Retrieved stored credentials for user: ${credentials.userId}") + Result.success(credentials) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to get stored credentials") + Result.failure(e) + } + } + + /** + * Get game-specific credentials using the game's clientId and clientSecret. + * This exchanges the Galaxy app's refresh token for a game-specific access token. + * + * @param context Application context + * @param clientId Game's client ID (from .info file) + * @param clientSecret Game's client secret (from build metadata) + * @return Game-specific credentials or error + */ + suspend fun getGameCredentials( + context: Context, + clientId: String, + clientSecret: String + ): Result { + return try { + val authFile = File(getAuthConfigPath(context)) + if (!authFile.exists()) { + return Result.failure(Exception("No stored credentials found")) + } + + // Read auth file + val authContent = withContext(Dispatchers.IO) { authFile.readText() } + val authJson = JSONObject(authContent) + + // Check if we already have credentials for this game + if (authJson.has(clientId)) { + val gameCredentials = authJson.getJSONObject(clientId) + + // Check if expired + val loginTime = gameCredentials.optDouble("loginTime", 0.0) + val expiresIn = gameCredentials.optInt("expires_in", 0) + val isExpired = System.currentTimeMillis() / 1000.0 >= loginTime + expiresIn + + if (!isExpired) { + // Return existing valid credentials + return Result.success(GOGCredentials( + accessToken = gameCredentials.getString("access_token"), + refreshToken = gameCredentials.optString("refresh_token", ""), + userId = gameCredentials.getString("user_id"), + username = gameCredentials.optString("username", "GOG User") + )) + } + } + + // Need to get/refresh game-specific token + // Get Galaxy app's refresh token + val galaxyCredentials = if (authJson.has(GOGConstants.GOG_CLIENT_ID)) { + authJson.getJSONObject(GOGConstants.GOG_CLIENT_ID) + } else { + return Result.failure(Exception("No Galaxy credentials found")) + } + + val refreshToken = galaxyCredentials.optString("refresh_token", "") + if (refreshToken.isEmpty()) { + return Result.failure(Exception("No refresh token available")) + } + + // Request game-specific token using Galaxy's refresh token + Timber.tag("GOG").d("Requesting game-specific token for clientId: $clientId") + val url = tokenUrl.toHttpUrl().newBuilder() + .addQueryParameter("client_id", clientId) + .addQueryParameter("client_secret", clientSecret) + .addQueryParameter("grant_type", "refresh_token") + .addQueryParameter("refresh_token", refreshToken) + .build() + + val request = okhttp3.Request.Builder() + .url(url) + .get() + .build() + + val tokenJson = withContext(Dispatchers.IO) { + httpClient.newCall(request).execute() + }.use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Timber.tag("GOG").e("Failed to get game token: HTTP ${response.code} - $errorBody") + return Result.failure(Exception("Failed to get game-specific token: HTTP ${response.code}")) + } + + val responseBody = response.body?.string() ?: return Result.failure(Exception("Empty response")) + val json = JSONObject(responseBody) + + // Store the new game-specific credentials + json.put("loginTime", System.currentTimeMillis() / 1000.0) + authJson.put(clientId, json) + + // Write updated auth file + withContext(Dispatchers.IO) { authFile.writeText(authJson.toString(2)) } + + Timber.tag("GOG").i("Successfully obtained game-specific token for clientId: $clientId") + json + } + + return Result.success(GOGCredentials( + accessToken = tokenJson.getString("access_token"), + refreshToken = tokenJson.optString("refresh_token", refreshToken), + userId = tokenJson.getString("user_id"), + username = tokenJson.optString("username", "GOG User") + )) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to get game-specific credentials") + Result.failure(e) + } + } + + /** + * Validate credentials, automatically refreshing if expired + */ + suspend fun validateCredentials(context: Context): Result { + return try { + if (!hasStoredCredentials(context)) { + Timber.tag("GOG").d("No stored credentials found for validation") + return Result.success(false) + } + + Timber.tag("GOG").d("Starting credentials validation") + + // Try to get credentials - this will automatically refresh if needed + val credentialsResult = getStoredCredentials(context) + + if (credentialsResult.isSuccess) { + Timber.tag("GOG").d("Credentials validation successful") + Result.success(true) + } else { + Timber.tag("GOG").e("Credentials validation failed: ${credentialsResult.exceptionOrNull()?.message}") + Result.success(false) + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to validate credentials") + Result.failure(e) + } + } + + /** + * Clear stored credentials (logout) + */ + fun clearStoredCredentials(context: Context): Boolean { + return try { + val authFile = File(getAuthConfigPath(context)) + if (authFile.exists()) { + authFile.delete() + } else { + true + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to clear GOG credentials") + false + } + } + + /** + * Refresh credentials using refresh token + */ + private suspend fun refreshCredentials(context: Context, clientId: String, clientSecret: String): Result { + return try { + val authFile = File(getAuthConfigPath(context)) + if (!authFile.exists()) { + return Result.failure(Exception("No stored credentials found")) + } + + // Read current credentials + val authContent = withContext(Dispatchers.IO) { authFile.readText() } + val authJson = JSONObject(authContent) + + // Get refresh token from Galaxy credentials + val galaxyCredentials = if (authJson.has(GOGConstants.GOG_CLIENT_ID)) { + authJson.getJSONObject(GOGConstants.GOG_CLIENT_ID) + } else { + return Result.failure(Exception("No Galaxy credentials found")) + } + + val refreshToken = galaxyCredentials.optString("refresh_token", "") + if (refreshToken.isEmpty()) { + return Result.failure(Exception("No refresh token available")) + } + + // Request new tokens + Timber.tag("GOG").d("Refreshing credentials for clientId: $clientId") + val tokenUrlWithParams = tokenUrl.toHttpUrl().newBuilder() + .addQueryParameter("client_id", clientId) + .addQueryParameter("client_secret", clientSecret) + .addQueryParameter("grant_type", "refresh_token") + .addQueryParameter("refresh_token", refreshToken) + .build() + + val request = okhttp3.Request.Builder() + .url(tokenUrlWithParams) + .get() + .build() + + val tokenJson = withContext(Dispatchers.IO) { + httpClient.newCall(request).execute() + }.use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Timber.tag("GOG").e("Failed to refresh credentials: HTTP ${response.code} - $errorBody") + return Result.failure(Exception("Failed to refresh credentials: HTTP ${response.code}")) + } + + val responseBody = response.body?.string() ?: return Result.failure(Exception("Empty response")) + JSONObject(responseBody) + } + + // Update credentials in auth file + tokenJson.put("loginTime", System.currentTimeMillis() / 1000.0) + authJson.put(clientId, tokenJson) + withContext(Dispatchers.IO) { authFile.writeText(authJson.toString(2)) } + + Timber.tag("GOG").i("Successfully refreshed credentials for clientId: $clientId") + Result.success(true) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to refresh credentials") + Result.failure(e) + } + } + + /** + * Extract authorization code from either a full URL or plain code string. + * + * @param input Either a full GOG redirect URL or a plain authorization code + * @return The extracted authorization code, or empty string if not found + */ + fun extractCodeFromInput(input: String): String { + return if (input.startsWith("http")) { + // Extract code parameter from URL + val codeParam = input.substringAfter("code=", "") + if (codeParam.isEmpty()) { + "" + } else { + // Remove any additional parameters after the code + val cleanCode = codeParam.substringBefore("&") + Timber.tag("GOG").d("Extracted authorization code") + cleanCode + } + } else { + input + } + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt new file mode 100644 index 0000000000..eeac19fe5e --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -0,0 +1,594 @@ +package app.gamenative.service.gog + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.MessageDigest +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import java.util.concurrent.TimeUnit + + +class GOGCloudSavesManager( + private val context: Context +) { + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + companion object { + private const val CLOUD_STORAGE_BASE_URL = "https://cloudstorage.gog.com" + private const val USER_AGENT = "GOGGalaxyCommunicationService/2.0.13.27 (Windows_32bit) dont_sync_marker/true installation_source/gog" + private const val DELETION_MD5 = "aadd86936a80ee8a369579c3926f1b3c" + } + + enum class SyncAction { + UPLOAD, + DOWNLOAD, + CONFLICT, + NONE + } + + /** + * Represents a local save file + */ + data class SyncFile( + val relativePath: String, + val absolutePath: String, + var md5Hash: String? = null, + var updateTime: String? = null, + var updateTimestamp: Long? = null + ) { + /** + * Calculate MD5 hash and metadata for this file + */ + suspend fun calculateMetadata() = withContext(Dispatchers.IO) { + try { + val file = File(absolutePath) + if (!file.exists() || !file.isFile) { + Timber.w("File does not exist: $absolutePath") + return@withContext + } + + // Get file modification timestamp + val timestamp = file.lastModified() + val instant = Instant.ofEpochMilli(timestamp) + updateTime = DateTimeFormatter.ISO_INSTANT.format(instant) + updateTimestamp = timestamp / 1000 // Convert to seconds + + // Calculate MD5 of gzipped content (matching Python implementation) + FileInputStream(file).use { fis -> + val digest = MessageDigest.getInstance("MD5") + val buffer = java.io.ByteArrayOutputStream() + + GZIPOutputStream(buffer).use { gzipOut -> + val fileBuffer = ByteArray(8192) + var bytesRead: Int + while (fis.read(fileBuffer).also { bytesRead = it } != -1) { + gzipOut.write(fileBuffer, 0, bytesRead) + } + } + + md5Hash = digest.digest(buffer.toByteArray()) + .joinToString("") { "%02x".format(it) } + } + + Timber.d("Calculated metadata for $relativePath: md5=$md5Hash, timestamp=$updateTimestamp") + } catch (e: Exception) { + Timber.e(e, "Failed to calculate metadata for $absolutePath") + } + } + } + + /** + * Represents a cloud save file + */ + data class CloudFile( + val relativePath: String, + val md5Hash: String, + val updateTime: String?, + val updateTimestamp: Long? + ) { + val isDeleted: Boolean + get() = md5Hash == DELETION_MD5 + } + + /** + * Classifies sync actions based on file differences + */ + data class SyncClassifier( + val updatedLocal: List = emptyList(), + val updatedCloud: List = emptyList(), + val notExistingLocally: List = emptyList(), + val notExistingRemotely: List = emptyList() + ) { + fun determineAction(): SyncAction { + return when { + updatedLocal.isEmpty() && updatedCloud.isNotEmpty() -> SyncAction.DOWNLOAD + updatedLocal.isNotEmpty() && updatedCloud.isEmpty() -> SyncAction.UPLOAD + updatedLocal.isEmpty() && updatedCloud.isEmpty() -> SyncAction.NONE + else -> SyncAction.CONFLICT + } + } + } + + /** + * Synchronize save files for a game - We grab the directories for ALL games, then download the exact ones we want. + * @param localPath Path to local save directory + * @param dirname Cloud save directory name + * @param clientId Game's client ID (from remote config) + * @param clientSecret Game's client secret (from build metadata) + * @param lastSyncTimestamp Timestamp of last sync (0 for initial sync) + * @param preferredAction User's preferred action (download, upload, or none) + * @return New sync timestamp, or 0 on failure + */ + suspend fun syncSaves( + localPath: String, + dirname: String, + clientId: String, + clientSecret: String, + lastSyncTimestamp: Long = 0, + preferredAction: String = "none" + ): Long = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG-CloudSaves").i("Starting sync for path: $localPath") + Timber.tag("GOG-CloudSaves").i("Cloud dirname: $dirname") + Timber.tag("GOG-CloudSaves").i("Cloud client ID: $clientId") + Timber.tag("GOG-CloudSaves").i("Last sync timestamp: $lastSyncTimestamp") + Timber.tag("GOG-CloudSaves").i("Preferred action: $preferredAction") + + // Ensure directory exists + val syncDir = File(localPath) + if (!syncDir.exists()) { + Timber.tag("GOG-CloudSaves").i("Creating sync directory: $localPath") + syncDir.mkdirs() + } + + // Get local files + val localFiles = scanLocalFiles(syncDir) + Timber.tag("GOG-CloudSaves").i("Found ${localFiles.size} local file(s)") + + // Get game-specific authentication credentials + // This exchanges the Galaxy refresh token for a game-specific access token + val credentials = GOGAuthManager.getGameCredentials(context, clientId, clientSecret).getOrNull() ?: run { + Timber.tag("GOG-CloudSaves").e("Failed to get game-specific credentials") + return@withContext 0L + } + Timber.tag("GOG-CloudSaves").d("Using game-specific credentials for userId: ${credentials.userId}, clientId: $clientId") + + // Get cloud files using game-specific clientId in URL path + Timber.tag("GOG").d("[Cloud Saves] Fetching cloud file list for dirname: $dirname") + val cloudFiles = getCloudFiles(credentials.userId, clientId, dirname, credentials.accessToken) + Timber.tag("GOG").d("[Cloud Saves] Retrieved ${cloudFiles.size} total cloud files") + val downloadableCloud = cloudFiles.filter { !it.isDeleted } + Timber.tag("GOG").i("[Cloud Saves] Found ${downloadableCloud.size} downloadable cloud file(s) (excluding deleted)") + if (downloadableCloud.isNotEmpty()) { + downloadableCloud.forEach { file -> + Timber.tag("GOG").d("[Cloud Saves] - Cloud file: ${file.relativePath} (md5: ${file.md5Hash}, modified: ${file.updateTime})") + } + } + + // Handle simple cases first + when { + localFiles.isNotEmpty() && cloudFiles.isEmpty() -> { + Timber.tag("GOG-CloudSaves").i("No files in cloud, uploading ${localFiles.size} file(s)") + localFiles.forEach { file -> + uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken) + } + return@withContext currentTimestamp() + } + + localFiles.isEmpty() && downloadableCloud.isNotEmpty() -> { + Timber.tag("GOG-CloudSaves").i("No files locally, downloading ${downloadableCloud.size} file(s)") + downloadableCloud.forEach { file -> + downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken) + } + return@withContext currentTimestamp() + } + + localFiles.isEmpty() && cloudFiles.isEmpty() -> { + Timber.tag("GOG-CloudSaves").i("No files locally or in cloud, nothing to sync") + return@withContext currentTimestamp() + } + } + + // Handle preferred action + if (preferredAction == "download" && downloadableCloud.isNotEmpty()) { + Timber.tag("GOG-CloudSaves").i("Forcing download of ${downloadableCloud.size} file(s) (user requested)") + downloadableCloud.forEach { file -> + downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken) + } + return@withContext currentTimestamp() + } + + if (preferredAction == "upload" && localFiles.isNotEmpty()) { + Timber.tag("GOG-CloudSaves").i("Forcing upload of ${localFiles.size} file(s) (user requested)") + localFiles.forEach { file -> + uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken) + } + return@withContext currentTimestamp() + } + + // Complex sync scenario - use classifier + val classifier = classifyFiles(localFiles, cloudFiles, lastSyncTimestamp) + when (classifier.determineAction()) { + SyncAction.DOWNLOAD -> { + Timber.tag("GOG-CloudSaves").i("Downloading ${classifier.updatedCloud.size} updated cloud file(s)") + classifier.updatedCloud.forEach { file -> + downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken) + } + classifier.notExistingLocally.forEach { file -> + if (!file.isDeleted) { + downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken) + } + } + } + + SyncAction.UPLOAD -> { + Timber.tag("GOG-CloudSaves").i("Uploading ${classifier.updatedLocal.size} updated local file(s)") + classifier.updatedLocal.forEach { file -> + uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken) + } + classifier.notExistingRemotely.forEach { file -> + uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken) + } + } + + SyncAction.CONFLICT -> { + Timber.tag("GOG-CloudSaves").w("Sync conflict detected - comparing timestamps") + + // Compare timestamps for matching files + val localMap = classifier.updatedLocal.associateBy { it.relativePath } + val cloudMap = classifier.updatedCloud.associateBy { it.relativePath } + + val toUpload = mutableListOf() + val toDownload = mutableListOf() + + // Check files that exist in both and were both updated + val commonPaths = localMap.keys.intersect(cloudMap.keys) + commonPaths.forEach { path -> + val localFile = localMap[path]!! + val cloudFile = cloudMap[path]!! + + val localTime = localFile.updateTimestamp ?: 0L + val cloudTime = cloudFile.updateTimestamp ?: 0L + + when { + localTime > cloudTime -> { + Timber.tag("GOG-CloudSaves").i("Local file is newer: $path (local: $localTime > cloud: $cloudTime)") + toUpload.add(localFile) + } + cloudTime > localTime -> { + Timber.tag("GOG-CloudSaves").i("Cloud file is newer: $path (cloud: $cloudTime > local: $localTime)") + toDownload.add(cloudFile) + } + else -> { + Timber.tag("GOG-CloudSaves").w("Files have same timestamp, skipping: $path") + } + } + } + + // Upload files that only exist locally or are newer locally + (localMap.keys - commonPaths).forEach { path -> + toUpload.add(localMap[path]!!) + } + + // Download files that only exist in cloud or are newer in cloud + (cloudMap.keys - commonPaths).forEach { path -> + toDownload.add(cloudMap[path]!!) + } + + // Handle files not existing in either location + toUpload.addAll(classifier.notExistingRemotely) + toDownload.addAll(classifier.notExistingLocally.filter { !it.isDeleted }) + + // Execute uploads + if (toUpload.isNotEmpty()) { + Timber.tag("GOG-CloudSaves").i("Uploading ${toUpload.size} file(s) based on timestamp comparison") + toUpload.forEach { file -> + uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken) + } + } + + // Execute downloads + if (toDownload.isNotEmpty()) { + Timber.tag("GOG-CloudSaves").i("Downloading ${toDownload.size} file(s) based on timestamp comparison") + toDownload.forEach { file -> + downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken) + } + } + } + SyncAction.NONE -> { + Timber.tag("GOG-CloudSaves").i("No sync needed - files are up to date") + } + } + + Timber.tag("GOG-CloudSaves").i("Sync completed successfully") + return@withContext currentTimestamp() + + } catch (e: Exception) { + Timber.tag("GOG-CloudSaves").e(e, "Sync failed: ${e.message}") + return@withContext 0L + } + } + + /** + * Scan local directory for save files + */ + private suspend fun scanLocalFiles(directory: File): List = withContext(Dispatchers.IO) { + val files = mutableListOf() + + fun scanRecursive(dir: File, basePath: String) { + dir.listFiles()?.forEach { file -> + if (file.isFile) { + val relativePath = file.absolutePath.removePrefix(basePath) + .removePrefix("/") + .replace("\\", "/") + files.add(SyncFile(relativePath, file.absolutePath)) + } else if (file.isDirectory) { + scanRecursive(file, basePath) + } + } + } + + scanRecursive(directory, directory.absolutePath) + + // Calculate metadata for all files + files.forEach { it.calculateMetadata() } + + files + } + + /** + * Get cloud files list from GOG API + */ + private suspend fun getCloudFiles( + userId: String, + clientId: String, + dirname: String, + authToken: String + ): List = withContext(Dispatchers.IO) { + try { + // List all files (don't include dirname in URL - it's used as a prefix filter) + val url = "$CLOUD_STORAGE_BASE_URL/v1/$userId/$clientId" + Timber.tag("GOG").d("[Cloud Saves] API Request: GET $url (dirname filter: $dirname)") + + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $authToken") + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json") + .header("X-Object-Meta-User-Agent", USER_AGENT) + .build() + + val response = httpClient.newCall(request).execute() + response.use { + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "No response body" + Timber.tag("GOG").e("[Cloud Saves] Failed to fetch cloud files: HTTP ${response.code}") + Timber.tag("GOG").e("[Cloud Saves] Response body: $errorBody") + return@withContext emptyList() + } + + val responseBody = response.body?.string() ?: "" + if (responseBody.isEmpty()) { + Timber.tag("GOG").d("[Cloud Saves] Empty response body from cloud storage API") + return@withContext emptyList() + } + + val items = try { + JSONArray(responseBody) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to parse JSON array response") + Timber.tag("GOG").e("[Cloud Saves] Response was: $responseBody") + return@withContext emptyList() + } + + Timber.tag("GOG").d("[Cloud Saves] Found ${items.length()} total items in cloud storage") + + val files = mutableListOf() + for (i in 0 until items.length()) { + val fileObj = items.getJSONObject(i) + val name = fileObj.optString("name", "") + val hash = fileObj.optString("hash", "") + val lastModified = fileObj.optString("last_modified") + + Timber.tag("GOG").d("[Cloud Saves] Examining item $i: name='$name', dirname='$dirname'") + + // Filter files that belong to this save location (name starts with dirname/) + if (name.isNotEmpty() && hash.isNotEmpty() && name.startsWith("$dirname/")) { + val timestamp = try { + Instant.parse(lastModified).epochSecond + } catch (e: Exception) { + null + } + + // Remove the dirname prefix to get relative path + val relativePath = name.removePrefix("$dirname/") + files.add(CloudFile(relativePath, hash, lastModified, timestamp)) + Timber.tag("GOG").d("[Cloud Saves] ✓ Matched: relativePath='$relativePath'") + } else { + Timber.tag("GOG").d("[Cloud Saves] ✗ Skipped (doesn't match dirname or missing data)") + } + } + + Timber.tag("GOG").i("[Cloud Saves] Retrieved ${files.size} cloud files for dirname '$dirname'") + files + } + + } catch (e: Exception) { + Timber.tag("GOG-CloudSaves").e(e, "Failed to get cloud files") + emptyList() + } + } + + /** + * Upload file to GOG cloud storage + */ + private suspend fun uploadFile( + userId: String, + clientId: String, + dirname: String, + file: SyncFile, + authToken: String + ) = withContext(Dispatchers.IO) { + try { + val localFile = File(file.absolutePath) + val fileSize = localFile.length() + + Timber.tag("GOG-CloudSaves").i("Uploading: ${file.relativePath} (${fileSize} bytes)") + + val url = "$CLOUD_STORAGE_BASE_URL/v1/$userId/$clientId/$dirname/${file.relativePath}" + + val requestBody = localFile.readBytes().toRequestBody("application/octet-stream".toMediaType()) + + val requestBuilder = Request.Builder() + .url(url) + .put(requestBody) + .header("Authorization", "Bearer $authToken") + .header("User-Agent", USER_AGENT) + .header("X-Object-Meta-User-Agent", USER_AGENT) + .header("Content-Type", "application/octet-stream") + + // Add last modified timestamp header if available + file.updateTime?.let { timestamp -> + requestBuilder.header("X-Object-Meta-LocalLastModified", timestamp) + } + + val response = httpClient.newCall(requestBuilder.build()).execute() + response.use { + if (response.isSuccessful) { + Timber.tag("GOG-CloudSaves").i("Successfully uploaded: ${file.relativePath}") + } else { + val errorBody = response.body?.string() ?: "No response body" + Timber.tag("GOG-CloudSaves").e("Failed to upload ${file.relativePath}: HTTP ${response.code}") + Timber.tag("GOG-CloudSaves").e("Upload error body: $errorBody") + } + } + + } catch (e: Exception) { + Timber.tag("GOG-CloudSaves").e(e, "Failed to upload ${file.relativePath}") + } + } + + /** + * Download file from GOG cloud storage + */ + private suspend fun downloadFile( + userId: String, + clientId: String, + dirname: String, + file: CloudFile, + syncDir: File, + authToken: String + ) = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG-CloudSaves").i("Downloading: ${file.relativePath}") + + val url = "$CLOUD_STORAGE_BASE_URL/v1/$userId/$clientId/$dirname/${file.relativePath}" + + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $authToken") + .header("User-Agent", USER_AGENT) + .header("X-Object-Meta-User-Agent", USER_AGENT) + .build() + + val response = httpClient.newCall(request).execute() + response.use { + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "No response body" + Timber.tag("GOG-CloudSaves").e("Failed to download ${file.relativePath}: HTTP ${response.code}") + Timber.tag("GOG-CloudSaves").e("Download error body: $errorBody") + return@withContext + } + + val bytes = response.body?.bytes() ?: return@withContext + Timber.tag("GOG-CloudSaves").d("Downloaded ${bytes.size} bytes for ${file.relativePath}") + + // Save to local file + val localFile = File(syncDir, file.relativePath) + localFile.parentFile?.mkdirs() + + FileOutputStream(localFile).use { fos -> + fos.write(bytes) + } + + // Preserve timestamp if available + file.updateTimestamp?.let { timestamp -> + localFile.setLastModified(timestamp * 1000) + } + + Timber.tag("GOG-CloudSaves").i("Successfully downloaded: ${file.relativePath}") + } + + } catch (e: Exception) { + Timber.tag("GOG-CloudSaves").e(e, "Failed to download ${file.relativePath}") + } + } + + /** + * Classify files for sync decision + */ + private fun classifyFiles( + localFiles: List, + cloudFiles: List, + timestamp: Long + ): SyncClassifier { + val updatedLocal = mutableListOf() + val updatedCloud = mutableListOf() + val notExistingLocally = mutableListOf() + val notExistingRemotely = mutableListOf() + + val localPaths = localFiles.map { it.relativePath }.toSet() + val cloudPaths = cloudFiles.map { it.relativePath }.toSet() + + // Check local files + localFiles.forEach { file -> + if (file.relativePath !in cloudPaths) { + notExistingRemotely.add(file) + } + val fileTimestamp = file.updateTimestamp + if (fileTimestamp != null && fileTimestamp > timestamp) { + updatedLocal.add(file) + } + } + + // Check cloud files + cloudFiles.forEach { file -> + if (file.isDeleted) return@forEach + + if (file.relativePath !in localPaths) { + notExistingLocally.add(file) + } + val fileTimestamp = file.updateTimestamp + if (fileTimestamp != null && fileTimestamp > timestamp) { + updatedCloud.add(file) + } + } + + return SyncClassifier(updatedLocal, updatedCloud, notExistingLocally, notExistingRemotely) + } + + /** + * Get current timestamp in seconds + */ + private fun currentTimestamp(): Long { + return System.currentTimeMillis() / 1000 + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt new file mode 100644 index 0000000000..be52b52d92 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt @@ -0,0 +1,78 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.PrefManager +import java.io.File +import java.nio.file.Paths +import timber.log.Timber + +/** + * Constants for GOG integration + */ +object GOGConstants { + private var appContext: Context? = null + + /** + * Initialize GOGConstants with application context + */ + fun init(context: Context) { + appContext = context.applicationContext + } + // GOG API URLs + const val GOG_BASE_API_URL = "https://api.gog.com" + const val GOG_AUTH_URL = "https://auth.gog.com" + const val GOG_EMBED_URL = "https://embed.gog.com" + const val GOG_GAMESDB_URL = "https://gamesdb.gog.com" + + // GOG Client ID for authentication - These are public and not sensitive information. + const val GOG_CLIENT_ID = "46899977096215655" + const val GOG_CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" + + // GOG uses a standard redirect URI that we can intercept + const val GOG_REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client" + + // GOG OAuth authorization URL with redirect + const val GOG_AUTH_LOGIN_URL = "https://auth.gog.com/auth?client_id=$GOG_CLIENT_ID&redirect_uri=$GOG_REDIRECT_URI&response_type=code&layout=client" + + /** + * Internal GOG games installation path (similar to Steam's internal path) + * Uses application's internal files directory + */ + val internalGOGGamesPath: String + get() { + val context = appContext ?: throw IllegalStateException("GOGConstants not initialized. Call init() first.") + val path = Paths.get(context.filesDir.absolutePath, "GOG", "games", "common").toString() + // Ensure directory exists for StatFs + File(path).mkdirs() + return path + } + + /** + * External GOG games installation path (similar to Steam's external path) + * {externalStoragePath}/GOG/games/common/ + */ + val externalGOGGamesPath: String + get() { + val path = Paths.get(PrefManager.externalStoragePath, "GOG", "games", "common").toString() + // Ensure directory exists for StatFs + File(path).mkdirs() + return path + } + + val defaultGOGGamesPath: String + get() { + return if (PrefManager.useExternalStorage && File(PrefManager.externalStoragePath).exists()) { + Timber.i("GOG using external storage: $externalGOGGamesPath") + externalGOGGamesPath + } else { + Timber.i("GOG using internal storage: $internalGOGGamesPath") + internalGOGGamesPath + } + } + + fun getGameInstallPath(gameTitle: String): String { + // Sanitize game title for filesystem + val sanitizedTitle = gameTitle.replace(Regex("[^a-zA-Z0-9 ]"), "").trim() + return Paths.get(defaultGOGGamesPath, sanitizedTitle).toString() + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt new file mode 100644 index 0000000000..98a9728491 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt @@ -0,0 +1,1133 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.data.DownloadInfo +import app.gamenative.service.gog.api.DepotFile +import app.gamenative.service.gog.api.FileChunk +import app.gamenative.service.gog.api.GOGApiClient +import app.gamenative.service.gog.api.GOGManifestParser +import app.gamenative.utils.Net +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.ByteArrayOutputStream +import java.io.File +import java.security.MessageDigest +import java.util.zip.Inflater +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import okhttp3.Request +import timber.log.Timber + +/** + * Custom exception for HTTP status errors with typed status code + */ +class HttpStatusException(val statusCode: Int, message: String) : Exception(message) + +/** + * GOGDownloadManager handles downloading GOG games + * + * GOG's CDN structure (Gen 2): + * 1. Fetch build manifest (contains depots and product metadata) + * 2. Fetch depot manifests (contains file lists with chunks) + * 3. Get secure CDN links (time-limited URLs for chunks) -> We have issues here + * 4. Download chunks from CDN (zlib compressed data) -> We have issues here + * 5. Decompress and verify chunks (MD5) + * 6. Assemble files from chunks + * + * GOG Chunk Format (Gen 2): + * - Chunks are identified by compressedMd5 hash + * - Downloaded from secure CDN URLs (time-limited) + * - Compressed using zlib + * - Verified using MD5 hash after decompression + * - Multiple chunks assemble into single files + */ +@Singleton +class GOGDownloadManager @Inject constructor( + private val apiClient: GOGApiClient, + private val parser: GOGManifestParser, + private val gogManager: GOGManager, + @ApplicationContext private val context: Context, +) { + private val WINDOWS_OS_VERSION = "windows" + private val httpClient = Net.http + + /** + * Context needed to refresh secure CDN links when they expire + */ + private data class SecureLinkContext( + val gameId: String, + val generation: Int, + val productIds: Set, + val chunkToProductMap: Map, + ) + + companion object { + private const val MAX_PARALLEL_DOWNLOADS = 4 + private const val CHUNK_BUFFER_SIZE = 1024 * 1024 // 1MB buffer + private const val MAX_CHUNK_RETRIES = 3 // Maximum retries per chunk + private const val RETRY_DELAY_MS = 1000L // Initial retry delay in milliseconds + private const val DEPENDENCY_URL = "https://content-system.gog.com/dependencies/repository?generation=2" + } + + /** + * Download and install a GOG game + * + * @param gameId GOG game ID (numeric) + * @param installPath Directory where game will be installed + * @param downloadInfo Progress tracker + * @param language Target language (e.g., "en-US") + * @param withDlcs Whether to include DLC content + * @param supportDir Optional directory for support files (redistributables) + * @return Result indicating success or failure + */ + suspend fun downloadGame( + gameId: String, + installPath: File, + downloadInfo: DownloadInfo, + language: String = "en-US", + withDlcs: Boolean = false, + supportDir: File? = null, + ): Result = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG").i("Starting download for game $gameId to ${installPath.absolutePath}") + + if(supportDir != null) { + Timber.tag("GOG").i("Will also put dependencies into ${supportDir.absolutePath}") + } + + // Get the actual game from database to check what ID we have stored + val dbGame = gogManager.getGameFromDbById(gameId) + + if (dbGame == null) { + return@withContext Result.failure( + Exception("Failed to fetch game from DB"), + ) + } + + Timber.tag("GOG").d("Database game ID: ${dbGame.id}, title: ${dbGame.title}") + + // Emit download started event so UI can attach progress listeners + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, true), + ) + + downloadInfo.updateStatusMessage("Fetching builds...") + + // Step 1: Get available builds + val buildsResult = apiClient.getBuildsForGame(gameId, WINDOWS_OS_VERSION) + if (buildsResult.isFailure) { + return@withContext Result.failure( + buildsResult.exceptionOrNull() ?: Exception("Failed to fetch builds"), + ) + } + + // Get the best build (Most recent, match OS and the generation must be 2) + val builds = buildsResult.getOrThrow() + val selectedBuild = parser.selectBuild(builds.items, platform = WINDOWS_OS_VERSION) + ?: return@withContext Result.failure(Exception("No suitable build found for Windows")) + + Timber.tag("GOG").i("Selected build: ${selectedBuild.buildId} (Gen ${selectedBuild.generation}, Platform: ${selectedBuild.platform})") + Timber.tag("GOG").d("Build productId: ${selectedBuild.productId}, input gameId: $gameId") + Timber.tag("GOG").d("Full build details: buildId=${selectedBuild.buildId}, productId=${selectedBuild.productId}, platform=${selectedBuild.platform}, gen=${selectedBuild.generation}, version=${selectedBuild.versionName}, branch=${selectedBuild.branch}, legacyBuildId=${selectedBuild.legacyBuildId}") + + val realGameId = gameId + + downloadInfo.updateStatusMessage("Fetching manifest...") + + // Step 2: Fetch main manifest + val gameManifestResult = apiClient.fetchManifest(selectedBuild.link) + if (gameManifestResult.isFailure) { + return@withContext Result.failure( + gameManifestResult.exceptionOrNull() ?: Exception("Failed to fetch manifest"), + ) + } + + val gameManifest = gameManifestResult.getOrThrow() + Timber.tag("GOG").d("Game Manifest: ${gameManifest.installDirectory}, ${gameManifest.depots.size} depot(s)") + Timber.tag("GOG").d("Game Manifest baseProductId: ${gameManifest.baseProductId}") + + gameManifest.products?.let { products -> + Timber.tag("GOG").d("Manifest products: ${products.joinToString { "name=${it.name}, id=${it.productId}" }}") + } + + // Grab Dependencies from the gameManifest for later. + val dependencies = gameManifest.dependencies + + downloadInfo.updateStatusMessage("Filtering depots...") + + // Step 3: Filter depots by language and bitness (32-bit or 64-bit) + val languageDepots = parser.filterDepotsByLanguage(gameManifest, language) + if (languageDepots.isEmpty()) { + return@withContext Result.failure(Exception("No depots found for language: $language")) + } + + // TODO: Verify if we need this anymore. + val bitnessDepots = parser.filterDepotsByBitness(languageDepots, bitness = "64") + if (bitnessDepots.isEmpty()) { + return@withContext Result.failure(Exception("No 64-bit depots found for language: $language")) + } + + // Filter by ownership to exclude unowned DLC depots + val ownedGameIds = gogManager.getAllGameIds() + val depots = parser.filterDepotsByOwnership(bitnessDepots, ownedGameIds) + if (depots.isEmpty()) { + return@withContext Result.failure(Exception("No owned depots found for language: $language")) + } + + Timber.tag("GOG").d("Found ${depots.size} owned depot(s) for $language (64-bit)") + depots.forEachIndexed { index, depot -> + Timber.tag("GOG").d(" Depot $index: productId=${depot.productId}, manifest=${depot.manifest}, size=${depot.size}, compressedSize=${depot.compressedSize}") + } + + downloadInfo.updateStatusMessage("Fetching depot manifests...") + + // Step 4: Fetch depot manifests to get file lists + // Track which depot each file came from for proper productId mapping + data class FileWithDepot(val file: DepotFile, val depotProductId: String) + val allFilesWithDepots = mutableListOf() + + for ((index, depot) in depots.withIndex()) { + downloadInfo.updateStatusMessage("Fetching depot ${index + 1}/${depots.size}...") + + val depotResult = apiClient.fetchDepotManifest(depot.manifest) + if (depotResult.isFailure) { + return@withContext Result.failure( + depotResult.exceptionOrNull() ?: Exception("Failed to fetch depot manifest"), + ) + } + + val files = depotResult.getOrThrow().files + files.forEach { file -> + allFilesWithDepots.add(FileWithDepot(file, depot.productId)) + } + } + + val allFiles = allFilesWithDepots.map { it.file } + Timber.tag("GOG").d("Total files from all depots: ${allFiles.size}") + + // Step 5: Separate base game, DLC, and support files + val (baseFiles, dlcFiles) = parser.separateBaseDLC(allFiles, gameManifest.baseProductId) + val filesToDownload = if (withDlcs) baseFiles + dlcFiles else baseFiles + val (gameFiles, supportFiles) = parser.separateSupportFiles(filesToDownload) + + // Calculate sizes separately for transparency + val (baseGameFiles, _) = parser.separateSupportFiles(baseFiles) + val baseGameSize = parser.calculateTotalSize(baseGameFiles) + val dlcSize = if (withDlcs && dlcFiles.isNotEmpty()) { + val (dlcGameFiles, _) = parser.separateSupportFiles(dlcFiles) + parser.calculateTotalSize(dlcGameFiles) + } else { + 0L + } + + Timber.tag("GOG").d( + """ + |Download plan: + | Base game files: ${baseFiles.size} + | DLC files: ${dlcFiles.size} + | Game files to download: ${gameFiles.size} + | Support files: ${supportFiles.size} + | Base game size: ${baseGameSize / 1_000_000.0} MB + | DLC size: ${dlcSize / 1_000_000.0} MB + | Including DLCs: $withDlcs + """.trimMargin(), + ) + + // Step 6: Calculate sizes and extract chunk hashes + val totalSize = parser.calculateTotalSize(gameFiles) + val chunkHashes = parser.extractChunkHashes(gameFiles) + + Timber.tag("GOG").d( + """ + |Download stats: + | Total compressed size: ${totalSize / 1_000_000.0} MB (${if (withDlcs) "including DLC" else "base game only"}) + | Unique chunks: ${chunkHashes.size} + | Files: ${gameFiles.size} + """.trimMargin(), + ) + + downloadInfo.setTotalExpectedBytes(totalSize) + + // Step 7: Get secure CDN links for chunks + downloadInfo.updateStatusMessage("Getting secure download links...") + + // Build mapping of product ID to secure URLs and chunk to product ID + val productUrlMap = mutableMapOf>() + val chunkToProductMap = mutableMapOf() + + Timber.tag("GOG").d("Mapping chunks to products. gameId parameter: $gameId, realGameId: $realGameId, manifest baseProductId: ${gameManifest.baseProductId}") + + // Map each chunk to its product ID using depot info + allFilesWithDepots.forEach { (file, depotProductId) -> + // Use depot's productId as fallback when file has null/placeholder productId + + // TODO: Remove this logic and always use the depotProductId. + val productId = when { + file.productId == null -> { + Timber.tag("GOG").d("File ${file.path} has null productId, using depotProductId: $depotProductId") + depotProductId + } + file.productId == "2147483047" -> { + Timber.tag("GOG").d("File ${file.path} has placeholder productId, using depotProductId: $depotProductId") + depotProductId + } + else -> { + Timber.tag("GOG").d("File ${file.path} has productId: ${file.productId}") + file.productId + } + } + + // Only include files from products the user owns + if (productId in ownedGameIds) { + file.chunks.forEach { chunk -> + chunkToProductMap[chunk.compressedMd5] = productId + } + } else { + Timber.tag("GOG").d("Skipping file ${file.path} from unowned product $productId") + } + } + + // Get unique product IDs we need to fetch secure links for + val productIds = chunkToProductMap.values.toSet() + Timber.tag("GOG").d("Need secure links for ${productIds.size} owned product(s): ${productIds.joinToString()}") + Timber.tag("GOG").d("Mapped ${chunkToProductMap.size} chunks to products") + + // Fetch secure links for each product + for (productId in productIds) { + val linksResult = apiClient.getSecureLink( + productId = productId, + path = "/", + generation = selectedBuild.generation, + ) + if (linksResult.isSuccess) { + val urls = linksResult.getOrThrow().urls + productUrlMap[productId] = urls + } else { + return@withContext Result.failure( + linksResult.exceptionOrNull() ?: Exception("Failed to get secure links for product $productId"), + ) + } + } + + // Build chunk URL map using the correct product URL for each chunk + val chunkUrlMap = parser.buildChunkUrlMapWithProducts(chunkHashes, chunkToProductMap, productUrlMap) + + // Store context for refreshing secure links if they expire + val secureLinkContext = SecureLinkContext( + gameId = realGameId, + generation = selectedBuild.generation, + productIds = productIds, + chunkToProductMap = chunkToProductMap, + ) + + // Step 8: Download chunks + Timber.tag("GOG").i("Downoading Chunks for game $gameId") + + downloadInfo.updateStatusMessage("Downloading chunks...") + + val chunkCacheDir = File(installPath, ".gog_chunks") + chunkCacheDir.mkdirs() + + val downloadResult = downloadChunks( + chunkUrlMap = chunkUrlMap, + chunkCacheDir = chunkCacheDir, + downloadInfo = downloadInfo, + chunkHashes = chunkHashes, + secureLinkContext = secureLinkContext, + chunkToProductMap = chunkToProductMap, + ) + + if (downloadResult.isFailure) { + return@withContext downloadResult + } + + // Step 9: Assemble game files + downloadInfo.updateStatusMessage("Assembling files...") + + // Use installPath directly since it already includes the game-specific folder + val gameInstallDir = installPath + gameInstallDir.mkdirs() + + val assembleResult = assembleFiles(gameFiles, chunkCacheDir, gameInstallDir, downloadInfo) + if (assembleResult.isFailure) { + return@withContext assembleResult + } + + // Download Dependencies (They will either go to root or supportDir depending on ) + if (supportDir != null && dependencies.isNotEmpty()) { + downloadInfo.updateStatusMessage("Downloading dependencies...") + supportDir.mkdirs() + + val dependencyResult = downloadDependencies(gameId, dependencies, installPath, supportDir, downloadInfo) + if (dependencyResult.isFailure){ + Timber.tag("GOG").w("Failed to install Dependencies: ${dependencyResult.exceptionOrNull()?.message}") + } + + } + + // Step 11: Cleanup + chunkCacheDir.deleteRecursively() + + // Step 12: Update database with install info + downloadInfo.updateStatusMessage("Updating database...") + try { + val game = gogManager.getGameFromDbById(gameId) + if (game != null) { + // Use installPath directly since it already includes the game-specific folder + val installSize = calculateDirectorySize(installPath) + val updatedGame = game.copy( + isInstalled = true, + installPath = installPath.absolutePath, + installSize = installSize, + ) + gogManager.updateGame(updatedGame) + Timber.tag("GOG").i("Updated database: game marked as installed, size: ${installSize / 1_000_000} MB") + } else { + Timber.tag("GOG").w("Game $gameId not found in database, skipping DB update") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to update database for game $gameId") + // Don't fail the entire download for DB issues - They can try again and it will auto-detect and finish + } + + // Step 13: Emit completion event + downloadInfo.updateStatusMessage("Complete") + downloadInfo.setProgress(1.0f) + downloadInfo.setActive(false) + downloadInfo.emitProgressChange() + + // Notify UI that installation status changed + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(gameId.toIntOrNull() ?: 0), + ) + + Timber.tag("GOG").i("Download completed successfully for game $gameId") + Result.success(Unit) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Download failed: ${e.message}") + downloadInfo.updateStatusMessage("Failed: ${e.message}") + downloadInfo.setProgress(-1.0f) + downloadInfo.setActive(false) + downloadInfo.emitProgressChange() + + // Emit download stopped event on failure + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, false), + ) + + Result.failure(e) + } + } + + /** + * Download all chunks from CDN with parallel execution + * + * @param chunkUrlMap Map of chunk MD5 hash to secure CDN URL + * @param chunkCacheDir Directory to cache downloaded chunks + * @param downloadInfo Progress tracker + * @param chunkHashes List of all chunk hashes needed + * @param secureLinkContext Context for refreshing secure links if they expire + * @param chunkToProductMap Map of chunk MD5 hash to product ID for debugging + */ + private suspend fun downloadChunks( + chunkUrlMap: Map, + chunkCacheDir: File, + downloadInfo: DownloadInfo, + chunkHashes: List, + secureLinkContext: SecureLinkContext, + chunkToProductMap: Map, + ): Result = withContext(Dispatchers.IO) { + try { + var currentChunkUrlMap = chunkUrlMap + val chunks = chunkUrlMap.entries.toList() + val totalChunks = chunks.size + var downloadedChunks = 0 + + Timber.tag("GOG").d("Downloading $totalChunks chunks...") + + // Initialize download progress + downloadInfo.setProgress(0.0f) + downloadInfo.setActive(true) + downloadInfo.emitProgressChange() + + // Download in batches to avoid overwhelming the system + chunks.chunked(MAX_PARALLEL_DOWNLOADS).forEach { chunkBatch -> + if (!downloadInfo.isActive()) { + Timber.tag("GOG").w("Download cancelled by user") + return@withContext Result.failure(Exception("Download cancelled")) + } + + // Download batch in parallel with retry logic + val results = chunkBatch.map { (chunkMd5, _) -> + async { + // Use current URL map in case it was refreshed + val url = currentChunkUrlMap[chunkMd5] ?: return@async Result.failure( + Exception("No URL found for chunk $chunkMd5"), + ) + downloadChunkWithRetry(chunkMd5, url, chunkCacheDir, downloadInfo) + } + }.awaitAll() + + // Check if any download failed due to expired links (401/403/404) + val expiredLinkFailures = results.zip(chunkBatch).filter { (result, _) -> + val exception = result.exceptionOrNull() + exception is HttpStatusException && exception.statusCode in listOf(401, 403, 404) + } + + if (expiredLinkFailures.isNotEmpty()) { + Timber.tag("GOG").w("Detected ${expiredLinkFailures.size} expired secure link(s), refreshing...") + + // Log which products the failing chunks belong to + expiredLinkFailures.forEach { (result, chunk) -> + val chunkMd5 = chunk.key + val productId = chunkToProductMap[chunkMd5] + Timber.tag("GOG").w("Chunk $chunkMd5 belongs to product $productId: ${result.exceptionOrNull()?.message}") + } + + // Refresh secure links + val refreshResult = refreshSecureLinks(secureLinkContext, chunkHashes) + if (refreshResult.isSuccess) { + currentChunkUrlMap = refreshResult.getOrThrow() + Timber.tag("GOG").i("Secure links refreshed successfully, retrying failed chunks") + + // Retry the failed chunks with new URLs + val retryResults = chunkBatch.map { (chunkMd5, _) -> + async { + val url = currentChunkUrlMap[chunkMd5] ?: return@async Result.failure( + Exception("No URL found for chunk $chunkMd5 after refresh"), + ) + downloadChunkWithRetry(chunkMd5, url, chunkCacheDir, downloadInfo) + } + }.awaitAll() + + // Check retry results + retryResults.firstOrNull { it.isFailure }?.let { failedResult -> + return@withContext Result.failure( + failedResult.exceptionOrNull() ?: Exception("Failed to download chunk after link refresh"), + ) + } + } else { + Timber.tag("GOG").e("Failed to refresh secure links: ${refreshResult.exceptionOrNull()?.message}") + return@withContext Result.failure( + refreshResult.exceptionOrNull() ?: Exception("Failed to refresh secure links"), + ) + } + } else { + // Check if any download failed for other reasons + results.firstOrNull { it.isFailure }?.let { failedResult -> + return@withContext Result.failure( + failedResult.exceptionOrNull() ?: Exception("Failed to download chunk"), + ) + } + } + + downloadedChunks += chunkBatch.size + + // Update progress with smooth interpolation + val progress = downloadedChunks.toFloat() / totalChunks + downloadInfo.setProgress(progress) + downloadInfo.updateStatusMessage("Downloading chunks ($downloadedChunks/$totalChunks)") + downloadInfo.emitProgressChange() + + Timber.tag("GOG").d("Progress: ${(progress * 100).toInt()}% ($downloadedChunks/$totalChunks chunks)") + } + + Timber.tag("GOG").i("All $totalChunks chunks downloaded successfully") + Result.success(Unit) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to download chunks") + Result.failure(e) + } + } + + + /** + * Downloads the dependencies for a given game by using the dependency array from the game's depo-list + * It will download the dependencies using the dependency URL + * + */ + private suspend fun downloadDependencies( + gameId: String, + dependencies: List, + gameDir: File, + supportDir: File, + downloadInfo: DownloadInfo + ): Result = withContext(Dispatchers.IO) { + try { + if (dependencies.isEmpty()) { + Timber.tag("GOG").d("No dependencies to download") + return@withContext Result.success(Unit) + } + + Timber.tag("GOG").i("Downloading ${dependencies.size} dependencies: ${dependencies.joinToString()}") + + // Get dependency repository + val dependencyRepositoryResult = apiClient.fetchDependencyRepository(DEPENDENCY_URL) + if (dependencyRepositoryResult.isFailure) { + return@withContext Result.failure( + dependencyRepositoryResult.exceptionOrNull() ?: Exception("Failed to fetch Dependency repository"), + ) + } + + val repositoryManifestUrl = dependencyRepositoryResult.getOrThrow().repositoryManifest + if (repositoryManifestUrl.isBlank()) { + return@withContext Result.failure(Exception("Empty repository manifest URL")) + } + + // Get the decompressed manifest + val dependencyManifestResult = apiClient.fetchDependencyManifest(repositoryManifestUrl) + if (dependencyManifestResult.isFailure) { + return@withContext Result.failure( + dependencyManifestResult.exceptionOrNull() ?: Exception("Failed to fetch Dependency manifest"), + ) + } + + val dependencyManifest = dependencyManifestResult.getOrThrow() + + // Filter depots by the dependencyId so we only install what we need (e.g., MSVC2013) + val filteredDepots = dependencyManifest.depots.filter { depot -> + dependencies.contains(depot.dependencyId) + } + + if (filteredDepots.isEmpty()) { + Timber.tag("GOG").w("No matching dependency depots found for: ${dependencies.joinToString()}") + return@withContext Result.success(Unit) + } + + Timber.tag("GOG").d("Found ${filteredDepots.size} dependency depot(s) to download") + + // Get open link URLs for dependencies + val openLinkResult = apiClient.getDependencyOpenLink() + if (openLinkResult.isFailure) { + return@withContext Result.failure( + openLinkResult.exceptionOrNull() ?: Exception("Failed to get dependency open link"), + ) + } + + val dependencyBaseUrls = openLinkResult.getOrThrow() + if (dependencyBaseUrls.isEmpty()) { + return@withContext Result.failure(Exception("No dependency URLs returned")) + } + + Timber.tag("GOG").d("Got ${dependencyBaseUrls.size} dependency base URL(s)") + + // Download each dependency + for ((index, depot) in filteredDepots.withIndex()) { + downloadInfo.updateStatusMessage("Downloading dependency ${index + 1}/${filteredDepots.size}: ${depot.readableName}") + + Timber.tag("GOG").i("Downloading dependency: ${depot.readableName} (${depot.dependencyId})") + + // Determine install directory based on executable path + // If path starts with __redist, install to supportDir, otherwise install to gameDir + val installBaseDir = if (depot.executable?.path?.startsWith("__redist") == true) { + Timber.tag("GOG").d("Dependency ${depot.dependencyId} has __redist path, installing to supportDir") + supportDir + } else { + Timber.tag("GOG").d("Dependency ${depot.dependencyId} has no __redist path, installing to gameDir") + gameDir + } + + // Fetch depot manifest to get file list using open link URLs + val depotManifestResult = apiClient.fetchDependencyDepotManifest(depot.manifest, dependencyBaseUrls) + if (depotManifestResult.isFailure) { + Timber.tag("GOG").w("Failed to fetch depot manifest for ${depot.readableName}: ${depotManifestResult.exceptionOrNull()?.message}") + continue + } + + val depotManifest = depotManifestResult.getOrThrow() + val depotFiles = depotManifest.files + + if (depotFiles.isEmpty()) { + Timber.tag("GOG").w("No files in dependency depot: ${depot.readableName}") + continue + } + + // Extract chunk hashes + val chunkHashes = parser.extractChunkHashes(depotFiles) + + // Build chunk URL map using dependency base URLs + val chunkUrlMap = buildChunkUrlMap(chunkHashes, dependencyBaseUrls) + + // Create cache directory for this dependency + val depotCacheDir = File(installBaseDir, ".gog_dep_${depot.dependencyId}") + depotCacheDir.mkdirs() + + // Download chunks + val downloadResult = downloadChunksSimple(chunkUrlMap, depotCacheDir, downloadInfo) + if (downloadResult.isFailure) { + Timber.tag("GOG").w("Failed to download chunks for ${depot.readableName}: ${downloadResult.exceptionOrNull()?.message}") + continue + } + + // Assemble files - the file paths in the manifest already contain the full directory structure + // so we use installBaseDir directly without adding depot.dependencyId + val depotInstallDir = installBaseDir + depotInstallDir.mkdirs() + + // Strip __redist/ prefix from file paths if they're being installed to supportDir + // This prevents paths like supportDir/__redist/DirectX and gives us supportDir/DirectX instead + val filesToAssemble = if (installBaseDir == supportDir) { + depotFiles.map { file -> + if (file.path.startsWith("__redist/")) { + file.copy(path = file.path.removePrefix("__redist/")) + } else { + file + } + } + } else { + depotFiles + } + + val assembleResult = assembleFiles(filesToAssemble, depotCacheDir, depotInstallDir, downloadInfo) + if (assembleResult.isFailure) { + Timber.tag("GOG").w("Failed to assemble files for ${depot.readableName}: ${assembleResult.exceptionOrNull()?.message}") + continue + } + + // Cleanup cache + depotCacheDir.deleteRecursively() + + Timber.tag("GOG").i("Successfully downloaded dependency: ${depot.readableName} to ${depotInstallDir.absolutePath}") + } + + Timber.tag("GOG").i("Completed downloading ${filteredDepots.size} dependencies") + Result.success(Unit) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to download dependencies") + Result.failure(e) + } + } + + /** + * Build chunk URL map using base URLs + */ + private fun buildChunkUrlMap(chunkHashes: List, baseUrls: List): Map { + val chunkUrlMap = mutableMapOf() + val baseUrl = baseUrls.firstOrNull() ?: return emptyMap() + // Ensure base URL ends with / for proper concatenation + val normalizedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" + + chunkHashes.forEach { hash -> + // Build GOG Galaxy path format: AA/BB/CCDD... + val galaxyPath = if (hash.length >= 4) { + "${hash.substring(0, 2)}/${hash.substring(2, 4)}/$hash" + } else { + hash + } + chunkUrlMap[hash] = "$normalizedBaseUrl$galaxyPath" + } + + return chunkUrlMap + } + + /** + * Simplified chunk download without retry and secure link refresh + * Used for dependencies which use open links + */ + private suspend fun downloadChunksSimple( + chunkUrlMap: Map, + chunkCacheDir: File, + downloadInfo: DownloadInfo, + ): Result = withContext(Dispatchers.IO) { + try { + val chunks = chunkUrlMap.entries.toList() + + // Download in batches + chunks.chunked(MAX_PARALLEL_DOWNLOADS).forEach { chunkBatch -> + val results = chunkBatch.map { (chunkMd5, url) -> + async { + downloadChunk(chunkMd5, url, chunkCacheDir, downloadInfo) + } + }.awaitAll() + + // Check if any download failed + results.firstOrNull { it.isFailure }?.let { failedResult -> + return@withContext Result.failure( + failedResult.exceptionOrNull() ?: Exception("Failed to download chunk"), + ) + } + } + + Result.success(Unit) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to download dependency chunks") + Result.failure(e) + } + } + + /** + * Refresh secure CDN links when they expire + * + * @param context Context containing info needed to fetch new links + * @param chunkHashes List of chunk hashes needed + * @return New chunk URL map with fresh secure links + */ + private suspend fun refreshSecureLinks( + context: SecureLinkContext, + chunkHashes: List, + ): Result> = withContext(Dispatchers.IO) { + try { + val productUrlMap = mutableMapOf>() + + // Get secure links for each product + for (productId in context.productIds) { + val linksResult = apiClient.getSecureLink( + productId = productId, + path = "/", + generation = context.generation, + ) + if (linksResult.isSuccess) { + productUrlMap[productId] = linksResult.getOrThrow().urls + } else { + return@withContext Result.failure( + linksResult.exceptionOrNull() ?: Exception("Failed to refresh secure links for product $productId"), + ) + } + } + + Timber.tag("GOG").d("Refreshed secure links for ${productUrlMap.size} product(s)") + + // Rebuild chunk URL map with new secure links + val newChunkUrlMap = parser.buildChunkUrlMapWithProducts(chunkHashes, context.chunkToProductMap, productUrlMap) + Result.success(newChunkUrlMap) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to refresh secure links") + Result.failure(e) + } + } + + /** + * Download a single chunk with retry logic + * + * @param chunkMd5 Compressed MD5 hash (chunk identifier) + * @param url Secure CDN URL (time-limited) + * @param chunkCacheDir Cache directory + * @param downloadInfo Progress tracker + */ + private suspend fun downloadChunkWithRetry( + chunkMd5: String, + url: String, + chunkCacheDir: File, + downloadInfo: DownloadInfo, + ): Result = withContext(Dispatchers.IO) { + var lastException: Exception? = null + + repeat(MAX_CHUNK_RETRIES) { attempt -> + val result = downloadChunk(chunkMd5, url, chunkCacheDir, downloadInfo) + + if (result.isSuccess) { + if (attempt > 0) { + Timber.tag("GOG").i("Chunk $chunkMd5 downloaded successfully after ${attempt + 1} attempts") + } + return@withContext result + } + + lastException = result.exceptionOrNull() as? Exception + + if (attempt < MAX_CHUNK_RETRIES - 1) { + val delay = RETRY_DELAY_MS * (1 shl attempt) // Exponential backoff: 1s, 2s, 4s + Timber.tag("GOG").w("Chunk $chunkMd5 download failed (attempt ${attempt + 1}/$MAX_CHUNK_RETRIES): ${lastException?.message}. Retrying in ${delay}ms...") + kotlinx.coroutines.delay(delay) + } + } + + Timber.tag("GOG").e(lastException, "Failed to download chunk $chunkMd5 after $MAX_CHUNK_RETRIES attempts") + Result.failure(lastException ?: Exception("Failed to download chunk $chunkMd5")) + } + + /** + * Download a single chunk from GOG CDN + * + * @param chunkMd5 Compressed MD5 hash (chunk identifier) + * @param url Secure CDN URL (time-limited) + * @param chunkCacheDir Cache directory + * @param downloadInfo Progress tracker + */ + private suspend fun downloadChunk( + chunkMd5: String, + url: String, + chunkCacheDir: File, + downloadInfo: DownloadInfo, + ): Result = withContext(Dispatchers.IO) { + try { + val chunkFile = File(chunkCacheDir, "$chunkMd5.chunk") + + // Skip if already downloaded and verified + if (chunkFile.exists()) { + val existingMd5 = calculateMd5(chunkFile.readBytes()) + if (existingMd5 == chunkMd5) { + Timber.tag("GOG").d("Chunk $chunkMd5 already exists and verified, skipping") + return@withContext Result.success(chunkFile) + } else { + Timber.tag("GOG").w("Chunk $chunkMd5 exists but failed verification, re-downloading") + chunkFile.delete() + } + } + + // Download compressed chunk + Timber.tag("GOG").d("Downloading chunk $chunkMd5 from: $url") + + val request = Request.Builder() + .url(url) + .header("User-Agent", "GOG Galaxy") + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.tag("GOG").e("HTTP ${response.code} for chunk $chunkMd5 from URL: $url") + return@withContext Result.failure( + HttpStatusException(response.code, "HTTP ${response.code} downloading chunk $chunkMd5") + ) + } + + val compressedBytes = response.body?.bytes() + ?: return@withContext Result.failure(Exception("Empty response for chunk $chunkMd5")) + + // Verify compressed MD5 + val actualMd5 = calculateMd5(compressedBytes) + if (actualMd5 != chunkMd5) { + return@withContext Result.failure( + Exception("Compressed MD5 mismatch for chunk: expected $chunkMd5, got $actualMd5"), + ) + } + + // Save compressed chunk (will decompress during assembly) + chunkFile.writeBytes(compressedBytes) + downloadInfo.updateBytesDownloaded(compressedBytes.size.toLong()) + + Result.success(chunkFile) + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to download chunk $chunkMd5") + Result.failure(e) + } + } + + /** + * Assemble files from downloaded chunks + * + * @param files List of files to assemble + * @param chunkCacheDir Directory containing downloaded chunks + * @param installDir Target installation directory + * @param downloadInfo Progress tracker + */ + private suspend fun assembleFiles( + files: List, + chunkCacheDir: File, + installDir: File, + downloadInfo: DownloadInfo, + ): Result = withContext(Dispatchers.IO) { + try { + val totalFiles = files.size + + for ((index, file) in files.withIndex()) { + if (!downloadInfo.isActive()) { + return@withContext Result.failure(Exception("Download cancelled")) + } + + downloadInfo.updateStatusMessage("Assembling ${index + 1}/$totalFiles: ${file.path}") + + val assembleResult = assembleFile(file, chunkCacheDir, installDir) + if (assembleResult.isFailure) { + return@withContext Result.failure( + assembleResult.exceptionOrNull() ?: Exception("Failed to assemble ${file.path}"), + ) + } + } + + Timber.tag("GOG").i("Assembled $totalFiles file(s) successfully") + Result.success(Unit) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to assemble files") + Result.failure(e) + } + } + + /** + * Assemble a single file from its chunks + * + * @param file File metadata with chunks + * @param chunkCacheDir Directory containing downloaded chunks + * @param installDir Target installation directory + */ + private suspend fun assembleFile( + file: DepotFile, + chunkCacheDir: File, + installDir: File, + ): Result = withContext(Dispatchers.IO) { + try { + val outputFile = File(installDir, file.path) + outputFile.parentFile?.mkdirs() + + outputFile.outputStream().use { output -> + for (chunk in file.chunks) { + // Get compressed chunk file + val chunkFile = File(chunkCacheDir, "${chunk.compressedMd5}.chunk") + + if (!chunkFile.exists()) { + return@withContext Result.failure( + Exception("Chunk file missing: ${chunk.compressedMd5}"), + ) + } + + // Read compressed data + val compressedBytes = chunkFile.readBytes() + + // Decompress chunk + val decompressedBytes = decompressChunk(compressedBytes, chunk) + if (decompressedBytes.isFailure) { + return@withContext Result.failure( + decompressedBytes.exceptionOrNull() + ?: Exception("Failed to decompress chunk ${chunk.compressedMd5}"), + ) + } + + val data = decompressedBytes.getOrThrow() + + // Verify decompressed MD5 + val actualMd5 = calculateMd5(data) + if (actualMd5 != chunk.md5) { + return@withContext Result.failure( + Exception("Decompressed MD5 mismatch for chunk: expected ${chunk.md5}, got $actualMd5"), + ) + } + + // Write to output file + output.write(data) + } + } + + // Verify final file hash if provided + if (file.md5 != null) { + val fileMd5 = calculateMd5File(outputFile) + if (fileMd5 != file.md5) { + Timber.tag("GOG").w("File MD5 mismatch: ${file.path}, expected ${file.md5}, got $fileMd5") + // Don't fail - some games have incorrect MD5 in manifest + } + } + + Timber.tag("GOG").d("Assembled: ${file.path} (${outputFile.length()} bytes)") + Result.success(outputFile) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to assemble file ${file.path}") + Result.failure(e) + } + } + + /** + * Decompress a GOG chunk using zlib + * + * GOG chunks are compressed with zlib + * If chunk.compressedSize is null, data is uncompressed + * + * @param compressedBytes Compressed chunk data + * @param chunk Chunk metadata + * @return Decompressed data + */ + private fun decompressChunk(compressedBytes: ByteArray, chunk: FileChunk): Result { + return try { + // If no compressed size specified, data is already uncompressed + if (chunk.compressedSize == null) { + return Result.success(compressedBytes) + } + + // Decompress using zlib + val inflater = Inflater() + try { + inflater.setInput(compressedBytes) + val outputStream = ByteArrayOutputStream(chunk.size.toInt()) + val buffer = ByteArray(8192) + + while (!inflater.finished()) { + val count = inflater.inflate(buffer) + if (count > 0) { + outputStream.write(buffer, 0, count) + } else { + // No bytes produced - check if we need more input or a dictionary + if (inflater.needsInput()) { + throw java.io.IOException( + "Incomplete zlib data: decompression requires more input but none available" + ) + } else if (inflater.needsDictionary()) { + throw java.io.IOException( + "Zlib data requires a preset dictionary which is not supported" + ) + } + // If neither condition is true, inflater is still processing internally + // Continue loop, but this should be rare + } + } + + val decompressed = outputStream.toByteArray() + + // Verify size matches expected + if (decompressed.size.toLong() != chunk.size) { + return Result.failure( + Exception("Decompressed size mismatch: expected ${chunk.size}, got ${decompressed.size}"), + ) + } + + Result.success(decompressed) + } finally { + inflater.end() + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to decompress chunk ${chunk.compressedMd5}") + Result.failure(e) + } + } + + /** + * Calculate MD5 hash of byte array + */ + private fun calculateMd5(data: ByteArray): String { + val digest = MessageDigest.getInstance("MD5") + digest.update(data) + return digest.digest().joinToString("") { "%02x".format(it) } + } + + /** + * Calculate MD5 hash of file + */ + private fun calculateMd5File(file: File): String { + val digest = MessageDigest.getInstance("MD5") + file.inputStream().use { input -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + /** + * Calculate the total size of a directory recursively + * + * @param directory The directory to calculate size for + * @return Total size in bytes + */ + private fun calculateDirectorySize(directory: File): Long { + var size = 0L + try { + if (!directory.exists() || !directory.isDirectory) { + return 0L + } + + val files = directory.listFiles() ?: return 0L + for (file in files) { + size += if (file.isDirectory) { + calculateDirectorySize(file) + } else { + file.length() + } + } + } catch (e: Exception) { + Timber.tag("GOG").w(e, "Error calculating directory size for ${directory.name}") + } + return size + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt new file mode 100644 index 0000000000..cd2cc63c3f --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -0,0 +1,1152 @@ +package app.gamenative.service.gog + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import app.gamenative.data.DownloadInfo +import app.gamenative.data.GOGCloudSavesLocation +import app.gamenative.data.GOGCloudSavesLocationTemplate +import app.gamenative.data.GOGGame +import app.gamenative.data.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.PathType +import app.gamenative.enums.ReleaseState +import app.gamenative.enums.SyncResult +import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.FileUtils +import app.gamenative.utils.MarkerUtils +import app.gamenative.utils.Net +import app.gamenative.utils.StorageUtils +import com.winlator.container.Container +import com.winlator.core.envvars.EnvVars +import com.winlator.xenvironment.components.GuestProgramLauncherComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.EnumSet +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber + +/** + * Data class to hold size information from gogdl info command + */ +data class GameSizeInfo( + val downloadSize: Long, + val diskSize: Long, +) + +/** + * Unified manager for GOG game and library operations. + * + * Responsibilities: + * - Database CRUD for GOG games + * - Library syncing from GOG API + * - Game downloads and installation + * - Installation verification + * - Executable discovery + * - Wine launch commands + * - File system operations + * + * Uses GOGPythonBridge for all GOGDL command execution. + * Uses GOGAuthManager for authentication checks. + */ +@Singleton +class GOGManager @Inject constructor( + private val gogGameDao: GOGGameDao, + @ApplicationContext private val context: Context, +) { + + // Thread-safe cache for download sizes + private val downloadSizeCache = ConcurrentHashMap() + private val REFRESH_BATCH_SIZE = 10 + + // Cache for remote config API responses (clientId -> save locations) + // This avoids fetching the same config multiple times + private val remoteConfigCache = ConcurrentHashMap>() + + // Timestamp storage for sync state (gameId_locationName -> timestamp) + // Persisted to disk to survive app restarts + private val syncTimestamps = ConcurrentHashMap() + private val timestampFile = File(context.filesDir, "gog_sync_timestamps.json") + + // Track active sync operations to prevent concurrent syncs + private val activeSyncs = ConcurrentHashMap.newKeySet() + + init { + // Load persisted cloudsave timestamps on initialization + loadCloudSaveTimestampsFromDisk() + } + + suspend fun getGameFromDbById(gameId: String): GOGGame? { + return withContext(Dispatchers.IO) { + try { + gogGameDao.getById(gameId) + } catch (e: Exception) { + Timber.e(e, "Failed to get GOG game by ID: $gameId") + null + } + } + } + + suspend fun insertGame(game: GOGGame) { + withContext(Dispatchers.IO) { + gogGameDao.insert(game) + } + } + + suspend fun updateGame(game: GOGGame) { + withContext(Dispatchers.IO) { + gogGameDao.update(game) + } + } + + suspend fun deleteAllGames() { + withContext(Dispatchers.IO) { + gogGameDao.deleteAll() + } + } + + suspend fun getAllGameIds(): Set { + return withContext(Dispatchers.IO) { + try { + gogGameDao.getAllGameIdsIncludingExcluded().toSet() + } catch (e: Exception) { + Timber.e(e, "Failed to get all game IDs") + emptySet() + } + } + } + + suspend fun startBackgroundSync(context: Context): Result = withContext(Dispatchers.IO) { + try { + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.w("Cannot start background sync: no stored credentials") + return@withContext Result.failure(Exception("No stored credentials found")) + } + + Timber.tag("GOG").i("Starting GOG library background sync...") + + val result = refreshLibrary(context) + + if (result.isSuccess) { + val count = result.getOrNull() ?: 0 + Timber.tag("GOG").i("Background sync completed: $count games synced") + return@withContext Result.success(Unit) + } else { + val error = result.exceptionOrNull() + Timber.e(error, "Background sync failed: ${error?.message}") + return@withContext Result.failure(error ?: Exception("Background sync failed")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to sync GOG library in background") + Result.failure(e) + } + } + + /** + * Refresh the entire library (called manually by user) + * Fetches all games from GOG API and updates the database + * ! Note: If someone wants to improve this logic, I'd recommend seeing + * ! if coroutine parallel downloading would work without being rate-limited + */ + suspend fun refreshLibrary(context: Context): Result = withContext(Dispatchers.IO) { + try { + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.w("Cannot refresh library: not authenticated with GOG") + return@withContext Result.failure(Exception("Not authenticated with GOG")) + } + + Timber.tag("GOG").i("Refreshing GOG library from GOG API...") + + // Fetch games from GOG via GOGDL Python backend + + var gameIdList = GOGApiClient.getGameIds(context) + + if (!gameIdList.isSuccess) { + val error = gameIdList.exceptionOrNull() + Timber.e(error, "Failed to fetch GOG game IDs: ${error?.message}") + return@withContext Result.failure(error ?: Exception("Failed to fetch GOG game IDs")) + } + + val gameIds = gameIdList.getOrNull() ?: emptyList() + Timber.tag("GOG").i("Successfully fetched ${gameIds.size} game IDs from GOG") + + if (gameIds.isEmpty()) { + Timber.w("No games found in GOG library") + return@withContext Result.success(0) + } + + val ignoredGameId = "1801418160" // Hidden ID for GOG Galaxy that we should ignore. + + // Get existing game IDs from database to avoid re-fetching + val existingGameIds = gogGameDao.getAllGameIdsIncludingExcluded().toMutableSet() + existingGameIds.add(ignoredGameId) + + Timber.tag("GOG").d("Found ${existingGameIds.size} games already in database") + + // Filter to only new games that need details fetched + val newGameIds = gameIds.filter { it !in existingGameIds } + Timber.tag("GOG").d("${newGameIds.size} new games need details fetched") + + if (newGameIds.isEmpty()) { + Timber.tag("GOG").d("No new games to fetch, library is up to date") + return@withContext Result.success(0) + } + + var totalProcessed = 0 + + Timber.tag("GOG").d("Getting Game Details for ${newGameIds.size} new GOG Games...") + + val games = mutableListOf() + + // Use direct HTTP calls via GOGApiClient + for ((index, id) in newGameIds.withIndex()) { + try { + // Fetch game details using direct HTTP call + val result = GOGApiClient.getGameById(context, id) + + if (result.isSuccess) { + val gameDetails = result.getOrNull() + if (gameDetails != null) { + Timber.tag("GOG").d("Got Game Details for ID: $id") + val game = parseGameObject(gameDetails) + if (game != null) { + games.add(game) + Timber.tag("GOG").d("Refreshed Game: ${game.title}") + totalProcessed++ + } + } + } else { + Timber.w("GOG game ID $id not found in library after refresh") + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse game details for ID: $id") + } + + if ((index + 1) % REFRESH_BATCH_SIZE == 0 || index == newGameIds.size - 1) { + if (games.isNotEmpty()) { + gogGameDao.upsertPreservingInstallStatus(games) + Timber.tag("GOG").d("Batch inserted ${games.size} games (processed ${index + 1}/${newGameIds.size})") + games.clear() + } + } + } + val detectedCount = detectAndUpdateExistingInstallations() + if (detectedCount > 0) { + Timber.d("Detected and updated $detectedCount existing installations") + } + Timber.tag("GOG").i("Successfully refreshed GOG library with $totalProcessed games") + return@withContext Result.success(totalProcessed) + } catch (e: Exception) { + Timber.e(e, "Failed to refresh GOG library") + return@withContext Result.failure(e) + } + } + + private fun parseGameObject(parsedGame: ParsedGogGame): GOGGame? { + val title = parsedGame.title + val id = parsedGame.id + val downloadSize = parsedGame.downloadSize + val isSecret = parsedGame.isSecret + val isDlc = parsedGame.isDlc + // Added Exclude so that we still store a record in the DB but we don't expose it. + // This reduces the amount of fetching we do from the APIs and we also reduce chances of Amazon Prime duplicates etc. + // Had to put in an extra case for some games not using isSecret but still are amazon prime duplicates... + val exclude = + title == "Unknown Game" || title.startsWith("product_title_") || title == "Unknown" || downloadSize == 0L || isSecret || + title.endsWith("Amazon Prime") || isDlc + + return GOGGame( + id = id, + title = title, + exclude = exclude, + slug = parsedGame.slug, + imageUrl = parsedGame.imageUrl, + iconUrl = parsedGame.iconUrl, + description = parsedGame.description, + releaseDate = parsedGame.releaseDate, + developer = parsedGame.developer, + publisher = parsedGame.publisher, + genres = parsedGame.genres, + languages = parsedGame.languages, + downloadSize = parsedGame.downloadSize, + installSize = 0L, + isInstalled = false, + installPath = "", + lastPlayed = 0L, + playTime = 0L, + ) + } + + /** + * Scan the GOG games directories for existing installations + * and update the database with installation info + * + * @return Number of installations detected and updated + */ + private suspend fun detectAndUpdateExistingInstallations(): Int = withContext(Dispatchers.IO) { + var detectedCount = 0 + + try { + // Check both internal and external storage paths + val pathsToCheck = listOf( + GOGConstants.internalGOGGamesPath, + GOGConstants.externalGOGGamesPath, + ) + + for (basePath in pathsToCheck) { + val baseDir = File(basePath) + if (!baseDir.exists() || !baseDir.isDirectory) { + Timber.d("Skipping non-existent path: $basePath") + continue + } + + Timber.d("Scanning for installations in: $basePath") + val installDirs = baseDir.listFiles { file -> file.isDirectory } ?: emptyArray() + + for (installDir in installDirs) { + try { + val detectedGame = detectGameFromDirectory(installDir) + if (detectedGame != null) { + // Update database with installation info + val existingGame = getGameFromDbById(detectedGame.id) + if (existingGame != null && !existingGame.isInstalled) { + val updatedGame = existingGame.copy( + isInstalled = true, + installPath = detectedGame.installPath, + installSize = detectedGame.installSize, + ) + updateGame(updatedGame) + detectedCount++ + Timber.i("Detected existing installation: ${existingGame.title} at ${installDir.absolutePath}") + } else if (existingGame != null) { + Timber.d("Game ${existingGame.title} already marked as installed") + } + } + } catch (e: Exception) { + Timber.w(e, "Error detecting game in ${installDir.name}") + } + } + } + } catch (e: Exception) { + Timber.e(e, "Error during installation detection") + } + + detectedCount + } + + /** + * Try to detect which game is installed in the given directory + * + * @param installDir The directory to check + * @return GOGGame with installation info, or null if no game detected + */ + private suspend fun detectGameFromDirectory(installDir: File): GOGGame? { + if (!installDir.exists() || !installDir.isDirectory) { + return null + } + + val dirName = installDir.name + Timber.d("Checking directory: $dirName") + + // Look for .info files which contain game metadata + val infoFiles = installDir.listFiles { file -> + file.isFile && file.extension == "info" + } ?: emptyArray() + + if (infoFiles.isNotEmpty()) { + // Try to parse game ID from .info file + val infoFile = infoFiles.first() + try { + val infoContent = infoFile.readText() + val infoJson = JSONObject(infoContent) + val gameId = infoJson.optString("gameId", "") + if (gameId.isNotEmpty()) { + val game = getGameFromDbById(gameId) + if (game != null) { + val installSize = FileUtils.calculateDirectorySize(installDir) + return game.copy( + isInstalled = true, + installPath = installDir.absolutePath, + installSize = installSize, + ) + } + } + } catch (e: Exception) { + Timber.w(e, "Error parsing .info file: ${infoFile.name}") + } + } + + // Fallback: Try to match by directory name with game titles in database + val allGames = gogGameDao.getAllAsList() + for (game in allGames) { + // Sanitize game title to match directory naming convention + val sanitizedTitle = game.title.replace(Regex("[^a-zA-Z0-9 ]"), "").trim() + + if (dirName.equals(sanitizedTitle, ignoreCase = true)) { + // Verify it's actually a game directory (has executables or subdirectories) + val hasContent = installDir.listFiles()?.any { + it.isDirectory || it.extension in listOf("exe", "dll", "bat") + } == true + + if (hasContent) { + val installSize = FileUtils.calculateDirectorySize(installDir) + Timber.d("Matched directory '$dirName' to game '${game.title}'") + return game.copy( + isInstalled = true, + installPath = installDir.absolutePath, + installSize = installSize, + ) + } + } + } + + return null + } + + suspend fun refreshSingleGame(gameId: String, context: Context): Result { + return try { + Timber.d("Fetching single game data for gameId: $gameId via direct HTTP...") + + if (!GOGAuthManager.hasStoredCredentials(context)) { + return Result.failure(Exception("Not authenticated")) + } + + val result = GOGApiClient.getGameById(context, gameId) + + if (result.isFailure) { + return Result.failure(result.exceptionOrNull() ?: Exception("Failed to fetch game data")) + } + + val gameDetails = result.getOrNull() + if (gameDetails == null) { + Timber.w("Game $gameId not found in GOG library") + return Result.success(null) + } + + val game = parseGameObject(gameDetails) + if (game == null) { + Timber.tag("GOG").w("Skipping Invalid GOG App with id: $gameId") + return Result.success(null) + } + insertGame(game) + return Result.success(game) + } catch (e: Exception) { + Timber.e(e, "Error fetching single game data for $gameId") + Result.failure(e) + } + } + + suspend fun deleteGame(context: Context, libraryItem: LibraryItem): Result { + return withContext(Dispatchers.IO) { + try { + val gameId = libraryItem.gameId.toString() + val installPath = getGameInstallPath(gameId, libraryItem.name) + val installDir = File(installPath) + + // Delete the manifest file + val manifestPath = File(context.filesDir, "manifests/$gameId") + if (manifestPath.exists()) { + manifestPath.delete() + Timber.i("Deleted manifest file for game $gameId") + } + + // Delete game files + if (installDir.exists()) { + val success = installDir.deleteRecursively() + if (success) { + Timber.i("Successfully deleted game directory: $installPath") + } else { + Timber.w("Failed to delete some game files") + } + } else { + Timber.w("GOG game directory doesn't exist: $installPath") + } + + // Remove all markers + val appDirPath = getAppDirPath(libraryItem.appId) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Update database - mark as not installed + val game = getGameFromDbById(gameId) + if (game != null) { + val updatedGame = game.copy(isInstalled = false, installPath = "") + gogGameDao.update(updatedGame) + Timber.d("Updated database: game marked as not installed") + } + + // Delete container (must run on Main thread) + withContext(Dispatchers.Main) { + ContainerUtils.deleteContainer(context, libraryItem.appId) + } + + // Trigger library refresh event + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(libraryItem.gameId), + ) + + Result.success(Unit) + } catch (e: Exception) { + Timber.e(e, "Failed to delete GOG game ${libraryItem.gameId}") + Result.failure(e) + } + } + } + + fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean { + try { + val appDirPath = getAppDirPath(libraryItem.appId) + + // Use marker-based approach + val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + val isInstalled = isDownloadComplete && !isDownloadInProgress + + // Update database if status changed + val gameId = libraryItem.gameId.toString() + val game = runBlocking { getGameFromDbById(gameId) } + if (game != null && isInstalled != game.isInstalled) { + val installPath = if (isInstalled) getGameInstallPath(gameId, libraryItem.name) else "" + val updatedGame = game.copy(isInstalled = isInstalled, installPath = installPath) + runBlocking { gogGameDao.update(updatedGame) } + } + + return isInstalled + } catch (e: Exception) { + Timber.e(e, "Error checking if GOG game is installed") + return false + } + } + + fun verifyInstallation(gameId: String): Pair { + val game = runBlocking { getGameFromDbById(gameId) } + val installPath = game?.installPath + + if (game == null || installPath == null || !game.isInstalled) { + return Pair(false, "Game not marked as installed in database") + } + + val installDir = File(installPath) + if (!installDir.exists()) { + return Pair(false, "Install directory not found: $installPath") + } + + if (!installDir.isDirectory) { + return Pair(false, "Install path is not a directory") + } + + val contents = installDir.listFiles() + if (contents == null || contents.isEmpty()) { + return Pair(false, "Install directory is empty") + } + + Timber.i("Installation verified for game $gameId at $installPath") + return Pair(true, null) + } + + // Get the exe. There is a v1 and v2 depending on the age of the game. + suspend fun getInstalledExe(libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { + val gameId = libraryItem.gameId.toString() + try { + val game = getGameFromDbById(gameId) ?: return@withContext "" + val installPath = getGameInstallPath(game.id, game.title) + + // Try V2 structure first (game_$gameId subdirectory) + val v2GameDir = File(installPath, "game_$gameId") + if (v2GameDir.exists()) { + return@withContext getGameExecutable(installPath, v2GameDir) + } + + // Try V1 structure + val installDirFile = File(installPath) + val subdirs = installDirFile.listFiles()?.filter { + it.isDirectory && it.name != "saves" + } ?: emptyList() + + if (subdirs.isNotEmpty()) { + return@withContext getGameExecutable(installPath, subdirs.first()) + } + + "" + } catch (e: Exception) { + Timber.e(e, "Failed to get executable for GOG game $gameId") + "" + } + } + + private fun getGameExecutable(installPath: String, gameDir: File): String { + val result = getMainExecutableFromGOGInfo(gameDir, installPath) + if (result.isSuccess) { + val exe = result.getOrNull() ?: "" + Timber.d("Found GOG game executable from info file: $exe") + return exe + } + Timber.e(result.exceptionOrNull(), "Failed to find executable from GOG info file in: ${gameDir.absolutePath}") + return "" + } + + private fun findGOGInfoFile(directory: File, gameId: String? = null, maxDepth: Int = 3, currentDepth: Int = 0): File? { + if (!directory.exists() || !directory.isDirectory) { + return null + } + + // Check current directory first + val infoFile = directory.listFiles()?.find { + it.isFile && if (gameId != null) { + it.name == "goggame-$gameId.info" + } else { + it.name.startsWith("goggame-") && it.name.endsWith(".info") + } + } + + if (infoFile != null) { + return infoFile + } + + // If max depth reached, stop searching + if (currentDepth >= maxDepth) { + return null + } + + // Search subdirectories recursively + val subdirs = directory.listFiles()?.filter { it.isDirectory } ?: emptyList() + for (subdir in subdirs) { + val found = findGOGInfoFile(subdir, gameId, maxDepth, currentDepth + 1) + if (found != null) { + return found + } + } + + return null + } + + private fun getMainExecutableFromGOGInfo(gameDir: File, installPath: String): Result { + return try { + val infoFile = findGOGInfoFile(gameDir) + ?: return Result.failure(Exception("GOG info file not found in ${gameDir.absolutePath}")) + + val content = infoFile.readText() + val jsonObject = JSONObject(content) + + if (!jsonObject.has("playTasks")) { + return Result.failure(Exception("playTasks array not found in ${infoFile.name}")) + } + + val playTasks = jsonObject.getJSONArray("playTasks") + for (i in 0 until playTasks.length()) { + val task = playTasks.getJSONObject(i) + if (task.has("isPrimary") && task.getBoolean("isPrimary")) { + val executablePath = task.getString("path") + + // Construct full path - executablePath may include subdirectories + val exeFile = File(gameDir, executablePath) + + if (exeFile.exists()) { + val parentDir = gameDir.parentFile ?: gameDir + val relativePath = exeFile.relativeTo(parentDir).path + return Result.success(relativePath) + } + + return Result.failure(Exception("Primary executable '$executablePath' not found in ${gameDir.absolutePath}")) + } + } + Result.failure(Exception("No primary executable found in playTasks")) + } catch (e: Exception) { + Result.failure(Exception("Error parsing GOG info file in ${gameDir.absolutePath}: ${e.message}", e)) + } + } + + fun getGogWineStartCommand( + libraryItem: LibraryItem, + container: Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: EnvVars, + guestProgramLauncherComponent: GuestProgramLauncherComponent, + ): String { + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId) + + // Verify installation + val (isValid, errorMessage) = verifyInstallation(gameId.toString()) + if (!isValid) { + Timber.e("Installation verification failed: $errorMessage") + return "\"explorer.exe\"" + } + + val game = runBlocking { getGameFromDbById(gameId.toString()) } + if (game == null) { + Timber.e("Game not found for ID: $gameId") + return "\"explorer.exe\"" + } + + val gameInstallPath = getGameInstallPath(gameId.toString(), game.title) + val gameDir = File(gameInstallPath) + + if (!gameDir.exists()) { + Timber.e("Game directory does not exist: $gameInstallPath") + return "\"explorer.exe\"" + } + + // Use container's configured executable path if available, otherwise auto-detect + val executablePath = if (container.executablePath.isNotEmpty()) { + Timber.d("Using configured executable path from container: ${container.executablePath}") + container.executablePath + } else { + val detectedPath = runBlocking { getInstalledExe(libraryItem) } + Timber.d("Auto-detected executable path: $detectedPath") + detectedPath + } + + if (executablePath.isEmpty()) { + Timber.w("No executable found, opening file manager") + return "\"explorer.exe\"" + } + + // Find the drive letter that's mapped to this game's install path + var gogDriveLetter: String? = null + for (drive in com.winlator.container.Container.drivesIterator(container.drives)) { + if (drive[1] == gameInstallPath) { + gogDriveLetter = drive[0] + Timber.d("Found GOG game mapped to ${drive[0]}: drive") + break + } + } + + if (gogDriveLetter == null) { + Timber.e("GOG game directory not mapped to any drive: $gameInstallPath") + return "\"explorer.exe\"" + } + + val gameInstallDir = File(gameInstallPath) + val execFile = File(gameInstallPath, executablePath) + // Handle potential IllegalArgumentException if paths don't share a common ancestor + val relativePath = try { + execFile.relativeTo(gameInstallDir).path.replace('/', '\\') + } catch (e: IllegalArgumentException) { + Timber.e(e, "Failed to compute relative path from $gameInstallDir to $execFile") + return "\"explorer.exe\"" + } + + val windowsPath = "$gogDriveLetter:\\$relativePath" + + // Set working directory + val execWorkingDir = execFile.parentFile + if (execWorkingDir != null) { + guestProgramLauncherComponent.workingDir = execWorkingDir + envVars.put("WINEPATH", "$gogDriveLetter:\\") + } else { + guestProgramLauncherComponent.workingDir = gameDir + } + + Timber.d("GOG Wine command: \"$windowsPath\"") + return "\"$windowsPath\"" + } + + // ========================================================================== + // CLOUD SAVES + // ========================================================================== + + /** + * Read GOG game info file and extract clientId + * @param appId Game ID + * @param installPath Optional install path, if null will try to get from game database + * @return JSONObject with game info, or null if not found + */ + suspend fun readInfoFile(appId: String, installPath: String?): JSONObject? = withContext(Dispatchers.IO) { + try { + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + var path = installPath + + // If no install path provided, try to get from database + if (path == null) { + val game = getGameFromDbById(gameId.toString()) + path = game?.installPath + } + + if (path == null || path.isEmpty()) { + Timber.w("No install path found for game $gameId") + return@withContext null + } + + val installDir = File(path) + if (!installDir.exists()) { + Timber.w("Install directory does not exist: $path") + return@withContext null + } + + // Look for goggame-{gameId}.info file - check root first, then common subdirectories + val infoFile = findGOGInfoFile(installDir, gameId.toString()) + + if (infoFile == null || !infoFile.exists()) { + Timber.w("Info file not found for game $gameId in ${installDir.absolutePath}") + return@withContext null + } + + val infoContent = infoFile.readText() + val infoJson = JSONObject(infoContent) + Timber.d("Successfully read info file for game $gameId") + return@withContext infoJson + } catch (e: Exception) { + Timber.e(e, "Failed to read info file for appId $appId") + return@withContext null + } + } + + /** + * Fetch save locations from GOG Remote Config API + * @param context Android context + * @param appId Game app ID + * @param installPath Game install path + * @return Pair of (clientSecret, List of save location templates), or null if cloud saves not enabled or API call fails + */ + suspend fun getSaveSyncLocation( + context: Context, + appId: String, + installPath: String, + ): Pair>? = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG").d("[Cloud Saves] Getting save sync location for $appId") + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val infoJson = readInfoFile(appId, installPath) + + if (infoJson == null) { + Timber.tag("GOG").w("[Cloud Saves] Cannot get save sync location: info file not found") + return@withContext null + } + + // Extract clientId from info file + val clientId = infoJson.optString("clientId", "") + if (clientId.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No clientId found in info file for game $gameId") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Client ID: $clientId") + + // Get clientSecret from build metadata + val clientSecret = GOGApiClient.getClientSecret(context, gameId.toString(), installPath) ?: "" + if (clientSecret.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No clientSecret available for game $gameId") + } else { + Timber.tag("GOG").d("[Cloud Saves] Got client secret for game") + } + + // Check cache first + remoteConfigCache[clientId]?.let { cachedLocations -> + Timber.tag("GOG").d("[Cloud Saves] Using cached save locations for clientId $clientId (${cachedLocations.size} locations)") + // Cache only contains locations, we still need to fetch clientSecret fresh + return@withContext Pair(clientSecret, cachedLocations) + } + + // Android runs games through Wine, so always use Windows platform + val syncPlatform = "Windows" + + // Fetch remote config + val url = "https://remote-config.gog.com/components/galaxy_client/clients/$clientId?component_version=2.0.45" + Timber.tag("GOG").d("[Cloud Saves] Fetching remote config from: $url") + + val request = Request.Builder() + .url(url) + .build() + + val response = Net.http.newCall(request).execute() + response.use { + if (!response.isSuccessful) { + Timber.tag("GOG").w("[Cloud Saves] Failed to fetch remote config: HTTP ${response.code}") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Successfully fetched remote config") + + val responseBody = response.body?.string() + if (responseBody == null) { + Timber.tag("GOG").w("[Cloud Saves] Empty response body from remote config") + return@withContext null + } + val configJson = JSONObject(responseBody) + + // Parse response: content.Windows.cloudStorage.locations + val content = configJson.optJSONObject("content") + if (content == null) { + Timber.tag("GOG").w("[Cloud Saves] No 'content' field in remote config response") + return@withContext null + } + + val platformContent = content.optJSONObject(syncPlatform) + if (platformContent == null) { + Timber.tag("GOG").d("[Cloud Saves] No cloud storage config for platform $syncPlatform") + return@withContext null + } + + val cloudStorage = platformContent.optJSONObject("cloudStorage") + if (cloudStorage == null) { + Timber.tag("GOG").d("[Cloud Saves] No cloudStorage field for platform $syncPlatform") + return@withContext null + } + + val enabled = cloudStorage.optBoolean("enabled", false) + if (!enabled) { + Timber.tag("GOG").d("[Cloud Saves] Cloud saves not enabled for game $gameId") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Cloud saves are enabled for game $gameId") + + val locationsArray = cloudStorage.optJSONArray("locations") + if (locationsArray == null || locationsArray.length() == 0) { + Timber.tag("GOG").d("[Cloud Saves] No save locations configured for game $gameId") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Found ${locationsArray.length()} location(s) in config") + + val locations = mutableListOf() + for (i in 0 until locationsArray.length()) { + val locationObj = locationsArray.getJSONObject(i) + val name = locationObj.optString("name", "__default") + val location = locationObj.optString("location", "") + if (location.isNotEmpty()) { + Timber.tag("GOG").d("[Cloud Saves] Location ${i + 1}: '$name' = '$location'") + locations.add(GOGCloudSavesLocationTemplate(name, location)) + } else { + Timber.tag("GOG").w("[Cloud Saves] Skipping location ${i + 1} with empty path") + } + } + + // Cache the result + if (locations.isNotEmpty()) { + remoteConfigCache[clientId] = locations + Timber.tag("GOG").d("[Cloud Saves] Cached ${locations.size} save locations for clientId $clientId") + } + + Timber.tag("GOG").i("[Cloud Saves] Found ${locations.size} save location(s) for game $gameId") + return@withContext Pair(clientSecret, locations) + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to get save sync location for appId $appId") + return@withContext null + } + } + + /** + * Get resolved save directory paths for a game + * @param context Android context + * @param appId Game app ID + * @param gameTitle Game title (for fallback) + * @return List of resolved save locations, or null if cloud saves not available + */ + suspend fun getSaveDirectoryPath( + context: Context, + appId: String, + gameTitle: String, + ): List? = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG").d("[Cloud Saves] Getting save directory path for $appId ($gameTitle)") + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val game = getGameFromDbById(gameId.toString()) + + if (game == null) { + Timber.tag("GOG").w("[Cloud Saves] Game not found for appId $appId") + return@withContext null + } + + val installPath = game.installPath + if (installPath.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] Game not installed: $appId") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Game install path: $installPath") + + // Get clientId from info file + val infoJson = readInfoFile(appId, installPath) + val clientId = infoJson?.optString("clientId", "") ?: "" + if (clientId.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No clientId found in info file for game $gameId") + return@withContext null + } + Timber.tag("GOG").d("[Cloud Saves] Client ID: $clientId") + + // Fetch save locations from API (Android runs games through Wine, so always Windows) + Timber.tag("GOG").d("[Cloud Saves] Fetching save locations from API") + val result = getSaveSyncLocation(context, appId, installPath) + + val clientSecret: String + val locations: List + + // If no locations from API, use default Windows path + if (result == null || result.second.isEmpty()) { + clientSecret = "" + Timber.tag("GOG").d("[Cloud Saves] No save locations from API, using default for game $gameId") + val defaultLocation = "%LOCALAPPDATA%/GOG.com/Galaxy/Applications/$clientId/Storage/Shared/Files" + Timber.tag("GOG").d("[Cloud Saves] Using default location: $defaultLocation") + locations = listOf(GOGCloudSavesLocationTemplate("__default", defaultLocation)) + } else { + clientSecret = result.first + locations = result.second + Timber.tag("GOG").i("[Cloud Saves] Retrieved ${locations.size} save location(s) from API") + } + + // Resolve each location + val resolvedLocations = mutableListOf() + for ((index, locationTemplate) in locations.withIndex()) { + Timber.tag("GOG").d("[Cloud Saves] Resolving location ${index + 1}/${locations.size}: '${locationTemplate.name}' = '${locationTemplate.location}'") + // Resolve GOG variables (, etc.) to Windows env vars + var resolvedPath = PathType.resolveGOGPathVariables(locationTemplate.location, installPath) + Timber.tag("GOG").d("[Cloud Saves] After GOG variable resolution: $resolvedPath") + + // Map GOG Windows path to device path using PathType + // Pass appId to ensure we use the correct container-specific wine prefix + resolvedPath = PathType.toAbsPathForGOG(context, resolvedPath, appId) + Timber.tag("GOG").d("[Cloud Saves] After path mapping to Wine prefix: $resolvedPath") + + // Normalize path to resolve any '..' or '.' components + try { + val normalizedPath = File(resolvedPath).canonicalPath + // Ensure trailing slash for directories + resolvedPath = if (!normalizedPath.endsWith("/")) "$normalizedPath/" else normalizedPath + Timber.tag("GOG").d("[Cloud Saves] After normalization: $resolvedPath") + } catch (e: Exception) { + Timber.tag("GOG").w(e, "[Cloud Saves] Failed to normalize path, using as-is: $resolvedPath") + } + + resolvedLocations.add( + GOGCloudSavesLocation( + name = locationTemplate.name, + location = resolvedPath, + clientId = clientId, + clientSecret = clientSecret, + ), + ) + } + + Timber.tag("GOG").i("[Cloud Saves] Resolved ${resolvedLocations.size} save location(s) for game $gameId") + for (loc in resolvedLocations) { + Timber.tag("GOG").d("[Cloud Saves] - '${loc.name}': ${loc.location}") + } + return@withContext resolvedLocations + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to get save directory path for appId $appId") + return@withContext null + } + } + + /** + * Get stored sync timestamp for a game+location + * @param appId Game app ID + * @param locationName Location name + * @return Timestamp string, or "0" if not found + */ + fun getCloudSaveSyncTimestamp(appId: String, locationName: String): String { + val key = "${appId}_$locationName" + return syncTimestamps.getOrDefault(key, "0") + } + + /** + * Store sync timestamp for a game+location + * @param appId Game app ID + * @param locationName Location name + * @param timestamp Timestamp string + */ + fun setCloudSaveSyncTimestamp(appId: String, locationName: String, timestamp: String) { + val key = "${appId}_$locationName" + syncTimestamps[key] = timestamp + Timber.d("Stored sync timestamp for $key: $timestamp") + // Persist to disk + saveCloudSaveTimestampsToDisk() + } + + /** + * Start a sync operation for a game (prevents concurrent syncs) + * @param appId Game app ID + * @return true if sync can proceed, false if one is already in progress + */ + fun startSync(appId: String): Boolean { + return activeSyncs.add(appId) + } + + /** + * End a sync operation for a game + * @param appId Game app ID + */ + fun endSync(appId: String) { + activeSyncs.remove(appId) + } + + /** + * Load timestamps from disk + */ + private fun loadCloudSaveTimestampsFromDisk() { + try { + if (timestampFile.exists()) { + val json = timestampFile.readText() + val map = org.json.JSONObject(json) + map.keys().forEach { key -> + syncTimestamps[key] = map.getString(key) + } + Timber.tag("GOG").i("[Cloud Saves] Loaded ${syncTimestamps.size} sync timestamps from disk") + } else { + Timber.tag("GOG").d("[Cloud Saves] No persisted timestamps found (first run)") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to load timestamps from disk") + } + } + + /** + * Save timestamps to disk + */ + private fun saveCloudSaveTimestampsToDisk() { + try { + val json = org.json.JSONObject() + syncTimestamps.forEach { (key, value) -> + json.put(key, value) + } + timestampFile.writeText(json.toString()) + Timber.tag("GOG").d("[Cloud Saves] Saved ${syncTimestamps.size} timestamps to disk") + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to save timestamps to disk") + } + } + + // ========================================================================== + // FILE SYSTEM & PATHS + // ========================================================================== + + fun getAppDirPath(appId: String): String { + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val game = runBlocking { getGameFromDbById(gameId.toString()) } + + if (game != null) { + return GOGConstants.getGameInstallPath(game.title) + } + + Timber.w("Could not find game for appId $appId") + return GOGConstants.defaultGOGGamesPath + } + + fun getGameInstallPath(gameId: String, gameTitle: String): String { + return GOGConstants.getGameInstallPath(gameTitle) + } +} 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 0000000000..325fcf5943 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -0,0 +1,650 @@ +package app.gamenative.service.gog + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import app.gamenative.data.DownloadInfo +import app.gamenative.data.GOGCredentials +import app.gamenative.data.GOGGame +import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem +import app.gamenative.service.NotificationHelper +import app.gamenative.utils.ContainerUtils +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Inject +import kotlinx.coroutines.* +import timber.log.Timber + + +/** + * GOG Service - thin abstraction layer that delegates to managers. + * + * Architecture: + * - GOGApiClient: Api Layer for interacting with GOG's APIs + * - GOGDownloadManager: Handles Download Logic for Games + * - GOGConstants: Shared Constants for our GOG-related data + * - GOGCloudSavesManager: Handler for Cloud Saves + * - GOGAuthManager: Authentication and account management + * - GOGManager: Game library, downloads, and installation + * - GOGManifestParser: Parses and has utils for parsing/extracting/decompressing manifests. + * - GOGDataMdoels: Data Models for GOG-related Data types such as API responses + * + */ +@AndroidEntryPoint +class GOGService : Service() { + + companion object { + private const val ACTION_SYNC_LIBRARY = "app.gamenative.GOG_SYNC_LIBRARY" + private const val ACTION_MANUAL_SYNC = "app.gamenative.GOG_MANUAL_SYNC" + private const val SYNC_THROTTLE_MILLIS = 15 * 60 * 1000L // 15 minutes + + private var instance: GOGService? = null + + // Sync tracking variables + private var syncInProgress: Boolean = false + private var backgroundSyncJob: Job? = null + private var lastSyncTimestamp: Long = 0L + private var hasPerformedInitialSync: Boolean = false + + val isRunning: Boolean + get() = instance != null + + fun start(context: Context) { + // If already running, do nothing + if (isRunning) { + Timber.d("[GOGService] Service already running, skipping start") + return + } + + // First-time start: always sync without throttle + if (!hasPerformedInitialSync) { + Timber.i("[GOGService] First-time start - starting service with initial sync") + val intent = Intent(context, GOGService::class.java) + intent.action = ACTION_SYNC_LIBRARY + context.startForegroundService(intent) + return + } + + // Subsequent starts: always start service, but check throttle for sync + val now = System.currentTimeMillis() + val timeSinceLastSync = now - lastSyncTimestamp + + val intent = Intent(context, GOGService::class.java) + if (timeSinceLastSync >= SYNC_THROTTLE_MILLIS) { + Timber.i("[GOGService] Starting service with automatic sync (throttle passed)") + intent.action = ACTION_SYNC_LIBRARY + } else { + val remainingMinutes = (SYNC_THROTTLE_MILLIS - timeSinceLastSync) / 1000 / 60 + Timber.d("[GOGService] Starting service without sync - throttled (${remainingMinutes}min remaining)") + // Start service without sync action + } + context.startForegroundService(intent) + } + + fun triggerLibrarySync(context: Context) { + Timber.i("[GOGService] Triggering manual library sync (bypasses throttle)") + val intent = Intent(context, GOGService::class.java) + intent.action = ACTION_MANUAL_SYNC + context.startForegroundService(intent) + } + + fun stop() { + instance?.let { service -> + service.stopSelf() + } + } + + // ========================================================================== + // AUTHENTICATION - Delegate to GOGAuthManager + // ========================================================================== + + suspend fun authenticateWithCode(context: Context, authorizationCode: String): Result { + return GOGAuthManager.authenticateWithCode(context, authorizationCode) + } + + fun hasStoredCredentials(context: Context): Boolean { + return GOGAuthManager.hasStoredCredentials(context) + } + + suspend fun getStoredCredentials(context: Context): Result { + return GOGAuthManager.getStoredCredentials(context) + } + + suspend fun validateCredentials(context: Context): Result { + return GOGAuthManager.validateCredentials(context) + } + + fun clearStoredCredentials(context: Context): Boolean { + return GOGAuthManager.clearStoredCredentials(context) + } + + /** + * Logout from GOG - clears credentials, database, and stops service + */ + suspend fun logout(context: Context): Result { + return withContext(Dispatchers.IO) { + try { + Timber.i("[GOGService] Logging out from GOG...") + + // Get instance first before stopping the service + val instance = getInstance() + if (instance == null) { + Timber.w("[GOGService] Service instance not available during logout") + return@withContext Result.failure(Exception("Service not running")) + } + + // Clear stored credentials + val credentialsCleared = clearStoredCredentials(context) + if (!credentialsCleared) { + Timber.w("[GOGService] Failed to clear credentials during logout") + } + + // Clear all GOG games from database + instance.gogManager.deleteAllGames() + Timber.i("[GOGService] All GOG games removed from database") + + // Stop the service + stop() + + Timber.i("[GOGService] Logout completed successfully") + Result.success(Unit) + } catch (e: Exception) { + Timber.e(e, "[GOGService] Error during logout") + Result.failure(e) + } + } + } + + // ========================================================================== + // SYNC & OPERATIONS + // ========================================================================== + + fun hasActiveOperations(): Boolean { + return syncInProgress || backgroundSyncJob?.isActive == true + } + + private fun setSyncInProgress(inProgress: Boolean) { + syncInProgress = inProgress + } + + fun isSyncInProgress(): Boolean = syncInProgress + + fun getInstance(): GOGService? = instance + + // ========================================================================== + // DOWNLOAD OPERATIONS - Delegate to instance GOGManager + // ========================================================================== + + fun hasActiveDownload(): Boolean { + return getInstance()?.activeDownloads?.isNotEmpty() ?: false + } + + fun getCurrentlyDownloadingGame(): String? { + return getInstance()?.activeDownloads?.keys?.firstOrNull() + } + + fun getDownloadInfo(gameId: String): DownloadInfo? { + return getInstance()?.activeDownloads?.get(gameId) + } + + fun cleanupDownload(gameId: String) { + getInstance()?.activeDownloads?.remove(gameId) + } + + fun cancelDownload(gameId: String): Boolean { + val instance = getInstance() + val downloadInfo = instance?.activeDownloads?.get(gameId) + + return if (downloadInfo != null) { + Timber.i("Cancelling download for game: $gameId") + downloadInfo.cancel() + instance.activeDownloads.remove(gameId) + Timber.d("Download cancelled for game: $gameId") + true + } else { + Timber.w("No active download found for game: $gameId") + false + } + } + + // ========================================================================== + // GAME & LIBRARY OPERATIONS - Delegate to instance GOGManager + // ========================================================================== + + fun getGOGGameOf(gameId: String): GOGGame? { + return runBlocking(Dispatchers.IO) { + getInstance()?.gogManager?.getGameFromDbById(gameId) + } + } + + suspend fun updateGOGGame(game: GOGGame) { + getInstance()?.gogManager?.updateGame(game) + } + + fun isGameInstalled(gameId: String): Boolean { + return runBlocking(Dispatchers.IO) { + val game = getInstance()?.gogManager?.getGameFromDbById(gameId) + if (game?.isInstalled != true) { + return@runBlocking false + } + + // Verify the installation is actually valid + val (isValid, errorMessage) = getInstance()?.gogManager?.verifyInstallation(gameId) + ?: Pair(false, "Service not available") + if (!isValid) { + Timber.w("Game $gameId marked as installed but verification failed: $errorMessage") + } + isValid + } + } + + fun getInstallPath(gameId: String): String? { + return runBlocking(Dispatchers.IO) { + val game = getInstance()?.gogManager?.getGameFromDbById(gameId) + if (game?.isInstalled == true) game.installPath else null + } + } + + fun verifyInstallation(gameId: String): Pair { + return getInstance()?.gogManager?.verifyInstallation(gameId) + ?: Pair(false, "Service not available") + } + + suspend fun getInstalledExe(libraryItem: LibraryItem): String { + return getInstance()?.gogManager?.getInstalledExe(libraryItem) + ?: "" + } + + fun getGogWineStartCommand( + libraryItem: LibraryItem, + container: com.winlator.container.Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: com.winlator.core.envvars.EnvVars, + guestProgramLauncherComponent: com.winlator.xenvironment.components.GuestProgramLauncherComponent, + ): String { + return getInstance()?.gogManager?.getGogWineStartCommand( + libraryItem, container, bootToContainer, appLaunchInfo, envVars, guestProgramLauncherComponent, + ) ?: "\"explorer.exe\"" + } + + suspend fun refreshLibrary(context: Context): Result { + return getInstance()?.gogManager?.refreshLibrary(context) + ?: Result.failure(Exception("Service not available")) + } + + fun downloadGame(context: Context, gameId: String, installPath: String): Result { + val instance = getInstance() ?: return Result.failure(Exception("Service not available")) + + // Create DownloadInfo for progress tracking + val downloadInfo = DownloadInfo(jobCount = 1, gameId = 0, downloadingAppIds = CopyOnWriteArrayList()) + + // Track in activeDownloads first + instance.activeDownloads[gameId] = downloadInfo + + // Launch download in service scope so it runs independently + instance.scope.launch { + try { + Timber.d("[Download] Starting download for game $gameId") + val commonRedistDir = File(installPath, "_CommonRedist") + Timber.tag("GOG").d("Will install dependencies to _CommonRedist") + + val result = instance.gogDownloadManager.downloadGame( + gameId, File(installPath), + downloadInfo, "en-US", true, commonRedistDir, + ) + + if (result.isFailure) { + val error = result.exceptionOrNull() + Timber.e(error, "[Download] Failed for game $gameId") + downloadInfo.setProgress(-1.0f) + downloadInfo.setActive(false) + + // Show failure toast + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Download failed: ${error?.message ?: "Unknown error"}", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } else { + Timber.i("[Download] Completed successfully for game $gameId") + downloadInfo.setProgress(1.0f) + downloadInfo.setActive(false) + + // Show success toast + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Download completed successfully!", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + } + } catch (e: Exception) { + Timber.e(e, "[Download] Exception for game $gameId") + downloadInfo.setProgress(-1.0f) + downloadInfo.setActive(false) + + // Show error toast + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Download error: ${e.message ?: "Unknown error"}", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } finally { + // Remove from activeDownloads for both success and failure + // so UI knows download is complete and to prevent stale entries + instance.activeDownloads.remove(gameId) + Timber.d("[Download] Finished for game $gameId, progress: ${downloadInfo.getProgress()}, active: ${downloadInfo.isActive()}") + } + } + + return Result.success(downloadInfo) + } + + suspend fun refreshSingleGame(gameId: String, context: Context): Result { + return getInstance()?.gogManager?.refreshSingleGame(gameId, context) + ?: Result.failure(Exception("Service not available")) + } + + /** + * Delete/uninstall a GOG game + * Delegates to GOGManager.deleteGame + */ + suspend fun deleteGame(context: Context, libraryItem: LibraryItem): Result { + return getInstance()?.gogManager?.deleteGame(context, libraryItem) + ?: Result.failure(Exception("Service not available")) + } + + /** + * Sync GOG cloud saves for a game + * @param context Android context + * @param appId Game app ID (e.g., "gog_123456") + * @param preferredAction Preferred sync action: "download", "upload", or "none" + * @return true if sync succeeded, false otherwise + */ + suspend fun syncCloudSaves( + context: Context, + appId: String, + preferredAction: String = "none", + ): Boolean = withContext(Dispatchers.IO) { + try { + Timber.tag("GOG").d("[Cloud Saves] syncCloudSaves called for $appId with action: $preferredAction") + + // Check if there's already a sync in progress for this appId + val serviceInstance = getInstance() + if (serviceInstance == null) { + Timber.tag("GOG").e("[Cloud Saves] Service instance not available for sync start") + return@withContext false + } + + if (!serviceInstance.gogManager.startSync(appId)) { + Timber.tag("GOG").w("[Cloud Saves] Sync already in progress for $appId, skipping duplicate sync") + return@withContext false + } + + try { + val instance = getInstance() + if (instance == null) { + Timber.tag("GOG").e("[Cloud Saves] Service instance not available") + return@withContext false + } + + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.tag("GOG").e("[Cloud Saves] Cannot sync saves: not authenticated") + return@withContext false + } + + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + Timber.tag("GOG").d("[Cloud Saves] Using auth config path: $authConfigPath") + + // Get game info + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + Timber.tag("GOG").d("[Cloud Saves] Extracted game ID: $gameId from appId: $appId") + val game = instance.gogManager.getGameFromDbById(gameId.toString()) + + if (game == null) { + Timber.tag("GOG").e("[Cloud Saves] Game not found for appId: $appId") + return@withContext false + } + Timber.tag("GOG").d("[Cloud Saves] Found game: ${game.title}") + + // Get save directory paths (Android runs games through Wine, so always Windows) + Timber.tag("GOG").d("[Cloud Saves] Resolving save directory paths for $appId") + val saveLocations = instance.gogManager.getSaveDirectoryPath(context, appId, game.title) + + if (saveLocations == null || saveLocations.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No save locations found for game $appId (cloud saves may not be enabled)") + return@withContext false + } + Timber.tag("GOG").i("[Cloud Saves] Found ${saveLocations.size} save location(s) for $appId") + + var allSucceeded = true + + // Sync each save location + for ((index, location) in saveLocations.withIndex()) { + try { + Timber.tag("GOG").d("[Cloud Saves] Processing location ${index + 1}/${saveLocations.size}: '${location.name}'") + + // Log directory state BEFORE sync + try { + val saveDir = java.io.File(location.location) + Timber.tag("GOG").d("[Cloud Saves] [BEFORE] Checking directory: ${location.location}") + Timber.tag("GOG").d("[Cloud Saves] [BEFORE] Directory exists: ${saveDir.exists()}, isDirectory: ${saveDir.isDirectory}") + if (saveDir.exists() && saveDir.isDirectory) { + val filesBefore = saveDir.listFiles() + if (filesBefore != null && filesBefore.isNotEmpty()) { + Timber.tag("GOG").i( + "[Cloud Saves] [BEFORE] ${filesBefore.size} files in '${location.name}': ${filesBefore.joinToString(", ") { + it.name + }}", + ) + } else { + Timber.tag("GOG").i("[Cloud Saves] [BEFORE] Directory '${location.name}' is empty") + } + } else { + Timber.tag("GOG").i("[Cloud Saves] [BEFORE] Directory '${location.name}' does not exist yet") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] [BEFORE] Failed to check directory") + } + + // Get stored timestamp for this location + val timestampStr = instance.gogManager.getCloudSaveSyncTimestamp(appId, location.name) + val timestamp = timestampStr.toLongOrNull() ?: 0L + + Timber.tag("GOG").i("[Cloud Saves] Syncing '${location.name}' for game $gameId (clientId: ${location.clientId}, path: ${location.location}, timestamp: $timestamp, action: $preferredAction)") + + // Validate clientSecret is available + if (location.clientSecret.isEmpty()) { + Timber.tag("GOG").e("[Cloud Saves] Missing clientSecret for '${location.name}', skipping sync") + continue + } + + val cloudSavesManager = GOGCloudSavesManager(context) + val newTimestamp = cloudSavesManager.syncSaves( + clientId = location.clientId, + clientSecret = location.clientSecret, + localPath = location.location, + dirname = location.name, + lastSyncTimestamp = timestamp, + preferredAction = preferredAction, + ) + + if (newTimestamp > 0) { + // Success - store new timestamp + instance.gogManager.setCloudSaveSyncTimestamp(appId, location.name, newTimestamp.toString()) + Timber.tag("GOG").d("[Cloud Saves] Updated timestamp for '${location.name}': $newTimestamp") + + // Log the save files in the directory after sync + try { + val saveDir = java.io.File(location.location) + if (saveDir.exists() && saveDir.isDirectory) { + val files = saveDir.listFiles() + if (files != null && files.isNotEmpty()) { + val fileList = files.joinToString(", ") { it.name } + Timber.tag("GOG").i("[Cloud Saves] [$preferredAction] Files in '${location.name}': $fileList (${files.size} files)") + + // Log detailed file info + files.forEach { file -> + val size = if (file.isFile) "${file.length()} bytes" else "directory" + Timber.tag("GOG").d("[Cloud Saves] [$preferredAction] - ${file.name} ($size)") + } + } else { + Timber.tag("GOG").w("[Cloud Saves] [$preferredAction] Directory '${location.name}' is empty at: ${location.location}") + } + } else { + Timber.tag("GOG").w("[Cloud Saves] [$preferredAction] Directory not found: ${location.location}") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to list files in directory: ${location.location}") + } + + Timber.tag("GOG").i("[Cloud Saves] Successfully synced save location '${location.name}' for game $gameId") + } else { + Timber.tag("GOG").e("[Cloud Saves] Failed to sync save location '${location.name}' for game $gameId (timestamp: $newTimestamp)") + allSucceeded = false + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Exception syncing save location '${location.name}' for game $gameId") + allSucceeded = false + } + } + + if (allSucceeded) { + Timber.tag("GOG").i("[Cloud Saves] All save locations synced successfully for $appId") + return@withContext true + } else { + Timber.tag("GOG").w("[Cloud Saves] Some save locations failed to sync for $appId") + return@withContext false + } + } finally { + // Always end the sync, even if an exception occurred + getInstance()?.gogManager?.endSync(appId) + Timber.tag("GOG").d("[Cloud Saves] Sync completed and lock released for $appId") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to sync cloud saves for App ID: $appId") + return@withContext false + } + } + } + + private lateinit var notificationHelper: NotificationHelper + + @Inject + lateinit var gogManager: GOGManager + + @Inject + lateinit var gogDownloadManager: GOGDownloadManager + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Track active downloads by game ID + private val activeDownloads = ConcurrentHashMap() + + // GOGManager is injected by Hilt + override fun onCreate() { + super.onCreate() + instance = this + + // Initialize notification helper for foreground service + notificationHelper = NotificationHelper(applicationContext) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Timber.d("[GOGService] onStartCommand() - action: ${intent?.action}") + + // Start as foreground service + val notification = notificationHelper.createForegroundNotification("Connected") + startForeground(1, notification) // Use different ID than SteamService (which uses 1) + + // Determine if we should sync based on the action + val shouldSync = when (intent?.action) { + ACTION_MANUAL_SYNC -> { + Timber.i("[GOGService] Manual sync requested - bypassing throttle") + true + } + + ACTION_SYNC_LIBRARY -> { + Timber.i("[GOGService] Automatic sync requested") + true + } + + null -> { + // Service restarted by Android with null intent (START_STICKY behavior) + // Only sync if we haven't done initial sync yet, or if it's been a while + val timeSinceLastSync = System.currentTimeMillis() - lastSyncTimestamp + val shouldResync = !hasPerformedInitialSync || timeSinceLastSync >= SYNC_THROTTLE_MILLIS + + if (shouldResync) { + Timber.i("[GOGService] Service restarted by Android - performing sync (hasPerformedInitialSync=$hasPerformedInitialSync, timeSinceLastSync=${timeSinceLastSync}ms)") + true + } else { + Timber.d("[GOGService] Service restarted by Android - skipping sync (throttled)") + false + } + } + + else -> { + // Service started without sync action (e.g., just to keep it alive) + Timber.d("[GOGService] Service started without sync action") + false + } + } + + // Start background library sync if requested + if (shouldSync && (backgroundSyncJob == null || backgroundSyncJob?.isActive != true)) { + Timber.i("[GOGService] Starting background library sync") + backgroundSyncJob?.cancel() // Cancel any existing job + backgroundSyncJob = scope.launch { + try { + setSyncInProgress(true) + Timber.d("[GOGService]: Starting background library sync") + + val syncResult = gogManager.startBackgroundSync(applicationContext) + if (syncResult.isFailure) { + Timber.w("[GOGService]: Failed to start background sync: ${syncResult.exceptionOrNull()?.message}") + } else { + Timber.i("[GOGService]: Background library sync completed successfully") + // Update last sync timestamp on successful sync + lastSyncTimestamp = System.currentTimeMillis() + // Mark that initial sync has been performed + hasPerformedInitialSync = true + } + } catch (e: Exception) { + Timber.e(e, "[GOGService]: Exception starting background sync") + } finally { + setSyncInProgress(false) + } + } + } else if (shouldSync) { + Timber.d("[GOGService] Background sync already in progress, skipping") + } + + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + + // Cancel sync operations + backgroundSyncJob?.cancel() + setSyncInProgress(false) + + scope.cancel() // Cancel any ongoing operations + stopForeground(STOP_FOREGROUND_REMOVE) + notificationHelper.cancel() + instance = null + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/app/src/main/java/app/gamenative/service/gog/api/GOGApiClient.kt b/app/src/main/java/app/gamenative/service/gog/api/GOGApiClient.kt new file mode 100644 index 0000000000..ee869dff7d --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/api/GOGApiClient.kt @@ -0,0 +1,456 @@ +package app.gamenative.service.gog.api + +import android.content.Context +import app.gamenative.service.gog.GOGAuthManager +import app.gamenative.utils.Net +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import org.json.JSONObject +import timber.log.Timber + +/** + * Native Kotlin API client for GOG Content System + * + * Replaces Python GOGDL API calls with direct HTTP requests + */ +@Singleton +class GOGApiClient @Inject constructor( + @ApplicationContext private val context: Context, + private val parser: GOGManifestParser, +) { + + companion object { + private const val GOG_CONTENT_SYSTEM = "https://content-system.gog.com" + private const val GOG_CDN = "https://gog-cdn-fastly.gog.com" + } + + private val httpClient = Net.http + + // TODO: Compose any functions to reduce DRYNESS. + + /** + * Get all available builds for a game (Gen 2 Only) + */ + suspend fun getBuildsForGame(gameId: String, platform: String = "windows"): Result = + withContext(Dispatchers.IO) { + try { + val credentials = GOGAuthManager.getStoredCredentials(context).getOrNull() + if (credentials == null) { + return@withContext Result.failure(Exception("Not authenticated")) + } + + // ! Filter to Gen 2 only as they're a more consistent format for gog + val url = "$GOG_CONTENT_SYSTEM/products/$gameId/os/$platform/builds?generation=2" + + Timber.tag("GOG").d("Fetching builds from: $url") + + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure( + Exception("Failed to fetch builds: HTTP ${response.code}"), + ) + } + + val jsonStr = response.body?.string() + ?: return@withContext Result.failure(Exception("Empty response")) + + val buildsResponse = parser.parseBuilds(jsonStr) + + Timber.tag("GOG").d("Found ${buildsResponse.totalCount} build(s) for game $gameId") + + if(buildsResponse.totalCount == 0){ + return@withContext Result.failure( + Exception("No viable builds found"), + ) + } + + Result.success(buildsResponse) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to get builds for game $gameId") + Result.failure(e) + } + } + + suspend fun fetchDependencyRepository(url: String): Result = withContext(Dispatchers.IO){ + try { + val credentials = GOGAuthManager.getStoredCredentials(context).getOrNull() + + if (credentials == null) { + return@withContext Result.failure(Exception("Not authenticated")) + } + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure( + Exception("Failed to fetch manifest: HTTP ${response.code}"), + ) + } + + val jsonStr = response.body?.string() + ?: return@withContext Result.failure(Exception("Empty response from dependency repository")) + + val json = JSONObject(jsonStr) + + val dependencyRepositoryDetails = DependencyRepository.fromJson(json) + Result.success(dependencyRepositoryDetails) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to fetch dependency repository from $url") + Result.failure(e) + } + } + + /** + * Get open link for dependencies (doesn't require product ID) + * + * @return List of CDN URLs for dependencies + */ + suspend fun getDependencyOpenLink(): Result> = withContext(Dispatchers.IO) { + try { + val credentials = GOGAuthManager.getStoredCredentials(context).getOrNull() + if (credentials == null) { + return@withContext Result.failure(Exception("Not authenticated")) + } + + val url = "$GOG_CONTENT_SYSTEM/open_link?generation=2&_version=2&path=/dependencies/store/" + + Timber.tag("GOG").d("Getting dependency open link") + + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure( + Exception("Failed to get dependency open link: HTTP ${response.code}"), + ) + } + + val jsonStr = response.body?.string() + ?: return@withContext Result.failure(Exception("Empty response")) + + val json = JSONObject(jsonStr) + val urlsArray = json.optJSONArray("urls") + val urls = mutableListOf() + + if (urlsArray != null) { + for (i in 0 until urlsArray.length()) { + val urlObj = urlsArray.optJSONObject(i) + if (urlObj != null) { + val urlFormat = urlObj.optString("url_format", "") + val paramsObj = urlObj.optJSONObject("parameters") + + if (urlFormat.isNotEmpty() && paramsObj != null) { + var constructedUrl = urlFormat + val keys = paramsObj.keys() + while (keys.hasNext()) { + val key = keys.next() + val value = paramsObj.get(key).toString() + constructedUrl = constructedUrl.replace("{$key}", value) + } + constructedUrl = constructedUrl.replace("\\/", "/") + if (constructedUrl.isNotEmpty()) { + urls.add(constructedUrl) + } + } + } + } + } + + Timber.tag("GOG").d("Got ${urls.size} dependency URL(s)") + Result.success(urls) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to get dependency open link") + Result.failure(e) + } + } + + suspend fun fetchDependencyManifest(manifestUrl: String): Result = withContext(Dispatchers.IO) { + try { + val credentials = GOGAuthManager.getStoredCredentials(context).getOrNull() + if (credentials == null) { + return@withContext Result.failure(Exception("Not authenticated")) + } + + val request = Request.Builder() + .url(manifestUrl) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure( + Exception("Failed to fetch manifest: HTTP ${response.code}"), + ) + } + + val manifestBytes = response.body?.bytes() + ?: return@withContext Result.failure(Exception("Empty response")) + + // Decompress based on detected format + val manifestStr = parser.decompressManifest(manifestBytes) + + Timber.tag("GOG").d("Manifest decompressed, size: ${manifestStr.length} bytes") + + val manifest = parser.parseDependencyManifest(manifestStr) + + Result.success(manifest) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to fetch dependency manifest from $manifestUrl") + Result.failure(e) + } + } + + /** + * Fetch build manifest (zlib or gzip compressed JSON) + * + * @param manifestUrl URL from build.link field + * @return Parsed manifest data + */ + suspend fun fetchManifest(manifestUrl: String): Result = + withContext(Dispatchers.IO) { + try { + val credentials = GOGAuthManager.getStoredCredentials(context).getOrNull() + if (credentials == null) { + return@withContext Result.failure(Exception("Not authenticated")) + } + + Timber.tag("GOG").d("Fetching manifest from: $manifestUrl") + + val request = Request.Builder() + .url(manifestUrl) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure( + Exception("Failed to fetch manifest: HTTP ${response.code}"), + ) + } + + val manifestBytes = response.body?.bytes() + ?: return@withContext Result.failure(Exception("Empty response")) + + // Decompress based on detected format + val manifestStr = parser.decompressManifest(manifestBytes) + + Timber.tag("GOG").d("Manifest decompressed, size: ${manifestStr.length} bytes") + + val manifest = parser.parseManifest(manifestStr) + + Timber.tag("GOG").i( + "Dependency Manifest parsed: ${manifest.installDirectory}, ${manifest.depots.size} depot(s)", + ) + + Result.success(manifest) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to fetch manifest from $manifestUrl") + Result.failure(e) + } + } + + /** + * Fetch depot manifest (contains file list for a specific depot) + * + * @param manifestHash Hash from depot.manifest field + * @return Parsed depot manifest + */ + suspend fun fetchDepotManifest(manifestHash: String): Result = + withContext(Dispatchers.IO) { + try { + val credentials = GOGAuthManager.getStoredCredentials(context).getOrNull() + if (credentials == null) { + return@withContext Result.failure(Exception("Not authenticated")) + } + + // Build depot manifest URL + val path = gogGalaxyPath(manifestHash) + val url = "$GOG_CDN/content-system/v2/meta/$path" + + Timber.tag("GOG").d("Fetching depot manifest: $url") + + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure( + Exception("Failed to fetch depot manifest: HTTP ${response.code}"), + ) + } + + val depotBytes = response.body?.bytes() + ?: return@withContext Result.failure(Exception("Empty response")) + + // Depot manifests are also compressed + val depotStr = parser.decompressManifest(depotBytes) + + val depotManifest = parser.parseDepotManifest(depotStr) + + Timber.tag("GOG").d( + "Depot manifest parsed: ${depotManifest.files.size} file(s), " + + "${depotManifest.directories.size} dir(s)", + ) + + Result.success(depotManifest) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to fetch depot manifest $manifestHash") + Result.failure(e) + } + } + + /** + * Fetch dependency depot manifest using open link CDN URLs + * Dependencies don't require authentication per-file, they use open links + * Note: Manifests use /dependencies/meta/ path, not /dependencies/store/ + * + * @param manifestHash Hash from depot.manifest field + * @param baseUrls List of open link CDN URLs (unused, we use hardcoded CDN) + * @return Parsed depot manifest + */ + suspend fun fetchDependencyDepotManifest( + manifestHash: String, + baseUrls: List + ): Result = withContext(Dispatchers.IO) { + try { + // Build depot manifest URL + // Dependency manifests use /dependencies/meta/ path on the CDN + val path = gogGalaxyPath(manifestHash) + val url = "$GOG_CDN/content-system/v2/dependencies/meta/$path" + + Timber.tag("GOG").d("Fetching dependency depot manifest: $url") + + val request = Request.Builder() + .url(url) + .build() + + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure( + Exception("Failed to fetch dependency depot manifest: HTTP ${response.code}"), + ) + } + + val depotBytes = response.body?.bytes() + ?: return@withContext Result.failure(Exception("Empty response")) + + // Depot manifests are compressed + val depotStr = parser.decompressManifest(depotBytes) + + val depotManifest = parser.parseDepotManifest(depotStr) + + Timber.tag("GOG").d( + "Dependency depot manifest parsed: ${depotManifest.files.size} file(s), " + + "${depotManifest.directories.size} dir(s)", + ) + + Result.success(depotManifest) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to fetch dependency depot manifest $manifestHash") + Result.failure(e) + } + } + + /** + * Get secure download links for a product + * + * These are time-limited CDN URLs that work for all chunks in the product + * No need to pass chunk hashes - the URLs work for any chunk + * + * @param productId Game or DLC product ID + * @param path Path prefix (usually "/" for gen 2) + * @param generation API generation (1 or 2) + * @param root Optional root path (e.g., "/patches/store" for patches) + * @return List of secure CDN URLs + */ + suspend fun getSecureLink( + productId: String, + path: String = "/", + generation: Int = 2, + root: String? = null, + ): Result = withContext(Dispatchers.IO) { + try { + val credentials = GOGAuthManager.getStoredCredentials(context).getOrNull() + if (credentials == null) { + return@withContext Result.failure(Exception("Not authenticated")) + } + + // Build secure link URL based on generation + var url = if (generation == 2) { + "$GOG_CONTENT_SYSTEM/products/$productId/secure_link?_version=2&generation=2&path=$path" + } else { + "$GOG_CONTENT_SYSTEM/products/$productId/secure_link?_version=2&type=depot&path=$path" + } + + // Add root parameter if provided (for patches) + if (root != null) { + url += "&root=$root" + } + + Timber.tag("GOG").d("Getting secure link for product $productId (gen $generation)") + + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure( + Exception("Failed to get secure link: HTTP ${response.code}"), + ) + } + + val jsonStr = response.body?.string() + ?: return@withContext Result.failure(Exception("Empty response")) + + // Log the actual response to debug parsing issues + Timber.tag("GOG").d("Secure link response: $jsonStr") + + val secureLinks = parser.parseSecureLinks(jsonStr) + + Timber.tag("GOG").d("Got ${secureLinks.urls.size} secure URL(s) for product $productId") + + Result.success(secureLinks) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "Failed to get secure link for product $productId") + Result.failure(e) + } + } + + /** + * Convert manifest hash to GOG Galaxy CDN path format + * + * Format: AA/BB/CCDD... where AA, BB are first two pairs of hex digits + * Example: "abc123..." -> "ab/c1/abc123..." + */ + private fun gogGalaxyPath(hash: String): String { + if (hash.length < 4) return hash + return "${hash.substring(0, 2)}/${hash.substring(2, 4)}/$hash" + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/api/GOGDataModels.kt b/app/src/main/java/app/gamenative/service/gog/api/GOGDataModels.kt new file mode 100644 index 0000000000..0b8eb64a75 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/api/GOGDataModels.kt @@ -0,0 +1,464 @@ +package app.gamenative.service.gog.api + +import org.json.JSONObject + +/** + * Response from GOG builds API + */ +data class BuildsResponse( + val totalCount: Int, + val count: Int, + val items: List +) { + companion object { + fun fromJson(json: JSONObject): BuildsResponse { + val itemsArray = json.optJSONArray("items") + val items = mutableListOf() + + if (itemsArray != null) { + for (i in 0 until itemsArray.length()) { + items.add(GOGBuild.fromJson(itemsArray.getJSONObject(i))) + } + } + + return BuildsResponse( + totalCount = json.optInt("total_count", 0), + count = json.optInt("count", 0), + items = items + ) + } + } +} + + +data class DependencyRepository( + val repositoryManifest: String, + val generation: Int, + val buildId: String +) { companion object { + fun fromJson(json: JSONObject): DependencyRepository { + return DependencyRepository( + repositoryManifest = json.optString("repository_manifest", ""), + buildId = json.optString("build_id", ""), + generation = json.optInt("generation", 2), + ) + } + } +} + +/** + * Individual build metadata + */ +data class GOGBuild( + val buildId: String, + val productId: String, + val platform: String, + val generation: Int, // 1 = legacy, 2 = modern + val versionName: String, + val branch: String?, + val link: String, // Manifest download URL + val legacyBuildId: String? +) { + companion object { + fun fromJson(json: JSONObject): GOGBuild { + return GOGBuild( + buildId = json.optString("build_id", ""), + productId = json.optString("product_id", ""), + platform = json.optString("os", "windows"), + generation = json.optInt("generation", 2), + versionName = json.optString("version_name", ""), + branch = if (json.has("branch") && !json.isNull("branch")) json.getString("branch") else null, + link = json.optString("link", ""), + legacyBuildId = if (json.has("legacy_build_id") && !json.isNull("legacy_build_id")) json.getString("legacy_build_id") else null + ) + } + } +} + +data class Executable( + val arguments: String?, + val path: String +) + +data class DependencyDepot( + val compressedSize: Long, + val dependencyId: String, + val executable: Executable?, + val isInternal: Boolean, + val languages: List, + val manifest: String, + val osBitness: List?, + val readableName: String, + val signature: String, + val size: Long, +) +// repository Manifest is a URL that will give back a compressed zlib JSON +// generation is always 2 since we always use Generation 2 in the URL +data class GOGDependencyManifestMeta( + val depots: List, +) { + companion object { + fun fromJson(json: JSONObject): GOGDependencyManifestMeta { + val depotsArray = json.optJSONArray("depots") + val depots = mutableListOf() + + if(depotsArray != null) { + for (i in 0 until depotsArray.length()) { + val depotObj = depotsArray.getJSONObject(i) + + // Parse Languages for this depot + val languagesArray = depotObj.optJSONArray("languages") + val languages = mutableListOf() + if (languagesArray != null) { + for (j in 0 until languagesArray.length()) { + languages.add(languagesArray.getString(j)) + } + } + + // Parse bitness for this depot + val bitnessArray = depotObj.optJSONArray("osBitness") + val osBitness = if (bitnessArray != null) { + val list = mutableListOf() + for (j in 0 until bitnessArray.length()) { + list.add(bitnessArray.getString(j)) + } + list + } else null + + // Parse executable for this depot + val executableObj = depotObj.optJSONObject("executable") + val executable = if (executableObj != null) { + Executable( + arguments = if (executableObj.has("arguments") && !executableObj.isNull("arguments")) + executableObj.getString("arguments") else null, + path = executableObj.optString("path", "") + ) + } else null + + val depot = DependencyDepot ( + compressedSize = depotObj.optLong("compressedSize", 0), + dependencyId = depotObj.optString("dependencyId", ""), + executable = executable, + languages = languages, + osBitness = osBitness, + isInternal = depotObj.optBoolean("internal", false), + manifest = depotObj.optString("manifest", ""), + readableName = depotObj.optString("readableName", ""), + signature = depotObj.optString("signature", ""), + size = depotObj.optLong("size", 0), + ) + depots.add(depot) + } + } + + return GOGDependencyManifestMeta(depots = depots) + } + } +} + +/** + * Main manifest metadata + */ +data class GOGManifestMeta( + val baseProductId: String, + val installDirectory: String, + val depots: List, + val dependencies: List, + val products: List +) { + companion object { + fun fromJson(json: JSONObject): GOGManifestMeta { + val depotsArray = json.optJSONArray("depots") + val depots = mutableListOf() + + if (depotsArray != null) { + for (i in 0 until depotsArray.length()) { + depots.add(Depot.fromJson(depotsArray.getJSONObject(i))) + } + } + + val dependenciesArray = json.optJSONArray("dependencies") + val dependencies = mutableListOf() + + if (dependenciesArray != null) { + for (i in 0 until dependenciesArray.length()) { + dependencies.add(dependenciesArray.getString(i)) + } + } + + val productsArray = json.optJSONArray("products") + val products = mutableListOf() + + if (productsArray != null) { + for (i in 0 until productsArray.length()) { + products.add(Product.fromJson(productsArray.getJSONObject(i))) + } + } + + return GOGManifestMeta( + baseProductId = json.optString("baseProductId", ""), + installDirectory = json.optString("installDirectory", ""), + depots = depots, + dependencies = dependencies, + products = products + ) + } + } +} + +/** + * Depot metadata (contains files for specific language/platform) + */ +data class Depot( + val productId: String, + val languages: List, + val manifest: String, // Hash pointing to depot manifest + val compressedSize: Long, + val size: Long, + val osBitness: List? +) { + companion object { + fun fromJson(json: JSONObject): Depot { + val languagesArray = json.optJSONArray("languages") + val languages = mutableListOf() + + if (languagesArray != null) { + for (i in 0 until languagesArray.length()) { + languages.add(languagesArray.getString(i)) + } + } + + val bitnessArray = json.optJSONArray("osBitness") + val osBitness = if (bitnessArray != null) { + val list = mutableListOf() + for (i in 0 until bitnessArray.length()) { + list.add(bitnessArray.getString(i)) + } + list + } else null + + return Depot( + productId = json.optString("productId", ""), + languages = languages, + manifest = json.optString("manifest", ""), + compressedSize = json.optLong("compressedSize", 0), + size = json.optLong("size", 0), + osBitness = osBitness + ) + } + } + + /** + * Check if this depot matches the target language + */ + fun matchesLanguage(targetLanguage: String): Boolean { + return languages.contains("*") || languages.any { + it.equals(targetLanguage, ignoreCase = true) + } + } +} + +/** + * Product metadata (base game or DLC) + */ +data class Product( + val productId: String, + val name: String +) { + companion object { + fun fromJson(json: JSONObject): Product { + return Product( + productId = json.optString("productId", ""), + name = json.optString("name", "") + ) + } + } +} + +/** + * Depot manifest (contains file list) + */ +data class DepotManifest( + val files: List, + val directories: List, + val links: List +) { + companion object { + fun fromJson(json: JSONObject): DepotManifest { + val depotObj = json.optJSONObject("depot") ?: json + val itemsArray = depotObj.optJSONArray("items") + + val files = mutableListOf() + val directories = mutableListOf() + val links = mutableListOf() + + if (itemsArray != null) { + for (i in 0 until itemsArray.length()) { + val item = itemsArray.getJSONObject(i) + when (item.optString("type", "")) { + "DepotFile" -> files.add(DepotFile.fromJson(item)) + "DepotDirectory" -> directories.add(DepotDirectory.fromJson(item)) + "DepotLink" -> links.add(DepotLink.fromJson(item)) + } + } + } + + return DepotManifest( + files = files, + directories = directories, + links = links + ) + } + } +} + +/** + * File in depot manifest + */ +data class DepotFile( + val path: String, + val chunks: List, + val md5: String?, + val sha256: String?, + val flags: List, + val productId: String? +) { + companion object { + fun fromJson(json: JSONObject): DepotFile { + val chunksArray = json.optJSONArray("chunks") + val chunks = mutableListOf() + + if (chunksArray != null) { + for (i in 0 until chunksArray.length()) { + chunks.add(FileChunk.fromJson(chunksArray.getJSONObject(i))) + } + } + + val flagsArray = json.optJSONArray("flags") + val flags = mutableListOf() + + if (flagsArray != null) { + for (i in 0 until flagsArray.length()) { + flags.add(flagsArray.getString(i)) + } + } + + return DepotFile( + path = json.optString("path", "").replace("\\", "/").removePrefix("/"), + chunks = chunks, + md5 = if (json.has("md5") && !json.isNull("md5")) json.getString("md5") else null, + sha256 = if (json.has("sha256") && !json.isNull("sha256")) json.getString("sha256") else null, + flags = flags, + productId = if (json.has("productId") && !json.isNull("productId")) json.getString("productId") else null + ) + } + } + + /** + * Check if this is a support file (redistributable) + */ + fun isSupportFile(): Boolean = flags.contains("support") +} + +/** + * Chunk within a file + */ +data class FileChunk( + val compressedMd5: String, + val md5: String, + val size: Long, + val compressedSize: Long? +) { + companion object { + fun fromJson(json: JSONObject): FileChunk { + return FileChunk( + compressedMd5 = json.optString("compressedMd5", ""), + md5 = json.optString("md5", ""), + size = json.optLong("size", 0), + compressedSize = if (json.has("compressedSize") && !json.isNull("compressedSize")) { + json.optLong("compressedSize", 0) + } else { + null + } + ) + } + } +} + +/** + * Directory in depot + */ +data class DepotDirectory( + val path: String +) { + companion object { + fun fromJson(json: JSONObject): DepotDirectory { + return DepotDirectory( + path = json.optString("path", "").replace("\\", "/").removeSuffix("/") + ) + } + } +} + +/** + * Symbolic link in depot + */ +data class DepotLink( + val path: String, + val target: String +) { + companion object { + fun fromJson(json: JSONObject): DepotLink { + return DepotLink( + path = json.optString("path", ""), + target = json.optString("target", "") + ) + } + } +} + +/** + * Secure download links response + */ +data class SecureLinksResponse( + val urls: List +) { + companion object { + fun fromJson(json: JSONObject): SecureLinksResponse { + val urlsArray = json.optJSONArray("urls") + val urls = mutableListOf() + + if (urlsArray != null) { + for (i in 0 until urlsArray.length()) { + val urlObj = urlsArray.optJSONObject(i) + if (urlObj != null) { + // GOG returns URL objects with url_format template and parameters + // We need to merge them: {base_url}/token=nva={expires_at}... etc. + val urlFormat = urlObj.optString("url_format", "") + val paramsObj = urlObj.optJSONObject("parameters") + + if (urlFormat.isNotEmpty() && paramsObj != null) { + // Replace all {param} placeholders with actual values + var constructedUrl = urlFormat + val keys = paramsObj.keys() + while (keys.hasNext()) { + val key = keys.next() + val value = paramsObj.get(key).toString() + constructedUrl = constructedUrl.replace("{$key}", value) + } + + // Clean up escaped slashes from JSON + constructedUrl = constructedUrl.replace("\\/", "/") + + if (constructedUrl.isNotEmpty()) { + urls.add(constructedUrl) + } + } + } + } + } + + return SecureLinksResponse(urls = urls) + } + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/api/GOGManifestParser.kt b/app/src/main/java/app/gamenative/service/gog/api/GOGManifestParser.kt new file mode 100644 index 0000000000..e39e1b4759 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/api/GOGManifestParser.kt @@ -0,0 +1,397 @@ +package app.gamenative.service.gog.api + +import timber.log.Timber +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.Inflater +import javax.inject.Inject +import javax.inject.Singleton +import org.json.JSONObject + +/** + * Handles parsing of GOG manifest data + * Separates parsing logic from network operations + */ +@Singleton +class GOGManifestParser @Inject constructor() { + + companion object { + private const val TAG = "GOG" + } + + /** + * Select the correct build - Uses Gen 2 builds as they're all there. + * @param builds List of available builds + * @param preferredGeneration Preferred generation (1 or 2), null = auto-detect + * @param platform Target platform (e.g., "windows", "linux", "osx") + * @return Selected build or null if none suitable + */ + fun selectBuild( + builds: List, + preferredGeneration: Int? = null, + platform: String = "windows" + ): GOGBuild? { + if (builds.isEmpty()) { + Timber.tag(TAG).w("No builds available") + return null + } + + // Filter by generation and platform + val filtered = builds.filter { + it.generation == 2 && it.platform.equals(platform, ignoreCase = true) + } + + if (filtered.isEmpty()) { + Timber.tag(TAG).w("No Gen 2 builds found for platform: $platform") + return null + } + + val selected = filtered.first() + Timber.tag(TAG).d("Selected build ${selected.buildId} for platform ${selected.platform}") + + return selected + } + + /** + * Filter depots based on language + * + * @param manifest Main manifest metadata + * @param language Target language (e.g., "en-US") or is * + * @return Filtered list of depots matching language + */ + fun filterDepotsByLanguage(manifest: GOGManifestMeta, language: String): List { + val filtered = manifest.depots.filter { depot -> + depot.matchesLanguage(language) || (depot.languages?.contains("*") == true) + } + + Timber.tag(TAG).d("Filtered ${filtered.size}/${manifest.depots.size} depots for language: $language") + return filtered + } + + /** + * Filter depots based on OS bitness + * + * @param depots List of depots to filter + * @param bitness Target bitness (e.g., "64", "32") + * @return Filtered list of depots matching bitness + */ + fun filterDepotsByBitness(depots: List, bitness: String = "64"): List { + val filtered = depots.filter { depot -> + depot.osBitness == null || depot.osBitness.contains(bitness) + } + + Timber.tag(TAG).d("Filtered ${filtered.size}/${depots.size} depots for bitness: $bitness") + return filtered + } + + /** + * Filter depots based on ownership + * + * @param depots List of depots to filter + * @param ownedProductIds Set of product IDs the user owns + * @return Filtered list of depots for owned products only + */ + fun filterDepotsByOwnership(depots: List, ownedProductIds: Set): List { + val filtered = depots.filter { depot -> + depot.productId in ownedProductIds + } + + Timber.tag(TAG).d("Filtered ${filtered.size}/${depots.size} depots for owned products") + return filtered + } + + /** + * Separate base game files from DLC files + * + * @param files All depot files + * @param baseProductId Base product ID + * @return Pair of (base game files, DLC files) + */ + fun separateBaseDLC(files: List, baseProductId: String): Pair, List> { + val baseFiles = mutableListOf() + val dlcFiles = mutableListOf() + + files.forEach { file -> + if (file.productId == null || file.productId == baseProductId) { + baseFiles.add(file) + } else { + dlcFiles.add(file) + } + } + + Timber.tag(TAG).d("Separated: ${baseFiles.size} base files, ${dlcFiles.size} DLC files") + return Pair(baseFiles, dlcFiles) + } + + /** + * Separate support files (redistributables) from game files + * + * @param files All depot files + * @return Pair of (game files, support files) + */ + fun separateSupportFiles(files: List): Pair, List> { + val gameFiles = mutableListOf() + val supportFiles = mutableListOf() + + files.forEach { file -> + if (file.isSupportFile()) { + supportFiles.add(file) + } else { + gameFiles.add(file) + } + } + + Timber.tag(TAG).d("Separated: ${gameFiles.size} game files, ${supportFiles.size} support files") + return Pair(gameFiles, supportFiles) + } + + /** + * Calculate total download size across multiple depot files + * + * @param files List of depot files + * @return Total compressed size in bytes + */ + fun calculateTotalSize(files: List): Long { + return files.sumOf { file -> + file.chunks.sumOf { chunk -> + chunk.compressedSize ?: chunk.size + } + } + } + + /** + * Calculate total uncompressed size + * + * @param files List of depot files + * @return Total uncompressed size in bytes + */ + fun calculateUncompressedSize(files: List): Long { + return files.sumOf { file -> + file.chunks.sumOf { it.size } + } + } + + /** + * Find DLC products in manifest + * + * @param manifest Main manifest metadata + * @return List of DLC products (excluding base game) + */ + fun findDLCProducts(manifest: GOGManifestMeta): List { + return manifest.products.filter { it.productId != manifest.baseProductId } + } + + /** + * Check if manifest contains any DLC content + * + * @param manifest Main manifest metadata + * @return True if DLC is present + */ + fun hasDLC(manifest: GOGManifestMeta): Boolean { + return findDLCProducts(manifest).isNotEmpty() + } + + /** + * Build a mapping of chunk MD5 -> secure CDN URL + * + * @param chunks List of chunk MD5 hashes + * @param baseUrls List of base CDN URLs (e.g., https://gog-cdn-fastly.gog.com/...) + * @return Map of chunk MD5 to download URL + */ + fun buildChunkUrlMap(chunks: List, baseUrls: List): Map { + if (baseUrls.isEmpty()) { + Timber.tag(TAG).w("No base CDN URLs provided") + return emptyMap() + } + + // Use the first (highest priority) CDN URL as base + val baseCdnUrl = baseUrls.first() + + // Build full URL for each chunk: baseUrl/aa/bb/aabbccdd... + // Where aa/bb are first 4 chars of MD5 hash + return chunks.associateWith { chunkMd5 -> + if (chunkMd5.length >= 4) { + val first2 = chunkMd5.substring(0, 2) + val next2 = chunkMd5.substring(2, 4) + "$baseCdnUrl/$first2/$next2/$chunkMd5" + } else { + "$baseCdnUrl/$chunkMd5" + } + } + } + + /** + * Build a mapping of chunk MD5 -> secure CDN URL with per-product URLs + * Each product (base game + DLCs) has its own CDN path with different URLs + * + * @param chunks List of chunk MD5 hashes + * @param chunkToProductMap Map of chunk hash to product ID + * @param productUrlMap Map of product ID to list of secure URLs for that product + * @return Map of chunk MD5 to download URL + */ + fun buildChunkUrlMapWithProducts( + chunks: List, + chunkToProductMap: Map, + productUrlMap: Map> + ): Map { + val chunkUrlMap = mutableMapOf() + + for (chunkMd5 in chunks) { + val productId = chunkToProductMap[chunkMd5] + if (productId == null) { + Timber.tag(TAG).w("No product ID found for chunk $chunkMd5") + continue + } + + val productUrls = productUrlMap[productId] + if (productUrls.isNullOrEmpty()) { + Timber.tag(TAG).w("No URLs found for product $productId (chunk $chunkMd5)") + continue + } + + // Use the first (highest priority) CDN URL for this product + val baseCdnUrl = productUrls.first() + + // Build full URL for chunk: baseUrl/aa/bb/aabbccdd... + // Where aa/bb are first 4 chars of MD5 hash + val chunkUrl = if (chunkMd5.length >= 4) { + val first2 = chunkMd5.substring(0, 2) + val next2 = chunkMd5.substring(2, 4) + "$baseCdnUrl/$first2/$next2/$chunkMd5" + } else { + "$baseCdnUrl/$chunkMd5" + } + + chunkUrlMap[chunkMd5] = chunkUrl + } + + Timber.tag(TAG).d("Built ${chunkUrlMap.size} chunk URLs from ${productUrlMap.size} product(s)") + return chunkUrlMap + } + + /** + * Extract all unique chunk hashes from depot files + * Preserves order for secure link requests + * + * @param files List of depot files + * @return List of unique compressed MD5 hashes + */ + fun extractChunkHashes(files: List): List { + val seen = mutableSetOf() + val ordered = mutableListOf() + + files.forEach { file -> + file.chunks.forEach { chunk -> + if (seen.add(chunk.compressedMd5)) { + ordered.add(chunk.compressedMd5) + } + } + } + + Timber.tag(TAG).d("Extracted ${ordered.size} unique chunks from ${files.size} files") + return ordered + } + + /** + * Detect generation from build metadata + * + * @param build Build metadata + * @return 1 for legacy, 2 for modern GOG builds + */ + fun detectGeneration(build: GOGBuild): Int { + return build.generation + } + + /** + * Parse builds response JSON + */ + fun parseBuilds(json: String): BuildsResponse { + return BuildsResponse.fromJson(JSONObject(json)) + } + + /** + * Parse manifest metadata JSON + */ + fun parseDependencyManifest(json: String): GOGDependencyManifestMeta { + return GOGDependencyManifestMeta.fromJson(JSONObject(json)) + } + + /** + * Parse manifest metadata JSON + */ + fun parseManifest(json: String): GOGManifestMeta { + return GOGManifestMeta.fromJson(JSONObject(json)) + } + + /** + * Parse depot manifest JSON + */ + fun parseDepotManifest(json: String): DepotManifest { + return DepotManifest.fromJson(JSONObject(json)) + } + + /** + * Parse secure links response JSON + */ + fun parseSecureLinks(json: String): SecureLinksResponse { + return SecureLinksResponse.fromJson(JSONObject(json)) + } + + /** + * Decompress GOG manifest data (auto-detects zlib or gzip) + */ + fun decompressManifest(data: ByteArray): String { + // Check compression type by magic bytes + val isGzipped = data.size >= 2 && + data[0] == 0x1f.toByte() && + data[1] == 0x8b.toByte() + + val isZlib = data.size >= 2 && + data[0] == 0x78.toByte() && + ( + data[1] == 0x9c.toByte() || + data[1] == 0x01.toByte() || + data[1] == 0xda.toByte() + ) + + return when { + isGzipped -> { + // Decompress gzip + GZIPInputStream(ByteArrayInputStream(data)).use { inputStream -> + inputStream.bufferedReader().use { it.readText() } + } + } + + isZlib -> { + // Decompress zlib (same as Epic chunk decompression) + val inflater = Inflater() + try { + inflater.setInput(data) + val outputStream = ByteArrayOutputStream() + val buffer = ByteArray(8192) + + while (!inflater.finished()) { + val count = inflater.inflate(buffer) + if (count > 0) { + outputStream.write(buffer, 0, count) + } else if (inflater.needsInput()) { + // No more input data available but decompression not finished - malformed data + throw Exception("Incomplete or malformed zlib data: decompression ended prematurely") + } + // If count == 0 but !needsInput(), inflater is still processing, continue loop + } + + outputStream.toString("UTF-8") + } finally { + inflater.end() + } + } + + else -> { + // Try as plain text + String(data, Charsets.UTF_8) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/service/handler/PluviaHandler.kt b/app/src/main/java/app/gamenative/service/handler/PluviaHandler.kt deleted file mode 100644 index e5deb35ea8..0000000000 --- a/app/src/main/java/app/gamenative/service/handler/PluviaHandler.kt +++ /dev/null @@ -1,39 +0,0 @@ -package app.gamenative.service.handler - -import app.gamenative.service.callback.EmoticonListCallback -import `in`.dragonbra.javasteam.base.ClientMsgProtobuf -import `in`.dragonbra.javasteam.base.IPacketMsg -import `in`.dragonbra.javasteam.enums.EMsg -import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientserverFriends.CMsgClientGetEmoticonList -import `in`.dragonbra.javasteam.steam.handlers.ClientMsgHandler -import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg - -/** - * Custom handler to handle dispatching that JavaSteam does not support. - */ -class PluviaHandler : ClientMsgHandler() { - - companion object { - fun getCallback(packetMsg: IPacketMsg): CallbackMsg? = when (packetMsg.msgType) { - EMsg.ClientEmoticonList -> EmoticonListCallback(packetMsg) - else -> null - } - } - - /** - * Handles a client message. This should not be called directly. - * @param packetMsg The packet message that contains the data. - */ - override fun handleMsg(packetMsg: IPacketMsg) { - val callback = getCallback(packetMsg) ?: return - - client.postCallback(callback) - } - - fun getEmoticonList() { - ClientMsgProtobuf( - CMsgClientGetEmoticonList::class.java, - EMsg.ClientGetEmoticonList, - ).also(client::send) - } -} diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index c0296a40ec..618aa2a8b0 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -60,7 +60,6 @@ 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.chat.ChatScreen import app.gamenative.ui.screen.login.UserLoginScreen import app.gamenative.ui.screen.settings.SettingsScreen import app.gamenative.ui.screen.xserver.XServerScreen @@ -75,6 +74,8 @@ import app.gamenative.utils.UpdateInstaller import com.google.android.play.core.splitcompat.SplitCompat import com.winlator.container.Container import com.winlator.container.ContainerManager +import com.winlator.core.TarCompressorUtils +import com.winlator.xenvironment.ImageFs import com.winlator.xenvironment.ImageFsInstaller import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientObjects.ECloudPendingRemoteOperation import java.util.Date @@ -85,6 +86,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber +import java.io.File /** * Navigates from the LoginUser screen to the target route, popping the login screen from the stack. @@ -479,6 +481,14 @@ fun PluviaMain( viewModel.startConnecting() // Update ViewModel state for UI context.startForegroundService(Intent(context, SteamService::class.java)) } + // Start GOGService if user has GOG + if (app.gamenative.service.gog.GOGService.hasStoredCredentials(context) && + !app.gamenative.service.gog.GOGService.isRunning) { + Timber.tag("GOG").d("[PluviaMain]: Starting GOGService for logged-in user") + app.gamenative.service.gog.GOGService.start(context) + } else { + Timber.tag("GOG").d("GOG SERVICE Not going to start: ${app.gamenative.service.gog.GOGService.isRunning}") + } // Handle navigation when already logged in (e.g., app resumed with active session) // Only navigate if currently on LoginUser screen to avoid disrupting user's current view if (SteamService.isLoggedIn && !SteamService.isGameRunning) { @@ -961,6 +971,23 @@ fun PluviaMain( onClickPlay = { appId, asContainer -> viewModel.setLaunchedAppId(appId) viewModel.setBootToContainer(asContainer) + viewModel.setTestGraphics(false) + viewModel.setOffline(isOffline) + preLaunchApp( + context = context, + appId = appId, + setLoadingDialogVisible = viewModel::setLoadingDialogVisible, + setLoadingProgress = viewModel::setLoadingDialogProgress, + setLoadingMessage = viewModel::setLoadingDialogMessage, + setMessageDialogState = { msgDialogState = it }, + onSuccess = viewModel::launchApp, + isOffline = isOffline, + ) + }, + onTestGraphics = { appId -> + viewModel.setLaunchedAppId(appId) + viewModel.setBootToContainer(true) + viewModel.setTestGraphics(true) viewModel.setOffline(isOffline) preLaunchApp( context = context, @@ -992,31 +1019,12 @@ fun PluviaMain( ) } - /** Full Screen Chat **/ - composable( - route = "chat/{id}", - arguments = listOf( - navArgument(PluviaScreen.Chat.ARG_ID) { - type = NavType.LongType - }, - ), - ) { - val id = it.arguments?.getLong(PluviaScreen.Chat.ARG_ID) ?: throw RuntimeException("Unable to get ID to chat") - ChatScreen( - friendId = id, - onBack = { - CoroutineScope(Dispatchers.Main).launch { - navController.popBackStack() - } - }, - ) - } - /** Game Screen **/ composable(route = PluviaScreen.XServer.route) { XServerScreen( appId = state.launchedAppId, bootToContainer = state.bootToContainer, + testGraphics = state.testGraphics, registerBackAction = { cb -> Timber.d("registerBackAction called: $cb") gameBackAction = cb @@ -1137,16 +1145,31 @@ fun preLaunchApp( "proton-9.0-x86_64.txz", ).await() } + if (container.wineVersion.contains("proton-9.0-x86_64") || container.wineVersion.contains("proton-9.0-arm64ec")) { + val protonVersion = container.wineVersion + val imageFs = ImageFs.find(context) + val outFile = File(imageFs.rootDir, "/opt/$protonVersion") + val binDir = File(outFile, "bin") + if (!binDir.exists() || !binDir.isDirectory) { + Timber.i("Extracting $protonVersion to /opt/") + setLoadingMessage("Extracting $protonVersion") + setLoadingProgress(-1f) + val downloaded = File(imageFs.getFilesDir(), "$protonVersion.txz") + TarCompressorUtils.extract( + TarCompressorUtils.Type.XZ, + downloaded, + outFile, + ) + } + } } - if (!container.isUseLegacyDRM && !container.isLaunchRealSteam && - !SteamService.isFileInstallable(context, "experimental-drm.tzst") - ) { + if (!container.isUseLegacyDRM && !container.isLaunchRealSteam && !SteamService.isFileInstallable(context, "experimental-drm-20260116.tzst")) { setLoadingMessage("Downloading extras") SteamService.downloadFile( onDownloadProgress = { setLoadingProgress(it / 1.0f) }, this, context = context, - "experimental-drm.tzst", + "experimental-drm-20260116.tzst" ).await() } if (container.isLaunchRealSteam && !SteamService.isFileInstallable(context, "steam.tzst")) { @@ -1157,6 +1180,15 @@ fun preLaunchApp( context = context, ).await() } + if (container.isLaunchRealSteam && !SteamService.isFileInstallable(context, "steam-token.tzst")) { + setLoadingMessage("Downloading steam-token") + SteamService.downloadFile( + onDownloadProgress = { setLoadingProgress(it / 1.0f) }, + this, + context = context, + "steam-token.tzst" + ).await() + } val loadingMessage = if (container.containerVariant.equals(Container.GLIBC)) { context.getString(R.string.main_installing_glibc) } else { @@ -1204,10 +1236,36 @@ fun preLaunchApp( return@launch } + // For GOG Games, sync cloud saves before launch + val isGOGGame = ContainerUtils.extractGameSourceFromContainerId(appId) == GameSource.GOG + if (isGOGGame) { + Timber.tag("GOG").i("[Cloud Saves] GOG Game detected for $appId — syncing cloud saves before launch") + + // Sync cloud saves (download latest saves before playing) + Timber.tag("GOG").d("[Cloud Saves] Starting pre-game download sync for $appId") + val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves( + context = context, + appId = appId, + ) + + if (!syncSuccess) { + Timber.tag("GOG").w("[Cloud Saves] Download sync failed for $appId, proceeding with launch anyway") + // Don't block launch on sync failure - log warning and continue + } else { + Timber.tag("GOG").i("[Cloud Saves] Download sync completed successfully for $appId") + } + + setLoadingDialogVisible(false) + onSuccess(context, appId) + return@launch + } + // For Steam games, sync save files and check no pending remote operations are running val prefixToPath: (String) -> String = { prefix -> PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) } + setLoadingMessage("Syncing cloud saves") + setLoadingProgress(-1f) val postSyncInfo = SteamService.beginLaunchApp( appId = gameId, prefixToPath = prefixToPath, @@ -1215,6 +1273,10 @@ fun preLaunchApp( preferredSave = preferredSave, parentScope = this, isOffline = isOffline, + onProgress = { message, progress -> + setLoadingMessage(message) + setLoadingProgress(if (progress < 0) -1f else progress) + }, ).await() setLoadingDialogVisible(false) diff --git a/app/src/main/java/app/gamenative/ui/component/BBCodeText.kt b/app/src/main/java/app/gamenative/ui/component/BBCodeText.kt deleted file mode 100644 index 699a9c5d5f..0000000000 --- a/app/src/main/java/app/gamenative/ui/component/BBCodeText.kt +++ /dev/null @@ -1,421 +0,0 @@ -package app.gamenative.ui.component - -import android.content.res.Configuration -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.QuestionMark -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import app.gamenative.Constants -import app.gamenative.ui.theme.PluviaTheme -import com.skydoves.landscapist.ImageOptions -import com.skydoves.landscapist.coil.CoilImage -import app.gamenative.R - -/** - * A Custom wrapper that should be able to handle most bb code formating steam acknowledges. - * This also includes emoticon rendering like steam does in chat or in profiles. - * See: https://steamcommunity.com/comment/ForumTopic/formattinghelp - */ - -// TODO: -// Slash commands -// Rich Previews with http links - -enum class BBCode(val pattern: String, val groupCount: Int = 1) { - COLON("\u02D0([^\u02D0]+)\u02D0"), - EMOTICON("\\[emoticon]([^\\[]+)\\[/emoticon]"), - H1("\\[h1]([^\\[]+)\\[/h1]"), - H2("\\[h2]([^\\[]+)\\[/h2]"), - H3("\\[h3]([^\\[]+)\\[/h3]"), - BOLD("\\[b]([^\\[]+)\\[/b]"), - ITALIC("\\[i]([^\\[]+)\\[/i]"), - UNDERLINE("\\[u]([^\\[]+)\\[/u]"), - STRIKE_THROUGH("\\[strike]([^\\[]+)\\[/strike]"), - SPOILER("\\[spoiler]([^\\[]+)\\[/spoiler]"), - URL("\\[url=([^]]+)]([^\\[]+)\\[/url]", 2), - PLAIN_URL("(https?://\\S+)"), - HORIZONTAL_RULE("\\[hr]([^\\[]*?)\\[/hr]"), - CODE("\\[code]([^\\[]*?)\\[/code]"), - QUOTE("\\[quote=([^]]+)]([^\\[]*?)\\[/quote]", 2), - STICKER("\\[sticker type=\"(.*?)\".*?]\\[/sticker]"), - ; - - fun groupIndex(): Int = - ordinal + 1 + entries.foldIndexed( - 0, - ) { index, accum, current -> - if (index < ordinal) { - accum + current.groupCount - 1 - } else { - accum - } - } - - companion object { - fun pattern(): Regex = entries - .map { it.pattern } - .joinToString("|") - .toRegex() - } -} - -@Composable -fun BBCodeText( - modifier: Modifier = Modifier, - text: String, - color: Color = Color.Unspecified, - style: TextStyle = LocalTextStyle.current, -) { - val revealedSpoilers = remember { mutableStateMapOf() } - val layoutResult = remember { mutableStateOf(null) } - - val matches = BBCode.pattern().findAll(text).toList() - - val annotatedString = buildAnnotatedString { - var currentIndex = 0 - - matches.forEach { match -> - if (match.range.first > currentIndex) { - val textBefore = text.substring(currentIndex, match.range.first) - append(textBefore) - } - - when { - match.groups[BBCode.COLON.groupIndex()] != null || - match.groups[BBCode.EMOTICON.groupIndex()] != null - -> { - val emoticonName = match.groupValues - .getOrNull(1) - ?.takeUnless { it.isEmpty() } - ?: match.groupValues.getOrNull(BBCode.EMOTICON.groupIndex()) - - emoticonName?.let { emoticon -> - appendInlineContent(emoticon, "[emoji]") - } - } - match.groups[BBCode.H1.groupIndex()] != null -> { - withStyle( - style = SpanStyle( - fontSize = style.fontSize * 1.5f, - fontWeight = FontWeight.Bold, - baselineShift = BaselineShift(0.2f), - ), - block = { append(match.groupValues[BBCode.H1.groupIndex()]) }, - ) - } - match.groups[BBCode.H2.groupIndex()] != null -> { - withStyle( - style = SpanStyle( - fontSize = style.fontSize * 1.25f, - fontWeight = FontWeight.Bold, - baselineShift = BaselineShift(0.2f), - ), - block = { append(match.groupValues[BBCode.H2.groupIndex()]) }, - ) - } - match.groups[BBCode.H3.groupIndex()] != null -> { - withStyle( - style = SpanStyle( - fontSize = style.fontSize * 1.10f, - fontWeight = FontWeight.Bold, - baselineShift = BaselineShift(0.2f), - ), - block = { append(match.groupValues[BBCode.H3.groupIndex()]) }, - ) - } - match.groups[BBCode.BOLD.groupIndex()] != null -> { - withStyle( - style = SpanStyle(fontWeight = FontWeight.Bold, baselineShift = BaselineShift(0.2f)), - block = { append(match.groupValues[BBCode.BOLD.groupIndex()]) }, - - ) - } - match.groups[BBCode.UNDERLINE.groupIndex()] != null -> { - withStyle( - style = SpanStyle(textDecoration = TextDecoration.Underline, baselineShift = BaselineShift(0.2f)), - block = { append(match.groupValues[BBCode.UNDERLINE.groupIndex()]) }, - ) - } - match.groups[BBCode.ITALIC.groupIndex()] != null -> { - withStyle( - style = SpanStyle(fontStyle = FontStyle.Italic, baselineShift = BaselineShift(0.2f)), - block = { append(match.groupValues[BBCode.ITALIC.groupIndex()]) }, - ) - } - match.groups[BBCode.STRIKE_THROUGH.groupIndex()] != null -> { - withStyle( - style = SpanStyle(textDecoration = TextDecoration.LineThrough, baselineShift = BaselineShift(0.2f)), - block = { append(match.groupValues[BBCode.STRIKE_THROUGH.groupIndex()]) }, - ) - } - match.groups[BBCode.SPOILER.groupIndex()] != null -> { - val spoilerText = match.groupValues[BBCode.SPOILER.groupIndex()] - val spoilerId = "spoiler_${match.range.first}" - - val isRevealed = revealedSpoilers[spoilerId] ?: false - pushStringAnnotation("spoiler", spoilerId) - - withStyle( - style = SpanStyle( - background = if (isRevealed) Color.Unspecified else MaterialTheme.colorScheme.tertiaryContainer, - color = if (isRevealed) Color.Unspecified else MaterialTheme.colorScheme.tertiaryContainer, - baselineShift = BaselineShift(0.2f), - ), - block = { append(spoilerText) }, - ) - pop() - } - match.groups[BBCode.URL.groupIndex()] != null && - match.groups[BBCode.URL.groupIndex() + 1] != null - -> { - val url = match.groupValues[BBCode.URL.groupIndex()] - val linkText = match.groupValues[BBCode.URL.groupIndex() + 1].trim() - - pushStringAnnotation("URL", url) - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - baselineShift = BaselineShift(0.2f), - ), - block = { append(linkText) }, - ) - pop() - } - match.groups[BBCode.PLAIN_URL.groupIndex()] != null -> { - val url = match.groupValues[BBCode.PLAIN_URL.groupIndex()] - pushStringAnnotation("URL", url) - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - baselineShift = BaselineShift(0.2f), - ), - block = { append(url) }, - ) - pop() - } - match.groups[BBCode.HORIZONTAL_RULE.groupIndex()] != null -> { - withStyle( - style = SpanStyle(textDecoration = TextDecoration.LineThrough, baselineShift = BaselineShift(0.2f)), - block = { append(" ") }, - ) - } - match.groups[BBCode.CODE.groupIndex()] != null -> { - withStyle( - style = SpanStyle(fontFamily = FontFamily.Monospace, baselineShift = BaselineShift(0.2f)), - block = { append(match.groupValues[BBCode.CODE.groupIndex()]) }, - ) - } - match.groups[BBCode.QUOTE.groupIndex()] != null && - match.groups[BBCode.QUOTE.groupIndex() + 1] != null - -> { - withStyle( - style = SpanStyle( - background = MaterialTheme.colorScheme.surfaceVariant, - baselineShift = BaselineShift(0.2f), - ), - ) { - withStyle( - style = SpanStyle(fontStyle = FontStyle.Italic, baselineShift = BaselineShift(0.2f)), - block = { append("Originally posted by ${match.groupValues[BBCode.QUOTE.groupIndex()]}:\n") }, - ) - - append(match.groupValues[BBCode.QUOTE.groupIndex() + 1]) - } - } - match.groups[BBCode.STICKER.groupIndex()] != null -> { - val stickerType = match.groupValues[BBCode.STICKER.groupIndex()] - val stickerId = "sticker_$stickerType" - appendInlineContent(stickerId, "[sticker]") - } - } - - currentIndex = match.range.last + 1 - } - - if (currentIndex < text.length) { - append(text.substring(currentIndex)) - } - } - - // Build inline content map -// Build inline content map - val inlineContentMap = buildMap { - matches.forEach { match -> - when { - // Handle emoticons - match.groups[1] != null || match.groups[2] != null -> { - val emoticonName = match.groupValues - .getOrNull(1) - ?.takeUnless { it.isEmpty() } - ?: match.groupValues.getOrNull(2) - - emoticonName?.let { emoticon -> - put( - emoticon, - InlineTextContent( - placeholder = Placeholder( - width = style.fontSize, - height = style.fontSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - children = { - CoilImage( - modifier = Modifier.size(style.fontSize.value.dp), - imageModel = { Constants.Chat.EMOTICON_URL + emoticon }, - imageOptions = ImageOptions( - contentDescription = emoticon, - contentScale = ContentScale.Fit, - ), - loading = { - CircularProgressIndicator() - }, - failure = { - Icon(Icons.Filled.QuestionMark, null) - }, - previewPlaceholder = painterResource(R.drawable.ic_logo_color), - ) - }, - ), - ) - } - } - // Handle stickers - match.groups[18] != null -> { - val stickerType = match.groupValues[18] - val stickerId = "sticker_$stickerType" - put( - stickerId, - InlineTextContent( - placeholder = Placeholder( - width = 150.sp, - height = 150.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - children = { - CoilImage( - modifier = Modifier.size(150.dp), - imageModel = { Constants.Chat.STICKER_URL + stickerType }, - imageOptions = ImageOptions( - contentDescription = stickerType, - contentScale = ContentScale.Fit, - ), - loading = { - CircularProgressIndicator() - }, - failure = { - Icon(Icons.Filled.QuestionMark, null) - }, - previewPlaceholder = painterResource(R.drawable.ic_logo_color), - ) - }, - ), - ) - } - } - } - } - - Text( - text = annotatedString, - color = color, - modifier = modifier.pointerInput(Unit) { - detectTapGestures { offset -> - val position = annotatedString - .getStringAnnotations("spoiler", start = 0, end = annotatedString.length) - .firstOrNull { annotation -> - val textLayoutResult = layoutResult.value - textLayoutResult?.let { layoutResult -> - val bounds = layoutResult.getBoundingBox(annotation.start) - val expandedBounds = Rect( - bounds.left, - bounds.top, - bounds.left + layoutResult.size.width, - bounds.top + layoutResult.size.height, - ) - expandedBounds.contains(offset) - } ?: false - } - position?.let { annotation -> - revealedSpoilers[annotation.item] = !(revealedSpoilers[annotation.item] ?: false) - } - } - }, - style = style, - inlineContent = inlineContentMap, - onTextLayout = { layoutResult.value = it }, - ) -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -@Preview -@Composable -private fun Preview_BBCodeText() { - PluviaTheme { - Surface { - Column { - BBCodeText( - text = """ - [h1]Header 1 text[/h1] - [h2]Header 2 text[/h2] - [h3]Header 3 text[/h3] - [b]Bold text [/b] - [u]Underlined text [/u] - [i]Italic text [/i] - [strike]Strikethrough text[/strike] - [spoiler]Spoiler text[/spoiler] - [noparse]Doesn't parse [b]tags[/b][/noparse] - [hr][/hr] - [url=store.steampowered.com] Website link [/url] - https://www.youtube.com/watch?v=tax4e4hBBZc - [quote=author]Quoted text[/quote] - [code]Fixed-width font, preserves spaces[/code] - Some ːsteamhappyː for ːsteamsadː testing. - Hello World! [emoticon]steamhappy[/emoticon] - """.trimIndent(), - ) - - Spacer(Modifier.height(14.dp)) - - BBCodeText(text = "[sticker type=\"Winter2019JingleIntensifies\" limit=\"0\"][/sticker]") - } - } - } -} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index 605e0ac4ad..24e0ba6891 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -41,6 +42,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -98,6 +100,8 @@ import com.winlator.core.WineInfo import com.winlator.core.WineInfo.MAIN_WINE_VERSION import com.winlator.fexcore.FEXCoreManager import com.winlator.fexcore.FEXCorePresetManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.util.Locale import kotlin.math.roundToInt @@ -188,6 +192,15 @@ fun ContainerConfigDialog( var wowBox64Versions by remember { mutableStateOf(wowBox64VersionsBase) } // reuse existing base list var fexcoreVersions by remember { mutableStateOf(fexcoreVersionsBase) } var versionsLoaded by remember { mutableStateOf(false) } + var showCustomResolutionDialog by remember { mutableStateOf(false) } + var customResolutionValidationError by remember { mutableStateOf(null) } + + LaunchedEffect(visible) { + if (visible) { + showCustomResolutionDialog = false + customResolutionValidationError = null + } + } LaunchedEffect(Unit) { try { @@ -404,11 +417,19 @@ fun ContainerConfigDialog( } var customScreenWidth by rememberSaveable { val searchIndex = screenSizes.indexOfFirst { it.contains(config.screenSize) } - mutableStateOf(if (searchIndex <= 0) config.screenSize.split("x")[0] else "") + mutableStateOf( + if (searchIndex <= 0) { + config.screenSize.split("x").getOrElse(0) { "1280" } + } else "1280" + ) } var customScreenHeight by rememberSaveable { val searchIndex = screenSizes.indexOfFirst { it.contains(config.screenSize) } - mutableStateOf(if (searchIndex <= 0) config.screenSize.split("x")[1] else "") + mutableStateOf( + if (searchIndex <= 0) { + config.screenSize.split("x").getOrElse(1) { "720" } + } else "720" + ) } var graphicsDriverIndex by rememberSaveable { val driverIndex = graphicsDrivers.indexOfFirst { StringUtils.parseIdentifier(it) == config.graphicsDriver } @@ -694,6 +715,86 @@ fun ContainerConfigDialog( } } + val nonzeroResolutionError = stringResource( + R.string.container_config_custom_resolution_error_nonzero + ) + val aspectResolutionError = stringResource( + R.string.container_config_custom_resolution_error_aspect + ) + if (showCustomResolutionDialog) { + AlertDialog( + onDismissRequest = { showCustomResolutionDialog = false }, + title = { Text(text = stringResource(R.string.container_config_custom_resolution_title)) }, + text = { + Column { + Row { + OutlinedTextField( + modifier = Modifier.width(128.dp), + value = customScreenWidth, + onValueChange = { + customScreenWidth = it + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + label = { Text(text = stringResource(R.string.width)) }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = stringResource(R.string.container_config_custom_resolution_separator), + style = TextStyle(fontSize = 16.sp), + ) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedTextField( + modifier = Modifier.width(128.dp), + value = customScreenHeight, + onValueChange = { + customScreenHeight = it + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + label = { Text(text = stringResource(R.string.height)) }, + ) + } + if (customResolutionValidationError != null) { + Text( + text = customResolutionValidationError!!, + color = MaterialTheme.colorScheme.error, + style = TextStyle(fontSize = 16.sp), + modifier = Modifier.padding(top = 8.dp) + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val widthInt = customScreenWidth.toIntOrNull() ?: 0 + val heightInt = customScreenHeight.toIntOrNull() ?: 0 + if (widthInt == 0 || heightInt == 0) { + customResolutionValidationError = nonzeroResolutionError + } else if (widthInt <= heightInt) { + customResolutionValidationError = aspectResolutionError + } else { + customResolutionValidationError = null + applyScreenSizeToConfig() + showCustomResolutionDialog = false + } + }, + ) { + Text(text = stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton( + onClick = { + showCustomResolutionDialog = false + }, + ) { + Text(text = stringResource(R.string.cancel)) + } + } + ) + } + MessageDialog( visible = dismissDialogState.visible, title = dismissDialogState.title, @@ -1028,42 +1129,11 @@ fun ContainerConfigDialog( items = screenSizes, onItemSelected = { screenSizeIndex = it - applyScreenSizeToConfig() - }, - action = if (screenSizeIndex == 0) { - { - Row { - OutlinedTextField( - modifier = Modifier.width(128.dp), - value = customScreenWidth, - onValueChange = { - customScreenWidth = it - applyScreenSizeToConfig() - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - label = { Text(text = stringResource(R.string.width)) }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - modifier = Modifier.align(Alignment.CenterVertically), - text = "x", - style = TextStyle(fontSize = 16.sp), - ) - Spacer(modifier = Modifier.width(8.dp)) - OutlinedTextField( - modifier = Modifier.width(128.dp), - value = customScreenHeight, - onValueChange = { - customScreenHeight = it - applyScreenSizeToConfig() - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - label = { Text(text = stringResource(R.string.height)) }, - ) - } + if (it == 0) { + showCustomResolutionDialog = true + } else { + applyScreenSizeToConfig() } - } else { - null }, ) // Audio Driver Dropdown @@ -1931,11 +2001,16 @@ private fun ExecutablePathDropdown( ) { var expanded by remember { mutableStateOf(false) } var executables by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } val context = LocalContext.current // Load executables from A: drive when component is first created LaunchedEffect(containerData.drives) { - executables = scanExecutablesInADrive(containerData.drives) + isLoading = true + executables = withContext(Dispatchers.IO) { + ContainerUtils.scanExecutablesInADrive(containerData.drives) + } + isLoading = false } ExposedDropdownMenuBox( @@ -1946,10 +2021,15 @@ private fun ExecutablePathDropdown( OutlinedTextField( value = value, onValueChange = onValueChange, + readOnly = true, label = { Text(stringResource(R.string.container_config_executable_path)) }, placeholder = { Text(stringResource(R.string.container_config_executable_path_placeholder)) }, trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } }, modifier = Modifier .fillMaxWidth() @@ -1957,7 +2037,7 @@ private fun ExecutablePathDropdown( singleLine = true ) - if (executables.isNotEmpty()) { + if (!isLoading && executables.isNotEmpty()) { ExposedDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } @@ -1990,105 +2070,3 @@ private fun ExecutablePathDropdown( } } -/** - * Scans the container's A: drive for all .exe files - */ -private fun scanExecutablesInADrive(drives: String): List { - val executables = mutableListOf() - - try { - // Find the A: drive path from container drives - val aDrivePath = getADrivePath(drives) - if (aDrivePath == null) { - timber.log.Timber.w("No A: drive found in container drives") - return emptyList() - } - - val aDir = java.io.File(aDrivePath) - if (!aDir.exists() || !aDir.isDirectory) { - timber.log.Timber.w("A: drive path does not exist or is not a directory: $aDrivePath") - return emptyList() - } - - timber.log.Timber.d("Scanning for executables in A: drive: $aDrivePath") - - // Recursively scan for .exe files using walkTopDown - aDir.walkTopDown().forEach { file -> - if (file.isFile && file.name.lowercase().endsWith(".exe")) { - // Convert to relative Windows path format - val relativePath = aDir.toURI().relativize(file.toURI()).path - executables.add(relativePath) - } - } - - // Sort alphabetically and prioritize common game executables - executables.sortWith { a, b -> - val aScore = getExecutablePriority(a) - val bScore = getExecutablePriority(b) - - if (aScore != bScore) { - bScore.compareTo(aScore) // Higher priority first - } else { - a.compareTo(b, ignoreCase = true) // Alphabetical - } - } - - timber.log.Timber.d("Found ${executables.size} executables in A: drive") - - } catch (e: Exception) { - timber.log.Timber.e(e, "Error scanning A: drive for executables") - } - - return executables -} - -/** - * Gets the file system path for the container's A: drive - */ -private fun getADrivePath(drives: String): String? { - // Use the existing Container.drivesIterator logic - for (drive in Container.drivesIterator(drives)) { - if (drive[0] == "A") { - return drive[1] - } - } - return null -} - -/** - * Assigns priority scores to executables for better sorting - */ -private fun getExecutablePriority(exePath: String): Int { - val fileName = exePath.substringAfterLast('\\').lowercase() - val baseName = fileName.substringBeforeLast('.') - - return when { - // Highest priority: common game executable patterns - fileName.contains("game") -> 100 - fileName.contains("start") -> 85 - fileName.contains("main") -> 80 - fileName.contains("launcher") && !fileName.contains("unins") -> 75 - - // High priority: probable main executables - baseName.length >= 4 && !isSystemExecutable(fileName) -> 70 - - // Medium priority: any non-system executable - !isSystemExecutable(fileName) -> 50 - - // Low priority: system/utility executables - else -> 10 - } -} - -/** - * Checks if an executable is likely a system/utility file - */ -private fun isSystemExecutable(fileName: String): Boolean { - val systemKeywords = listOf( - "unins", "setup", "install", "config", "crash", "handler", - "viewer", "compiler", "tool", "redist", "vcredist", "directx", - "steam", "origin", "uplay", "epic", "battlenet" - ) - - return systemKeywords.any { fileName.contains(it) } -} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt new file mode 100644 index 0000000000..95fa1c9c76 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -0,0 +1,215 @@ +package app.gamenative.ui.component.dialog + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Login +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.service.gog.GOGConstants +import app.gamenative.ui.theme.PluviaTheme +import android.content.Intent +import android.net.Uri +import android.widget.Toast + +private fun extractCodeFromInput(input: String): String { + val trimmed = input.trim() + // Check if it's a URL with code parameter + if (trimmed.startsWith("http")) { + val codeMatch = Regex("[?&]code=([^&]+)").find(trimmed) + return codeMatch?.groupValues?.get(1) ?: "" + } + // Otherwise assume it's already the code + return trimmed +} + +/** + * GOG Login Dialog + * + * GOG uses OAuth2 authentication with automatic callback handling: + * 1. Open GOG login URL in browser + * 2. Login with GOG credentials + * 3. GOG redirects back to app with authorization code automatically + * ! Note: This UI will be temporary as we will migrate to a redirect flow. + */ +@Composable +fun GOGLoginDialog( + visible: Boolean, + onDismissRequest: () -> Unit, + onAuthCodeClick: (authCode: String) -> Unit, + isLoading: Boolean = false, + errorMessage: String? = null, +) { + val context = LocalContext.current + var authCode by rememberSaveable { mutableStateOf("") } + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val scrollState = rememberScrollState() + + if (!visible) return + AlertDialog( + onDismissRequest = onDismissRequest, + icon = { Icon(imageVector = Icons.Default.Login, contentDescription = null) }, + title = { Text(stringResource(R.string.gog_login_title)) }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .then( + if (isLandscape) { + Modifier + .heightIn(max = 300.dp) + .verticalScroll(scrollState) + } else { + Modifier + } + ), + verticalArrangement = Arrangement.spacedBy(if (isLandscape) 8.dp else 12.dp) + ) { + Text( + text = stringResource(R.string.gog_login_auto_auth_info), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + // Open browser button + Button( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(GOGConstants.GOG_AUTH_LOGIN_URL)) + context.startActivity(intent) + } catch (e: Exception) { + Toast.makeText( + context, + context.getString(R.string.gog_login_browser_error), + Toast.LENGTH_SHORT + ).show() + } + }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + contentPadding = if (isLandscape) PaddingValues(8.dp) else ButtonDefaults.ContentPadding + ) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.gog_login_open_button)) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = if (isLandscape) 4.dp else 8.dp)) + + // Manual code entry fallback + Text( + text = stringResource(R.string.gog_login_auth_example), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Authorization code input + OutlinedTextField( + value = authCode, + onValueChange = { authCode = it.trim() }, + label = { Text(stringResource(R.string.gog_login_auth_code_label)) }, + placeholder = { Text(stringResource(R.string.gog_login_auth_code_placeholder)) }, + singleLine = true, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) + + // Error message + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + // Loading indicator + if (isLoading) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth() + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + if (authCode.isNotBlank()) { + val extractedCode = extractCodeFromInput(authCode) + if (extractedCode.isNotEmpty()) { + onAuthCodeClick(extractedCode) + } + } + }, + enabled = !isLoading && authCode.isNotBlank() + ) { + Text(stringResource(R.string.gog_login_button)) + } + }, + dismissButton = { + TextButton( + onClick = onDismissRequest, + enabled = !isLoading + ) { + Text(stringResource(R.string.gog_login_cancel)) + } + } + ) + } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_GOGLoginDialog() { + PluviaTheme { + GOGLoginDialog( + visible = true, + onDismissRequest = {}, + onAuthCodeClick = {}, + isLoading = false, + errorMessage = null + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_GOGLoginDialogWithError() { + PluviaTheme { + GOGLoginDialog( + visible = true, + onDismissRequest = {}, + onAuthCodeClick = {}, + isLoading = false, + errorMessage = "Invalid authorization code. Please try again." + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_GOGLoginDialogLoading() { + PluviaTheme { + GOGLoginDialog( + visible = true, + onDismissRequest = {}, + onAuthCodeClick = {}, + isLoading = true, + errorMessage = null + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt new file mode 100644 index 0000000000..a2505b8310 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt @@ -0,0 +1,527 @@ +package app.gamenative.ui.component.dialog + +import android.content.Context +import android.content.res.Configuration +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.gamenative.BuildConfig +import app.gamenative.R +import app.gamenative.data.DepotInfo +import app.gamenative.service.SteamService +import app.gamenative.service.SteamService.Companion.INVALID_APP_ID +import app.gamenative.ui.component.LoadingScreen +import app.gamenative.ui.component.topbar.BackButton +import app.gamenative.ui.data.GameDisplayInfo +import app.gamenative.ui.internal.fakeAppInfo +import app.gamenative.ui.theme.PluviaTheme +import app.gamenative.utils.StorageUtils +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.collections.orEmpty + +private data class InstallSizeInfo( + val downloadSize: String, + val installSize: String, + val availableSpace: String, + val installBytes: Long, + val availableBytes: Long, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GameManagerDialog( + visible: Boolean, + onGetDisplayInfo: @Composable (Context) -> GameDisplayInfo, + onInstall: (List) -> Unit, + onDismissRequest: () -> Unit +) { + val context = LocalContext.current + val scrollState = rememberScrollState() + + val downloadableDepots = remember { mutableStateMapOf() } + val allDownloadableApps = remember { mutableStateListOf>() } + val selectedAppIds = remember { mutableStateMapOf() } + val enabledAppIds = remember { mutableStateMapOf() } + + val displayInfo = onGetDisplayInfo(context) + val gameId = displayInfo.gameId + + val installedApp = remember(gameId) { + SteamService.getInstalledApp(gameId) + } + val installedDlcIds = installedApp?.dlcDepots.orEmpty() + + val indirectDlcAppIds = remember(gameId) { + SteamService.getDownloadableDlcAppsOf(gameId).orEmpty().map { it.id } + } + + val mainAppDlcIdsWithoutProperDepotDlcIds = remember(gameId) { + SteamService.getMainAppDlcIdsWithoutProperDepotDlcIds(gameId).toList() + } + + LaunchedEffect(visible) { + scrollState.animateScrollTo(0) + + downloadableDepots.clear() + allDownloadableApps.clear() + + // Get Downloadable Depots + val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId) + downloadableDepots.putAll(allPossibleDownloadableDepots) + + // Get Optional DLC IDs + val optionalDlcIds = allPossibleDownloadableDepots + .filter { it.value.optionalDlcId == it.value.dlcAppId } + .map { it.value.dlcAppId } + + // Add DLCs + downloadableDepots + .toSortedMap() + .filter { (_, depot) -> + return@filter depot.dlcAppId != INVALID_APP_ID // Skip Main App + }.values + .groupBy { it.dlcAppId } + .mapValues { it.value.first() } + .toMap() + .forEach { (_, depotInfo) -> + allDownloadableApps.add(Pair(depotInfo.dlcAppId, depotInfo)) + val installed = SteamService.getInstalledApp(depotInfo.dlcAppId) + selectedAppIds[depotInfo.dlcAppId] = + installed != null || // For installed Base Game and Indirect DLC App + installedDlcIds.contains(depotInfo.dlcAppId) || // For installed DLC from Main Depot + ( !indirectDlcAppIds.contains(depotInfo.dlcAppId) && !optionalDlcIds.contains(depotInfo.dlcAppId) ) // Not in indirect DLC and not in optional DLC ids + + enabledAppIds[depotInfo.dlcAppId] = !installedDlcIds.contains(depotInfo.dlcAppId) && installed == null + } + + allDownloadableApps.sortBy { it.first } + + // Add Base Game + allDownloadableApps.add(0, Pair(gameId, downloadableDepots.toSortedMap().values.first())) + selectedAppIds[gameId] = true + enabledAppIds[gameId] = false + } + + fun getDepotAppName(depotInfo: DepotInfo): String { + if (depotInfo.dlcAppId == INVALID_APP_ID) { + return displayInfo.name + } + + val app = SteamService.getAppInfoOf(depotInfo.dlcAppId) + if (app != null) { + return app.name + } + + return "DLC ${depotInfo.dlcAppId}" + } + + fun getSizeInfo(dlcAppId: Int): Pair { + if (dlcAppId == INVALID_APP_ID || dlcAppId == gameId) { + // Base game case + val depotsForBaseGame = downloadableDepots.filter { (_, depot) -> + depot.dlcAppId == INVALID_APP_ID + } + + val installBytes = depotsForBaseGame.values.sumOf { + it.manifests["public"]?.size ?: 0 + } + val downloadBytes = depotsForBaseGame.values.sumOf { + it.manifests["public"]?.download ?: 0 + } + + return Pair( + StorageUtils.formatBinarySize(downloadBytes), + StorageUtils.formatBinarySize(installBytes) + ) + } + + // DLC case + val depotsForDlc = downloadableDepots.filter { (_, depot) -> + depot.dlcAppId == dlcAppId + } + + val installBytes = depotsForDlc.values.sumOf { + it.manifests["public"]?.size ?: 0 + } + val downloadBytes = depotsForDlc.values.sumOf { + it.manifests["public"]?.download ?: 0 + } + + return Pair( + StorageUtils.formatBinarySize(downloadBytes), + StorageUtils.formatBinarySize(installBytes) + ) + } + + fun getInstallSizeInfo(): InstallSizeInfo { + val availableBytes = StorageUtils.getAvailableSpace(SteamService.defaultStoragePath) + + // For Base Game + val baseGameInstallBytes = if (installedApp == null) { + downloadableDepots + .filter { (_, depot) -> + depot.dlcAppId == INVALID_APP_ID + }.values.sumOf { it.manifests["public"]?.size ?: 0 } + } else { + 0L + } + + val baseGameDownloadBytes = if (installedApp == null) { + downloadableDepots + .filter { (_, depot) -> + depot.dlcAppId == INVALID_APP_ID + }.values.sumOf { it.manifests["public"]?.download ?: 0 } + } else { + 0L + } + + // For Selected DLCs + val selectedInstallBytes = downloadableDepots + .filter { (_, depot) -> + selectedAppIds[depot.dlcAppId] == true && enabledAppIds[depot.dlcAppId] == true + } + .values.sumOf { it.manifests["public"]?.size ?: 0 } + + val selectedDownloadBytes = downloadableDepots + .filter { (_, depot) -> + selectedAppIds[depot.dlcAppId] == true && enabledAppIds[depot.dlcAppId] == true + } + .values.sumOf { it.manifests["public"]?.download ?: 0 } + + return InstallSizeInfo( + downloadSize = StorageUtils.formatBinarySize(baseGameDownloadBytes + selectedDownloadBytes), + installSize = StorageUtils.formatBinarySize(baseGameInstallBytes + selectedInstallBytes), + availableSpace = StorageUtils.formatBinarySize(availableBytes), + installBytes = baseGameInstallBytes + selectedInstallBytes, + availableBytes = availableBytes + ) + } + + val selectableAppIds by remember(enabledAppIds.toMap()) { + derivedStateOf { + enabledAppIds.filter { it.value }.keys.toList() + } + } + + val allSelectableSelected by remember(selectedAppIds.toMap(), selectableAppIds) { + derivedStateOf { + selectableAppIds.isNotEmpty() && selectableAppIds.all { selectedAppIds[it] == true } + } + } + + val installSizeInfo by remember(downloadableDepots.keys.toSet(), selectedAppIds.toMap(), enabledAppIds.toMap()) { + derivedStateOf { getInstallSizeInfo() } + } + + fun installSizeDisplay() : String { + return context.getString( + R.string.steam_install_space, + installSizeInfo.downloadSize, + installSizeInfo.installSize, + installSizeInfo.availableSpace + ) + } + + fun installButtonEnabled() : Boolean { + if (installSizeInfo.availableBytes < installSizeInfo.installBytes) { + return false + } + + if (installedApp != null) { + val installed = installedDlcIds.toSet() - mainAppDlcIdsWithoutProperDepotDlcIds.toSet() + val realSelectedAppIds = selectedAppIds.filter { it.value }.keys - installed + return (realSelectedAppIds.size - 1) > 0 // -1 for main app + } + + return selectedAppIds.filter { it.value }.isNotEmpty() + } + + when { + visible -> { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + ), + content = { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.Start, + ) { + // Hero Section with Game Image Background + Box( + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + ) { + // Hero background image + if (displayInfo.heroImageUrl != null) { + CoilImage( + modifier = Modifier.fillMaxSize(), + imageModel = { displayInfo.heroImageUrl }, + imageOptions = ImageOptions(contentScale = ContentScale.Crop), + loading = { LoadingScreen() }, + failure = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + // Gradient background as fallback + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.primary + ) { } + } + }, + previewPlaceholder = painterResource(R.drawable.testhero), + ) + } else { + // Fallback gradient background when no hero image + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.primary + ) { } + } + + // Gradient overlay + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.8f) + ) + ) + ) + ) + + // Back button (top left) + Box( + modifier = Modifier + .padding(20.dp) + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp) + ) + ) { + BackButton(onClick = onDismissRequest) + } + + // Game title and subtitle + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(20.dp) + ) { + Text( + text = displayInfo.name, + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Bold, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.5f), + offset = Offset(0f, 2f), + blurRadius = 10f + ) + ), + color = Color.White + ) + + Text( + text = "${displayInfo.developer} • ${ + remember(displayInfo.releaseDate) { + if (displayInfo.releaseDate > 0) { + SimpleDateFormat("yyyy", Locale.getDefault()).format(Date(displayInfo.releaseDate * 1000)) + } else { + "" + } + } + }", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.9f) + ) + } + } + + Column( + modifier = Modifier.fillMaxWidth() + ) { + // Select All toggle + if (selectableAppIds.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End + ) { + Button( + onClick = { + val newState = !allSelectableSelected + selectableAppIds.forEach { appId -> + selectedAppIds[appId] = newState + } + } + ) { + Text( + text = if (allSelectableSelected) "Deselect all" else "Select all" + ) + } + } + } + + allDownloadableApps.forEach { (dlcAppId, depotInfo) -> + val checked = selectedAppIds[dlcAppId] ?: false + val enabled = enabledAppIds[dlcAppId] ?: false + + ListItem( + headlineContent = { + Column { + Text( + text = getDepotAppName(depotInfo) + ) + // Add size display + val (downloadSize, installSize) = getSizeInfo(dlcAppId) + Text( + text = "$downloadSize download • $installSize install", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + }, + trailingContent = { + Checkbox( + checked = checked, + enabled = enabled, + onCheckedChange = { isChecked -> + // Update the local (unsaved) state only + selectedAppIds[dlcAppId] = isChecked + } + ) + }, + modifier = Modifier.clickable(enabled = enabled) { + // Toggle checkbox when ListItem is clicked + selectedAppIds[dlcAppId] = !checked + } + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + } + } + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, start = 8.dp, bottom = 8.dp, end = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(0.5f), + text = installSizeDisplay() + ) + + Button( + enabled = installButtonEnabled(), + onClick = { + onInstall(selectedAppIds + .filter { selectedId -> selectedId.key in enabledAppIds.filter { enabledId -> enabledId.value } } + .filter { selectedId -> selectedId.value }.keys.toList()) + } + ) { + Text(stringResource(R.string.install)) + } + } + } + } + }, + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +fun Preview_GameManagerDialog() { + val fakeApp = fakeAppInfo(1) + val displayInfo = GameDisplayInfo( + name = fakeApp.name, + developer = fakeApp.developer, + releaseDate = fakeApp.releaseDate, + heroImageUrl = fakeApp.getHeroUrl(), + iconUrl = fakeApp.iconUrl, + gameId = fakeApp.id, + appId = "STEAM_${fakeApp.id}", + installLocation = null, + sizeOnDisk = null, + sizeFromStore = null, + lastPlayedText = null, + playtimeText = null, + ) + + PluviaTheme { + GameManagerDialog( + visible = true, + onGetDisplayInfo = { + return@GameManagerDialog displayInfo + }, + onInstall = {}, + onDismissRequest = {} + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/state/GameManagerDialogState.kt b/app/src/main/java/app/gamenative/ui/component/dialog/state/GameManagerDialogState.kt new file mode 100644 index 0000000000..64bdfcd76a --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/state/GameManagerDialogState.kt @@ -0,0 +1,23 @@ +package app.gamenative.ui.component.dialog.state + +import androidx.compose.runtime.saveable.mapSaver +import app.gamenative.data.LibraryItem + +data class GameManagerDialogState( + val visible: Boolean, +) { + companion object { + val Saver = mapSaver( + save = { state -> + mapOf( + "visible" to state.visible, + ) + }, + restore = { savedMap -> + GameManagerDialogState( + visible = savedMap["visible"] as Boolean, + ) + }, + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/icons/VR.kt b/app/src/main/java/app/gamenative/ui/component/icons/VR.kt deleted file mode 100644 index 3085c06223..0000000000 --- a/app/src/main/java/app/gamenative/ui/component/icons/VR.kt +++ /dev/null @@ -1,70 +0,0 @@ -package app.gamenative.ui.component.icons - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.materialPath -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -@Suppress("UnusedReceiverParameter") -val Icons.Filled.VR: ImageVector - get() { - if (vr != null) { - return vr!! - } - vr = ImageVector.Builder( - name = "VR", - defaultWidth = 24.0.dp, - defaultHeight = 24.0.dp, - viewportWidth = 36.0F, - viewportHeight = 36.0F, - ).materialPath { - moveTo(11.45F, 26.5F) - horizontalLineTo(7.625F) - lineTo(1.0F, 9.0F) - horizontalLineTo(5.025F) - lineTo(9.625F, 22.325F) - lineTo(14.1F, 9.0F) - horizontalLineTo(18.125F) - lineTo(11.45F, 26.5F) - close() - }.materialPath { - moveTo(34.552F, 26.5F) - horizontalLineTo(30.477F) - lineTo(26.952F, 20.6F) - horizontalLineTo(26.527F) - horizontalLineTo(23.927F) - verticalLineTo(26.5F) - horizontalLineTo(20.252F) - verticalLineTo(9.0F) - horizontalLineTo(26.802F) - curveTo(29.202F, 9.0F, 30.9686F, 9.48333F, 32.102F, 10.45F) - curveTo(33.2353F, 11.4F, 33.802F, 12.7333F, 33.802F, 14.45F) - curveTo(33.802F, 15.8F, 33.502F, 16.925F, 32.902F, 17.825F) - curveTo(32.3186F, 18.725F, 31.4936F, 19.4083F, 30.427F, 19.875F) - lineTo(34.552F, 26.5F) - - moveTo(23.927F, 12.125F) - verticalLineTo(17.45F) - horizontalLineTo(26.802F) - curveTo(27.7686F, 17.45F, 28.5186F, 17.2083F, 29.052F, 16.725F) - curveTo(29.602F, 16.225F, 29.877F, 15.5417F, 29.877F, 14.675F) - curveTo(29.877F, 13.825F, 29.6103F, 13.1917F, 29.077F, 12.775F) - curveTo(28.5603F, 12.3417F, 27.727F, 12.125F, 26.577F, 12.125F) - horizontalLineTo(23.927F) - close() - }.build() - return vr!! - } -private var vr: ImageVector? = null - -@Preview -@Composable -@Suppress("UnusedPrivateMember") -private fun IconVRPreview() { - Image(modifier = Modifier.size(64.dp), imageVector = Icons.Filled.VR, contentDescription = null) -} 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 f97992756b..28ad685abb 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 @@ -37,9 +37,7 @@ fun AccountButton( var persona by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - SteamService.userSteamId?.let { id -> - persona = SteamService.getPersonaStateOf(id) - } + persona = SteamService.instance?.localPersona?.value } DisposableEffect(true) { diff --git a/app/src/main/java/app/gamenative/ui/data/ChatState.kt b/app/src/main/java/app/gamenative/ui/data/ChatState.kt deleted file mode 100644 index bacefd5657..0000000000 --- a/app/src/main/java/app/gamenative/ui/data/ChatState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.gamenative.ui.data - -import app.gamenative.data.Emoticon -import app.gamenative.data.FriendMessage -import app.gamenative.data.SteamFriend - -data class ChatState( - val friend: SteamFriend = SteamFriend(0), - val messages: List = listOf(), - val emoticons: List = listOf(), - val isLoading: Boolean = true, -) diff --git a/app/src/main/java/app/gamenative/ui/data/FriendsState.kt b/app/src/main/java/app/gamenative/ui/data/FriendsState.kt deleted file mode 100644 index 09fc730339..0000000000 --- a/app/src/main/java/app/gamenative/ui/data/FriendsState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package app.gamenative.ui.data - -import app.gamenative.PrefManager -import app.gamenative.data.OwnedGames -import app.gamenative.data.SteamFriend -import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.ProfileInfoCallback - -data class FriendsState( - val friendsList: Map> = emptyMap(), - val collapsedListSections: Set = PrefManager.friendsListHeader, - val profileFriend: SteamFriend? = null, - val profileFriendInfo: ProfileInfoCallback? = null, - val profileFriendGames: List = emptyList(), - val profileFriendAlias: List = emptyList(), -) diff --git a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt index a6e1607178..81b935a73d 100644 --- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt +++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt @@ -23,9 +23,10 @@ data class LibraryState( val isSearching: Boolean = false, val searchQuery: String = "", - // App Source filters (Steam / Custom Games) + // App Source filters (Steam / Custom Games / GOG) val showSteamInLibrary: Boolean = PrefManager.showSteamInLibrary, val showCustomGamesInLibrary: Boolean = PrefManager.showCustomGamesInLibrary, + val showGOGInLibrary: Boolean = PrefManager.showGOGInLibrary, // Loading state for skeleton loaders val isLoading: Boolean = false, 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 90a1129cec..9e8c81c2c2 100644 --- a/app/src/main/java/app/gamenative/ui/data/MainState.kt +++ b/app/src/main/java/app/gamenative/ui/data/MainState.kt @@ -19,6 +19,7 @@ data class MainState( val isSteamConnected: Boolean = false, val launchedAppId: String = "", val bootToContainer: Boolean = false, + val testGraphics: Boolean = false, val showBootingSplash: Boolean = false, val bootingSplashText: String = "Booting...", diff --git a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt index 2e7167a4d7..c2e1a8a17f 100644 --- a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt +++ b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt @@ -19,5 +19,7 @@ enum class AppOptionMenuType(val text: String) { ForceCloudSync("Force cloud sync"), ForceDownloadRemote("Force download remote saves"), ForceUploadLocal("Force upload local saves"), - FetchSteamGridDBImages("Fetch game images") + FetchSteamGridDBImages("Fetch game images"), + TestGraphics("Test graphics"), + ManageGameContent("Manage DLC") } diff --git a/app/src/main/java/app/gamenative/ui/internal/FakeSteamFriends.kt b/app/src/main/java/app/gamenative/ui/internal/FakeSteamFriends.kt deleted file mode 100644 index f975946c7c..0000000000 --- a/app/src/main/java/app/gamenative/ui/internal/FakeSteamFriends.kt +++ /dev/null @@ -1,28 +0,0 @@ -package app.gamenative.ui.internal - -import app.gamenative.data.SteamFriend -import `in`.dragonbra.javasteam.enums.EPersonaState -import kotlin.random.Random - -fun fakeSteamFriends( - id: Long = 0, - online: Boolean = true, - inGame: Boolean = true, -): List { - return List(5) { item -> - SteamFriend( - id = item + id, - name = "Friend $item", - avatarHash = when (item) { - 0 -> "eb59deb3b9282854064421f7c43f4c79bceaf6d8" - 1 -> "59d19880457012c47ea57bc29f599a4d2f663a35" - 2 -> "df3be70187c6d900600796c86963e3e3a1376deb" - 3 -> "d8ebede431682097c76492df1c2209b552c8f61b" - 4 -> "c9180f93ac892fa7d078f5946239d049e987e3b6" - else -> "" - }, - state = if (online) EPersonaState.Online else EPersonaState.Offline, - gameAppID = if (inGame) Random.nextInt(1, 1000) else 0, - ) - } -} diff --git a/app/src/main/java/app/gamenative/ui/model/ChatViewModel.kt b/app/src/main/java/app/gamenative/ui/model/ChatViewModel.kt deleted file mode 100644 index 3191bc1ece..0000000000 --- a/app/src/main/java/app/gamenative/ui/model/ChatViewModel.kt +++ /dev/null @@ -1,115 +0,0 @@ -package app.gamenative.ui.model - -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import app.gamenative.db.dao.EmoticonDao -import app.gamenative.db.dao.FriendMessagesDao -import app.gamenative.db.dao.SteamFriendDao -import app.gamenative.service.SteamService -import app.gamenative.ui.data.ChatState -import dagger.hilt.android.lifecycle.HiltViewModel -import `in`.dragonbra.javasteam.types.SteamID -import javax.inject.Inject -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import timber.log.Timber - -@HiltViewModel -class ChatViewModel @Inject constructor( - private val friendDao: SteamFriendDao, - private val messagesDao: FriendMessagesDao, - private val emoticonDao: EmoticonDao, -) : ViewModel() { - - private val _chatState = MutableStateFlow(ChatState()) - val chatState: StateFlow = _chatState.asStateFlow() - - // Keep the chat scroll state. This will last longer as the VM will stay alive. - var listState: LazyListState by mutableStateOf(LazyListState(0, 0)) - - private var chatJob: Job? = null - private var typingJob: Job? = null - private var lastTypingSent = 0L - - override fun onCleared() { - super.onCleared() - - Timber.d("onCleared") - - chatJob?.cancel() - } - - fun setFriend(id: Long) { - Timber.d("Chatting with $id") - chatJob?.cancel() - - chatJob = viewModelScope.launch { - launch { - // Since were initiating a chat, refresh our list of emoticons and stickers - SteamService.getEmoticonList() - SteamService.getRecentMessages(id) - SteamService.ackMessage(id) - } - - launch { - emoticonDao.getAll().collect { list -> - Timber.tag("ChatViewModel").d("Got Emotes: ${list.size}") - _chatState.update { it.copy(emoticons = list) } - } - } - - launch { - friendDao.findFriendFlow(id).collect { friend -> - if (friend == null) { - throw RuntimeException("Friend is null and cannot proceed") - } - Timber.tag("ChatViewModel").d("Friend update $friend") - _chatState.update { it.copy(friend = friend) } - } - } - - launch { - messagesDao.getAllMessagesForFriend(id).collect { list -> - Timber.tag("ChatViewModel").d("New messages ${list.size}") - _chatState.update { it.copy(messages = list) } - } - } - } - } - - fun onTyping() { - val now = System.currentTimeMillis() - - if (typingJob == null || now - lastTypingSent > 15000) { - typingJob?.cancel() - typingJob = viewModelScope.launch { - SteamService.sendTypingMessage(_chatState.value.friend.id) - lastTypingSent = now - } - } - } - - fun onSendMessage(message: String) { - typingJob?.cancel() - typingJob = null - - viewModelScope.launch { - with(_chatState.value.friend) { - if (!SteamID(id).isValid) { - Timber.w("Friend ID invalid, not sending message") - return@launch - } - - SteamService.sendMessage(id, message) - } - } - } -} diff --git a/app/src/main/java/app/gamenative/ui/model/FriendsViewModel.kt b/app/src/main/java/app/gamenative/ui/model/FriendsViewModel.kt deleted file mode 100644 index 532e36f2bb..0000000000 --- a/app/src/main/java/app/gamenative/ui/model/FriendsViewModel.kt +++ /dev/null @@ -1,163 +0,0 @@ -package app.gamenative.ui.model - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import app.gamenative.PluviaApp -import app.gamenative.PrefManager -import app.gamenative.data.OwnedGames -import app.gamenative.db.dao.SteamFriendDao -import app.gamenative.events.SteamEvent -import app.gamenative.service.SteamService -import app.gamenative.ui.data.FriendsState -import dagger.hilt.android.lifecycle.HiltViewModel -import `in`.dragonbra.javasteam.types.SteamID -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import timber.log.Timber - -@HiltViewModel -class FriendsViewModel @Inject constructor( - private val steamFriendDao: SteamFriendDao, -) : ViewModel() { - - private val _friendsState = MutableStateFlow(FriendsState()) - val friendsState: StateFlow = _friendsState.asStateFlow() - - private var selectedFriendJob: Job? = null - private var observeFriendListJob: Job? = null - - private val onAliasHistory: (SteamEvent.OnAliasHistory) -> Unit = { - _friendsState.update { currentState -> currentState.copy(profileFriendAlias = it.names) } - } - - init { - observeFriendList() - PluviaApp.events.on(onAliasHistory) - } - - override fun onCleared() { - Timber.d("onCleared") - - selectedFriendJob?.cancel() - observeFriendListJob?.cancel() - PluviaApp.events.off(onAliasHistory) - } - - fun observeSelectedFriend(friendID: Long) { - selectedFriendJob?.cancel() - - // Force clear states when this method if is called again. - _friendsState.update { - it.copy( - profileFriend = null, - profileFriendGames = emptyList(), - profileFriendInfo = null, - profileFriendAlias = emptyList(), - ) - } - - viewModelScope.launch { - launch { - val resp = SteamService.getProfileInfo(SteamID(friendID)) - _friendsState.update { it.copy(profileFriendInfo = resp) } - } - launch { - val resp = SteamService.getOwnedGames(friendID).sortedWith( - compareBy { (it.sortAs ?: it.name).lowercase() } - .thenByDescending { it.playtimeTwoWeeks }, - ) - - resp.forEach { - Timber.d(it.toString()) - } - - _friendsState.update { it.copy(profileFriendGames = resp) } - } - selectedFriendJob = launch { - steamFriendDao.findFriendFlow(friendID).collect { friend -> - if (friend == null) { - Timber.w("Collecting friend was null") - return@collect - } - _friendsState.update { it.copy(profileFriend = friend) } - } - } - } - } - - fun onHeaderAction(value: String) { - _friendsState.update { currentState -> - val list = currentState.collapsedListSections.toMutableSet() - if (value in list) { - list.remove(value) - } else { - list.add(value) - } - PrefManager.friendsListHeader = list - currentState.copy(collapsedListSections = list) - } - } - - fun onBlock(friendID: Long) { - viewModelScope.launch { - SteamService.blockFriend(friendID) - } - } - - fun onRemove(friendID: Long) { - viewModelScope.launch { - SteamService.removeFriend(friendID) - } - } - - fun onNickName(value: String) { - viewModelScope.launch { - SteamService.setNickName(_friendsState.value.profileFriend!!.id, value) - } - } - - fun onAlias() { - viewModelScope.launch { - SteamService.requestAliasHistory(_friendsState.value.profileFriend!!.id) - } - } - - private fun observeFriendList() { - observeFriendListJob = viewModelScope.launch(Dispatchers.IO) { - steamFriendDao.getAllFriendsFlow().collect { friends -> - _friendsState.update { currentState -> - val sortedList = friends - .filter { it.isFriend && !it.isBlocked } - .sortedWith( - compareBy( - { it.isRequestRecipient.not() }, - { it.isPlayingGame.not() }, - { it.isInGameAwayOrSnooze }, - { it.isOnline.not() }, - { it.isAwayOrSnooze }, - { it.isOffline.not() }, - { it.nameOrNickname.lowercase() }, - ), - ) - - val groupedList = sortedList.groupBy { friend -> - when { - friend.isRequestRecipient -> "Friend Request" - friend.isPlayingGame || friend.isInGameAwayOrSnooze -> "In-Game" - friend.isOnline || friend.isAwayOrSnooze -> "Online" - else -> "Offline" - } - }.toMap() - - currentState.copy(friendsList = groupedList) - } - } - } - } -} 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 fbec3b58ea..0e0bd7cec5 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -10,10 +10,12 @@ import androidx.lifecycle.viewModelScope import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.data.GameCompatibilityStatus -import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.data.SteamApp +import app.gamenative.data.GOGGame +import app.gamenative.data.GameSource import app.gamenative.db.dao.SteamAppDao +import app.gamenative.db.dao.GOGGameDao import app.gamenative.events.AndroidEvent import app.gamenative.service.DownloadService import app.gamenative.service.SteamService @@ -33,6 +35,7 @@ import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -43,6 +46,7 @@ import timber.log.Timber @HiltViewModel class LibraryViewModel @Inject constructor( private val steamAppDao: SteamAppDao, + private val gogGameDao: GOGGameDao, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -68,10 +72,15 @@ class LibraryViewModel @Inject constructor( // Complete and unfiltered app list private var appList: List = emptyList() + private var gogGameList: List = emptyList() // Track if this is the first load to apply minimum load time private var isFirstLoad = true + // Track debounce job for search + private var searchDebounceJob: Job? = null + private val SEARCH_DEBOUNCE_MS = 500L // 500ms debounce + // Cache GPU name to avoid repeated calls private val gpuName: String by lazy { try { @@ -95,11 +104,21 @@ class LibraryViewModel @Inject constructor( // ownerIds = SteamService.familyMembers.ifEmpty { listOf(SteamService.userSteamId!!.accountID.toInt()) }, ).collect { apps -> Timber.tag("LibraryViewModel").d("Collecting ${apps.size} apps") - + // Check if the list has actually changed before triggering a re-filter if (appList.size != apps.size) { - // Don't filter if it's no change appList = apps + onFilterApps(paginationCurrentPage) + } + } + } + // Collect GOG games + viewModelScope.launch(Dispatchers.IO) { + gogGameDao.getAll().collect { games -> + Timber.tag("LibraryViewModel").d("Collecting ${games.size} GOG games") + // Check if the list has actually changed before triggering a re-filter + if (gogGameList != games) { + gogGameList = games onFilterApps(paginationCurrentPage) } } @@ -110,6 +129,7 @@ class LibraryViewModel @Inject constructor( } override fun onCleared() { + searchDebounceJob?.cancel() PluviaApp.events.off(onInstallStatusChanged) PluviaApp.events.off(onCustomGameImagesFetched) super.onCleared() @@ -140,6 +160,11 @@ class LibraryViewModel @Inject constructor( PrefManager.showCustomGamesInLibrary = newValue _state.update { it.copy(showCustomGamesInLibrary = newValue) } } + GameSource.GOG -> { + val newValue = !current.showGOGInLibrary + PrefManager.showGOGInLibrary = newValue + _state.update { it.copy(showGOGInLibrary = newValue) } + } } onFilterApps(paginationCurrentPage) } @@ -160,8 +185,18 @@ class LibraryViewModel @Inject constructor( } fun onSearchQuery(value: String) { + // Update UI immediately for responsive typing _state.update { it.copy(searchQuery = value) } - onFilterApps() + + // Cancel previous debounce job + searchDebounceJob?.cancel() + + // Start new debounce job + searchDebounceJob = viewModelScope.launch { + delay(SEARCH_DEBOUNCE_MS) + // Only trigger filter after user stops typing + onFilterApps() + } } // TODO: include other sort types @@ -194,6 +229,9 @@ class LibraryViewModel @Inject constructor( viewModelScope.launch { _state.update { it.copy(isRefreshing = true) } + // Clear compatibility cache on manual refresh to get fresh data + GameCompatibilityCache.clear() + try { val newApps = SteamService.refreshOwnedGamesFromServer() if (newApps > 0) { @@ -201,14 +239,24 @@ class LibraryViewModel @Inject constructor( } else { Timber.tag("LibraryViewModel").d("No newly owned games discovered during refresh") } + if (app.gamenative.service.gog.GOGService.hasStoredCredentials(context)) { + Timber.tag("LibraryViewModel").i("Triggering GOG library refresh") + app.gamenative.service.gog.GOGService.triggerLibrarySync(context) + } } catch (e: Exception) { Timber.tag("LibraryViewModel").e(e, "Failed to refresh owned games from server") } finally { onFilterApps(0).join() + // Fetch compatibility for current page after refresh + val currentPageGames = _state.value.appInfoList.map { it.name } + if (currentPageGames.isNotEmpty()) { + fetchCompatibilityForPage(currentPageGames) + } _state.update { it.copy(isRefreshing = false) } } } } + fun addCustomGameFolder(path: String) { viewModelScope.launch(Dispatchers.IO) { val normalizedPath = File(path).absolutePath @@ -232,16 +280,18 @@ class LibraryViewModel @Inject constructor( private fun onFilterApps(paginationPage: Int = 0): Job { // May be filtering 1000+ apps - in future should paginate at the point of DAO request Timber.tag("LibraryViewModel").d("onFilterApps - appList.size: ${appList.size}, isFirstLoad: $isFirstLoad") - return viewModelScope.launch { + return viewModelScope.launch(Dispatchers.IO) { _state.update { it.copy(isLoading = true) } val currentState = _state.value val currentFilter = AppFilter.getAppType(currentState.appInfoSortType) + // Fetch download directory apps once on IO thread and cache as a HashSet for O(1) lookups val downloadDirectoryApps = DownloadService.getDownloadDirectoryApps() + val downloadDirectorySet = downloadDirectoryApps.toHashSet() // Filter Steam apps first (no pagination yet) - val downloadDirectorySet = downloadDirectoryApps.toHashSet() + // Note: Don't sort individual lists - we'll sort the combined list for consistent ordering val filteredSteamApps: List = appList .asSequence() .filter { item -> @@ -323,12 +373,48 @@ class LibraryViewModel @Inject constructor( } val customEntries = customGameItems.map { LibraryEntry(it, true) } + // Filter GOG games + val filteredGOGGames = gogGameList + .asSequence() + .filter { game -> + if (currentState.searchQuery.isNotEmpty()) { + game.title.contains(currentState.searchQuery, ignoreCase = true) + } else { + true + } + } + .filter { game -> + if (currentState.appInfoSortType.contains(AppFilter.INSTALLED)) { + game.isInstalled + } else { + true + } + } + .toList() + + val gogEntries = filteredGOGGames.map { game -> + LibraryEntry( + item = LibraryItem( + index = 0, + appId = "${GameSource.GOG.name}_${game.id}", + name = game.title, + iconHash = game.imageUrl.ifEmpty { game.iconUrl }, + isShared = false, + gameSource = GameSource.GOG, + ), + isInstalled = game.isInstalled, + ) + } + + val gogInstalledCount = filteredGOGGames.count { it.isInstalled } // Save game counts for skeleton loaders (only when not searching, to get accurate counts) // This needs to happen before filtering by source, so we save the total counts if (currentState.searchQuery.isEmpty()) { PrefManager.customGamesCount = customGameItems.size PrefManager.steamGamesCount = filteredSteamApps.size - Timber.tag("LibraryViewModel").d("Saved counts - Custom: ${customGameItems.size}, Steam: ${filteredSteamApps.size}") + PrefManager.gogGamesCount = filteredGOGGames.size + PrefManager.gogInstalledGamesCount = gogInstalledCount + Timber.tag("LibraryViewModel").d("Saved counts - Custom: ${customGameItems.size}, Steam: ${filteredSteamApps.size}, GOG: ${filteredGOGGames.size}, GOG installed: $gogInstalledCount") } // Compute effective source filters based on current tab @@ -344,6 +430,11 @@ class LibraryViewModel @Inject constructor( } else { currentTab.showCustom } + val includeGOG = if (currentTab == app.gamenative.ui.enums.LibraryTab.ALL) { + _state.value.showGOGInLibrary + } else { + currentTab.showGoG + } // Combine both lists and apply sort option val sortComparator: Comparator = when (currentState.currentSortOption) { @@ -369,6 +460,7 @@ class LibraryViewModel @Inject constructor( val combined = buildList { if (includeSteam) addAll(steamEntries) if (includeOpen) addAll(customEntries) + if (includeGOG) addAll(gogEntries) }.sortedWith(sortComparator).mapIndexed { idx, entry -> entry.item.copy(index = idx, isInstalled = entry.isInstalled) } @@ -442,8 +534,19 @@ class LibraryViewModel @Inject constructor( Timber.tag("LibraryViewModel").d("Cached: ${cachedResults.size}, Uncached: ${uncachedGames.size}") - // Fetch uncached games in batches of 50 - val batchSize = 50 + // Update state with cached results immediately (for instant UI update) + if (cachedResults.isNotEmpty()) { + updateCompatibilityState(cachedResults) + } + + // Only fetch if there are uncached games + if (uncachedGames.isEmpty()) { + Timber.tag("LibraryViewModel").d("All games in page are cached, skipping API call") + return@launch + } + + // Fetch uncached games in batches of 25 + val batchSize = 25 val fetchedResults = mutableMapOf() for (i in uncachedGames.indices step batchSize) { @@ -453,39 +556,17 @@ class LibraryViewModel @Inject constructor( if (batchResults != null) { Timber.tag("LibraryViewModel").d("Received ${batchResults.size} results from API") - // Cache all results - batchResults.forEach { (gameName, response) -> - GameCompatibilityCache.cache(gameName, response) - fetchedResults[gameName] = response - } + // Cache all results using batch caching + GameCompatibilityCache.cacheAll(batchResults) + fetchedResults.putAll(batchResults) } else { Timber.tag("LibraryViewModel").w("API returned null for batch") } } - // Combine cached and fetched results - val allResults = cachedResults + fetchedResults - Timber.tag("LibraryViewModel").d("Total results: ${allResults.size}") - - // Convert to compatibility status map - val compatibilityMap = allResults.mapValues { (gameName, response) -> - val status = when { - response.isNotWorking -> GameCompatibilityStatus.NOT_COMPATIBLE - !response.hasBeenTried -> GameCompatibilityStatus.UNKNOWN - response.gpuPlayableCount > 0 -> GameCompatibilityStatus.GPU_COMPATIBLE - response.totalPlayableCount > 0 -> GameCompatibilityStatus.COMPATIBLE - else -> GameCompatibilityStatus.UNKNOWN - } - Timber.tag("LibraryViewModel").d("$gameName -> $status (totalPlayable: ${response.totalPlayableCount}, gpuPlayable: ${response.gpuPlayableCount}, isNotWorking: ${response.isNotWorking}, hasBeenTried: ${response.hasBeenTried})") - status - } - - // Update state with compatibility map (merge with existing) - _state.update { currentState -> - val mergedMap = currentState.compatibilityMap.toMutableMap() - mergedMap.putAll(compatibilityMap) - Timber.tag("LibraryViewModel").d("Updating state with ${compatibilityMap.size} new entries, total: ${mergedMap.size}") - currentState.copy(compatibilityMap = mergedMap) + // Update state with newly fetched results + if (fetchedResults.isNotEmpty()) { + updateCompatibilityState(fetchedResults) } } catch (e: Exception) { Timber.tag("LibraryViewModel").e(e, "Error fetching compatibility data: ${e.message}") @@ -493,4 +574,30 @@ class LibraryViewModel @Inject constructor( } } } + + /** + * Updates the state with compatibility results. + */ + private fun updateCompatibilityState( + results: Map + ) { + val compatibilityMap = results.mapValues { (gameName, response) -> + val status = when { + response.isNotWorking -> GameCompatibilityStatus.NOT_COMPATIBLE + !response.hasBeenTried -> GameCompatibilityStatus.UNKNOWN + response.gpuPlayableCount > 0 -> GameCompatibilityStatus.GPU_COMPATIBLE + response.totalPlayableCount > 0 -> GameCompatibilityStatus.COMPATIBLE + else -> GameCompatibilityStatus.UNKNOWN + } + status + } + + // Update state with compatibility map (merge with existing) + _state.update { currentState -> + val mergedMap = currentState.compatibilityMap.toMutableMap() + mergedMap.putAll(compatibilityMap) + Timber.tag("LibraryViewModel").d("Updated state with ${compatibilityMap.size} compatibility entries, total: ${mergedMap.size}") + currentState.copy(compatibilityMap = mergedMap) + } + } } 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 17bda46196..fbe6c620ee 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -8,6 +8,8 @@ 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.data.GameSource import app.gamenative.di.IAppTheme import app.gamenative.enums.AppTheme import app.gamenative.enums.LoginResult @@ -427,6 +429,10 @@ class MainViewModel @Inject constructor( _state.update { it.copy(bootToContainer = value) } } + fun setTestGraphics(value: Boolean) { + _state.update { it.copy(testGraphics = value) } + } + fun launchApp(context: Context, appId: String) { // Show booting splash before launching the app viewModelScope.launch { @@ -457,6 +463,7 @@ class MainViewModel @Inject constructor( fun exitSteamApp(context: Context, appId: String) { viewModelScope.launch { + Timber.tag("Exit").i("Exiting, getting feedback for appId: $appId") bootingSplashTimeoutJob?.cancel() bootingSplashTimeoutJob = null setShowBootingSplash(false) @@ -464,11 +471,38 @@ class MainViewModel @Inject constructor( val hadTemporaryOverride = IntentLaunchManager.hasTemporaryOverride(appId) val gameId = ContainerUtils.extractGameIdFromContainerId(appId) - + Timber.tag("Exit").i("Got game id: $gameId") SteamService.notifyRunningProcesses() - SteamService.closeApp(gameId, isOffline.value) { prefix -> - PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) - }.await() + + // Check if this is a GOG game and sync cloud saves + val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) + if (gameSource == GameSource.GOG) { + Timber.tag("GOG").i("[Cloud Saves] GOG Game detected for $appId — syncing cloud saves after close") + // Sync cloud saves (upload local changes to cloud) + // Run in background, don't block UI + viewModelScope.launch(Dispatchers.IO) { + try { + Timber.tag("GOG").d("[Cloud Saves] Starting post-game upload sync for $appId") + val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves( + context = context, + appId = appId, + preferredAction = "upload" + ) + if (syncSuccess) { + Timber.tag("GOG").i("[Cloud Saves] Upload sync completed successfully for $appId") + } else { + Timber.tag("GOG").w("[Cloud Saves] Upload sync failed for $appId") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Exception during upload sync for $appId") + } + } + } else { + // For Steam games, sync cloud saves + SteamService.closeApp(gameId, isOffline.value) { prefix -> + PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) + }.await() + } // Prompt user to save temporary container configuration if one was applied if (hadTemporaryOverride) { 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 e421cc09e5..cfb53b0c9e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt @@ -21,6 +21,7 @@ fun HomeScreen( onChat: (Long) -> Unit, onClickExit: () -> Unit, onClickPlay: (String, Boolean) -> Unit, + onTestGraphics: (String) -> Unit, onLogout: () -> Unit, onNavigateRoute: (String) -> Unit, onGoOnline: () -> Unit, @@ -36,6 +37,7 @@ fun HomeScreen( // Always show the Library screen HomeLibraryScreen( onClickPlay = onClickPlay, + onTestGraphics = onTestGraphics, onNavigateRoute = onNavigateRoute, onLogout = onLogout, onGoOnline = onGoOnline, @@ -57,6 +59,7 @@ private fun Preview_HomeScreenContent() { HomeScreen( onChat = {}, onClickPlay = { _, _ -> }, + onTestGraphics = { }, onLogout = {}, onNavigateRoute = {}, onClickExit = {}, diff --git a/app/src/main/java/app/gamenative/ui/screen/chat/ChatInput.kt b/app/src/main/java/app/gamenative/ui/screen/chat/ChatInput.kt deleted file mode 100644 index d62e51d904..0000000000 --- a/app/src/main/java/app/gamenative/ui/screen/chat/ChatInput.kt +++ /dev/null @@ -1,495 +0,0 @@ -package app.gamenative.ui.screen.chat - -import android.content.res.Configuration -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.QuestionMark -import androidx.compose.material.icons.outlined.EmojiEmotions -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusTarget -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.SemanticsPropertyKey -import androidx.compose.ui.semantics.SemanticsPropertyReceiver -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.dp -import app.gamenative.Constants -import app.gamenative.R -import app.gamenative.data.Emoticon -import app.gamenative.service.SteamService -import app.gamenative.ui.theme.PluviaTheme -import com.skydoves.landscapist.ImageOptions -import com.skydoves.landscapist.coil.CoilImage -import timber.log.Timber - -/** - * Heavily referenced from: - * https://github.com/android/compose-samples/tree/main/Jetchat - */ - -val KeyboardShownKey = SemanticsPropertyKey("KeyboardShownKey") -var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey - -enum class EmojiStickerSelector { - NONE, - EMOJI, - STICKER, -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ChatInput( - modifier: Modifier = Modifier, - onMessageSent: (String) -> Unit, - onTyping: () -> Unit, - onResetScroll: () -> Unit, -) { - var isEmoticonsShowing by rememberSaveable { mutableStateOf(EmojiStickerSelector.NONE) } - val dismissKeyboard = { isEmoticonsShowing = EmojiStickerSelector.NONE } - - // Intercept back navigation if there's a InputSelector visible - if (isEmoticonsShowing != EmojiStickerSelector.NONE) { - BackHandler(onBack = dismissKeyboard) - } - - var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue()) - } - - // Used to decide if the keyboard should be shown - var textFieldFocusState by remember { mutableStateOf(false) } - - Surface(tonalElevation = 2.dp, contentColor = MaterialTheme.colorScheme.secondary) { - Column(modifier = modifier) { - UserInputText( - textFieldValue = textState, - onTextChanged = { - textState = it - onTyping() - }, - // Only show the keyboard if there's no input selector and text field has focus - keyboardShown = isEmoticonsShowing == EmojiStickerSelector.NONE && textFieldFocusState, - // Close extended selector if text field receives focus - onTextFieldFocused = { focused -> - if (focused) { - isEmoticonsShowing = EmojiStickerSelector.NONE - onResetScroll() - } - textFieldFocusState = focused - }, - onMessageSent = { - onMessageSent(textState.text) - // Reset text field and close keyboard - textState = TextFieldValue() - // Move scroll to bottom - onResetScroll() - }, - isEmoticonShowing = isEmoticonsShowing, - onEmoticonClick = { - isEmoticonsShowing = if (isEmoticonsShowing == EmojiStickerSelector.NONE) { - EmojiStickerSelector.EMOJI - } else { - EmojiStickerSelector.NONE - } - }, - ) - - SelectorExpanded( - isEmoticonsShowing = isEmoticonsShowing, - onTextAdded = { textState = textState.addText(":$it: ") }, - onStickerAdded = { - onMessageSent("/sticker $it") - onResetScroll() - }, - ) - } - } -} - -private fun TextFieldValue.addText(newString: String): TextFieldValue { - val newText = this.text.replaceRange( - this.selection.start, - this.selection.end, - newString, - ) - val newSelection = TextRange( - start = newText.length, - end = newText.length, - ) - - return this.copy(text = newText, selection = newSelection) -} - -@Composable -private fun SelectorExpanded( - isEmoticonsShowing: EmojiStickerSelector, - onTextAdded: (String) -> Unit, - onStickerAdded: (String) -> Unit, -) { - if (isEmoticonsShowing == EmojiStickerSelector.NONE) return - - // Request focus to force the TextField to lose it - val focusRequester = FocusRequester() - // If the selector is shown, always request focus to trigger a TextField.onFocusChange. - SideEffect { - if (isEmoticonsShowing == EmojiStickerSelector.EMOJI || isEmoticonsShowing == EmojiStickerSelector.STICKER) { - focusRequester.requestFocus() - } - } - - var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) } - - var emotes by rememberSaveable { mutableStateOf(listOf()) } - LaunchedEffect(isEmoticonsShowing) { - emotes = SteamService.fetchEmoticons() - Timber.d("Emote size: ${emotes.size}") - } - - Surface(tonalElevation = 8.dp) { - when (isEmoticonsShowing) { - EmojiStickerSelector.EMOJI, - EmojiStickerSelector.STICKER, - -> EmojiSelector( - emotes = emotes, - focusRequester = focusRequester, - emojiSelector = selected, - onInnerSelection = { selected = it }, - onTextAdded = onTextAdded, - onStickerAdded = onStickerAdded, - ) - - else -> throw NotImplementedError("Invalid Emoji selector $isEmoticonsShowing") - } - } -} - -@ExperimentalFoundationApi -@Composable -private fun UserInputText( - keyboardType: KeyboardType = KeyboardType.Text, - onTextChanged: (TextFieldValue) -> Unit, - textFieldValue: TextFieldValue, - keyboardShown: Boolean, - onTextFieldFocused: (Boolean) -> Unit, - onMessageSent: () -> Unit, - isEmoticonShowing: EmojiStickerSelector, - onEmoticonClick: () -> Unit, -) { - Box( - Modifier.fillMaxWidth(), - ) { - UserInputTextField( - modifier = Modifier - .fillMaxWidth() - .semantics { - keyboardShownProperty = keyboardShown - }, - textFieldValue = textFieldValue, - onTextChanged = onTextChanged, - onTextFieldFocused = onTextFieldFocused, - keyboardType = keyboardType, - onMessageSent = onMessageSent, - isEmoticonShowing = isEmoticonShowing, - onEmoticonClick = onEmoticonClick, - ) - } -} - -@Composable -private fun UserInputTextField( - modifier: Modifier = Modifier, - textFieldValue: TextFieldValue, - onTextChanged: (TextFieldValue) -> Unit, - onTextFieldFocused: (Boolean) -> Unit, - isEmoticonShowing: EmojiStickerSelector, - onEmoticonClick: () -> Unit, - keyboardType: KeyboardType, - onMessageSent: () -> Unit, -) { - var lastFocusState by remember { mutableStateOf(false) } - - TextField( - modifier = modifier - .onFocusChanged { state -> - if (lastFocusState != state.isFocused) { - onTextFieldFocused(state.isFocused) - } - lastFocusState = state.isFocused - }, - value = textFieldValue, - onValueChange = { onTextChanged(it) }, - keyboardOptions = KeyboardOptions( - keyboardType = keyboardType, - imeAction = ImeAction.Send, - ), - keyboardActions = KeyboardActions { - if (textFieldValue.text.isNotBlank()) onMessageSent() - }, - maxLines = 3, - textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current), - placeholder = { - Text(text = androidx.compose.ui.res.stringResource(app.gamenative.R.string.chat_send_message)) - }, - leadingIcon = { - val colors = if (isEmoticonShowing == EmojiStickerSelector.NONE) { - IconButtonDefaults.iconButtonColors() - } else { - IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.onSecondary) - } - - IconButton( - colors = colors, - onClick = onEmoticonClick, - content = { - Icon(imageVector = Icons.Outlined.EmojiEmotions, null) - }, - ) - }, - trailingIcon = { - val buttonColors = ButtonDefaults.buttonColors( - disabledContainerColor = Color.Transparent, - disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), - ) - val border = if (textFieldValue.text.trim().isEmpty()) { - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), - ) - } else { - null - } - Button( - modifier = Modifier - .padding(horizontal = 12.dp) - .height(36.dp), - enabled = textFieldValue.text.trim().isNotEmpty(), - onClick = onMessageSent, - colors = buttonColors, - border = border, - contentPadding = PaddingValues(0.dp), - content = { - Text( - text = androidx.compose.ui.res.stringResource(app.gamenative.R.string.chat_send), - modifier = Modifier.padding(horizontal = 16.dp), - ) - }, - ) - }, - ) -} - -@Composable -fun EmojiSelector( - emotes: List, - focusRequester: FocusRequester, - emojiSelector: EmojiStickerSelector, - onInnerSelection: (EmojiStickerSelector) -> Unit, - onTextAdded: (String) -> Unit, - onStickerAdded: (String) -> Unit, -) { - Column( - modifier = Modifier - .focusRequester(focusRequester) // Requests focus when the Emoji selector is displayed - .focusTarget(), // Make the emoji selector focusable so it can steal focus from TextField - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - ) { - ExtendedSelectorInnerButton( - text = androidx.compose.ui.res.stringResource(app.gamenative.R.string.chat_emoticons), - onClick = { onInnerSelection(EmojiStickerSelector.EMOJI) }, - selected = emojiSelector == EmojiStickerSelector.EMOJI, - modifier = Modifier.weight(1f), - ) - ExtendedSelectorInnerButton( - text = androidx.compose.ui.res.stringResource(app.gamenative.R.string.chat_stickers), - onClick = { onInnerSelection(EmojiStickerSelector.STICKER) }, - selected = emojiSelector == EmojiStickerSelector.STICKER, - modifier = Modifier.weight(1f), - ) - } - - EmoteTable( - modifier = Modifier.padding(8.dp), - emoticons = emotes.filter { - if (emojiSelector == EmojiStickerSelector.EMOJI) !it.isSticker else it.isSticker - }, - onTextAdded = onTextAdded, - onStickerAdded = onStickerAdded, - ) - } -} - -@Composable -fun ExtendedSelectorInnerButton( - text: String, - onClick: () -> Unit, - selected: Boolean, - modifier: Modifier = Modifier, -) { - val colors = ButtonDefaults.buttonColors( - containerColor = if (selected) { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) - } else { - Color.Transparent - }, - disabledContainerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onSurface, - disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f), - ) - TextButton( - modifier = modifier - .padding(8.dp) - .height(36.dp), - onClick = onClick, - colors = colors, - contentPadding = PaddingValues(0.dp), - content = { Text(text = text, style = MaterialTheme.typography.titleSmall) }, - ) -} - -@Composable -fun EmoteTable( - modifier: Modifier = Modifier, - emoticons: List, - onTextAdded: (String) -> Unit, - onStickerAdded: (String) -> Unit, -) { - LazyVerticalGrid( - modifier = modifier.height(270.dp), - columns = GridCells.Adaptive(64.dp), - content = { - items(emoticons) { emoticon -> - CoilImage( - modifier = Modifier - .padding(8.dp) - .size(64.dp) - .clickable { - if (emoticon.isSticker) { - onStickerAdded(emoticon.name) - } else { - onTextAdded(emoticon.name) - } - }, - imageModel = { - val url = if (emoticon.isSticker) { - Constants.Chat.STICKER_URL - } else { - Constants.Chat.EMOTICON_URL - } - url + emoticon.name - }, - imageOptions = ImageOptions( - contentDescription = "${if (emoticon.isSticker) "Sticker" else "Emoticon"} ${emoticon.name}", - contentScale = ContentScale.Inside, - ), - loading = { - CircularProgressIndicator( - modifier = Modifier - .padding(8.dp) - .size(32.dp), - ) - }, - failure = { - Icon(Icons.Filled.QuestionMark, null) - }, - previewPlaceholder = painterResource(R.drawable.ic_logo_color), - ) - } - }, - ) -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -@Composable -fun Preview_ChatInput() { - PluviaTheme { - Column( - modifier = Modifier - .imePadding() - .fillMaxSize(), - ) { - Box(modifier = Modifier.weight(1f)) - ChatInput( - onMessageSent = {}, - onTyping = {}, - onResetScroll = {}, - ) - } - } -} - -internal class EmojiSelectorPreview : PreviewParameterProvider { - override val values = sequenceOf(EmojiStickerSelector.EMOJI, EmojiStickerSelector.STICKER) -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -@Composable -fun Preview_EmojiSelector( - @PreviewParameter(EmojiSelectorPreview::class) state: EmojiStickerSelector, -) { - PluviaTheme { - EmojiSelector( - emotes = List(25) { - Emoticon("emote$it", appID = it, isSticker = state == EmojiStickerSelector.STICKER) - }, - focusRequester = FocusRequester(), - emojiSelector = state, - onInnerSelection = {}, - onTextAdded = {}, - onStickerAdded = {}, - ) - } -} diff --git a/app/src/main/java/app/gamenative/ui/screen/chat/ChatMessageItem.kt b/app/src/main/java/app/gamenative/ui/screen/chat/ChatMessageItem.kt deleted file mode 100644 index 16fb07c441..0000000000 --- a/app/src/main/java/app/gamenative/ui/screen/chat/ChatMessageItem.kt +++ /dev/null @@ -1,106 +0,0 @@ -package app.gamenative.ui.screen.chat - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -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.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import app.gamenative.ui.component.BBCodeText -import app.gamenative.ui.theme.PluviaTheme - -@Composable -fun ChatBubble( - message: String, - timestamp: String, - fromLocal: Boolean, - modifier: Modifier = Modifier, - bubbleColor: Color = if (fromLocal) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, - textColor: Color = if (fromLocal) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondary, - timestampColor: Color = MaterialTheme.colorScheme.surfaceVariant, - bubbleShape: Shape = RoundedCornerShape(16.dp), - maxWidth: Dp = 280.dp, - contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp), -) { - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - horizontalAlignment = if (fromLocal) Alignment.End else Alignment.Start, - ) { - Box( - modifier = Modifier - .widthIn(max = maxWidth) - .background( - color = bubbleColor, - shape = bubbleShape, - ), - ) { - Column(modifier = Modifier.padding(contentPadding)) { - // The message - BBCodeText( - modifier = Modifier.align(if (fromLocal) Alignment.End else Alignment.Start), - text = message, - color = textColor, - style = MaterialTheme.typography.bodyLarge, - ) - - // The time - Text( - text = timestamp, - color = timestampColor, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .align(Alignment.End) - .padding(top = 6.dp), - ) - } - } - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -@Composable -fun ChatBubblePreview() { - PluviaTheme { - Surface { - Column { - ChatBubble( - message = "Hey", - timestamp = "Jan 00 - 00:00 PM", - fromLocal = true, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - ChatBubble( - message = ":O!!!", - timestamp = "Jan 00 - 00:00 PM", - fromLocal = false, - ) - - ChatBubble( - message = "Wow very cool, we should play a game together sometime! How does Team Fortress 2 sound?", - timestamp = "Jan 00 - 00:00 PM", - fromLocal = false, - ) - } - } - } -} diff --git a/app/src/main/java/app/gamenative/ui/screen/chat/ChatScreen.kt b/app/src/main/java/app/gamenative/ui/screen/chat/ChatScreen.kt deleted file mode 100644 index 129478de7d..0000000000 --- a/app/src/main/java/app/gamenative/ui/screen/chat/ChatScreen.kt +++ /dev/null @@ -1,391 +0,0 @@ -package app.gamenative.ui.screen.chat - -import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.exclude -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.ScaffoldDefaults -import androidx.compose.material3.SmallFloatingActionButton -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.PlatformTextStyle -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import app.gamenative.PrefManager -import app.gamenative.data.FriendMessage -import app.gamenative.data.SteamFriend -import app.gamenative.ui.component.topbar.BackButton -import app.gamenative.ui.data.ChatState -import app.gamenative.ui.internal.fakeSteamFriends -import app.gamenative.ui.model.ChatViewModel -import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.util.ListItemImage -import app.gamenative.utils.SteamUtils -import app.gamenative.utils.getAvatarURL -import kotlinx.coroutines.launch - -@Composable -fun ChatScreen( - friendId: Long, - viewModel: ChatViewModel = hiltViewModel(), - onBack: () -> Unit, -) { - val state by viewModel.chatState.collectAsStateWithLifecycle() - - LaunchedEffect(friendId) { - viewModel.setFriend(friendId) - } - - ChatScreenContent( - state = state, - scrollState = viewModel.listState, - onBack = onBack, - onTyping = viewModel::onTyping, - onSendMessage = viewModel::onSendMessage, - ) -} - -@Composable -private fun ChatScreenContent( - state: ChatState, - scrollState: LazyListState, - onBack: () -> Unit, - onTyping: () -> Unit, - onSendMessage: (String) -> Unit, -) { - val snackbarHost = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - val context = androidx.compose.ui.platform.LocalContext.current - - // NOTE: This should be removed once chat is considered stable. - LaunchedEffect(Unit) { - if (!PrefManager.ackChatPreview) { - scope.launch { - val result = snackbarHost.showSnackbar( - message = context.getString(app.gamenative.R.string.chat_preview_warning), - actionLabel = context.getString(app.gamenative.R.string.ok), - ) - - if (result == SnackbarResult.ActionPerformed) { - PrefManager.ackChatPreview = true - } - } - } - } - - Scaffold( - // Exclude ime and navigation bar padding so this can be added by the ChatInput composable - contentWindowInsets = ScaffoldDefaults - .contentWindowInsets - .exclude(WindowInsets.navigationBars) - .exclude(WindowInsets.ime), - topBar = { - ChatTopBar( - steamFriend = state.friend, - onBack = onBack, - // onProfile = { - // val msg = "View profile not implemented!\nTry long pressing a friend in the friends list?" - // scope.launch { snackbarHost.showSnackbar(msg) } - // }, - ) - }, - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - ChatMessages( - modifier = Modifier.weight(1f), - snackbarHost = snackbarHost, - state = state, - scrollState = scrollState, - ) - - // let this element handle the padding so that the elevation is shown behind the - // navigation bar - ChatInput( - modifier = Modifier - .navigationBarsPadding() - .imePadding(), - onMessageSent = onSendMessage, - onTyping = onTyping, - onResetScroll = { - // Scroll to the bottom of the list regardless of the scroll state - scope.launch { - scrollState.animateScrollToItem(0) - } - }, - ) - } - } -} - -@Composable -private fun ChatMessages( - modifier: Modifier = Modifier, - snackbarHost: SnackbarHostState, - state: ChatState, - scrollState: LazyListState, -) { - val scope = rememberCoroutineScope() - - Box(modifier = modifier) { - AnimatedVisibility(state.messages.isEmpty()) { - NoChatHistoryBox() - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = scrollState, - reverseLayout = true, - ) { - items(state.messages, key = { it.id }) { msg -> - ChatBubble( - message = msg.message, - timestamp = SteamUtils.fromSteamTime(msg.timestamp), - fromLocal = msg.fromLocal, - ) - } - } - - // Show the button if the first visible item is not the first one or if the offset is - // greater than the threshold. - val jumpToBottomButtonEnabled by remember { - derivedStateOf { - // Arbitrary threshold to show the button - scrollState.firstVisibleItemIndex > 3 - } - } - - LaunchedEffect(state.messages) { - if (!jumpToBottomButtonEnabled) { - scrollState.animateScrollToItem(0) - } - } - - AnimatedVisibility( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp), - visible = jumpToBottomButtonEnabled, - enter = fadeIn() + scaleIn(), - exit = scaleOut() + fadeOut(), - ) { - SmallFloatingActionButton( - onClick = { - scope.launch { - scrollState.animateScrollToItem(0) - } - }, - content = { - Icon(imageVector = Icons.Default.KeyboardDoubleArrowDown, contentDescription = null) - }, - ) - } - - SnackbarHost( - modifier = Modifier.align(Alignment.BottomCenter), - hostState = snackbarHost, - ) - } -} - -@Composable -private fun NoChatHistoryBox() { - 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 = androidx.compose.ui.res.stringResource(app.gamenative.R.string.chat_no_history), - textAlign = TextAlign.Center, - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ChatTopBar( - steamFriend: SteamFriend, - onBack: () -> Unit, - // onProfile: () -> Unit, -) { - CenterAlignedTopAppBar( - title = { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - ListItemImage( - image = { steamFriend.avatarHash.getAvatarURL() }, - size = 40.dp, - ) - - Spacer(modifier = Modifier.size(12.dp)) - - Column { - CompositionLocalProvider( - LocalContentColor provides steamFriend.statusColor, - LocalTextStyle provides TextStyle( - lineHeight = 1.em, - platformStyle = PlatformTextStyle(includeFontPadding = false), - ), - ) { - Text( - overflow = TextOverflow.Ellipsis, - fontSize = 20.sp, - maxLines = 1, - text = buildAnnotatedString { - append(steamFriend.nameOrNickname) - if (steamFriend.statusIcon != null) { - append(" ") - appendInlineContent("icon", "[icon]") - } - }, - inlineContent = mapOf( - "icon" to InlineTextContent( - Placeholder( - width = 16.sp, - height = 16.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - children = { - steamFriend.statusIcon?.let { - Icon(imageVector = it, tint = Color.LightGray, contentDescription = it.name) - } - }, - ), - ), - ) - - Text( - text = steamFriend.isPlayingGameName, - overflow = TextOverflow.Ellipsis, - fontSize = 12.sp, - maxLines = 1, - color = LocalContentColor.current.copy(alpha = .75f), - ) - } - } - } - }, - navigationIcon = { - BackButton(onClick = onBack) - }, - // actions = { - // IconButton( - // onClick = onProfile, - // content = { Icon(imageVector = Icons.Default.Person, contentDescription = null) }, - // ) - // }, - ) -} - -internal class MessagesPreviewProvider : PreviewParameterProvider> { - override val values = sequenceOf( - emptyList(), - List(20) { - FriendMessage( - id = it.plus(1).toLong(), - steamIDFriend = 76561198003805806, - fromLocal = it % 3 == 0, - message = if (it > 18) { - "[sticker type=\"Delivery Cat in a Blanket\", value=0][/sticker]" - } else { - """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, - sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - """.trimIndent() - }, - // lowPriority = false, - timestamp = 1737438789 + it, - ) - }, - ) -} - -/* NOTE: Launching this composable in preview will make the TopBar shift up.*/ -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -@Composable -private fun Preview_ChatScreenContent( - @PreviewParameter(MessagesPreviewProvider::class) messages: List, -) { - PluviaTheme { - ChatScreenContent( - state = ChatState( - friend = fakeSteamFriends()[1], - messages = messages, - ), - scrollState = rememberLazyListState(), - onBack = { }, - onSendMessage = { }, - onTyping = { }, - ) - } -} 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 4de977f577..95d22a788d 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 @@ -91,6 +91,7 @@ import app.gamenative.ui.enums.AppOptionMenuType import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.screen.library.appscreen.CustomGameAppScreen import app.gamenative.ui.screen.library.appscreen.SteamAppScreen +import app.gamenative.ui.screen.library.appscreen.GOGAppScreen import app.gamenative.ui.screen.library.components.GameOptionsPanel import app.gamenative.ui.theme.PluviaTheme import app.gamenative.ui.util.AdaptiveHeroHeight @@ -354,6 +355,7 @@ private fun InfoCard( fun AppScreen( libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit, + onTestGraphics: () -> Unit, onBack: () -> Unit, ) { // Get the appropriate screen model based on game source @@ -361,6 +363,7 @@ fun AppScreen( when (libraryItem.gameSource) { app.gamenative.data.GameSource.STEAM -> SteamAppScreen() app.gamenative.data.GameSource.CUSTOM_GAME -> CustomGameAppScreen() + app.gamenative.data.GameSource.GOG -> GOGAppScreen() } } @@ -368,6 +371,7 @@ fun AppScreen( screenModel.Content( libraryItem = libraryItem, onClickPlay = onClickPlay, + onTestGraphics = onTestGraphics, onBack = onBack, ) } @@ -398,6 +402,7 @@ internal fun AppScreenContent( downloadProgress: Float, hasPartialDownload: Boolean, isUpdatePending: Boolean, + downloadInfo: app.gamenative.data.DownloadInfo? = null, onDownloadInstallClick: () -> Unit, onPauseResumeClick: () -> Unit, onDeleteDownloadClick: () -> Unit, @@ -1090,6 +1095,7 @@ private fun Preview_AppScreen() { downloadProgress = .50f, hasPartialDownload = false, isUpdatePending = false, + downloadInfo = null, onDownloadInstallClick = { isDownloading = !isDownloading }, onPauseResumeClick = { }, onDeleteDownloadClick = { }, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index 5374241f4f..39befb4011 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 @@ -81,6 +81,7 @@ import app.gamenative.utils.CustomGameScanner fun HomeLibraryScreen( viewModel: LibraryViewModel = hiltViewModel(), onClickPlay: (String, Boolean) -> Unit, + onTestGraphics: (String) -> Unit, onNavigateRoute: (String) -> Unit, onLogout: () -> Unit, onGoOnline: () -> Unit, @@ -100,6 +101,7 @@ fun HomeLibraryScreen( onSearchQuery = viewModel::onSearchQuery, onRefresh = viewModel::onRefresh, onClickPlay = onClickPlay, + onTestGraphics = onTestGraphics, onNavigateRoute = onNavigateRoute, onLogout = onLogout, onGoOnline = onGoOnline, @@ -124,6 +126,7 @@ private fun LibraryScreenContent( onIsSearching: (Boolean) -> Unit, onSearchQuery: (String) -> Unit, onClickPlay: (String, Boolean) -> Unit, + onTestGraphics: (String) -> Unit, onRefresh: () -> Unit, onNavigateRoute: (String) -> Unit, onLogout: () -> Unit, @@ -149,6 +152,9 @@ private fun LibraryScreenContent( } var isSystemMenuOpen by remember { mutableStateOf(false) } + // Keep a stable reference to the selected item so detail view doesn't disappear during list refresh/pagination. + var selectedLibraryItem by remember { mutableStateOf(null) } + val filterFabExpanded by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } // Dialog state for add custom game prompt var showAddCustomGameDialog by remember { mutableStateOf(false) } @@ -321,12 +327,15 @@ private fun LibraryScreenContent( // Use Box to allow content to scroll behind the tab bar Box(modifier = Modifier.fillMaxSize()) { // Library list (content scrolls behind tab bar) - LibraryListPane( + LibraryListPane( state = state, listState = listState, currentLayout = currentPaneType, onPageChange = onPageChange, - onNavigate = { appId -> selectedAppId = appId }, + onNavigate = { appId -> + selectedAppId = appId + selectedLibraryItem = state.appInfoList.firstOrNull { it.appId == appId } + }, onRefresh = onRefresh, modifier = Modifier.fillMaxSize(), ) @@ -364,19 +373,22 @@ private fun LibraryScreenContent( } } } else { - // Find the LibraryItem from the state based on selectedAppId - val selectedLibraryItem = selectedAppId?.let { appId -> - state.appInfoList.find { it.appId == appId } - } - LibraryDetailPane( libraryItem = selectedLibraryItem, - onBack = { selectedAppId = null }, + onBack = { + selectedAppId = null + selectedLibraryItem = null + }, onClickPlay = { selectedLibraryItem?.let { libraryItem -> onClickPlay(libraryItem.appId, it) } }, + onTestGraphics = { + selectedLibraryItem?.let { libraryItem -> + onTestGraphics(libraryItem.appId) + } + }, ) } @@ -562,6 +574,7 @@ private fun Preview_LibraryScreenContent() { state = state.copy(modalBottomSheet = !currentState) }, onClickPlay = { _, _ -> }, + onTestGraphics = { }, onRefresh = { }, onNavigateRoute = {}, onLogout = {}, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 8e259f63f2..6691560f68 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -46,6 +47,22 @@ import kotlinx.coroutines.withContext * This defines the contract that all game source-specific screens must implement. */ abstract class BaseAppScreen { + // Shared state for install dialog - map of appId (String) to MessageDialogState + companion object { + private val installDialogStates = mutableStateMapOf() + + fun showInstallDialog(appId: String, state: app.gamenative.ui.component.dialog.state.MessageDialogState) { + installDialogStates[appId] = state + } + + fun hideInstallDialog(appId: String) { + installDialogStates.remove(appId) + } + + fun getInstallDialogState(appId: String): app.gamenative.ui.component.dialog.state.MessageDialogState? { + return installDialogStates[appId] + } + } /** * Get the game display information for rendering the UI. * This is called to get all the data needed for the common UI layout. @@ -188,6 +205,20 @@ abstract class BaseAppScreen { ) } + @Composable + protected open fun getTestGraphicsOption( + context: Context, + libraryItem: LibraryItem, + onTestGraphics: () -> Unit + ): AppMenuOption? { + return AppMenuOption( + AppOptionMenuType.TestGraphics, + onClick = { + onTestGraphicsClick(context, libraryItem, onTestGraphics) + } + ) + } + @Composable protected abstract fun getResetContainerOption( context: Context, @@ -355,10 +386,6 @@ abstract class BaseAppScreen { ) } - /** - * Hook method called when RunContainer is clicked. - * Override this to add custom behavior (e.g., analytics tracking). - */ protected open fun onRunContainerClick( context: Context, libraryItem: LibraryItem, @@ -367,6 +394,14 @@ abstract class BaseAppScreen { onClickPlay(true) } + protected open fun onTestGraphicsClick( + context: Context, + libraryItem: LibraryItem, + onTestGraphics: () -> Unit + ) { + onTestGraphics() + } + /** * Get the game folder path for image fetching. * Override this in subclasses to provide source-specific path resolution. @@ -439,6 +474,7 @@ abstract class BaseAppScreen { onEditContainer: () -> Unit, onBack: () -> Unit, onClickPlay: (Boolean) -> Unit, + onTestGraphics: () -> Unit, exportFrontendLauncher: ActivityResultLauncher ): List { val isInstalled = isInstalled(context, libraryItem) @@ -450,6 +486,7 @@ abstract class BaseAppScreen { if (isInstalled) { // Options only available when game is installed getRunContainerOption(context, libraryItem, onClickPlay)?.let { menuOptions.add(it) } + getTestGraphicsOption(context, libraryItem, onTestGraphics)?.let { menuOptions.add(it) } getResetContainerOption(context, libraryItem)?.let { menuOptions.add(it) } getCreateShortcutOption(context, libraryItem)?.let { menuOptions.add(it) } getExportContainerOption(context, libraryItem, exportFrontendLauncher)?.let { menuOptions.add(it) } @@ -484,6 +521,7 @@ abstract class BaseAppScreen { fun Content( libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit, + onTestGraphics: () -> Unit, onBack: () -> Unit, ) { val context = LocalContext.current @@ -583,7 +621,14 @@ abstract class BaseAppScreen { }, ) - val optionsMenu = getOptionsMenu(context, libraryItem, onEditContainer, onBack, onClickPlay, exportFrontendLauncher) + val optionsMenu = getOptionsMenu(context, libraryItem, onEditContainer, onBack, onClickPlay, onTestGraphics, exportFrontendLauncher) + + // Get download info based on game source for progress tracking + val downloadInfo = when (libraryItem.gameSource) { + app.gamenative.data.GameSource.STEAM -> app.gamenative.service.SteamService.getAppDownloadInfo(displayInfo.gameId) + app.gamenative.data.GameSource.GOG -> app.gamenative.service.gog.GOGService.getDownloadInfo(displayInfo.gameId.toString()) + app.gamenative.data.GameSource.CUSTOM_GAME -> null // Custom games don't support downloads yet + } DisposableEffect(libraryItem.appId) { val dispose = observeGameState( @@ -613,6 +658,7 @@ abstract class BaseAppScreen { downloadProgress = downloadProgressState, hasPartialDownload = hasPartialDownloadState, isUpdatePending = isUpdatePendingState, + downloadInfo = downloadInfo, onDownloadInstallClick = { onDownloadInstallClick(context, libraryItem, onClickPlay) uiScope.launch { @@ -627,7 +673,9 @@ abstract class BaseAppScreen { performStateRefresh(false) } }, - onDeleteDownloadClick = { onDeleteDownloadClick(context, libraryItem) }, + onDeleteDownloadClick = { + onDeleteDownloadClick(context, libraryItem) + }, onUpdateClick = { onUpdateClick(context, libraryItem) uiScope.launch { diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt new file mode 100644 index 0000000000..040bede0ac --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt @@ -0,0 +1,631 @@ +package app.gamenative.ui.screen.library.appscreen + +import android.content.Context +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.gamenative.R +import app.gamenative.data.GOGGame +import app.gamenative.data.LibraryItem +import app.gamenative.service.gog.GOGConstants +import app.gamenative.service.gog.GOGService +import app.gamenative.ui.data.AppMenuOption +import app.gamenative.ui.data.GameDisplayInfo +import app.gamenative.ui.enums.AppOptionMenuType +import com.winlator.container.ContainerData +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +/** + * GOG-specific implementation of BaseAppScreen + * Handles GOG games with integration to the Python gogdl backend + */ +class GOGAppScreen : BaseAppScreen() { + companion object { + private const val TAG = "GOGAppScreen" + + // Shared state for uninstall dialog - list of appIds that should show the dialog + private val uninstallDialogAppIds = mutableStateListOf() + + fun showUninstallDialog(appId: String) { + Timber.tag(TAG).d("showUninstallDialog: appId=$appId") + if (!uninstallDialogAppIds.contains(appId)) { + uninstallDialogAppIds.add(appId) + Timber.tag(TAG).d("Added to uninstall dialog list: $appId") + } + } + + fun hideUninstallDialog(appId: String) { + Timber.tag(TAG).d("hideUninstallDialog: appId=$appId") + uninstallDialogAppIds.remove(appId) + } + + fun shouldShowUninstallDialog(appId: String): Boolean { + val result = uninstallDialogAppIds.contains(appId) + Timber.tag(TAG).d("shouldShowUninstallDialog: appId=$appId, result=$result") + return result + } + + /** + * Formats bytes into a human-readable string (KB, MB, GB). + * Uses binary units (1024 base). + */ + private fun formatBytes(bytes: Long): String { + val kb = 1024.0 + val mb = kb * 1024 + val gb = mb * 1024 + return when { + bytes >= gb -> String.format(Locale.US, "%.1f GB", bytes / gb) + bytes >= mb -> String.format(Locale.US, "%.1f MB", bytes / mb) + bytes >= kb -> String.format(Locale.US, "%.1f KB", bytes / kb) + else -> "$bytes B" + } + } + } + + @Composable + override fun getGameDisplayInfo( + context: Context, + libraryItem: LibraryItem, + ): GameDisplayInfo { + Timber.tag(TAG).d("getGameDisplayInfo: appId=${libraryItem.appId}, name=${libraryItem.name}") + // Extract numeric gameId for GOGService calls + val gameId = libraryItem.gameId.toString() + + // Add a refresh trigger to re-fetch game data when install status changes + var refreshTrigger by remember { mutableStateOf(0) } + + // Listen for install status changes to refresh game data + LaunchedEffect(gameId) { + val installListener: (app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged) -> Unit = { event -> + if (event.appId == libraryItem.gameId) { + Timber.tag(TAG).d("Install status changed, refreshing game data for $gameId") + refreshTrigger++ + } + } + app.gamenative.PluviaApp.events.on(installListener) + } + + var gogGame by remember(gameId, refreshTrigger) { mutableStateOf(null) } + + LaunchedEffect(gameId, refreshTrigger) { + gogGame = GOGService.getGOGGameOf(gameId) + } + + val game = gogGame + + // Format sizes for display + val sizeOnDisk = if (game != null && game.isInstalled && game.installSize > 0) { + formatBytes(game.installSize) + } else { + null + } + + val sizeFromStore = if (game != null && game.downloadSize > 0) { + formatBytes(game.downloadSize) + } else { + null + } + + // Parse GOG's ISO 8601 release date string to Unix timestamp + // GOG returns dates like "2022-08-18T17:50:00+0300" (without colon in timezone) + // GameDisplayInfo expects Unix timestamp in SECONDS, not milliseconds + val releaseDateTimestamp = if (game?.releaseDate?.isNotEmpty() == true) { + try { + val formatter = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ") + val timestampMillis = java.time.ZonedDateTime.parse(game.releaseDate, formatter).toInstant().toEpochMilli() + val timestampSeconds = timestampMillis / 1000 + Timber.tag(TAG).d("Parsed release date '${game.releaseDate}' -> $timestampSeconds seconds (${java.util.Date(timestampMillis)})") + timestampSeconds + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to parse release date: ${game.releaseDate}") + 0L + } + } else { + 0L + } + + val displayInfo = GameDisplayInfo( + name = game?.title ?: libraryItem.name, + iconUrl = game?.iconUrl ?: libraryItem.iconHash, + heroImageUrl = game?.imageUrl ?: game?.iconUrl ?: libraryItem.iconHash, + gameId = libraryItem.gameId, // Use gameId property which handles conversion + appId = libraryItem.appId, + releaseDate = releaseDateTimestamp, + developer = game?.developer?.takeIf { it.isNotEmpty() } ?: "", // GOG API doesn't provide this + installLocation = game?.installPath?.takeIf { it.isNotEmpty() }, + sizeOnDisk = sizeOnDisk, + sizeFromStore = sizeFromStore, + ) + Timber.tag(TAG).d("Returning GameDisplayInfo: name=${displayInfo.name}, iconUrl=${displayInfo.iconUrl}, heroImageUrl=${displayInfo.heroImageUrl}, developer=${displayInfo.developer}, installLocation=${displayInfo.installLocation}") + return displayInfo + } + + override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean { + Timber.tag(TAG).d("isInstalled: checking appId=${libraryItem.appId}") + return try { + // GOGService expects numeric gameId + val installed = GOGService.isGameInstalled(libraryItem.gameId.toString()) + Timber.tag(TAG).d("isInstalled: appId=${libraryItem.appId}, result=$installed") + installed + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to check install status for ${libraryItem.appId}") + false + } + } + + override fun isValidToDownload(context: Context, libraryItem: LibraryItem): Boolean { + Timber.tag(TAG).d("isValidToDownload: checking appId=${libraryItem.appId}") + // GOG games can be downloaded if not already installed or downloading + val installed = isInstalled(context, libraryItem) + val downloading = isDownloading(context, libraryItem) + val valid = !installed && !downloading + Timber.tag(TAG).d("isValidToDownload: appId=${libraryItem.appId}, installed=$installed, downloading=$downloading, valid=$valid") + return valid + } + + override fun isDownloading(context: Context, libraryItem: LibraryItem): Boolean { + Timber.tag(TAG).d("isDownloading: checking appId=${libraryItem.appId}") + // Check if there's an active download for this GOG game + // GOGService expects numeric gameId + val downloadInfo = GOGService.getDownloadInfo(libraryItem.gameId.toString()) + val progress = downloadInfo?.getProgress() ?: 0f + val isActive = downloadInfo?.isActive() ?: false + val downloading = downloadInfo != null && isActive && progress < 1f + Timber.tag(TAG).d("isDownloading: appId=${libraryItem.appId}, hasDownloadInfo=${downloadInfo != null}, active=$isActive, progress=$progress, result=$downloading") + return downloading + } + + override fun getDownloadProgress(context: Context, libraryItem: LibraryItem): Float { + // GOGService expects numeric gameId + val downloadInfo = GOGService.getDownloadInfo(libraryItem.gameId.toString()) + val progress = downloadInfo?.getProgress() ?: 0f + Timber.tag(TAG).d("getDownloadProgress: appId=${libraryItem.appId}, progress=$progress") + return progress + } + + override fun hasPartialDownload(context: Context, libraryItem: LibraryItem): Boolean { + // GOG downloads cannot be paused/resumed, so never show as having partial download + // This prevents the UI from showing a resume button + return false + } + + override fun onDownloadInstallClick(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { + Timber.tag(TAG).i("onDownloadInstallClick: appId=${libraryItem.appId}, name=${libraryItem.name}") + // GOGService expects numeric gameId + val gameId = libraryItem.gameId.toString() + val downloadInfo = GOGService.getDownloadInfo(gameId) + val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + val installed = isInstalled(context, libraryItem) + + Timber.tag(TAG).d("onDownloadInstallClick: appId=${libraryItem.appId}, isDownloading=$isDownloading, installed=$installed") + + if (isDownloading) { + // Cancel ongoing download + Timber.tag(TAG).i("Cancelling GOG download for: ${libraryItem.appId}") + downloadInfo.cancel() + GOGService.cleanupDownload(gameId) + } else if (installed) { + // Already installed: launch game + Timber.tag(TAG).i("GOG game already installed, launching: ${libraryItem.appId}") + onClickPlay(false) + } else { + // Show install confirmation dialog + Timber.tag(TAG).i("Showing install confirmation dialog for: ${libraryItem.appId}") + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + try { + val game = GOGService.getGOGGameOf(gameId) + + // Calculate sizes + val downloadSize = app.gamenative.utils.StorageUtils.formatBinarySize(game?.downloadSize ?: 0L) + val availableSpace = app.gamenative.utils.StorageUtils.formatBinarySize( + app.gamenative.utils.StorageUtils.getAvailableSpace(app.gamenative.service.gog.GOGConstants.defaultGOGGamesPath) + ) + + val message = context.getString( + R.string.gog_install_confirmation_message, + downloadSize, + availableSpace + ) + val state = app.gamenative.ui.component.dialog.state.MessageDialogState( + visible = true, + type = app.gamenative.ui.enums.DialogType.INSTALL_APP, + title = context.getString(R.string.gog_install_game_title), + message = message, + confirmBtnText = context.getString(R.string.download), + dismissBtnText = context.getString(R.string.cancel) + ) + BaseAppScreen.showInstallDialog(libraryItem.appId, state) + } catch (e: Exception) { + Timber.e(e, "Failed to show install dialog for: ${libraryItem.appId}") + } + } + } + } + + private fun performDownload(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { + val gameId = libraryItem.gameId.toString() + Timber.i("Starting GOG game download: ${libraryItem.appId}") + CoroutineScope(Dispatchers.IO).launch { + try { + // Get install path + val installPath = GOGConstants.getGameInstallPath(libraryItem.name) + Timber.d("Downloading GOG game to: $installPath") + + // Show starting download toast + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Starting download for ${libraryItem.name}...", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + + // Start download - GOGService will handle monitoring, database updates, verification, and events + val result = GOGService.downloadGame(context, gameId, installPath) + + if (result.isSuccess) { + Timber.i("GOG download started successfully for: $gameId") + // Success toast will be shown when download completes (monitored by GOGService) + } else { + val error = result.exceptionOrNull() + Timber.e(error, "Failed to start GOG download") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Failed to start download: ${error?.message}", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + } catch (e: Exception) { + Timber.e(e, "Error during GOG download") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Download error: ${e.message}", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + } + } + + override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) { + Timber.tag(TAG).i("onPauseResumeClick: appId=${libraryItem.appId}") + // GOG downloads cannot be paused - only canceled + // This method should not be called for GOG since hasPartialDownload returns false, + // but if it is called, just cancel the download + val gameId = libraryItem.gameId.toString() + val downloadInfo = GOGService.getDownloadInfo(gameId) + + if (downloadInfo != null) { + Timber.tag(TAG).i("Cancelling GOG download: ${libraryItem.appId}") + downloadInfo.cancel() + GOGService.cleanupDownload(gameId) + } + } + + override fun onDeleteDownloadClick(context: Context, libraryItem: LibraryItem) { + Timber.tag(TAG).i("onDeleteDownloadClick: appId=${libraryItem.appId}") + // GOGService expects numeric gameId + val gameId = libraryItem.gameId.toString() + val downloadInfo = GOGService.getDownloadInfo(gameId) + val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + val isInstalled = isInstalled(context, libraryItem) + Timber.tag(TAG).d("onDeleteDownloadClick: appId=${libraryItem.appId}, isDownloading=$isDownloading, isInstalled=$isInstalled") + + if (isDownloading) { + // Cancel download immediately if currently downloading + Timber.tag(TAG).i("Cancelling active download for GOG game: ${libraryItem.appId}") + downloadInfo.cancel() + GOGService.cleanupDownload(gameId) + android.widget.Toast.makeText( + context, + "Download cancelled", + android.widget.Toast.LENGTH_SHORT, + ).show() + } else if (isInstalled) { + // Show uninstall confirmation dialog + Timber.tag(TAG).i("Showing uninstall dialog for: ${libraryItem.appId}") + showUninstallDialog(libraryItem.appId) + } + } + + private fun performUninstall(context: Context, libraryItem: LibraryItem) { + Timber.i("Uninstalling GOG game: ${libraryItem.appId}") + CoroutineScope(Dispatchers.IO).launch { + try { + // Delegate to GOGService which calls GOGManager.deleteGame + val result = GOGService.deleteGame(context, libraryItem) + + if (result.isSuccess) { + Timber.i("Successfully uninstalled GOG game: ${libraryItem.appId}") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Game uninstalled successfully", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + } else { + val error = result.exceptionOrNull() + Timber.e(error, "Failed to uninstall GOG game: ${libraryItem.appId}") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Failed to uninstall game: ${error?.message}", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + } catch (e: Exception) { + Timber.e(e, "Error uninstalling GOG game") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Failed to uninstall game: ${e.message}", + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + } + } + + override fun onUpdateClick(context: Context, libraryItem: LibraryItem) { + Timber.tag(TAG).i("onUpdateClick: appId=${libraryItem.appId}") + // TODO: Implement update for GOG games + // Check GOG for newer version and download if available + Timber.tag(TAG).d("Update clicked for GOG game: ${libraryItem.appId}") + } + + override fun getExportFileExtension(): String { + Timber.tag(TAG).d("getExportFileExtension: returning 'tzst'") + // GOG containers use the same export format as other Wine containers + return "tzst" + } + + override fun getInstallPath(context: Context, libraryItem: LibraryItem): String? { + Timber.tag(TAG).d("getInstallPath: appId=${libraryItem.appId}") + return try { + // GOGService expects numeric gameId + val path = GOGService.getInstallPath(libraryItem.gameId.toString()) + Timber.tag(TAG).d("getInstallPath: appId=${libraryItem.appId}, path=$path") + path + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to get install path for ${libraryItem.appId}") + null + } + } + + override fun loadContainerData(context: Context, libraryItem: LibraryItem): ContainerData { + Timber.tag(TAG).d("loadContainerData: appId=${libraryItem.appId}") + // Load GOG-specific container data using ContainerUtils + val container = app.gamenative.utils.ContainerUtils.getOrCreateContainer(context, libraryItem.appId) + val containerData = app.gamenative.utils.ContainerUtils.toContainerData(container) + Timber.tag(TAG).d("loadContainerData: loaded container for ${libraryItem.appId}") + return containerData + } + + override fun saveContainerConfig(context: Context, libraryItem: LibraryItem, config: ContainerData) { + Timber.tag(TAG).i("saveContainerConfig: appId=${libraryItem.appId}") + // Save GOG-specific container configuration using ContainerUtils + app.gamenative.utils.ContainerUtils.applyToContainer(context, libraryItem.appId, config) + Timber.tag(TAG).d("saveContainerConfig: saved container config for ${libraryItem.appId}") + } + + override fun supportsContainerConfig(): Boolean { + Timber.tag(TAG).d("supportsContainerConfig: returning true") + // GOG games support container configuration like other Wine games + return true + } + + /** + * GOG games support standard container reset + */ + @Composable + override fun getResetContainerOption( + context: Context, + libraryItem: LibraryItem, + ): AppMenuOption { + return AppMenuOption( + optionType = AppOptionMenuType.ResetToDefaults, + onClick = { + resetContainerToDefaults(context, libraryItem) + }, + ) + } + override fun getGameFolderPathForImageFetch(context: Context, libraryItem: LibraryItem): String? { + return null // GOG Stores full URLs in their database entry. + } + + override fun observeGameState( + context: Context, + libraryItem: LibraryItem, + onStateChanged: () -> Unit, + onProgressChanged: (Float) -> Unit, + onHasPartialDownloadChanged: ((Boolean) -> Unit)?, + ): (() -> Unit)? { + Timber.tag(TAG).d("[OBSERVE] Setting up observeGameState for appId=${libraryItem.appId}, gameId=${libraryItem.gameId}") + val disposables = mutableListOf<() -> Unit>() + var currentProgressListener: ((Float) -> Unit)? = null + + // Listen for download status changes + val downloadStatusListener: (app.gamenative.events.AndroidEvent.DownloadStatusChanged) -> Unit = { event -> + Timber.tag(TAG).d("[OBSERVE] DownloadStatusChanged event received: event.appId=${event.appId}, libraryItem.gameId=${libraryItem.gameId}, match=${event.appId == libraryItem.gameId}") + if (event.appId == libraryItem.gameId) { + Timber.tag(TAG).d("[OBSERVE] Download status changed for ${libraryItem.appId}, isDownloading=${event.isDownloading}") + if (event.isDownloading) { + // Download started - attach progress listener + // GOGService expects numeric gameId + val downloadInfo = GOGService.getDownloadInfo(libraryItem.gameId.toString()) + if (downloadInfo != null) { + // Remove previous listener if exists + currentProgressListener?.let { listener -> + downloadInfo.removeProgressListener(listener) + } + // Add new listener and track it + val progressListener: (Float) -> Unit = { progress -> + onProgressChanged(progress) + } + downloadInfo.addProgressListener(progressListener) + currentProgressListener = progressListener + + // Add cleanup for this listener + disposables += { + currentProgressListener?.let { listener -> + downloadInfo.removeProgressListener(listener) + currentProgressListener = null + } + } + } + } else { + // Download stopped/completed - clean up listener + currentProgressListener?.let { listener -> + val downloadInfo = GOGService.getDownloadInfo(libraryItem.gameId.toString()) + downloadInfo?.removeProgressListener(listener) + currentProgressListener = null + } + onHasPartialDownloadChanged?.invoke(false) + } + onStateChanged() + } + } + app.gamenative.PluviaApp.events.on(downloadStatusListener) + disposables += + { app.gamenative.PluviaApp.events.off(downloadStatusListener) } + + // Listen for install status changes + val installListener: (app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged) -> Unit = { event -> + Timber.tag(TAG).d("[OBSERVE] LibraryInstallStatusChanged event received: event.appId=${event.appId}, libraryItem.gameId=${libraryItem.gameId}, match=${event.appId == libraryItem.gameId}") + if (event.appId == libraryItem.gameId) { + Timber.tag(TAG).d("[OBSERVE] Install status changed for ${libraryItem.appId}, calling onStateChanged()") + onStateChanged() + } + } + app.gamenative.PluviaApp.events.on(installListener) + disposables += + { app.gamenative.PluviaApp.events.off(installListener) } + + // Return cleanup function + return { + disposables.forEach { it() } + } + } + + /** + * GOG-specific dialogs (install confirmation, uninstall confirmation) + */ + @Composable + override fun AdditionalDialogs( + libraryItem: LibraryItem, + onDismiss: () -> Unit, + onEditContainer: () -> Unit, + onBack: () -> Unit, + ) { + Timber.tag(TAG).d("AdditionalDialogs: composing for appId=${libraryItem.appId}") + val context = LocalContext.current + + + // Monitor uninstall dialog state + var showUninstallDialog by remember { mutableStateOf(shouldShowUninstallDialog(libraryItem.appId)) } + LaunchedEffect(libraryItem.appId) { + snapshotFlow { shouldShowUninstallDialog(libraryItem.appId) } + .collect { shouldShow -> + showUninstallDialog = shouldShow + } + } + + // Shared install dialog state (from BaseAppScreen) + val appId = libraryItem.appId + var installDialogState by remember(appId) { + mutableStateOf(BaseAppScreen.getInstallDialogState(appId) ?: app.gamenative.ui.component.dialog.state.MessageDialogState(false)) + } + LaunchedEffect(appId) { + snapshotFlow { BaseAppScreen.getInstallDialogState(appId) } + .collect { state -> + installDialogState = state ?: app.gamenative.ui.component.dialog.state.MessageDialogState(false) + } + } + + // Show install dialog if visible + if (installDialogState.visible) { + val onDismissRequest: (() -> Unit)? = { + BaseAppScreen.hideInstallDialog(appId) + } + val onDismissClick: (() -> Unit)? = { + BaseAppScreen.hideInstallDialog(appId) + } + val onConfirmClick: (() -> Unit)? = when (installDialogState.type) { + app.gamenative.ui.enums.DialogType.INSTALL_APP -> { + { + BaseAppScreen.hideInstallDialog(appId) + performDownload(context, libraryItem) {} + } + } + else -> null + } + app.gamenative.ui.component.dialog.MessageDialog( + visible = installDialogState.visible, + onDismissRequest = onDismissRequest, + onConfirmClick = onConfirmClick, + onDismissClick = onDismissClick, + confirmBtnText = installDialogState.confirmBtnText, + dismissBtnText = installDialogState.dismissBtnText, + title = installDialogState.title, + message = installDialogState.message, + ) + } + + // Show uninstall confirmation dialog + if (showUninstallDialog) { + AlertDialog( + onDismissRequest = { + hideUninstallDialog(libraryItem.appId) + }, + title = { Text(stringResource(R.string.gog_uninstall_game_title)) }, + text = { + Text( + text = stringResource( + R.string.gog_uninstall_confirmation_message, + libraryItem.name, + ), + ) + }, + confirmButton = { + TextButton( + onClick = { + hideUninstallDialog(libraryItem.appId) + performUninstall(context, libraryItem) + }, + ) { + Text(stringResource(R.string.uninstall)) + } + }, + dismissButton = { + TextButton( + onClick = { + hideUninstallDialog(libraryItem.appId) + }, + ) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index 230062b224..b375870184 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -35,6 +35,8 @@ import app.gamenative.ui.enums.DialogType import app.gamenative.ui.screen.library.GameMigrationDialog import app.gamenative.utils.BestConfigService import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.GameCompatibilityCache +import app.gamenative.utils.GameCompatibilityService import app.gamenative.utils.MarkerUtils import app.gamenative.utils.SteamUtils import app.gamenative.utils.StorageUtils @@ -51,6 +53,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import app.gamenative.ui.component.dialog.GameManagerDialog +import app.gamenative.ui.component.dialog.state.GameManagerDialogState import timber.log.Timber private data class InstallSizeInfo( @@ -130,6 +136,20 @@ class SteamAppScreen : BaseAppScreen() { return installDialogStates[gameId] } + private val gameManagerDialogStates = mutableStateMapOf() + + fun showGameManagerDialog(gameId: Int, state: GameManagerDialogState) { + gameManagerDialogStates[gameId] = state + } + + fun hideGameManagerDialog(gameId: Int) { + gameManagerDialogStates.remove(gameId) + } + + fun getGameManagerDialogState(gameId: Int): GameManagerDialogState? { + return gameManagerDialogStates[gameId] + } + // Shared state for update/verify operation - map of gameId to AppOptionMenuType private val pendingUpdateVerifyOperations = mutableStateMapOf() @@ -255,16 +275,14 @@ class SteamAppScreen : BaseAppScreen() { } } - // Fetch best config compatibility info for uninstalled games + // Fetch compatibility info from cache var compatibilityMessage by remember { mutableStateOf(null) } var compatibilityColor by remember { mutableStateOf(null) } LaunchedEffect(isInstalled, gameId, appInfo.name) { - // Check if container exists try { - val gpuName = GPUInformation.getRenderer(context) - val bestConfig = BestConfigService.fetchBestConfig(appInfo.name, gpuName) - if (bestConfig != null) { - val message = BestConfigService.getCompatibilityMessage(context, bestConfig.matchType) + val cachedResponse = GameCompatibilityCache.getCached(appInfo.name) + if (cachedResponse != null) { + val message = GameCompatibilityService.getCompatibilityMessageFromResponse(context, cachedResponse) compatibilityMessage = message.text compatibilityColor = message.color.value } else { @@ -272,7 +290,7 @@ class SteamAppScreen : BaseAppScreen() { compatibilityColor = null } } catch (e: Exception) { - Timber.tag("SteamAppScreen").e(e, "Failed to fetch best config") + Timber.tag("SteamAppScreen").e(e, "Failed to get compatibility from cache") compatibilityMessage = null compatibilityColor = null } @@ -443,15 +461,11 @@ class SteamAppScreen : BaseAppScreen() { } else if (!isInstalled) { // Request storage permissions first, then show install dialog // This will be handled by the permission launcher in AdditionalDialogs - showInstallDialog( + showGameManagerDialog( gameId, - MessageDialogState( - visible = true, - type = DialogType.INSTALL_APP_PENDING, - title = context.getString(R.string.download_prompt_title), - message = context.getString(R.string.calculating_space_requirements), - dismissBtnText = context.getString(R.string.cancel), - ), + GameManagerDialogState( + visible = true + ) ) } else { // Already installed: launch app @@ -602,8 +616,9 @@ class SteamAppScreen : BaseAppScreen() { val gameId = libraryItem.gameId val appId = libraryItem.appId val appInfo = SteamService.getAppInfoOf(gameId) ?: return emptyList() + val isDownloadInProgress = SteamService.getDownloadingAppInfoOf(gameId) != null - if (!isInstalled) { + if (!isInstalled || isDownloadInProgress) { return emptyList() } @@ -620,6 +635,17 @@ class SteamAppScreen : BaseAppScreen() { container.saveData() }, ), + AppMenuOption( + AppOptionMenuType.ManageGameContent, + onClick = { + showGameManagerDialog( + gameId, + GameManagerDialogState( + visible = true, + ) + ) + } + ), AppMenuOption( AppOptionMenuType.VerifyFiles, onClick = { @@ -838,6 +864,17 @@ class SteamAppScreen : BaseAppScreen() { } } + var gameManagerDialogState by remember(gameId) { + mutableStateOf(getGameManagerDialogState(gameId) ?: GameManagerDialogState(false)) + } + + LaunchedEffect(gameId) { + snapshotFlow { getGameManagerDialogState(gameId) } + .collect { state -> + gameManagerDialogState = state ?: GameManagerDialogState(false) + } + } + // Migration state val scope = rememberCoroutineScope() var showMoveDialog by remember { mutableStateOf(false) } @@ -901,6 +938,7 @@ class SteamAppScreen : BaseAppScreen() { Toast.LENGTH_SHORT, ).show() hideInstallDialog(gameId) + hideGameManagerDialog(gameId) } } @@ -955,6 +993,18 @@ class SteamAppScreen : BaseAppScreen() { } } + LaunchedEffect(gameManagerDialogState.visible, hasStoragePermission) { + if (!gameManagerDialogState.visible) return@LaunchedEffect + if (!hasStoragePermission) { + permissionLauncher.launch( + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ), + ) + } + } + // Install dialog (INSTALL_APP, NOT_ENOUGH_SPACE, CANCEL_APP_DOWNLOAD) if (installDialogState.visible) { val onDismissRequest: (() -> Unit)? = { @@ -1186,5 +1236,36 @@ class SteamAppScreen : BaseAppScreen() { totalFiles = total, ) } + + if (gameManagerDialogState.visible) { + GameManagerDialog( + visible = true, + onGetDisplayInfo = { context -> + return@GameManagerDialog getGameDisplayInfo(context, libraryItem) + }, + onInstall = { dlcAppIds -> + hideGameManagerDialog(gameId) + + val installedApp = SteamService.getInstalledApp(gameId) + if (installedApp != null) { + // Remove markers if the app is already installed + MarkerUtils.removeMarker(getAppDirPath(gameId), Marker.STEAM_DLL_REPLACED) + MarkerUtils.removeMarker(getAppDirPath(gameId), Marker.STEAM_DLL_RESTORED) + MarkerUtils.removeMarker(getAppDirPath(gameId), Marker.STEAM_COLDCLIENT_USED) + } + + PostHog.capture( + event = "game_install_started", + properties = mapOf("game_name" to (appInfo?.name ?: "")) + ) + CoroutineScope(Dispatchers.IO).launch { + SteamService.downloadApp(gameId, dlcAppIds, isUpdateOrVerify = false) + } + }, + onDismissRequest = { + hideGameManagerDialog(gameId) + } + ) + } } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt index 1f198f3d89..f4ec61a2d7 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt @@ -33,11 +33,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.AddToHomeScreen import androidx.compose.material.icons.automirrored.filled.Help import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Animation import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.PlayArrow @@ -332,6 +334,8 @@ private fun getIconForOption(type: AppOptionMenuType): ImageVector { AppOptionMenuType.ForceDownloadRemote -> Icons.Default.CloudDownload AppOptionMenuType.ForceUploadLocal -> Icons.Default.CloudUpload AppOptionMenuType.FetchSteamGridDBImages -> Icons.Default.Image + AppOptionMenuType.ManageGameContent -> Icons.Default.FolderOpen + AppOptionMenuType.TestGraphics -> Icons.Default.Animation } } @@ -356,6 +360,7 @@ private fun groupOptions(options: List): Map gameManagement.add(option) // Container Settings @@ -376,7 +381,11 @@ private fun groupOptions(options: List): Map helpInfo.add(option) + + + } } 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 cbe4c7314a..6321963591 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 @@ -8,11 +8,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -22,14 +26,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.gamenative.PrefManager +import app.gamenative.R import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.ui.enums.PaneType +import app.gamenative.ui.icons.Steam +import app.gamenative.ui.icons.Steam import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.theme.PluviaTheme @@ -120,6 +129,15 @@ internal fun AppItem( } } +@Composable +fun GameSourceIcon(gameSource: GameSource, modifier: Modifier = Modifier, iconSize: Int = 12) { + when (gameSource) { + GameSource.STEAM -> Icon(imageVector = Icons.Filled.Steam, contentDescription = "Steam", modifier = modifier.size(iconSize.dp).alpha(0.7f)) + GameSource.CUSTOM_GAME -> Icon(imageVector = Icons.Filled.Folder, contentDescription = "Custom Game", modifier = modifier.size(iconSize.dp).alpha(0.7f)) + GameSource.GOG -> Icon(painter = painterResource(R.drawable.ic_gog), contentDescription = "Gog", modifier = modifier.size(iconSize.dp).alpha(0.7f)) + } +} + /*********** * PREVIEW * ***********/ @@ -142,6 +160,7 @@ private fun Preview_AppItem() { name = item.name, iconHash = item.iconHash, isShared = idx % 2 == 0, + gameSource = GameSource.STEAM, ) }, itemContent = { @@ -178,7 +197,7 @@ private fun Preview_AppItemGrid() { name = item.name, iconHash = item.iconHash, isShared = idx % 2 == 0, - gameSource = GameSource.STEAM, + gameSource = GameSource.CUSTOM_GAME, ) } 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 new file mode 100644 index 0000000000..e69de29bb2 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 2be0e7348d..626a211962 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 @@ -19,6 +19,7 @@ import java.util.EnumSet internal fun LibraryDetailPane( libraryItem: LibraryItem?, onClickPlay: (Boolean) -> Unit, + onTestGraphics: () -> Unit, onBack: () -> Unit, ) { Surface { @@ -43,6 +44,7 @@ internal fun LibraryDetailPane( AppScreen( libraryItem = libraryItem, onClickPlay = onClickPlay, + onTestGraphics = onTestGraphics, onBack = onBack, ) } @@ -67,6 +69,7 @@ private fun Preview_LibraryDetailPane() { gameSource = GameSource.STEAM, ), onClickPlay = { }, + onTestGraphics = { }, onBack = { }, ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt index 61e2b33a26..df5bc74173 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt @@ -50,6 +50,7 @@ import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.service.SteamService +import app.gamenative.service.gog.GOGService import app.gamenative.ui.component.CompatibilityBadge import app.gamenative.ui.enums.PaneType import app.gamenative.ui.theme.PluviaTheme @@ -241,6 +242,7 @@ private fun GridStatusIcons(appInfo: LibraryItem) { mutableStateOf( when (appInfo.gameSource) { GameSource.STEAM -> SteamService.isAppInstalled(appInfo.gameId) + GameSource.GOG -> GOGService.isGameInstalled(appInfo.gameId.toString()) GameSource.CUSTOM_GAME -> true }, ) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt index 078cf78734..5463ef8794 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt @@ -255,12 +255,6 @@ fun SystemMenu( var showSupporters by remember { mutableStateOf(false) } var showStatusPicker by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - SteamService.userSteamId?.let { id -> - persona = SteamService.getPersonaStateOf(id) - } - } - DisposableEffect(true) { val onPersonaStateReceived: (SteamEvent.PersonaStateReceived) -> Unit = { event -> Timber.d("SystemMenu onPersonaStateReceived: ${event.persona.state}") diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt index c457a91dbc..7011a21e14 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Login import androidx.compose.material.icons.filled.Map import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -60,10 +61,72 @@ import com.winlator.core.AppUtils import app.gamenative.ui.component.dialog.MessageDialog import app.gamenative.ui.component.dialog.LoadingDialog import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch import app.gamenative.utils.LocaleHelper +import app.gamenative.ui.component.dialog.GOGLoginDialog +import app.gamenative.service.gog.GOGService +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import timber.log.Timber +import app.gamenative.PluviaApp +import app.gamenative.events.AndroidEvent + +/** + * Shared GOG authentication handler that manages the complete auth flow. + * + * @param context Android context for service operations + * @param authCode The OAuth authorization code + * @param coroutineScope Coroutine scope for async operations + * @param onLoadingChange Callback when loading state changes + * @param onError Callback when an error occurs (receives error message) + * @param onSuccess Callback when authentication succeeds (receives game count) + * @param onDialogClose Callback to close the login dialog + */ +private suspend fun handleGogAuthentication( + context: Context, + authCode: String, + coroutineScope: CoroutineScope, + onLoadingChange: (Boolean) -> Unit, + onError: (String?) -> Unit, + onSuccess: (Int) -> Unit, + onDialogClose: () -> Unit +) { + onLoadingChange(true) + onError(null) + + try { + Timber.d("[SettingsGOG]: Starting authentication...") + val result = GOGService.authenticateWithCode(context, authCode) + + if (result.isSuccess) { + Timber.i("[SettingsGOG]: ✓ Authentication successful!") + + // Start GOGService and trigger immediate library sync (bypasses throttle) + Timber.i("[SettingsGOG]: Starting GOGService and triggering immediate library sync") + GOGService.start(context) + GOGService.triggerLibrarySync(context) + + // Authentication succeeded - manual sync triggered + onSuccess(0) + onLoadingChange(false) + onDialogClose() + } else { + val error = result.exceptionOrNull()?.message ?: "Authentication failed" + Timber.e("[SettingsGOG]: Authentication failed: $error") + onLoadingChange(false) + onError(error) + } + } catch (e: Exception) { + Timber.e(e, "[SettingsGOG]: Authentication exception: ${e.message}") + onLoadingChange(false) + onError(e.message ?: "Authentication failed") + } +} @Composable fun SettingsGroupInterface( @@ -119,6 +182,51 @@ fun SettingsGroupInterface( ) } + // GOG login dialog state + var openGOGLoginDialog by rememberSaveable { mutableStateOf(false) } + var gogLoginLoading by rememberSaveable { mutableStateOf(false) } + var gogLoginError by rememberSaveable { mutableStateOf(null) } + var gogLoginSuccess by rememberSaveable { mutableStateOf(false) } + + // GOG library sync state + var gogLibrarySyncing by rememberSaveable { mutableStateOf(false) } + var gogLibrarySyncError by rememberSaveable { mutableStateOf(null) } + var gogLibrarySyncSuccess by rememberSaveable { mutableStateOf(false) } + var gogLibraryGameCount by rememberSaveable { mutableStateOf(0) } + + val coroutineScope = rememberCoroutineScope() + + // Listen for GOG OAuth callback + DisposableEffect(Unit) { + Timber.d("[SettingsGOG]: Setting up GOG auth code event listener") + val onGOGAuthCodeReceived: (AndroidEvent.GOGAuthCodeReceived) -> Unit = { event -> + Timber.i("[SettingsGOG]: ✓ Received GOG auth code event! Code: ${event.authCode.take(20)}...") + + coroutineScope.launch { + handleGogAuthentication( + context = context, + authCode = event.authCode, + coroutineScope = coroutineScope, + onLoadingChange = { gogLoginLoading = it }, + onError = { gogLoginError = it }, + onSuccess = { count -> + gogLibraryGameCount = count + gogLoginSuccess = true + }, + onDialogClose = { openGOGLoginDialog = false } + ) + } + } + + PluviaApp.events.on(onGOGAuthCodeReceived) + Timber.d("[SettingsGOG]: GOG auth code event listener registered") + + onDispose { + PluviaApp.events.off(onGOGAuthCodeReceived) + Timber.d("[SettingsGOG]: GOG auth code event listener unregistered") + } + } + SettingsGroup( modifier = Modifier.background(Color.Transparent), title = { Text(text = stringResource(R.string.settings_interface_title)) }, @@ -193,6 +301,36 @@ fun SettingsGroupInterface( } } + // GOG logout confirmation dialog state + var showGOGLogoutDialog by rememberSaveable { mutableStateOf(false) } + var gogLogoutLoading by rememberSaveable { mutableStateOf(false) } + + // GOG Integration + SettingsGroup(title = { Text(text = stringResource(R.string.gog_integration_title)) }) { + SettingsMenuLink( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.gog_settings_login_title)) }, + subtitle = { Text(text = stringResource(R.string.gog_settings_login_subtitle)) }, + onClick = { + openGOGLoginDialog = true + gogLoginError = null + gogLoginSuccess = false + } + ) + + // Logout button - only show if credentials exist + if (app.gamenative.service.gog.GOGAuthManager.hasStoredCredentials(context)) { + SettingsMenuLink( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.gog_settings_logout_title)) }, + subtitle = { Text(text = stringResource(R.string.gog_settings_logout_subtitle)) }, + onClick = { + showGOGLogoutDialog = true + } + ) + } + } + // Downloads settings SettingsGroup( modifier = Modifier.background(Color.Transparent), @@ -469,6 +607,107 @@ fun SettingsGroupInterface( progress = -1f, // Indeterminate progress message = stringResource(R.string.settings_language_changing), ) + + // GOG Login Dialog + GOGLoginDialog( + visible = openGOGLoginDialog, + onDismissRequest = { + openGOGLoginDialog = false + gogLoginError = null + gogLoginLoading = false + }, + onAuthCodeClick = { authCode -> + coroutineScope.launch { + handleGogAuthentication( + context = context, + authCode = authCode, + coroutineScope = coroutineScope, + onLoadingChange = { gogLoginLoading = it }, + onError = { gogLoginError = it }, + onSuccess = { count -> + gogLibraryGameCount = count + gogLoginSuccess = true + }, + onDialogClose = { openGOGLoginDialog = false } + ) + } + }, + isLoading = gogLoginLoading, + errorMessage = gogLoginError + ) + + // Success message dialog + if (gogLoginSuccess) { + MessageDialog( + visible = true, + onDismissRequest = { gogLoginSuccess = false }, + onConfirmClick = { gogLoginSuccess = false }, + confirmBtnText = "OK", + icon = Icons.Default.Login, + title = stringResource(R.string.gog_login_success_title), + message = stringResource(R.string.gog_login_success_message) + ) + } + + // GOG logout confirmation dialog + MessageDialog( + visible = showGOGLogoutDialog, + title = stringResource(R.string.gog_logout_confirm_title), + message = stringResource(R.string.gog_logout_confirm_message), + confirmBtnText = stringResource(R.string.gog_logout_confirm), + dismissBtnText = stringResource(R.string.cancel), + onConfirmClick = { + showGOGLogoutDialog = false + gogLogoutLoading = true + coroutineScope.launch { + try { + Timber.d("[SettingsGOG] Starting logout...") + val result = GOGService.logout(context) + + if (result.isSuccess) { + Timber.i("[SettingsGOG] Logout successful") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + context.getString(R.string.gog_logout_success), + android.widget.Toast.LENGTH_SHORT + ).show() + } + } else { + val error = result.exceptionOrNull() + Timber.e(error, "[SettingsGOG] Logout failed") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + context.getString(R.string.gog_logout_failed, error?.message ?: "Unknown error"), + android.widget.Toast.LENGTH_LONG + ).show() + } + } + } catch (e: Exception) { + Timber.e(e, "[SettingsGOG] Exception during logout") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + context.getString(R.string.gog_logout_failed, e.message ?: "Unknown error"), + android.widget.Toast.LENGTH_LONG + ).show() + } + } finally { + gogLogoutLoading = false + } + } + }, + onDismissRequest = { showGOGLogoutDialog = false }, + onDismissClick = { showGOGLogoutDialog = false } + ) + + // GOG logout loading dialog + LoadingDialog( + visible = gogLogoutLoading, + progress = -1f, + message = stringResource(R.string.gog_logout_in_progress) + ) } @@ -531,3 +770,5 @@ private fun Preview_SettingsScreen() { ) } } + + diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt new file mode 100644 index 0000000000..17dc2c84dc --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt @@ -0,0 +1,277 @@ +package app.gamenative.ui.screen.xserver + +import android.graphics.PointF +import android.util.Log +import android.view.KeyEvent +import android.view.MotionEvent +import com.winlator.inputcontrols.Binding +import com.winlator.inputcontrols.ControlElement +import com.winlator.inputcontrols.ControlsProfile +import com.winlator.inputcontrols.ExternalController +import com.winlator.inputcontrols.ExternalControllerBinding +import com.winlator.inputcontrols.GamepadState +import com.winlator.math.Mathf +import com.winlator.winhandler.WinHandler +import com.winlator.xserver.XServer +import java.util.Timer +import java.util.TimerTask + +/** + * Standalone handler for physical controller input that works independently of view visibility. + * Applies profile bindings to convert physical controller input into virtual gamepad state. + */ +class PhysicalControllerHandler( + private var profile: ControlsProfile?, + private val xServer: XServer?, + private val onOpenNavigationMenu: (() -> Unit)? = null +) { + private val TAG = "gncontrol" + private val mouseMoveOffset = PointF(0f, 0f) + private var mouseMoveTimer: Timer? = null + + fun setProfile(profile: ControlsProfile?) { + this.profile = profile + Log.d(TAG, "PhysicalControllerHandler: Profile set to ${profile?.name}") + + // Cancel mouse movement timer if profile is null + if (profile == null) { + mouseMoveTimer?.cancel() + mouseMoveTimer = null + mouseMoveOffset.set(0f, 0f) + } + } + + /** + * Clean up resources when handler is destroyed + */ + fun cleanup() { + mouseMoveTimer?.cancel() + mouseMoveTimer = null + mouseMoveOffset.set(0f, 0f) + } + + /** + * Handle physical controller button events. + * Extracted from InputControlsView.onKeyEvent() + */ + fun onKeyEvent(event: KeyEvent): Boolean { + if (profile != null && event.repeatCount == 0) { + val controller = profile?.getController(event.deviceId) + if (controller != null) { + val controllerBinding = controller.getControllerBinding(event.keyCode) + if (controllerBinding != null) { + if (event.action == KeyEvent.ACTION_DOWN) { + handleInputEvent(controllerBinding.binding, true) + } else if (event.action == KeyEvent.ACTION_UP) { + handleInputEvent(controllerBinding.binding, false) + } + return true + } + } + } + return false + } + + /** + * Handle physical controller analog stick and trigger events. + * Extracted from InputControlsView.onGenericMotionEvent() + */ + fun onGenericMotionEvent(event: MotionEvent): Boolean { + if (profile != null) { + val controller = profile?.getController(event.deviceId) + if (controller != null && controller.updateStateFromMotionEvent(event)) { + // Process trigger buttons (L2/R2) + var controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_L2) + if (controllerBinding != null) { + handleInputEvent( + controllerBinding.binding, + controller.state.isPressed(ExternalController.IDX_BUTTON_L2.toInt()) + ) + } + + controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_R2) + if (controllerBinding != null) { + handleInputEvent( + controllerBinding.binding, + controller.state.isPressed(ExternalController.IDX_BUTTON_R2.toInt()) + ) + } + + // Process analog stick input + processJoystickInput(controller) + return true + } + } + return false + } + + /** + * Create a timer for continuous mouse movement injection. + * Runs at 60 FPS, injecting mouse deltas based on mouseMoveOffset. + */ + private fun createMouseMoveTimer() { + if (profile != null && mouseMoveTimer == null) { + mouseMoveTimer = Timer() + mouseMoveTimer?.schedule(object : TimerTask() { + override fun run() { + // Skip injection if movement is below 8% deadzone to save CPU cycles + val magnitude = Math.sqrt((mouseMoveOffset.x * mouseMoveOffset.x + mouseMoveOffset.y * mouseMoveOffset.y).toDouble()) + if (magnitude < 0.08) return + + // Look up cursor speed dynamically so it updates when profile changes + val cursorSpeed = profile?.cursorSpeed ?: 1f + val deltaX = (mouseMoveOffset.x * 10 * cursorSpeed).toInt() + val deltaY = (mouseMoveOffset.y * 10 * cursorSpeed).toInt() + xServer?.injectPointerMoveDelta(deltaX, deltaY) + } + }, 0, 1000 / 60) + } + } + + /** + * Process analog stick input and apply bindings. + * Extracted from InputControlsView.processJoystickInput() + */ + private fun processJoystickInput(controller: ExternalController) { + // Reset mouse movement offset at the start - contributions will be added during processing + mouseMoveOffset.set(0f, 0f) + + val axes = intArrayOf( + MotionEvent.AXIS_X, + MotionEvent.AXIS_Y, + MotionEvent.AXIS_Z, + MotionEvent.AXIS_RZ, + MotionEvent.AXIS_HAT_X, + MotionEvent.AXIS_HAT_Y + ) + val values = floatArrayOf( + controller.state.thumbLX, + controller.state.thumbLY, + controller.state.thumbRX, + controller.state.thumbRY, + controller.state.dPadX.toFloat(), + controller.state.dPadY.toFloat() + ) + + for (i in axes.indices) { + var controllerBinding: ExternalControllerBinding? + if (Math.abs(values[i]) > ControlElement.STICK_DEAD_ZONE) { + val keyCode = ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i])) + controllerBinding = controller.getControllerBinding(keyCode) + if (controllerBinding != null) { + handleInputEvent(controllerBinding.binding, true, values[i]) + } + } else { + controllerBinding = controller.getControllerBinding( + ExternalControllerBinding.getKeyCodeForAxis(axes[i], 1.toByte()) + ) + if (controllerBinding != null) { + handleInputEvent(controllerBinding.binding, false, values[i]) + } + controllerBinding = controller.getControllerBinding( + ExternalControllerBinding.getKeyCodeForAxis(axes[i], (-1).toByte()) + ) + if (controllerBinding != null) { + handleInputEvent(controllerBinding.binding, false, values[i]) + } + } + } + } + + /** + * Apply a binding to the virtual gamepad state and send to WinHandler. + * Extracted from InputControlsView.handleInputEvent() + */ + private fun handleInputEvent(binding: Binding, isActionDown: Boolean, offset: Float = 0f) { + if (binding.isGamepad) { + val winHandler = xServer?.winHandler + val state = profile?.gamepadState + + if (state != null) { + val buttonIdx = binding.ordinal - Binding.GAMEPAD_BUTTON_A.ordinal + if (buttonIdx <= ExternalController.IDX_BUTTON_R2.toInt()) { + when (buttonIdx) { + ExternalController.IDX_BUTTON_L2.toInt() -> { + state.triggerL = if (isActionDown) 1.0f else 0f + state.setPressed(ExternalController.IDX_BUTTON_L2.toInt(), isActionDown) + } + ExternalController.IDX_BUTTON_R2.toInt() -> { + state.triggerR = if (isActionDown) 1.0f else 0f + state.setPressed(ExternalController.IDX_BUTTON_R2.toInt(), isActionDown) + } + else -> state.setPressed(buttonIdx, isActionDown) + } + } else { + when (binding) { + Binding.GAMEPAD_LEFT_THUMB_UP, Binding.GAMEPAD_LEFT_THUMB_DOWN -> { + state.thumbLY = if (isActionDown) offset else 0f + } + Binding.GAMEPAD_LEFT_THUMB_LEFT, Binding.GAMEPAD_LEFT_THUMB_RIGHT -> { + state.thumbLX = if (isActionDown) offset else 0f + } + Binding.GAMEPAD_RIGHT_THUMB_UP, Binding.GAMEPAD_RIGHT_THUMB_DOWN -> { + state.thumbRY = if (isActionDown) offset else 0f + } + Binding.GAMEPAD_RIGHT_THUMB_LEFT, Binding.GAMEPAD_RIGHT_THUMB_RIGHT -> { + state.thumbRX = if (isActionDown) offset else 0f + } + Binding.GAMEPAD_DPAD_UP, Binding.GAMEPAD_DPAD_RIGHT, + Binding.GAMEPAD_DPAD_DOWN, Binding.GAMEPAD_DPAD_LEFT -> { + state.dpad[binding.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal] = isActionDown + } + else -> {} + } + } + + if (winHandler != null) { + val controller = winHandler.currentController + if (controller != null) { + controller.state.copy(state) + } + winHandler.sendGamepadState() + winHandler.sendVirtualGamepadState(state) + } + } + } else { + // Handle special bindings + if (binding == Binding.OPEN_NAVIGATION_MENU) { + if (isActionDown) { + Log.d(TAG, "Opening navigation menu from controller binding") + onOpenNavigationMenu?.invoke() + } + } else if (binding == Binding.MOUSE_MOVE_LEFT || binding == Binding.MOUSE_MOVE_RIGHT) { + // Handle horizontal mouse movement - ADD contribution from this input + if (isActionDown) { + val contribution = if (offset != 0f) offset else if (binding == Binding.MOUSE_MOVE_LEFT) -1f else 1f + mouseMoveOffset.x += contribution + createMouseMoveTimer() + } + // Don't reset when isActionDown=false - mouseMoveOffset is reset at the start of processJoystickInput + } else if (binding == Binding.MOUSE_MOVE_DOWN || binding == Binding.MOUSE_MOVE_UP) { + // Handle vertical mouse movement - ADD contribution from this input + if (isActionDown) { + val contribution = if (offset != 0f) offset else if (binding == Binding.MOUSE_MOVE_UP) -1f else 1f + mouseMoveOffset.y += contribution + createMouseMoveTimer() + } + // Don't reset when isActionDown=false - mouseMoveOffset is reset at the start of processJoystickInput + } else { + // For keyboard/mouse button bindings, inject into XServer + val pointerButton = binding.pointerButton + if (isActionDown) { + if (pointerButton != null) { + xServer?.injectPointerButtonPress(pointerButton) + } else { + xServer?.injectKeyPress(binding.keycode) + } + } else { + if (pointerButton != null) { + xServer?.injectPointerButtonRelease(pointerButton) + } else { + xServer?.injectKeyRelease(binding.keycode) + } + } + } + } + } +} 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 d56d0de1e7..20232a042c 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 @@ -22,6 +22,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,16 +51,20 @@ import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.GameSource import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem import app.gamenative.data.SteamApp import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent import app.gamenative.service.SteamService +import app.gamenative.service.gog.GOGService +import app.gamenative.ui.component.settings.SettingsListDropdown import app.gamenative.ui.component.QuickMenu import app.gamenative.ui.component.QuickMenuAction import app.gamenative.ui.data.XServerState import app.gamenative.ui.model.XServerViewModel import app.gamenative.utils.ContainerUtils import app.gamenative.utils.CustomGameScanner +import app.gamenative.utils.SteamTokenLogin import app.gamenative.utils.SteamUtils import com.posthog.PostHog import com.winlator.PrefManager as WinlatorPrefManager @@ -126,6 +131,7 @@ import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption.REPLACE_EXISTING import java.util.Arrays import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean import kotlin.io.path.name import kotlin.text.lowercase import kotlinx.coroutines.CoroutineScope @@ -135,16 +141,25 @@ import org.json.JSONException import org.json.JSONObject import timber.log.Timber -// TODO: logs in composables are 'unstable' which can cause recomposition (performance issues) +// Always re-extract drivers and DXVK on every launch to handle cases of container corruption +// where games randomly stop working. Set to false once corruption issues are resolved. +private const val ALWAYS_REEXTRACT = true + +// Guard to prevent duplicate game_exited events when multiple exit triggers fire simultaneously +private val isExiting = AtomicBoolean(false) + +// TODO logs in composables are 'unstable' which can cause recomposition (performance issues) // TODO: this needs a bigger refactor/clean-up (logging, abstractions, state management, etc) + @Composable @OptIn(ExperimentalComposeUiApi::class) fun XServerScreen( lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, appId: String, bootToContainer: Boolean, - registerBackAction: (() -> Unit) -> Unit, + testGraphics: Boolean = false, + registerBackAction: ( ( ) -> Unit ) -> Unit, navigateBack: () -> Unit, onExit: () -> Unit, onWindowMapped: ((Context, Window) -> Unit)? = null, @@ -227,7 +242,14 @@ fun XServerScreen( } var win32AppWorkarounds: Win32AppWorkarounds? by remember { mutableStateOf(null) } + var physicalControllerHandler: PhysicalControllerHandler? by remember { mutableStateOf(null) } + DisposableEffect(Unit) { + onDispose { + physicalControllerHandler?.cleanup() + physicalControllerHandler = null + } + } var isKeyboardVisible = false // UI state is now managed by ViewModel - extract for local use @@ -408,7 +430,8 @@ fun XServerScreen( var handled = false if (isGamepad) { - handled = PluviaApp.inputControlsView?.onKeyEvent(it.event) == true + handled = physicalControllerHandler?.onKeyEvent(it.event) == true + if (!handled) handled = PluviaApp.inputControlsView?.onKeyEvent(it.event) == true // Final fallback to WinHandler passthrough if (!handled) handled = xServerView!!.getxServer().winHandler.onKeyEvent(it.event) } @@ -422,7 +445,8 @@ fun XServerScreen( var handled = false if (isGamepad && it.event != null) { - handled = PluviaApp.inputControlsView?.onGenericMotionEvent(it.event) == true + handled = physicalControllerHandler?.onGenericMotionEvent(it.event!!) == true + if (!handled) handled = PluviaApp.inputControlsView?.onGenericMotionEvent(it.event) == true // Final fallback to WinHandler passthrough if (!handled) handled = xServerView!!.getxServer().winHandler.onGenericMotionEvent(it.event) } @@ -673,56 +697,57 @@ fun XServerScreen( Timber.i("Doing things once") val envVars = EnvVars() - setupWineSystemFiles( - context, - firstTimeBoot, - xServerView!!.getxServer().screenInfo, - xServerState, - viewModel, - container, - containerManager, - envVars, - contentsManager, - onExtractFileListener, - ) - extractArm64ecInputDLLs(context, container) // REQUIRED: Uses updated xinput1_3 main.c from x86_64 build, prevents crashes with 3+ players, avoids need for input shim dlls. - extractx86_64InputDlls(context, container) - extractGraphicsDriverFiles( - context, - xServerState.graphicsDriver, - xServerState.dxwrapper, - xServerState.dxwrapperConfig!!, - container, - envVars, - firstTimeBoot, - vkbasaltConfig, - ) - changeWineAudioDriver(xServerState.audioDriver, container, ImageFs.find(context)) - setImagefsContainerVariant(context, container) - PluviaApp.xEnvironment = setupXEnvironment( - context, - appId, - bootToContainer, - xServerState, - viewModel, - envVars, - container, - appLaunchInfo, - xServerView!!.getxServer(), - containerVariantChanged, - onGameLaunchError, - navigateBack, - ) - } catch (e: Exception) { - Timber.e(e, "Error during wine setup operations") - onGameLaunchError?.invoke("Failed to setup wine: ${e.message}") - } finally { - setupExecutor.shutdown() - } + setupWineSystemFiles( + context, + firstTimeBoot, + xServerView!!.getxServer().screenInfo, + xServerState, + viewModel, + container, + containerManager, + envVars, + contentsManager, + onExtractFileListener, + ) + extractArm64ecInputDLLs(context, container) // REQUIRED: Uses updated xinput1_3 main.c from x86_64 build, prevents crashes with 3+ players, avoids need for input shim dlls. + extractx86_64InputDlls(context, container) + extractGraphicsDriverFiles( + context, + xServerState.graphicsDriver, + xServerState.dxwrapper, + xServerState.dxwrapperConfig!!, + container, + envVars, + firstTimeBoot, + vkbasaltConfig, + ) + changeWineAudioDriver(xServerState.audioDriver, container, ImageFs.find(context)) + setImagefsContainerVariant(context, container) + PluviaApp.xEnvironment = setupXEnvironment( + context, + appId, + bootToContainer, + testGraphics, + xServerState, + viewModel, + envVars, + container, + appLaunchInfo, + xServerView!!.getxServer(), + containerVariantChanged, + onGameLaunchError, + navigateBack, + ) + } catch (e: Exception) { + Timber.e(e, "Error during wine setup operations") + onGameLaunchError?.invoke("Failed to setup wine: ${e.message}") + } finally { + setupExecutor.shutdown() } } } - PluviaApp.xServerView = xServerView + } + PluviaApp.xServerView = xServerView; frameLayout.addView(xServerView) @@ -773,9 +798,11 @@ fun XServerScreen( Timber.d("=== Profile Loading Complete ===") setProfile(targetProfile) - // Store profile for auto-show logic - loadedProfile = targetProfile - } + physicalControllerHandler = PhysicalControllerHandler(targetProfile, xServerView.getxServer(), gameBack) + + // Store profile for auto-show logic + loadedProfile = targetProfile + } // Set overlay opacity from preferences if needed val opacity = PrefManager.getFloat("controls_opacity", InputControlsView.DEFAULT_OVERLAY_OPACITY) @@ -971,10 +998,39 @@ fun XServerScreen( val manager = PluviaApp.inputControlsManager ?: InputControlsManager(context) val profileIdStr = container.getExtra("profileId", "0") val profileId = profileIdStr.toIntOrNull() ?: 0 - val profile = if (profileId != 0) { + + // Get profile, but don't load profile 0 directly (will duplicate if needed) + var profile = if (profileId != 0) { manager.getProfile(profileId) } else { - manager.getProfile(0) + null // Will create new profile below + } + + // Auto-create profile if using default (profile 0) + if (profile == null) { + val allProfiles = manager.getProfiles(false) + val sourceProfile = manager.getProfile(0) + ?: allProfiles.firstOrNull { it.id == 2 } + ?: allProfiles.firstOrNull() + + if (sourceProfile != null) { + try { + // Duplicate profile 0 to create game-specific profile + profile = manager.duplicateProfile(sourceProfile) + + // Rename to game name + val gameName = currentAppInfo?.name ?: container.name + profile.setName("$gameName - Physical Controller") + profile.save() + + // Associate with container + container.putExtra("profileId", profile.id.toString()) + container.saveData() + } catch (e: Exception) { + Timber.e(e, "Failed to auto-create profile for container ${container.name}") + profile = sourceProfile // Fallback + } + } } if (profile != null) { @@ -990,6 +1046,15 @@ fun XServerScreen( profile = profile, onDismiss = { viewModel.setShowPhysicalControllerDialog(false) }, onSave = { + // Ensure controllersLoaded is true before saving + // (addController sets the flag even if controller already exists) + profile.addController("*") + + // Save profileId to container so it persists across launches + container.putExtra("profileId", profile.id.toString()) + container.saveData() + + // Save profile (will now write controllers since controllersLoaded = true) profile.save() profile.loadControllers() @@ -997,8 +1062,9 @@ fun XServerScreen( if (PluviaApp.inputControlsView?.profile != null) { PluviaApp.inputControlsView?.setProfile(profile) } + physicalControllerHandler?.setProfile(profile) viewModel.setShowPhysicalControllerDialog(false) - }, + } ) } } @@ -1355,8 +1421,10 @@ private fun setupXEnvironment( context: Context, appId: String, bootToContainer: Boolean, + testGraphics: Boolean, xServerState: XServerState, viewModel: XServerViewModel, + // xServerViewModel: XServerViewModel, envVars: EnvVars, // generateWinePrefix: Boolean, container: Container?, @@ -1472,7 +1540,7 @@ private fun setupXEnvironment( guestProgramLauncherComponent.setContainer(container) guestProgramLauncherComponent.setWineInfo(xServerState.wineInfo) val guestExecutable = "wine explorer /desktop=shell," + xServer.screenInfo + " " + - getWineStartCommand(appId, container, bootToContainer, appLaunchInfo, envVars, guestProgramLauncherComponent) + + getWineStartCommand(appId, container, bootToContainer, testGraphics, appLaunchInfo, envVars, guestProgramLauncherComponent) + (if (container.execArgs.isNotEmpty()) " " + container.execArgs else "") guestProgramLauncherComponent.isWoW64Mode = wow64Mode guestProgramLauncherComponent.guestExecutable = guestExecutable @@ -1494,7 +1562,16 @@ private fun setupXEnvironment( guestProgramLauncherComponent.box86Preset = container.box86Preset guestProgramLauncherComponent.box64Preset = container.box64Preset guestProgramLauncherComponent.setPreUnpack { - unpackExecutableFile(context, container.isNeedsUnpacking, container, appId, appLaunchInfo, guestProgramLauncherComponent, containerVariantChanged, onGameLaunchError) + unpackExecutableFile( + context = context, + needsUnpacking = container.isNeedsUnpacking, + container = container, + appId = appId, + appLaunchInfo = appLaunchInfo, + guestProgramLauncherComponent = guestProgramLauncherComponent, + containerVariantChanged = containerVariantChanged, + onError = onGameLaunchError + ) } val enableGstreamer = container.isGstreamerWorkaround() @@ -1569,6 +1646,19 @@ private fun setupXEnvironment( } environment.addComponent(guestProgramLauncherComponent) + // Moved here, as guestProgramLauncherComponent.environment is setup after addComponent() + if (container != null) { + if (container.isLaunchRealSteam) { + SteamTokenLogin( + steamId = PrefManager.steamUserSteamId64.toString(), + login = PrefManager.username, + token = PrefManager.refreshToken, + imageFs = imageFs, + guestProgramLauncherComponent = guestProgramLauncherComponent, + ).setupSteamFiles() + } + } + // Log container settings before starting if (container != null) { Timber.i("---- Launching Container ----") @@ -1594,7 +1684,7 @@ private fun setupXEnvironment( // Request encrypted app ticket for Steam games at launch time val isCustomGame = ContainerUtils.extractGameSourceFromContainerId(appId) == GameSource.CUSTOM_GAME val gameIdForTicket = ContainerUtils.extractGameIdFromContainerId(appId) - if (!bootToContainer && !isCustomGame && gameIdForTicket != null) { + if (!bootToContainer && !isCustomGame && gameIdForTicket != null && !container.isLaunchRealSteam) { CoroutineScope(Dispatchers.IO).launch { try { val ticket = SteamService.instance?.getEncryptedAppTicket(gameIdForTicket) @@ -1623,6 +1713,7 @@ private fun getWineStartCommand( appId: String, container: Container, bootToContainer: Boolean, + testGraphics: Boolean, appLaunchInfo: LaunchInfo?, envVars: EnvVars, guestProgramLauncherComponent: GuestProgramLauncherComponent, @@ -1632,23 +1723,50 @@ private fun getWineStartCommand( Timber.tag("XServerScreen").d("appLaunchInfo is $appLaunchInfo") - // Check if this is a Custom Game - val isCustomGame = ContainerUtils.extractGameSourceFromContainerId(appId) == GameSource.CUSTOM_GAME - val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) + // Check game source + val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) + val isCustomGame = gameSource == GameSource.CUSTOM_GAME + val isGOGGame = gameSource == GameSource.GOG + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) - if (!isCustomGame) { - if (container.executablePath.isEmpty()) { - container.executablePath = SteamService.getInstalledExe(steamAppId) + if (!isCustomGame && !isGOGGame) { + // Steam-specific setup + if (container.executablePath.isEmpty()){ + container.executablePath = SteamService.getInstalledExe(gameId) container.saveData() } if (!container.isUseLegacyDRM) { // Create ColdClientLoader.ini file - SteamUtils.writeColdClientIni(steamAppId, container) + SteamUtils.writeColdClientIni(gameId, container) } } - val args = if (bootToContainer) { + val args = if (testGraphics) { + "\"Z:/opt/apps/TestD3D.exe\"" + } else if (bootToContainer) { "\"wfm.exe\"" + } else if (isGOGGame) { + // For GOG games, use GOGService to get the launch command + Timber.tag("XServerScreen").i("Launching GOG game: $gameId") + + // Create a LibraryItem from the appId + val libraryItem = LibraryItem( + appId = appId, + name = "", // Name not needed for launch command + gameSource = GameSource.GOG + ) + + val gogCommand = GOGService.getGogWineStartCommand( + libraryItem = libraryItem, + container = container, + bootToContainer = bootToContainer, + appLaunchInfo = appLaunchInfo, + envVars = envVars, + guestProgramLauncherComponent = guestProgramLauncherComponent + ) + + Timber.tag("XServerScreen").i("GOG launch command: $gogCommand") + return "winhandler.exe $gogCommand" } else if (isCustomGame) { // For Custom Games, we can launch even without appLaunchInfo // Use the executable path from container config. If missing, try to auto-detect @@ -1700,21 +1818,21 @@ private fun getWineStartCommand( Timber.tag("XServerScreen").w("appLaunchInfo is null for Steam game: $appId") "\"wfm.exe\"" } else { - if (container.isLaunchRealSteam()) { + if (container.isLaunchRealSteam) { // Launch Steam with the applaunch parameter to start the game "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" -silent -vgui -tcp " + - "-nobigpicture -nofriendsui -nochatui -nointro -applaunch $steamAppId" + "-nobigpicture -nofriendsui -nochatui -nointro -applaunch $gameId" } else { var executablePath = "" if (container.executablePath.isNotEmpty()) { executablePath = container.executablePath } else { - executablePath = SteamService.getInstalledExe(steamAppId) + executablePath = SteamService.getInstalledExe(gameId) container.executablePath = executablePath container.saveData() } if (container.isUseLegacyDRM) { - val appDirPath = SteamService.getAppDirPath(steamAppId) + val appDirPath = SteamService.getAppDirPath(gameId) val executableDir = appDirPath + "/" + executablePath.substringBeforeLast("/", "") guestProgramLauncherComponent.workingDir = File(executableDir) Timber.i("Working directory is $executableDir") @@ -1746,7 +1864,12 @@ private fun getSteamlessTarget( ): String { val gameId = ContainerUtils.extractGameIdFromContainerId(appId) val appDirPath = SteamService.getAppDirPath(gameId) - val executablePath = SteamService.getInstalledExe(gameId) + // Use container.executablePath if set, otherwise fall back to auto-detection + val executablePath = if (container.executablePath.isNotEmpty()) { + container.executablePath + } else { + 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 @@ -1758,25 +1881,35 @@ private fun getSteamlessTarget( } return "$drive:\\$executablePath" } -private fun exit( - winHandler: WinHandler?, - environment: XEnvironment?, - frameRating: FrameRating?, - appInfo: SteamApp?, - container: Container, - onExit: () -> Unit, - navigateBack: () -> Unit, -) { + +/** + * Filters executables to exclude _CommonRedist folder and files ending in original.exe or unpacked.exe + */ +private fun filterExecutablesForSteamless(executables: List): List { + return executables.filter { exePath -> + val lowerPath = exePath.lowercase() + // Exclude _CommonRedist folder + !lowerPath.contains("_commonredist") && + // Exclude files ending in original.exe or unpacked.exe + !lowerPath.endsWith("original.exe") && + !lowerPath.endsWith("unpacked.exe") + } +} +private fun exit(winHandler: WinHandler?, environment: XEnvironment?, frameRating: FrameRating?, appInfo: SteamApp?, container: Container, onExit: () -> Unit, navigateBack: () -> Unit) { Timber.i("Exit called") - PostHog.capture( - event = "game_exited", - properties = mapOf( - "game_name" to appInfo?.name.toString(), - "session_length" to (frameRating?.sessionLengthSec ?: 0), - "avg_fps" to (frameRating?.avgFPS ?: 0.0), - "container_config" to container.containerJson, - ), - ) + + // Prevent duplicate PostHog events when multiple exit triggers fire simultaneously + if (isExiting.compareAndSet(false, true)) { + PostHog.capture( + event = "game_exited", + properties = mapOf( + "game_name" to appInfo?.name.toString(), + "session_length" to (frameRating?.sessionLengthSec ?: 0), + "avg_fps" to (frameRating?.avgFPS ?: 0.0), + "container_config" to container.containerJson, + ), + ) + } // Store session data in container metadata frameRating?.let { rating -> @@ -1804,121 +1937,215 @@ private fun exit( } /** - * Installs redistributables (vcredist, physx, XNA) from _CommonRedist folder - * if shared depots are present and the redistributable executables exist. + * Helper data class to hold redistributable installation context */ -private fun installRedistributables( - context: Context, - container: Container, - appId: String, - guestProgramLauncherComponent: GuestProgramLauncherComponent, - imageFs: ImageFs, -) { - try { - val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) - - // Get shared depots to determine if redistributables are needed - val downloadableDepots = SteamService.getDownloadableDepots(steamAppId) - val sharedDepots = downloadableDepots.filter { (_, depotInfo) -> - val manifest = depotInfo.manifests["public"] - manifest == null || manifest.gid == 0L - } - - if (sharedDepots.isEmpty()) { - Timber.i("No shared depots found, skipping redistributable installation") - return - } +private data class RedistContext( + val commonRedistDir: File, + val driveLetter: Char, + val guestProgramLauncherComponent: GuestProgramLauncherComponent +) - Timber.i("Found ${sharedDepots.size} shared depot(s), checking for redistributables") +/** + * Gets the _CommonRedist directory and drive letter for the game + * @return RedistContext if valid, null otherwise + */ +private fun getRedistDirectory( + appId: String, + container: Container, + guestProgramLauncherComponent: GuestProgramLauncherComponent +): RedistContext? { + val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) + val gameDirPath = SteamService.getAppDirPath(steamAppId) + val commonRedistDir = File(gameDirPath, "_CommonRedist") - // Get game directory path - val gameDirPath = SteamService.getAppDirPath(steamAppId) - val commonRedistDir = File(gameDirPath, "_CommonRedist") + if (!commonRedistDir.exists() || !commonRedistDir.isDirectory()) { + Timber.tag("installRedist").i("_CommonRedist directory not found at ${commonRedistDir.absolutePath}") + return null + } - if (!commonRedistDir.exists() || !commonRedistDir.isDirectory()) { - Timber.i("_CommonRedist directory not found at ${commonRedistDir.absolutePath}, skipping redistributable installation") - return - } + // Get the drive letter for the game directory + val drives = container.drives + val driveIndex = drives.indexOf(gameDirPath) + val driveLetter = if (driveIndex > 1) { + drives[driveIndex - 2] + } else { + Timber.tag("installRedist").e("Could not locate game drive for redistributables") + return null + } - // Get the drive letter for the game directory - val drives = container.drives - val driveIndex = drives.indexOf(gameDirPath) - val drive = if (driveIndex > 1) { - drives[driveIndex - 2] - } else { - Timber.e("Could not locate game drive for redistributables") - return - } + return RedistContext(commonRedistDir, driveLetter, guestProgramLauncherComponent) +} - // Find and install vcredist executables (only 64-bit: VC_redist.x64.exe) - val vcredistDir = File(commonRedistDir, "vcredist") +private fun installVcRedist(context: RedistContext) { + val vcredistDir = File(context.commonRedistDir, "vcredist") if (vcredistDir.exists() && vcredistDir.isDirectory()) { vcredistDir.walkTopDown() .filter { it.isFile && it.name.equals("VC_redist.x64.exe", ignoreCase = true) } .forEach { exeFile -> try { - val relativePath = exeFile.relativeTo(commonRedistDir).path.replace('/', '\\') + val relativePath = exeFile.relativeTo(context.commonRedistDir).path.replace('/', '\\') + val drive = context.driveLetter val winePath = "$drive:\\_CommonRedist\\$relativePath" - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing Visual C++ Redistributable...")) + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing Visual C++ Redistributables...")) Timber.i("Installing vcredist: $winePath") val cmd = "wine $winePath /quiet /norestart && wineserver -k" - val output = guestProgramLauncherComponent.execShellCommand(cmd) + val output = context.guestProgramLauncherComponent.execShellCommand(cmd) Timber.i("vcredist installation output: $output") } catch (e: Exception) { Timber.e(e, "Failed to install vcredist ${exeFile.name}") } } } +} - // Find and install PhysX redistributables (.msi files starting with "PhysX") - val physxDir = File(commonRedistDir, "PhysX") - if (physxDir.exists() && physxDir.isDirectory()) { - physxDir.walkTopDown() - .filter { - it.isFile && it.name.startsWith("PhysX", ignoreCase = true) && - it.name.endsWith(".msi", ignoreCase = true) - } - .forEach { msiFile -> - try { - val relativePath = msiFile.relativeTo(commonRedistDir).path.replace('/', '\\') - val winePath = "$drive:\\_CommonRedist\\$relativePath" - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing PhysX...")) - Timber.i("Installing PhysX: $winePath") - val cmd = "wine msiexec /i $winePath /quiet /norestart && wineserver -k" - val output = guestProgramLauncherComponent.execShellCommand(cmd) - Timber.i("PhysX installation output: $output") - } catch (e: Exception) { - Timber.e(e, "Failed to install PhysX ${msiFile.name}") - } +private fun installDotNetFramework(context: RedistContext) { + val dotnetDirs = listOf("dotNetFx", "dotnet", "DotNet") + + for (dirName in dotnetDirs) { + val dotnetDir = File(context.commonRedistDir, dirName) + if (!dotnetDir.exists() || !dotnetDir.isDirectory()) continue + + dotnetDir.walkTopDown() + .filter { it.isFile && + (it.name.startsWith("dotNetFx", ignoreCase = true) || + it.name.contains(".NET Framework", ignoreCase = true) || + it.name.startsWith("NDP", ignoreCase = true)) && + it.name.endsWith(".exe", ignoreCase = true) } + .forEach { exeFile -> + try { + val relativePath = exeFile.relativeTo(context.commonRedistDir).path.replace('/', '\\') + val winePath = "${context.driveLetter}:\\_CommonRedist\\$relativePath" + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing .NET Framework...")) + Timber.i("Installing .NET Framework: $winePath") + val cmd = "wine $winePath /q /norestart && wineserver -k" + val output = context.guestProgramLauncherComponent.execShellCommand(cmd) + Timber.i(".NET Framework installation output: $output") + } catch (e: Exception) { + Timber.e(e, "Failed to install .NET Framework ${exeFile.name}") } + } + } +} + +/** + * Installs OpenAL redistributables (oalinst.exe) (https://www.openal.org/) + * Helps with 3D audio implementations between 2001-2010 + */ +private fun installOpenAL(context: RedistContext) { + val openalDir = File(context.commonRedistDir, "OpenAL") + if (!openalDir.exists() || !openalDir.isDirectory()) return + + val openalInstaller = openalDir.walkTopDown() + .filter { it.isFile && + (it.name.equals("oalinst.exe", ignoreCase = true) || + it.name.startsWith("OpenAL", ignoreCase = true)) && + it.name.endsWith(".exe", ignoreCase = true) } + .firstOrNull() + + openalInstaller?.let { exeFile -> + try { + val relativePath = exeFile.relativeTo(context.commonRedistDir).path.replace('/', '\\') + val winePath = "${context.driveLetter}:\\_CommonRedist\\$relativePath" + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing OpenAL...")) + Timber.i("Installing OpenAL: $winePath") + val cmd = "wine $winePath /s && wineserver -k" + val output = context.guestProgramLauncherComponent.execShellCommand(cmd) + Timber.i("OpenAL installation output: $output") + } catch (e: Exception) { + Timber.e(e, "Failed to install OpenAL ${exeFile.name}") } + } +} - // Find and install XNA Framework redistributables (.msi files starting with "xna") - val xnaDir = File(commonRedistDir, "xnafx") - if (xnaDir.exists() && xnaDir.isDirectory()) { - xnaDir.walkTopDown() - .filter { - it.isFile && it.name.startsWith("xna", ignoreCase = true) && - it.name.endsWith(".msi", ignoreCase = true) +private fun installPhysX(context: RedistContext) { + val physxDir = File(context.commonRedistDir, "PhysX") + if (physxDir.exists() && physxDir.isDirectory()) { + physxDir.walkTopDown() + .filter { it.isFile && it.name.startsWith("PhysX", ignoreCase = true) && + it.name.endsWith(".msi", ignoreCase = true) } + .forEach { msiFile -> + try { + val relativePath = msiFile.relativeTo(context.commonRedistDir).path.replace('/', '\\') + val drive = context.driveLetter + val winePath = "$drive:\\_CommonRedist\\$relativePath" + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing PhysX...")) + Timber.i("Installing PhysX: $winePath") + val cmd = "wine msiexec /i $winePath /quiet /norestart && wineserver -k" + val output = context.guestProgramLauncherComponent.execShellCommand(cmd) + Timber.i("PhysX installation output: $output") + } catch (e: Exception) { + Timber.e(e, "Failed to install PhysX ${msiFile.name}") } - .forEach { msiFile -> - try { - val relativePath = msiFile.relativeTo(commonRedistDir).path.replace('/', '\\') - val winePath = "$drive:\\_CommonRedist\\$relativePath" - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing XNA Framework...")) - Timber.i("Installing XNA: $winePath") - val cmd = "wine msiexec /i $winePath /quiet /norestart && wineserver -k" - val output = guestProgramLauncherComponent.execShellCommand(cmd) - Timber.i("XNA installation output: $output") - } catch (e: Exception) { - Timber.e(e, "Failed to install XNA ${msiFile.name}") - } + } + } +} + +private fun installXNAFramework(context: RedistContext) { + val xnaDir = File(context.commonRedistDir, "xnafx") + if (xnaDir.exists() && xnaDir.isDirectory()) { + xnaDir.walkTopDown() + .filter { it.isFile && it.name.startsWith("xna", ignoreCase = true) && + it.name.endsWith(".msi", ignoreCase = true) } + .forEach { msiFile -> + try { + val relativePath = msiFile.relativeTo(context.commonRedistDir).path.replace('/', '\\') + val drive = context.driveLetter + val winePath = "$drive:\\_CommonRedist\\$relativePath" + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing XNA Framework...")) + Timber.i("Installing XNA: $winePath") + val cmd = "wine msiexec /i $winePath /quiet /norestart && wineserver -k" + val output = context.guestProgramLauncherComponent.execShellCommand(cmd) + Timber.i("XNA installation output: $output") + } catch (e: Exception) { + Timber.e(e, "Failed to install XNA ${msiFile.name}") } + } + } +} + +/** + * Installs redistributables from _CommonRedist folder + * if shared depots are present and the redistributable executables exist. + */ +private fun installRedistributables( + context: Context, + container: Container, + appId: String, + guestProgramLauncherComponent: GuestProgramLauncherComponent, + imageFs: ImageFs, +) { + try { + val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) + + // Get shared depots to determine if redistributables are needed + val downloadableDepots = SteamService.getDownloadableDepots(steamAppId) + val sharedDepots = downloadableDepots.filter { (_, depotInfo) -> + val manifest = depotInfo.manifests["public"] + manifest == null || manifest.gid == 0L } - Timber.i("Finished checking for redistributables") + if (sharedDepots.isEmpty()) { + Timber.tag("installRedist").i("No shared depots found, skipping redistributable installation") + return + } + + Timber.tag("installRedist").i("Found ${sharedDepots.size} shared depot(s), checking for redistributables") + + // Get redistributable directory context + val redistContext = getRedistDirectory(appId, container, guestProgramLauncherComponent) ?: run { + Timber.tag("installRedist").i("Could not set up redistributable context, skipping installation") + return + } + + installVcRedist(redistContext) + installDotNetFramework(redistContext) + installOpenAL(redistContext) + installPhysX(redistContext) + installXNAFramework(redistContext) + + Timber.tag("installRedist").i("Finished checking for redistributables") } catch (e: Exception) { - Timber.e(e, "Error in installRedistributables: ${e.message}") + Timber.tag("installRedist").e(e, "Error in installRedistributables: ${e.message}") } } @@ -1950,7 +2177,7 @@ private fun unpackExecutableFile( try { installRedistributables(context, container, appId, guestProgramLauncherComponent, imageFs) } catch (e: Exception) { - Timber.e(e, "Error installing redistributables: ${e.message}") + Timber.tag("installRedist").e(e, "Error installing redistributables: ${e.message}") } } if (!needsUnpacking) { @@ -1958,7 +2185,6 @@ private fun unpackExecutableFile( } try { val rootDir: File = imageFs.getRootDir() - val executableFile = getSteamlessTarget(appId, container, appLaunchInfo) try { PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM...")) @@ -1966,35 +2192,44 @@ private fun unpackExecutableFile( val origTxtFile = File("${imageFs.wineprefix}/dosdevices/a:/orig_dll_path.txt") if (origTxtFile.exists()) { - val relDllPath = origTxtFile.readText().trim() - if (relDllPath.isNotBlank()) { - val origDll = File("${imageFs.wineprefix}/dosdevices/a:/$relDllPath") - if (origDll.exists()) { - val genCmd = "wine z:\\generate_interfaces_file.exe A:\\" + relDllPath.replace('/', '\\') - Timber.i("Running generate_interfaces_file $genCmd") - val genOutput = guestProgramLauncherComponent.execShellCommand(genCmd) - - val origSteamInterfaces = File("${imageFs.wineprefix}/dosdevices/z:/steam_interfaces.txt") - if (origSteamInterfaces.exists()) { - val finalSteamInterfaces = File(origDll.parent, "steam_interfaces.txt") - try { - Files.copy( - origSteamInterfaces.toPath(), - finalSteamInterfaces.toPath(), - StandardCopyOption.REPLACE_EXISTING, - ) - Timber.i("Copied steam_interfaces.txt to ${finalSteamInterfaces.absolutePath}") - } catch (ioe: IOException) { - Timber.w(ioe, "Failed to copy steam_interfaces.txt") + val relDllPaths = origTxtFile.readLines().map { it.trim() }.filter { it.isNotBlank() } + if (relDllPaths.isNotEmpty()) { + Timber.i("Found ${relDllPaths.size} DLL path(s) in orig_dll_path.txt") + for (relDllPath in relDllPaths) { + try { + val origDll = File("${imageFs.wineprefix}/dosdevices/a:/$relDllPath") + if (origDll.exists()) { + val genCmd = "wine z:\\generate_interfaces_file.exe A:\\" + relDllPath.replace('/', '\\') + Timber.i("Running generate_interfaces_file $genCmd") + val genOutput = guestProgramLauncherComponent.execShellCommand(genCmd) + + val origSteamInterfaces = File("${imageFs.wineprefix}/dosdevices/z:/steam_interfaces.txt") + if (origSteamInterfaces.exists()) { + val finalSteamInterfaces = File(origDll.parent, "steam_interfaces.txt") + try { + Files.copy( + origSteamInterfaces.toPath(), + finalSteamInterfaces.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + Timber.i("Copied steam_interfaces.txt to ${finalSteamInterfaces.absolutePath}") + } catch (ioe: IOException) { + Timber.w(ioe, "Failed to copy steam_interfaces.txt for $relDllPath") + } + } else { + Timber.w("steam_interfaces.txt not found at $origSteamInterfaces for $relDllPath") + } + + Timber.i("Result of generate_interfaces_file command $genOutput") + } else { + Timber.w("DLL specified in orig_dll_path.txt not found: $origDll") } - } else { - Timber.w("steam_interfaces.txt not found at $origSteamInterfaces") + } catch (e: Exception) { + Timber.w(e, "Failed to process DLL path $relDllPath, continuing with next path") } - - Timber.i("Result of generate_interfaces_file command $genOutput") - } else { - Timber.w("DLL specified in orig_dll_path.txt not found: $origDll") } + } else { + Timber.i("orig_dll_path.txt is empty; skipping interface generation") } } else { Timber.i("orig_dll_path.txt not present; skipping interface generation") @@ -2004,37 +2239,86 @@ private fun unpackExecutableFile( } output = StringBuilder() - try { - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM...")) - val slCmd = "wine z:\\Steamless\\Steamless.CLI.exe $executableFile" - Timber.i("Running shell command $slCmd") - val slOutput = guestProgramLauncherComponent.execShellCommand(slCmd) - output.append(slOutput) - Timber.i("Result of Steamless command " + output) - } catch (e: Exception) { - Timber.e("Error running Steamless: $e") - } - val exe = File(imageFs.wineprefix + "/dosdevices/" + executableFile.replace("A:", "a:").replace('\\', '/')) - val unpackedExe = File( - imageFs.wineprefix + "/dosdevices/" + executableFile.replace("A:", "a:") - .replace('\\', '/') + ".unpacked.exe", - ) - val originalExe = File( - imageFs.wineprefix + "/dosdevices/" + executableFile.replace("A:", "a:") - .replace('\\', '/') + ".original.exe", - ) - Timber.i("Moving " + unpackedExe + " to " + exe) - try { - if (exe.exists() && unpackedExe.exists()) { - Files.copy(exe.toPath(), originalExe.toPath()) - Files.copy(unpackedExe.toPath(), exe.toPath(), REPLACE_EXISTING) + if (!container.isLaunchRealSteam && container.isUseLegacyDRM) { + // Scan all executables from A: drive and filter them + val allExecutables = ContainerUtils.scanExecutablesInADrive(container.drives) + Timber.i("Found ${allExecutables.size} executables in A: drive") + + val filteredExecutables = filterExecutablesForSteamless(allExecutables) + Timber.i("Filtered to ${filteredExecutables.size} executables for Steamless processing") + + if (filteredExecutables.isEmpty()) { + Timber.w("No executables to process with Steamless") } else { - val errorMsg = "Either original exe or unpacked exe does not exist. Original: ${exe.exists()}, Unpacked: ${unpackedExe.exists()}" - Timber.w(errorMsg) + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM...")) + + // Process each executable individually to handle errors per file + filteredExecutables.forEachIndexed { index, exePath -> + var batchFile: File? = null + try { + val normalizedPath = exePath.replace('/', '\\') + val windowsPath = "A:\\$normalizedPath" + + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM... (${index + 1}/${filteredExecutables.size})")) + + // Create a batch file that Wine can execute, to handle paths with spaces in them + batchFile = File(imageFs.getRootDir(), "tmp/steamless_wrapper_${index}.bat") + batchFile.parentFile?.mkdirs() + batchFile.writeText("@echo off\r\nz:\\Steamless\\Steamless.CLI.exe \"$windowsPath\"\r\n") + + val slCmd = "wine z:\\tmp\\steamless_wrapper_${index}.bat" + val slOutput = guestProgramLauncherComponent.execShellCommand(slCmd) + output.append(slOutput) + } catch (e: Exception) { + Timber.e(e, "Error running Steamless on $exePath, continuing with next file") + output.append("Error processing $exePath: ${e.message}\n") + } finally { + // Clean up batch file + batchFile?.delete() + } + } + + Timber.i("Finished processing ${filteredExecutables.size} executables. Result: $output") + + // Process file moving for all filtered executables + for (exePath in filteredExecutables) { + try { + // Paths from scanExecutablesInADrive use forward slashes (Unix format from URI) + // Use as-is for File operations (forward slashes work on Unix/Android) + val unixPath = exePath.replace('\\', '/') + val exe = File(imageFs.wineprefix + "/dosdevices/a:/" + unixPath) + val unpackedExe = File( + imageFs.wineprefix + "/dosdevices/a:/" + unixPath + ".unpacked.exe", + ) + val originalExe = File( + imageFs.wineprefix + "/dosdevices/a:/" + unixPath + ".original.exe", + ) + + // For logging, show Windows format + val windowsPath = "A:\\${exePath.replace('/', '\\')}" + + Timber.i("Moving files for $windowsPath") + if (exe.exists() && unpackedExe.exists()) { + if (originalExe.exists()) { + Timber.i("Original backup exists for $windowsPath; skipping overwrite") + } else { + Files.copy(exe.toPath(), originalExe.toPath(), REPLACE_EXISTING) + } + Files.copy(unpackedExe.toPath(), exe.toPath(), REPLACE_EXISTING) + Timber.i("Successfully moved files for $windowsPath") + } else { + val errorMsg = + "Either exe or unpacked exe does not exist for $windowsPath. Exe: ${exe.exists()}, Unpacked: ${unpackedExe.exists()}" + Timber.w(errorMsg) + } + } catch (e: Exception) { + Timber.e(e, "Error moving files for $exePath, continuing with next executable") + } + } } - } catch (e: IOException) { - Timber.e("Could not move files: $e") + } else { + Timber.i("Skipping Steamless (launchRealSteam=${container.isLaunchRealSteam}, useLegacyDRM=${container.isUseLegacyDRM})") } output = StringBuilder() @@ -2128,7 +2412,7 @@ private fun setupWineSystemFiles( viewModel.setDxwrapper("vkd3d-" + xServerState.dxwrapperConfig?.get("vkd3dVersion")) } - val needReextract = xServerState.dxwrapper != container.getExtra("dxwrapper") || container.wineVersion != wineVersion + val needReextract = ALWAYS_REEXTRACT || xServerState.dxwrapper != container.getExtra("dxwrapper") || container.wineVersion != wineVersion Timber.i("needReextract is " + needReextract) Timber.i("xServerState.dxwrapper is " + xServerState.dxwrapper) @@ -2522,7 +2806,7 @@ private fun extractGraphicsDriverFiles( val configDir = imageFs.configDir val sentinel = File(configDir, ".current_graphics_driver") // lives in shared tree val onDiskId = sentinel.takeIf { it.exists() }?.readText() ?: "" - val changed = cacheId != container.getExtra("graphicsDriver") || cacheId != onDiskId + val changed = ALWAYS_REEXTRACT || cacheId != container.getExtra("graphicsDriver") || cacheId != onDiskId Timber.i("Changed is " + changed + " will re-extract drivers accordingly.") val rootDir = imageFs.rootDir envVars.put("vblank_mode", "0") @@ -2700,7 +2984,7 @@ private fun extractGraphicsDriverFiles( val lastInstalledMainWrapper = container.getExtra("lastInstalledMainWrapper") // 3. Check if we need to extract a new wrapper file. - if (firstTimeBoot || mainWrapperSelection != lastInstalledMainWrapper) { + if (ALWAYS_REEXTRACT || firstTimeBoot || mainWrapperSelection != lastInstalledMainWrapper) { // We only extract if the selection is actually a wrapper file. if (mainWrapperSelection.lowercase(Locale.getDefault()).startsWith("wrapper")) { val assetPath = "graphics_driver/" + mainWrapperSelection.lowercase(Locale.getDefault()) + ".tzst" diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 4aaa129876..8bf1fe715b 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -5,6 +5,8 @@ import app.gamenative.PrefManager import app.gamenative.data.GameSource import app.gamenative.enums.Marker import app.gamenative.service.SteamService +import app.gamenative.service.gog.GOGConstants +import app.gamenative.service.gog.GOGService import app.gamenative.utils.BestConfigService import app.gamenative.utils.CustomGameScanner import com.winlator.container.Container @@ -38,19 +40,20 @@ object ContainerUtils { fun setContainerDefaults(context: Context){ // Override default driver and DXVK version based on Turnip capability if (GPUInformation.isTurnipCapable(context)) { - DefaultVersion.VARIANT = Container.GLIBC - DefaultVersion.DEFAULT_GRAPHICS_DRIVER = "turnip" - DefaultVersion.DXVK = "2.6.1-gplasync" + DefaultVersion.VARIANT = Container.BIONIC + DefaultVersion.WINE_VERSION = "proton-9.0-arm64ec" + DefaultVersion.DEFAULT_GRAPHICS_DRIVER = "Wrapper" + DefaultVersion.DXVK = "async-1.10.3" DefaultVersion.VKD3D = "2.14.1" DefaultVersion.WRAPPER = "turnip25.3.0_R3_Auto" DefaultVersion.STEAM_TYPE = Container.STEAM_TYPE_NORMAL - DefaultVersion.ASYNC_CACHE = "1" + DefaultVersion.ASYNC_CACHE = "0" } else { DefaultVersion.VARIANT = Container.BIONIC DefaultVersion.WINE_VERSION = "proton-9.0-arm64ec" - DefaultVersion.DEFAULT_GRAPHICS_DRIVER = "Wrapper-leegao" + DefaultVersion.DEFAULT_GRAPHICS_DRIVER = "Wrapper" DefaultVersion.DXVK = "async-1.10.3" - DefaultVersion.VKD3D = "2.6" + DefaultVersion.VKD3D = "2.14.1" DefaultVersion.STEAM_TYPE = Container.STEAM_TYPE_LIGHT DefaultVersion.ASYNC_CACHE = "0" } @@ -502,26 +505,46 @@ object ContainerUtils { // Set up container drives to include app val defaultDrives = PrefManager.drives - val drives = if (gameSource == GameSource.STEAM) { - // For Steam games, set up the app directory path - val gameId = extractGameIdFromContainerId(appId) - val appDirPath = SteamService.getAppDirPath(gameId) - val drive: Char = Container.getNextAvailableDriveLetter(defaultDrives) - "$defaultDrives$drive:$appDirPath" - } else { - // For Custom Games, find the game folder and map it to A: drive - val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appId) - if (gameFolderPath != null) { - // Check if A: is already in defaultDrives, if not use it, otherwise use next available - val drive: Char = if (defaultDrives.contains("A:")) { - Container.getNextAvailableDriveLetter(defaultDrives) + val drives = when (gameSource) { + GameSource.STEAM -> { + // For Steam games, set up the app directory path + val gameId = extractGameIdFromContainerId(appId) + val appDirPath = SteamService.getAppDirPath(gameId) + val drive: Char = Container.getNextAvailableDriveLetter(defaultDrives) + "$defaultDrives$drive:$appDirPath" + } + GameSource.CUSTOM_GAME -> { + // For Custom Games, find the game folder and map it to A: drive + val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appId) + if (gameFolderPath != null) { + // Check if A: is already in defaultDrives, if not use it, otherwise use next available + val drive: Char = if (defaultDrives.contains("A:")) { + Container.getNextAvailableDriveLetter(defaultDrives) + } else { + 'A' + } + "$defaultDrives$drive:$gameFolderPath" } else { - 'A' + Timber.w("Could not find folder path for Custom Game: $appId") + defaultDrives + } + } + GameSource.GOG -> { + // For GOG games, map the specific game directory to A: drive + val gameId = extractGameIdFromContainerId(appId) + val game = GOGService.getGOGGameOf(gameId.toString()) + if (game != null && game.installPath.isNotEmpty()) { + val gameInstallPath = game.installPath + val drive: Char = if (defaultDrives.contains("A:")) { + Container.getNextAvailableDriveLetter(defaultDrives) + } else { + 'A' + } + "$defaultDrives$drive:$gameInstallPath" + } else { + Timber.w("Could not find GOG game info for: $gameId, using default drives") + defaultDrives } - "$defaultDrives$drive:$gameFolderPath" - } else { - Timber.w("Could not find folder path for Custom Game: $appId") - defaultDrives } } Timber.d("Prepared container drives: $drives") @@ -702,6 +725,7 @@ object ContainerUtils { } // No custom config, so determine the DX wrapper synchronously (only for Steam games) + // For GOG and Custom Games, use the default DX wrapper from preferences if (gameSource == GameSource.STEAM) { runBlocking { try { @@ -761,38 +785,92 @@ object ContainerUtils { FEXCoreManager.deleteConfigFiles(context, container.id) // Ensure Custom Games have the A: drive mapped to the game folder + // and GOG games have a drive mapped to the GOG games directory val gameSource = extractGameSourceFromContainerId(appId) - if (gameSource == GameSource.CUSTOM_GAME) { - val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appId) - if (gameFolderPath != null) { - // Check if A: drive is already mapped to the correct path - var hasCorrectADrive = false + val gameFolderPath: String? = when (gameSource) { + GameSource.STEAM -> { + val gameId = extractGameIdFromContainerId(appId) + SteamService.getAppDirPath(gameId) + } + GameSource.CUSTOM_GAME -> { + CustomGameScanner.getFolderPathFromAppId(appId) + } + else -> null + } + + if (gameFolderPath != null) { + // Check if A: drive is already mapped to the correct path + var hasCorrectADrive = false + for (drive in Container.drivesIterator(container.drives)) { + if (drive[0] == "A" && drive[1] == gameFolderPath) { + hasCorrectADrive = true + break + } + } + + // If A: drive is not mapped correctly, update it + if (!hasCorrectADrive) { + val currentDrives = container.drives + // Rebuild drives string, excluding existing A: drive and adding new one + val drivesBuilder = StringBuilder() + drivesBuilder.append("A:$gameFolderPath") + + // Add all other drives (excluding A:) + for (drive in Container.drivesIterator(currentDrives)) { + if (drive[0] != "A") { + drivesBuilder.append("${drive[0]}:${drive[1]}") + } + } + + val updatedDrives = drivesBuilder.toString() + container.drives = updatedDrives + container.saveData() + Timber.d("Updated container drives to include A: drive mapping: $updatedDrives") + } + } else if (gameSource == GameSource.GOG) { + // Ensure GOG games have the specific game directory mapped + val gameId = extractGameIdFromContainerId(appId) + val game = runBlocking { GOGService.getGOGGameOf(gameId.toString()) } + if (game != null && game.installPath.isNotEmpty()) { + val gameInstallPath = game.installPath + var hasCorrectDriveMapping = false + + // Check if the specific game directory is already mapped for (drive in Container.drivesIterator(container.drives)) { - if (drive[0] == "A" && drive[1] == gameFolderPath) { - hasCorrectADrive = true + if (drive[1] == gameInstallPath) { + hasCorrectDriveMapping = true break } } - // If A: drive is not mapped correctly, update it - if (!hasCorrectADrive) { + // If specific game directory is not mapped, add/update it + if (!hasCorrectDriveMapping) { val currentDrives = container.drives - // Rebuild drives string, excluding existing A: drive and adding new one val drivesBuilder = StringBuilder() - drivesBuilder.append("A:$gameFolderPath") - // Add all other drives (excluding A:) - for (drive in Container.drivesIterator(currentDrives)) { - if (drive[0] != "A") { - drivesBuilder.append("${drive[0]}:${drive[1]}") + // Use A: drive for game, or next available + val drive: Char = if (!currentDrives.contains("A:")) { + 'A' + } else { + Container.getNextAvailableDriveLetter(currentDrives) + } + + drivesBuilder.append("$drive:$gameInstallPath") + + // Add all other drives (excluding the one we just used) + for (existingDrive in Container.drivesIterator(currentDrives)) { + if (existingDrive[0] != drive.toString()) { + drivesBuilder.append("${existingDrive[0]}:${existingDrive[1]}") } } val updatedDrives = drivesBuilder.toString() container.drives = updatedDrives container.saveData() - Timber.d("Updated container drives to include A: drive mapping: $updatedDrives") + Timber.d("Updated container drives to include $drive: drive mapping for GOG game: $updatedDrives") } + } else { + Timber.w("Could not find GOG game info for $gameId, skipping drive mapping update") } } @@ -857,7 +935,9 @@ object ContainerUtils { * Handles formats like: * - STEAM_123456 -> 123456 * - CUSTOM_GAME_571969840 -> 571969840 + * - GOG_19283103 -> 19283103 * - STEAM_123456(1) -> 123456 + * - 19283103 -> 19283103 (legacy GOG format) */ fun extractGameIdFromContainerId(containerId: String): Int { // Remove duplicate suffix like (1), (2) if present @@ -886,8 +966,121 @@ object ContainerUtils { return when { containerId.startsWith("STEAM_") -> GameSource.STEAM containerId.startsWith("CUSTOM_GAME_") -> GameSource.CUSTOM_GAME + containerId.startsWith("GOG_") -> GameSource.GOG // Add other platforms here.. else -> GameSource.STEAM // default fallback } } + + /** + * Gets the file system path for the container's A: drive + */ + fun getADrivePath(drives: String): String? { + // Use the existing Container.drivesIterator logic + for (drive in Container.drivesIterator(drives)) { + if (drive[0] == "A") { + return drive[1] + } + } + return null + } + + /** + * Scans the container's A: drive for all .exe files + */ + fun scanExecutablesInADrive(drives: String): List { + val executables = mutableListOf() + + try { + // Find the A: drive path from container drives + val aDrivePath = getADrivePath(drives) + if (aDrivePath == null) { + Timber.w("No A: drive found in container drives") + return emptyList() + } + + val aDir = File(aDrivePath) + if (!aDir.exists() || !aDir.isDirectory) { + Timber.w("A: drive path does not exist or is not a directory: $aDrivePath") + return emptyList() + } + + Timber.d("Scanning for executables in A: drive: $aDrivePath") + + // Recursively scan for .exe files using listFiles with depth limit + fun scanRecursive(dir: File, baseDir: File, depth: Int = 0, maxDepth: Int = 10) { + if (depth > maxDepth) return + + dir.listFiles()?.forEach { file -> + if (file.isDirectory) { + scanRecursive(file, baseDir, depth + 1, maxDepth) + } else if (file.isFile && file.name.lowercase().endsWith(".exe")) { + // Convert to relative Windows path format + val relativePath = baseDir.toURI().relativize(file.toURI()).path + executables.add(relativePath) + } + } + } + + scanRecursive(aDir, aDir) + + // Sort alphabetically and prioritize common game executables + executables.sortWith { a, b -> + val aScore = getExecutablePriority(a) + val bScore = getExecutablePriority(b) + + if (aScore != bScore) { + bScore.compareTo(aScore) // Higher priority first + } else { + a.compareTo(b, ignoreCase = true) // Alphabetical + } + } + + Timber.d("Found ${executables.size} executables in A: drive") + + } catch (e: Exception) { + Timber.e(e, "Error scanning A: drive for executables") + } + + return executables + } + + /** + * Assigns priority scores to executables for better sorting + */ + private fun getExecutablePriority(exePath: String): Int { + val fileName = exePath.substringAfterLast('\\').lowercase() + val baseName = fileName.substringBeforeLast('.') + + return when { + // Highest priority: common game executable patterns + fileName.contains("game") -> 100 + fileName.contains("start") -> 85 + fileName.contains("main") -> 80 + fileName.contains("launcher") && !fileName.contains("unins") -> 75 + + // High priority: probable main executables + baseName.length >= 4 && !isSystemExecutable(fileName) -> 70 + + // Medium priority: any non-system executable + !isSystemExecutable(fileName) -> 50 + + // Low priority: system/utility executables + else -> 10 + } + } + + /** + * Checks if an executable is likely a system/utility file + */ + private fun isSystemExecutable(fileName: String): Boolean { + val systemKeywords = listOf( + "unins", "setup", "install", "config", "crash", "handler", + "viewer", "compiler", "tool", "redist", "vcredist", "directx", + "steam", "origin", "uplay", "epic", "battlenet" + ) + + return systemKeywords.any { fileName.contains(it) } + } } + diff --git a/app/src/main/java/app/gamenative/utils/FileUtils.kt b/app/src/main/java/app/gamenative/utils/FileUtils.kt index aa852553b1..57a02eca96 100644 --- a/app/src/main/java/app/gamenative/utils/FileUtils.kt +++ b/app/src/main/java/app/gamenative/utils/FileUtils.kt @@ -17,6 +17,33 @@ import timber.log.Timber object FileUtils { + /** + * Calculate the total size of a directory recursively + * + * @param directory The directory to calculate size for + * @return Total size in bytes + */ + fun calculateDirectorySize(directory: File): Long { + var size = 0L + try { + if (!directory.exists() || !directory.isDirectory) { + return 0L + } + + val files = directory.listFiles() ?: return 0L + for (file in files) { + size += if (file.isDirectory) { + calculateDirectorySize(file) + } else { + file.length() + } + } + } catch (e: Exception) { + Timber.w(e, "Error calculating directory size for ${directory.name}") + } + return size + } + fun makeDir(dirName: String) { val homeItemsDir = File(dirName) homeItemsDir.mkdirs() diff --git a/app/src/main/java/app/gamenative/utils/GameCompatibilityCache.kt b/app/src/main/java/app/gamenative/utils/GameCompatibilityCache.kt index cd2e93f5b2..4e2d3ab196 100644 --- a/app/src/main/java/app/gamenative/utils/GameCompatibilityCache.kt +++ b/app/src/main/java/app/gamenative/utils/GameCompatibilityCache.kt @@ -1,47 +1,188 @@ package app.gamenative.utils +import app.gamenative.PrefManager +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import timber.log.Timber /** - * In-memory cache for game compatibility responses. - * Caches all games to avoid re-checking every time user navigates back to library. + * Persistent cache for game compatibility responses with 7-day TTL. + * Uses lazy expiration - checks expiration on access, not on load (optimizes performance). */ object GameCompatibilityCache { - private val cache = mutableMapOf() + private const val CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000L // 1 day + + private val inMemoryCache = mutableMapOf() + private val timestamps = mutableMapOf() + private var cacheLoaded = false + + @Serializable + data class CachedCompatibilityResponse( + val response: GameCompatibilityResponseData, + val timestamp: Long + ) + + @Serializable + data class GameCompatibilityResponseData( + val gameName: String, + val totalPlayableCount: Int, + val gpuPlayableCount: Int, + val avgRating: Float, + val hasBeenTried: Boolean, + val isNotWorking: Boolean + ) + + /** + * Converts GameCompatibilityService.GameCompatibilityResponse to serializable format + */ + private fun GameCompatibilityService.GameCompatibilityResponse.toData(): GameCompatibilityResponseData { + return GameCompatibilityResponseData( + gameName = this.gameName, + totalPlayableCount = this.totalPlayableCount, + gpuPlayableCount = this.gpuPlayableCount, + avgRating = this.avgRating, + hasBeenTried = this.hasBeenTried, + isNotWorking = this.isNotWorking + ) + } + + /** + * Converts serializable format back to GameCompatibilityService.GameCompatibilityResponse + */ + private fun GameCompatibilityResponseData.toResponse(): GameCompatibilityService.GameCompatibilityResponse { + return GameCompatibilityService.GameCompatibilityResponse( + gameName = this.gameName, + totalPlayableCount = this.totalPlayableCount, + gpuPlayableCount = this.gpuPlayableCount, + avgRating = this.avgRating, + hasBeenTried = this.hasBeenTried, + isNotWorking = this.isNotWorking + ) + } + + /** + * Loads cache from persistent storage into memory. + * Only parses JSON, no expiration filtering (lazy expiration). + */ + private fun loadCache() { + if (cacheLoaded) return + + try { + val cacheJson = PrefManager.gameCompatibilityCache + if (cacheJson.isEmpty() || cacheJson == "{}") { + cacheLoaded = true + return + } + + val cacheMap = Json.decodeFromString>(cacheJson) + + // Load all entries into memory (no expiration check here - lazy expiration) + // Store both response and timestamp for expiration checking + cacheMap.forEach { (gameName, cached) -> + inMemoryCache[gameName] = cached.response.toResponse() + timestamps[gameName] = cached.timestamp + } + + Timber.tag("GameCompatibilityCache").d("Loaded ${inMemoryCache.size} cached entries from persistent storage") + cacheLoaded = true + } catch (e: Exception) { + Timber.tag("GameCompatibilityCache").e(e, "Failed to load cache from persistent storage") + cacheLoaded = true // Mark as loaded to avoid retrying + } + } + + /** + * Saves cache to persistent storage. + */ + private fun saveCache() { + try { + val now = System.currentTimeMillis() + val cacheMap = inMemoryCache.mapValues { (gameName, response) -> + val timestamp = timestamps[gameName] ?: now + CachedCompatibilityResponse(response.toData(), timestamp) + } + val cacheJson = Json.encodeToString(cacheMap) + PrefManager.gameCompatibilityCache = cacheJson + Timber.tag("GameCompatibilityCache").d("Saved ${cacheMap.size} entries to persistent storage") + } catch (e: Exception) { + Timber.tag("GameCompatibilityCache").e(e, "Failed to save cache to persistent storage") + } + } /** - * Gets cached compatibility response for a game, if available. + * Gets cached compatibility response for a game, if available and not expired. + * Uses lazy expiration - checks expiration on access. */ fun getCached(gameName: String): GameCompatibilityService.GameCompatibilityResponse? { - return cache[gameName] + loadCache() + + val cached = inMemoryCache[gameName] ?: return null + val timestamp = timestamps[gameName] ?: return null + + // Lazy expiration check - only check when accessing + val now = System.currentTimeMillis() + if (now - timestamp >= CACHE_TTL_MS) { + // Expired - remove from cache + inMemoryCache.remove(gameName) + timestamps.remove(gameName) + Timber.tag("GameCompatibilityCache").d("Removed expired cache entry for: $gameName") + return null + } + + return cached } /** * Caches a compatibility response for a game. */ fun cache(gameName: String, response: GameCompatibilityService.GameCompatibilityResponse) { - cache[gameName] = response + loadCache() + val now = System.currentTimeMillis() + inMemoryCache[gameName] = response + timestamps[gameName] = now + saveCache() Timber.tag("GameCompatibilityCache").d("Cached compatibility for: $gameName") } /** - * Checks if a game's compatibility is cached. + * Caches multiple compatibility responses at once. + */ + fun cacheAll(responses: Map) { + loadCache() + val now = System.currentTimeMillis() + inMemoryCache.putAll(responses) + responses.keys.forEach { gameName -> + timestamps[gameName] = now + } + saveCache() + Timber.tag("GameCompatibilityCache").d("Cached ${responses.size} compatibility entries") + } + + /** + * Checks if a game's compatibility is cached and not expired. */ fun isCached(gameName: String): Boolean { - return cache.containsKey(gameName) + loadCache() + return getCached(gameName) != null } /** - * Clears the entire cache. + * Clears the entire cache (both memory and persistent storage). */ fun clear() { - cache.clear() + inMemoryCache.clear() + timestamps.clear() + PrefManager.gameCompatibilityCache = "{}" Timber.tag("GameCompatibilityCache").d("Cache cleared") } /** * Gets the current cache size. */ - fun size(): Int = cache.size + fun size(): Int { + loadCache() + return inMemoryCache.size + } } - diff --git a/app/src/main/java/app/gamenative/utils/GameCompatibilityService.kt b/app/src/main/java/app/gamenative/utils/GameCompatibilityService.kt index 0f0c6fb0ac..ccd63158d1 100644 --- a/app/src/main/java/app/gamenative/utils/GameCompatibilityService.kt +++ b/app/src/main/java/app/gamenative/utils/GameCompatibilityService.kt @@ -1,5 +1,8 @@ package app.gamenative.utils +import android.content.Context +import androidx.compose.ui.graphics.Color +import app.gamenative.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -43,6 +46,31 @@ object GameCompatibilityService { val isNotWorking: Boolean ) + /** + * Compatibility message with text and color. + */ + data class CompatibilityMessage( + val text: String, + val color: Color + ) + + /** + * Gets user-friendly compatibility message based on compatibility response. + * Uses totalPlayableCount and gpuPlayableCount to determine the message. + */ + fun getCompatibilityMessageFromResponse(context: Context, response: GameCompatibilityResponse): CompatibilityMessage { + return when { + response.totalPlayableCount > 0 && response.gpuPlayableCount > 0 -> + CompatibilityMessage(context.getString(R.string.best_config_exact_gpu_match), Color.Green) + response.gpuPlayableCount == 0 && response.totalPlayableCount > 0 -> + CompatibilityMessage(context.getString(R.string.best_config_fallback_match), Color.Yellow) + response.isNotWorking -> + CompatibilityMessage(context.getString(R.string.library_not_compatible), Color.Red) + else -> + CompatibilityMessage(context.getString(R.string.library_compatibility_unknown), Color.Gray) + } + } + /** * Fetches compatibility information for a batch of games. * Returns a map of game name to compatibility response, or null on error. diff --git a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt index 7650823972..0ee16d05f4 100644 --- a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt +++ b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt @@ -107,7 +107,7 @@ fun KeyValue.generateSteamApp(): SteamApp { // dlcAppIds = (this["common"]["extended"]["listofdlc"].value).Split(",").Select(uint.Parse).ToArray(), dlcAppIds = emptyList(), isFreeApp = this["common"]["extended"]["isfreeapp"].asBoolean(), - dlcForAppId = this["common"]["extended"]["dlcforappid"].asInteger(), + dlcForAppId = this["extended"]["dlcforappid"].asInteger(this["common"]["extended"]["dlcforappid"].asInteger()), mustOwnAppToPurchase = this["common"]["extended"]["mustownapptopurchase"].asInteger(), dlcAvailableOnStore = this["common"]["extended"]["dlcavailableonstore"].asBoolean(), optionalDlc = this["common"]["extended"]["optionaldlc"].asBoolean(), @@ -152,6 +152,7 @@ fun KeyValue.generateSteamApp(): SteamApp { root = PathType.from(it["root"].value), path = it["path"].value.orEmpty(), pattern = it["pattern"].value.orEmpty(), + recursive = it["recursive"].asInteger(0), ) }, ), diff --git a/app/src/main/java/app/gamenative/utils/LocaleHelper.kt b/app/src/main/java/app/gamenative/utils/LocaleHelper.kt index 1da631aa29..ba52e374c6 100644 --- a/app/src/main/java/app/gamenative/utils/LocaleHelper.kt +++ b/app/src/main/java/app/gamenative/utils/LocaleHelper.kt @@ -19,9 +19,13 @@ object LocaleHelper { "" to "System Default", "da" to "Dansk (Danish)", "en" to "English", + "it" to "Italiano", "pt-BR" to "Português Brasileiro (Brazilian Portuguese)", + "uk" to "Українська", "zh-TW" to "正體中文", - "zh-CN" to "简体中文" + "zh-CN" to "简体中文", + "fr" to "Français", + "de" to "Deutsch" ) /** diff --git a/app/src/main/java/app/gamenative/utils/NetworkUtils.kt b/app/src/main/java/app/gamenative/utils/NetworkUtils.kt index 3c09b2c95f..c148ec23b8 100644 --- a/app/src/main/java/app/gamenative/utils/NetworkUtils.kt +++ b/app/src/main/java/app/gamenative/utils/NetworkUtils.kt @@ -7,7 +7,9 @@ object Net { val http: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(0, TimeUnit.MILLISECONDS) // no per-packet timer + .readTimeout(60, TimeUnit.SECONDS) // 60s timeout for reading response data + .writeTimeout(30, TimeUnit.SECONDS) // 30s timeout for writing request data + .callTimeout(5, TimeUnit.MINUTES) // overall timeout for entire call .pingInterval(30, TimeUnit.SECONDS) // keep HTTP/2 alive .retryOnConnectionFailure(true) // default, but explicit .build() diff --git a/app/src/main/java/app/gamenative/utils/SteamTokenHelper.kt b/app/src/main/java/app/gamenative/utils/SteamTokenHelper.kt new file mode 100644 index 0000000000..b262df69f2 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/SteamTokenHelper.kt @@ -0,0 +1,141 @@ +package app.gamenative.utils + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class SteamTokenHelper { + companion object { + private val obf = intArrayOf( + 0x1739a3b0.toInt(), 0xb8907fe1.toInt(), 0x8290d3b7.toInt(), 0x72839cd0.toInt(), + 0x242df096.toInt(), 0x3829750b.toInt(), 0x38de7a77.toInt(), 0x72f0924c.toInt(), + 0x44783927.toInt(), 0x01925372.toInt(), 0x20902714.toInt(), 0x27585920.toInt(), + 0x27890632.toInt(), 0x82910476.toInt(), 0x72906721.toInt(), 0x28798904.toInt(), + 0x78592700.toInt() + ) + + fun obfuscate(ptext: ByteArray, key: Long): String { + val ctext = mutableListOf() + ctext.addAll(byteArrayOf(0x02, 0x00, 0x00, 0x00).toList()) + + var k1 = (key shr 0x1f).toInt() + var k2 = key.toInt() + var csum = 0 + + var remainingPtext = ptext + + while (remainingPtext.size >= 4) { + k1 = (k1 + 0x25fe6761) and 0xffffffff.toInt() + k2 = (k2 + 1) and 0xffffffff.toInt() + + val d = ByteBuffer.wrap(remainingPtext.sliceArray(0..3)) + .order(ByteOrder.LITTLE_ENDIAN) + .int + + val t = obf[k2 % 0x11] xor k1 xor d + csum = (csum + d) and 0xffffffff.toInt() + + val tBytes = ByteBuffer.allocate(4) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(t) + .array() + + ctext.addAll(tBytes.toList()) + + remainingPtext = remainingPtext.sliceArray(4 until remainingPtext.size) + } + + // Add remaining bytes + ctext.addAll(remainingPtext.toList()) + + k1 = (k1 + 0x25fe6761) and 0xffffffff.toInt() + k2 = (k2 + 1) and 0xffffffff.toInt() + val t = obf[k2 % 0x11] xor k1 xor csum + + val tBytes = ByteBuffer.allocate(4) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(t) + .array() + + ctext.addAll(tBytes.toList()) + + return byteArrayToHexString(ctext.toByteArray()) + } + + fun deobfuscate(data: String, key: Long): String { + val dataBytes = hexStringToByteArray(data) + + // Check header + if (dataBytes.size < 4 || + dataBytes[0] != 0x02.toByte() || + dataBytes[1] != 0x00.toByte() || + dataBytes[2] != 0x00.toByte() || + dataBytes[3] != 0x00.toByte()) { + throw Exception("wrong type of data") + } + + val csumData = dataBytes.sliceArray(dataBytes.size - 4 until dataBytes.size) + var ctext = dataBytes.sliceArray(4 until dataBytes.size - 4) + val ptext = mutableListOf() + + var k1 = (key shr 0x1f).toInt() + var k2 = key.toInt() + var csum = 0 + + while (ctext.size >= 4) { + k1 = (k1 + 0x25fe6761) and 0xffffffff.toInt() + k2 = (k2 + 1) and 0xffffffff.toInt() + + val d = ByteBuffer.wrap(ctext.sliceArray(0..3)) + .order(ByteOrder.LITTLE_ENDIAN) + .int + + val t = obf[k2 % 0x11] xor k1 xor d + csum = (csum + t) and 0xffffffff.toInt() + + val tBytes = ByteBuffer.allocate(4) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(t) + .array() + + ptext.addAll(tBytes.toList()) + + ctext = ctext.sliceArray(4 until ctext.size) + } + + // Add remaining bytes + ptext.addAll(ctext.toList()) + + k1 = (k1 + 0x25fe6761) and 0xffffffff.toInt() + k2 = (k2 + 1) and 0xffffffff.toInt() + val t = obf[k2 % 0x11] xor k1 xor csum + + val tBytes = ByteBuffer.allocate(4) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(t) + .array() + + if (!tBytes.contentEquals(csumData)) { + throw Exception("bad checksum! ${t.toString(16)}") + } + + return ptext.toByteArray().decodeToString() + } + + private fun hexStringToByteArray(hex: String): ByteArray { + val cleanHex = hex.replace(" ", "") + val len = cleanHex.length + val data = ByteArray(len / 2) + + for (i in 0 until len step 2) { + data[i / 2] = ((Character.digit(cleanHex[i], 16) shl 4) + + Character.digit(cleanHex[i + 1], 16)).toByte() + } + return data + } + + private fun byteArrayToHexString(bytes: ByteArray): String { + return bytes.joinToString("") { "%02x".format(it) } + } + } +} + diff --git a/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt b/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt new file mode 100644 index 0000000000..9fe796907d --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt @@ -0,0 +1,286 @@ +package app.gamenative.utils + +import android.annotation.SuppressLint +import android.content.Context +import com.auth0.android.jwt.JWT +import com.winlator.container.Container +import com.winlator.contents.ContentProfile +import com.winlator.contents.ContentsManager +import com.winlator.core.FileUtils +import com.winlator.core.TarCompressorUtils +import com.winlator.xenvironment.ImageFs +import com.winlator.xenvironment.components.GuestProgramLauncherComponent +import `in`.dragonbra.javasteam.types.KeyValue +import timber.log.Timber +import java.io.File +import java.nio.file.Files +import java.util.zip.CRC32 +import kotlin.io.path.absolutePathString +import kotlin.io.path.createDirectories +import kotlin.io.path.exists + +// This is the key to make config.vdf work +const val NULL_CHAR = '\u0000' +const val TOKEN_EXPIRE_TIME = 86400L // 1 day + +class SteamTokenLogin( + private val steamId: String, + private val login: String, + private val token: String, + private val imageFs: ImageFs, + private val guestProgramLauncherComponent: GuestProgramLauncherComponent? = null, +) { + fun setupSteamFiles() { + // For loginusers.vdf and reg values + SteamUtils.autoLoginUserChanges(imageFs = imageFs) + phase1SteamConfig() + } + + private fun hdr() : String { + val crc = CRC32() + crc.update(login.toByteArray()) + return "${crc.value.toString(16)}1" + } + + private fun execCommand(command: String) : String { + return guestProgramLauncherComponent?.execShellCommand(command, false) + ?: throw IllegalStateException("GuestProgramLauncherComponent is required for command execution") + } + + private fun killWineServer() { + try { + execCommand("wineserver -k") + } catch (e: Exception) { + Timber.tag("SteamTokenLogin").e("Failed to kill wineserver: ${e.message}") + } + } + + private fun encryptToken(token: String) : String { + // Simple encoding (not as secure as Windows CryptProtectData, but cross-platform) + // run steam-token.exe from extractDir and return the result + return execCommand("wine ${imageFs.rootDir}/opt/apps/steam-token.exe encrypt $login $token") + } + + private fun decryptToken(vdfValue: String) : String { + // Simple decoding (not as secure as Windows CryptProtectData, but cross-platform) + // run steam-token.exe from extractDir and return the result + return execCommand("wine ${imageFs.rootDir}/opt/apps/steam-token.exe decrypt $login $vdfValue") + } + + private fun obfuscateToken(value: String, mtbf: Long) : String { + return SteamTokenHelper.obfuscate(value.toByteArray(), mtbf) + } + + private fun deobfuscateToken(value: String, mtbf: Long) : String { + return SteamTokenHelper.deobfuscate(value, mtbf) + } + + @SuppressLint("BinaryOperationInTimber") + private fun createConfigVdf(): String { + // Simple hash-based encryption for cross-platform compatibility + val hdr = hdr() + + // e.g. 1329969238 + val minMTBF = 1000000000L + val maxMTBF = 2000000000L + var mtbf = kotlin.random.Random.nextLong(minMTBF, maxMTBF) + var encoded = "" + + // Try to encode the token until it get a value + do { + try { + encoded = obfuscateToken("$token$NULL_CHAR", mtbf) + } catch (_: Exception) { + mtbf = kotlin.random.Random.nextLong(minMTBF, maxMTBF) + } + } while (encoded == "") + + Timber.tag("SteamTokenLogin").d("MTBF: $mtbf") + Timber.tag("SteamTokenLogin").d("Encoded: $encoded") + + return """ + "InstallConfigStore" + { + "Software" + { + "Valve" + { + "Steam" + { + "MTBF" "$mtbf" + "ConnectCache" + { + "$hdr" "$encoded$NULL_CHAR" + } + "Accounts" + { + "$login" + { + "SteamID" "$steamId" + } + } + } + } + } + } + """.trimIndent() + } + + @SuppressLint("BinaryOperationInTimber") + private fun createLocalVdf(): String { + // Simple hash-based encryption for cross-platform compatibility + val hdr = hdr() + val encoded = encryptToken(token) + + return """ + "MachineUserConfigStore" + { + "Software" + { + "Valve" + { + "Steam" + { + "ConnectCache" + { + "$hdr" "$encoded" + } + } + } + } + } + """.trimIndent() + } + + /** + * Phase 1 Steam Config + * Write config.vdf + */ + fun phase1SteamConfig() { + val steamConfigDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/config").toPath() + Files.createDirectories(steamConfigDir) + + // Check if config.vdf not exists, or its contents does not contains MTBF + val configVdfPath = steamConfigDir.resolve("config.vdf") + + var shouldWriteConfig = true + var shouldProcessPhase2 = false + + if (Files.exists(configVdfPath)) { + val vdfContent = FileUtils.readString(configVdfPath.toFile()) + if (vdfContent.contains("ConnectCache")) { + // Find the value of ConnectCache + // Use structured parsing: + val vdfData = KeyValue.loadFromString(vdfContent)!! + val mtbf = vdfData["Software"]["Valve"]["Steam"]["MTBF"].value + val connectCacheValue = vdfData["Software"]["Valve"]["Steam"]["ConnectCache"][hdr()].value + + if (mtbf != null && connectCacheValue != null) { + try { + val dToken = deobfuscateToken(connectCacheValue.trimEnd(NULL_CHAR), mtbf.toLong()).trimEnd(NULL_CHAR) + if (JWT(dToken).isExpired(TOKEN_EXPIRE_TIME)) { + Timber.tag("SteamTokenLogin").d("Saved JWT expired, overriding config.vdf") + // If the saved JWT is expired, override it + shouldWriteConfig = true + } else { + Timber.tag("SteamTokenLogin").d("Saved JWT is not expired, do not override config.vdf") + shouldWriteConfig = false + } + } catch (_: Exception) { + Timber.tag("SteamTokenLogin").d("Cannot parse saved JWT, overriding config.vdf") + shouldWriteConfig = true + } + } else { + if (mtbf == null && connectCacheValue == null) { + Timber.tag("SteamTokenLogin").d("MTBF and ConnectCache not found, overriding config.vdf") + shouldWriteConfig = true + } else if (mtbf != null) { + Timber.tag("SteamTokenLogin").d("MTBF exists but ConnectCache not found, it is an updated steam client, processing phase 2") + shouldWriteConfig = false + shouldProcessPhase2 = true + } + } + } else if (vdfContent.contains("MTBF")) { + Timber.tag("SteamTokenLogin").d("MTBF exists but ConnectCache not found, it is an updated steam client, processing phase 2") + shouldWriteConfig = false + shouldProcessPhase2 = true + } + } + + if (shouldWriteConfig) { + Timber.tag("SteamTokenLogin").d("Overriding config.vdf") + + Files.write( + steamConfigDir.resolve("config.vdf"), + createConfigVdf().toByteArray(), + ) + + // Set permissions + FileUtils.chmod(File(steamConfigDir.absolutePathString(), "loginusers.vdf"), 505) // 0771 + FileUtils.chmod(File(steamConfigDir.absolutePathString(), "config.vdf"), 505) // 0771 + + // Remove local.vdf + val localSteamDir = File(imageFs.wineprefix, "drive_c/users/${ImageFs.USER}/AppData/Local/Steam").toPath() + localSteamDir.createDirectories() + + if (localSteamDir.resolve("local.vdf").exists()) { + Files.delete(localSteamDir.resolve("local.vdf")) + } + } else if (shouldProcessPhase2) { + phase2LocalConfig() + } + } + + /** + * Phase 2 Local Config + * Refresh local.vdf value + */ + fun phase2LocalConfig() { + try { + // Extract steam-token.tzst + val extractDir = File(imageFs.rootDir, "/opt/apps/") + Files.createDirectories(extractDir.toPath()) + TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, File(imageFs.filesDir, "steam-token.tzst"), extractDir) + + val localSteamDir = File(imageFs.wineprefix, "drive_c/users/${ImageFs.USER}/AppData/Local/Steam").toPath() + Files.createDirectories(localSteamDir) + + // Remove local.vdf + if (localSteamDir.resolve("local.vdf").exists()) { + val vdfContent = FileUtils.readString(localSteamDir.resolve("local.vdf").toFile()) + val vdfData = KeyValue.loadFromString(vdfContent)!! + val connectCacheValue = vdfData["Software"]["Valve"]["Steam"]["ConnectCache"][hdr()].value + if (connectCacheValue != null) { + try { + val dToken = decryptToken(connectCacheValue.trimEnd(NULL_CHAR)) + val savedJWT = JWT(dToken) + + // If the saved JWT is not expired, do not override it + if (!savedJWT.isExpired(TOKEN_EXPIRE_TIME)) { + Timber.tag("SteamTokenLogin").d("Saved JWT is not expired, do not override local.vdf") + return + } + } catch (e: Exception) { + Timber.tag("SteamTokenLogin").d("An unexpected error occurred: ${e.message}") + e.printStackTrace() + } + } + } + + Timber.tag("SteamTokenLogin").d("Overriding local.vdf") + + Files.write( + localSteamDir.resolve("local.vdf"), + createLocalVdf().toByteArray(), + ) + + killWineServer() + + // Set permissions + FileUtils.chmod(File(localSteamDir.absolutePathString(), "local.vdf"), 505) // 0771 + } catch (e: Exception) { + Timber.tag("SteamTokenLogin").d("An unexpected error occurred: ${e.message}") + e.printStackTrace() + } + } +} diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index db32664de2..6984650845 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -3,10 +3,14 @@ package app.gamenative.utils import android.annotation.SuppressLint import android.content.Context import android.provider.Settings +import androidx.navigation.ActivityNavigator import app.gamenative.PrefManager import app.gamenative.data.DepotInfo import app.gamenative.data.LibraryItem +import app.gamenative.data.SaveFilePattern +import app.gamenative.data.SteamApp import app.gamenative.enums.Marker +import app.gamenative.enums.PathType import app.gamenative.service.SteamService import app.gamenative.service.SteamService.Companion.getAppDirName import app.gamenative.service.SteamService.Companion.getAppInfoOf @@ -15,6 +19,7 @@ import com.winlator.container.ContainerManager import com.winlator.core.TarCompressorUtils import com.winlator.core.WineRegistryEditor import com.winlator.xenvironment.ImageFs +import `in`.dragonbra.javasteam.types.KeyValue import `in`.dragonbra.javasteam.util.HardwareUtils import java.io.File import java.io.FileOutputStream @@ -116,26 +121,26 @@ object SteamUtils { Timber.i("Generated steam_interfaces.txt (${sorted.size} interfaces)") } - private fun copyOriginalSteamDll(dllPath: Path, appDirPath: String) { + private fun copyOriginalSteamDll(dllPath: Path, appDirPath: String): String? { // 1️⃣ back-up next to the original DLL val backup = dllPath.parent.resolve("${dllPath.fileName}.orig") if (Files.notExists(backup)) { try { Files.copy(dllPath, backup) Timber.i("Copied original ${dllPath.fileName} to $backup") - - // 2️⃣ record the relative path inside the app directory - val relPath = Paths.get(appDirPath).relativize(backup) - Files.write( - Paths.get(appDirPath).resolve("orig_dll_path.txt"), - listOf(relPath.toString()), - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING - ) } catch (e: IOException) { Timber.w(e, "Failed to back up ${dllPath.fileName}") + return null } } + // 2️⃣ return the relative path inside the app directory (even if backup already existed) + return try { + val relPath = Paths.get(appDirPath).relativize(backup) + relPath.toString() + } catch (e: Exception) { + Timber.w(e, "Failed to compute relative path for ${dllPath.fileName}") + null + } } /** @@ -152,8 +157,9 @@ object SteamUtils { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) Timber.i("Starting replaceSteamApi for appId: $appId") Timber.i("Checking directory: $appDirPath") - var replaced32 = false - var replaced64 = false + var replaced32Count = 0 + var replaced64Count = 0 + val backupPaths = mutableSetOf() val imageFs = ImageFs.find(context) autoLoginUserChanges(imageFs) setupLightweightSteamConfig(imageFs, SteamService.userSteamId?.toString()) @@ -169,13 +175,14 @@ object SteamUtils { val is64Bit = path.name.equals("steam_api64.dll", ignoreCase = true) val is32Bit = path.name.equals("steam_api.dll", ignoreCase = true) - if ((is32Bit && replaced32) || (is64Bit && replaced64)) return@forEach - if (is64Bit || is32Bit) { val dllName = if (is64Bit) "steam_api64.dll" else "steam_api.dll" Timber.i("Found $dllName at ${path.absolutePathString()}, replacing...") generateInterfacesFile(path) - copyOriginalSteamDll(path, appDirPath) + val relPath = copyOriginalSteamDll(path, appDirPath) + if (relPath != null) { + backupPaths.add(relPath) + } Files.delete(path) Files.createFile(path) FileOutputStream(path.absolutePathString()).use { fos -> @@ -184,16 +191,34 @@ object SteamUtils { } } Timber.i("Replaced $dllName") - if (is64Bit) replaced64 = true else replaced32 = true + if (is64Bit) replaced64Count++ else replaced32Count++ ensureSteamSettings(context, path, appId, ticketBase64) } } - Timber.i("Finished replaceSteamApi for appId: $appId. Replaced 32bit: $replaced32, Replaced 64bit: $replaced64") + // Write all collected backup paths to orig_dll_path.txt + if (backupPaths.isNotEmpty()) { + try { + Files.write( + Paths.get(appDirPath).resolve("orig_dll_path.txt"), + backupPaths.sorted(), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ) + Timber.i("Wrote ${backupPaths.size} DLL backup paths to orig_dll_path.txt") + } catch (e: IOException) { + Timber.w(e, "Failed to write orig_dll_path.txt") + } + } + + Timber.i("Finished replaceSteamApi for appId: $appId. Replaced 32bit: $replaced32Count, Replaced 64bit: $replaced64Count") // Restore unpacked executable if it exists (for DRM-free mode) restoreUnpackedExecutable(context, steamAppId) + // Restore original steamclient.dll files if they exist + restoreSteamclientFiles(context, steamAppId) + // Create Steam ACF manifest for real Steam compatibility createAppManifest(context, steamAppId) MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_REPLACED) @@ -212,15 +237,19 @@ object SteamUtils { } MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_RESTORED) + + // Make a backup before extracting + backupSteamclientFiles(context, steamAppId) + val imageFs = ImageFs.find(context) - val downloaded = File(imageFs.getFilesDir(), "experimental-drm.tzst") + val downloaded = File(imageFs.getFilesDir(), "experimental-drm-20260116.tzst") TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, downloaded, imageFs.getRootDir(), ) putBackSteamDlls(appDirPath) - restoreUnpackedExecutable(context, steamAppId) + restoreOriginalExecutable(context, steamAppId) // Get ticket and pass to ensureSteamSettings val ticketBase64 = SteamService.instance?.getEncryptedAppTicketBase64(steamAppId) @@ -229,6 +258,62 @@ object SteamUtils { MarkerUtils.addMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) } + fun steamClientFiles() : Array { + return arrayOf( + "GameOverlayRenderer.dll", + "GameOverlayRenderer64.dll", + "steamclient.dll", + "steamclient64.dll", + "steamclient_loader_x32.exe", + "steamclient_loader_x64.exe", + ) + } + + fun backupSteamclientFiles(context: Context, steamAppId: Int) { + val imageFs = ImageFs.find(context) + + var backupCount = 0 + + val backupDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/steamclient_backup") + backupDir.mkdirs() + + steamClientFiles().forEach { file -> + val dll = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/$file") + if (dll.exists()) { + Files.copy(dll.toPath(), File(backupDir, "$file.orig").toPath(), StandardCopyOption.REPLACE_EXISTING) + backupCount++ + } + } + + Timber.i("Finished backupSteamclientFiles for appId: $steamAppId. Backed up $backupCount file(s)") + } + + fun restoreSteamclientFiles(context: Context, steamAppId: Int) { + val imageFs = ImageFs.find(context) + + var restoredCount = 0 + + val origDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + + val backupDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/steamclient_backup") + if (backupDir.exists()) { + steamClientFiles().forEach { file -> + val dll = File(backupDir, "$file.orig") + if (dll.exists()) { + Files.copy(dll.toPath(), File(origDir, file).toPath(), StandardCopyOption.REPLACE_EXISTING) + restoredCount++ + } + } + } + + val extraDllDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/extra_dlls") + if (extraDllDir.exists()) { + extraDllDir.deleteRecursively() + } + + Timber.i("Finished restoreSteamclientFiles for appId: $steamAppId. Restored $restoredCount file(s)") + } + internal fun writeColdClientIni(steamAppId: Int, container: Container) { val gameName = getAppDirName(getAppInfoOf(steamAppId)) val executablePath = container.executablePath.replace("/", "\\") @@ -251,16 +336,18 @@ object SteamUtils { [Injection] IgnoreLoaderArchDifference=1 + DllsToInjectFolder=extra_dlls """.trimIndent(), ) } - private fun autoLoginUserChanges(imageFs: ImageFs) { + fun autoLoginUserChanges(imageFs: ImageFs) { val vdfFileText = SteamService.getLoginUsersVdfOauth( steamId64 = SteamService.userSteamId?.convertToUInt64().toString(), account = PrefManager.username, refreshToken = PrefManager.refreshToken, accessToken = PrefManager.accessToken, // may be blank + personaName = SteamService.instance?.localPersona?.value?.name ?: PrefManager.username ) val steamConfigDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/config") try { @@ -272,7 +359,6 @@ object SteamUtils { val hkcu = "Software\\Valve\\Steam" WineRegistryEditor(userRegFile).use { reg -> reg.setStringValue("Software\\Valve\\Steam", "AutoLoginUser", PrefManager.username) - reg.setDwordValue("Software\\Valve\\Steam", "RememberPassword", 1) reg.setStringValue(hkcu, "SteamExe", steamExe) reg.setStringValue(hkcu, "SteamPath", steamRoot) reg.setStringValue(hkcu, "InstallPath", steamRoot) @@ -593,6 +679,10 @@ object SteamUtils { cfgFile.writeText("BootStrapperInhibitAll=Enable\nBootStrapperForceSelfUpdate=False") } } + + // Update or modify localconfig.vdf + updateOrModifyLocalConfig(imageFs, container, steamAppId.toString(), SteamService.userSteamId!!.accountID.toString()) + skipFirstTimeSteamSetup(imageFs.rootDir) val appDirPath = SteamService.getAppDirPath(steamAppId) if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_DLL_RESTORED)) { @@ -612,6 +702,9 @@ object SteamUtils { // Restore original executable if it exists (for real Steam mode) restoreOriginalExecutable(context, steamAppId) + // Restore original steamclient.dll files if they exist + restoreSteamclientFiles(context, steamAppId) + // Create Steam ACF manifest for real Steam compatibility createAppManifest(context, steamAppId) MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_RESTORED) @@ -664,28 +757,28 @@ object SteamUtils { val imageFs = ImageFs.find(context) val dosDevicesPath = File(imageFs.wineprefix, "dosdevices/a:") - dosDevicesPath.walkTopDown().maxDepth(10).firstOrNull { - it.isFile && it.name.endsWith(".original.exe", ignoreCase = true) - }?.let { file -> - try { - val origPath = file.toPath() - val originalPath = origPath.parent.resolve(origPath.name.removeSuffix(".original.exe")) - Timber.i("Found ${origPath.name} at ${origPath.absolutePathString()}, restoring...") + dosDevicesPath.walkTopDown().maxDepth(10) + .filter { it.isFile && it.name.endsWith(".original.exe", ignoreCase = true) } + .forEach { file -> + try { + val origPath = file.toPath() + val originalPath = origPath.parent.resolve(origPath.name.removeSuffix(".original.exe")) + Timber.i("Found ${origPath.name} at ${origPath.absolutePathString()}, restoring...") - // Delete the current exe if it exists - if (Files.exists(originalPath)) { - Files.delete(originalPath) - } + // Delete the current exe if it exists + if (Files.exists(originalPath)) { + Files.delete(originalPath) + } - // Copy the backup back to the original location - Files.copy(origPath, originalPath) + // Copy the backup back to the original location + Files.copy(origPath, originalPath) - Timber.i("Restored ${originalPath.fileName} from backup") - restoredCount++ - } catch (e: IOException) { - Timber.w(e, "Failed to restore ${file.name} from backup") + Timber.i("Restored ${originalPath.fileName} from backup") + restoredCount++ + } catch (e: IOException) { + Timber.w(e, "Failed to restore ${file.name} from backup") + } } - } Timber.i("Finished restoreOriginalExecutable for appId: $steamAppId. Restored $restoredCount executable(s)") } @@ -712,16 +805,22 @@ object SteamUtils { appIdFile.toFile().writeText(steamAppId.toString()) } val depotsFile = settingsDir.resolve("depots.txt") - if (Files.notExists(depotsFile)) { - SteamService.getInstalledDepotsOf(steamAppId)?.let { depotsList -> - Files.createFile(depotsFile) - depotsFile.toFile().writeText(depotsList.joinToString(System.lineSeparator())) - } + if (Files.exists(depotsFile)) { + Files.delete(depotsFile) + } + SteamService.getInstalledDepotsOf(steamAppId)?.sorted()?.let { depotsList -> + Files.createFile(depotsFile) + depotsFile.toFile().writeText(depotsList.joinToString(System.lineSeparator())) } val configsIni = settingsDir.resolve("configs.user.ini") val accountName = PrefManager.username - val accountSteamId = SteamService.userSteamId?.convertToUInt64()?.toString() ?: "0" + val accountSteamId = SteamService.userSteamId?.convertToUInt64()?.toString() + ?: PrefManager.steamUserSteamId64.takeIf { it != 0L }?.toString() + ?: "0" + val accountId = SteamService.userSteamId?.accountID + ?: PrefManager.steamUserAccountId.takeIf { it != 0 }?.toLong() + ?: 0L val container = ContainerUtils.getOrCreateContainer(context, appId) val language = runCatching { (container.getExtra("language", null) @@ -729,25 +828,68 @@ object SteamUtils { ?: "english" }.getOrDefault("english").lowercase() - val iniContent = """ - [user::general] - account_name=$accountName - account_steamid=$accountSteamId - language=$language - ticket=$ticketBase64 - """.trimIndent() + // Get appInfo to check if saveFilePatterns exist (used for both user and app configs) + val appInfo = getAppInfoOf(steamAppId) + val hasSaveFilePatterns = appInfo?.ufs?.saveFilePatterns?.isNotEmpty() == true + + val iniContent = buildString { + appendLine("[user::general]") + appendLine("account_name=$accountName") + appendLine("account_steamid=$accountSteamId") + appendLine("language=$language") + if (!ticketBase64.isNullOrEmpty()) { + appendLine("ticket=$ticketBase64") + } + + // Only add [user::saves] section if no saveFilePatterns are defined + if (!hasSaveFilePatterns) { + val steamUserDataPath = "C:\\Program Files (x86)\\Steam\\userdata\\$accountId" + appendLine() + appendLine("[user::saves]") + appendLine("local_save_path=$steamUserDataPath") + } + } if (Files.notExists(configsIni)) Files.createFile(configsIni) configsIni.toFile().writeText(iniContent) val appIni = settingsDir.resolve("configs.app.ini") - val dlcIds = SteamService.getDlcDepotsOf(steamAppId) + val dlcIds = SteamService.getInstalledDlcDepotsOf(steamAppId) + val dlcApps = SteamService.getDownloadableDlcAppsOf(steamAppId) + val hiddenDlcApps = SteamService.getHiddenDlcAppsOf(steamAppId) + val appendedDlcIds = mutableListOf() val forceDlc = container.isForceDlc() val appIniContent = buildString { appendLine("[app::dlcs]") appendLine("unlock_all=${if (forceDlc) 1 else 0}") - dlcIds?.forEach { appendLine("$it=dlc$it") } + dlcIds?.sorted()?.forEach { + appendLine("$it=dlc$it") + appendedDlcIds.add(it) + } + + dlcApps?.forEach { dlcApp -> + val installedDlcApp = SteamService.getInstalledApp(dlcApp.id) + if (installedDlcApp != null && !appendedDlcIds.contains(dlcApp.id)) { + appendLine("${dlcApp.id}=dlc${dlcApp.id}") + appendedDlcIds.add(dlcApp.id) + } + } + + // only add hidden dlc apps if not found in appendedDlcIds + hiddenDlcApps?.forEach { hiddenDlcApp -> + if (!appendedDlcIds.contains(hiddenDlcApp.id) && + // only add hidden dlc apps if it is not a DLC of the main app + appInfo!!.depots.filter { (_, depot) -> depot.dlcAppId == hiddenDlcApp.id }.size <= 1) { + appendLine("${hiddenDlcApp.id}=dlc${hiddenDlcApp.id}") + } + } + + // Add cloud save config sections if appInfo exists + if (appInfo != null) { + appendLine() + append(generateCloudSaveConfig(appInfo)) + } } if (Files.notExists(appIni)) Files.createFile(appIni) @@ -803,6 +945,37 @@ object SteamUtils { supportedLanguagesFile.toFile().writeText(supportedLanguages.joinToString("\n")) } + /** + * Generates cloud save configuration sections for configs.app.ini + * Returns empty string if no Windows save patterns are found + */ + private fun generateCloudSaveConfig(appInfo: SteamApp): String { + // Filter to only Windows save patterns + val windowsPatterns = appInfo.ufs.saveFilePatterns.filter { it.root.isWindows } + + return buildString { + if (windowsPatterns.isNotEmpty()) { + appendLine("[app::cloud_save::general]") + appendLine("create_default_dir=1") + appendLine("create_specific_dirs=1") + appendLine() + appendLine("[app::cloud_save::win]") + val uniqueDirs = LinkedHashSet() + windowsPatterns.forEach { pattern -> + val root = if (pattern.root.name == "GameInstall") "gameinstall" else pattern.root.name + val path = pattern.path + .replace("{64BitSteamID}", "{::64BitSteamID::}") + .replace("{Steam3AccountID}", "{::Steam3AccountID::}") + uniqueDirs.add("{::$root::}/$path") + } + + uniqueDirs.forEachIndexed { index, dir -> + appendLine("dir${index + 1}=$dir") + } + } + } + } + private fun convertToWindowsPath(unixPath: String): String { // Find the drive_c component and convert everything after to Windows semantics val marker = "/drive_c/" @@ -909,31 +1082,83 @@ object SteamUtils { override fun onResponse(call: Call, res: Response) { res.use { - val body = it.body?.string() ?: run { callback(-1); return } - Timber.i("[DX Fetch] Raw fbody etchDirect3DMajor for body=%s", body) - val arr = JSONObject(body) - .optJSONArray("cargoquery") ?: run { callback(-1); return } + try { + val body = it.body?.string() ?: run { callback(-1); return } + Timber.i("[DX Fetch] Raw body fetchDirect3DMajor for body=%s", body) + val arr = JSONObject(body) + .optJSONArray("cargoquery") ?: run { callback(-1); return } + + // There should be at most one row; take the first. + val raw = arr.optJSONObject(0) + ?.optJSONObject("title") + ?.optString("Direct3D versions") + ?.trim() ?: "" + + Timber.i("[DX Fetch] Raw fetchDirect3DMajor for raw=%s", raw) + + // Extract highest DX major number present. + val dx = Regex("\\b(9|10|11|12)\\b") + .findAll(raw) + .map { it.value.toInt() } + .maxOrNull() ?: -1 + + Timber.i("[DX Fetch] dx fetchDirect3DMajor is dx=%d", dx) + + callback(dx) + } catch (e: Exception){ + callback(-1) + } + } + } + }) + } - // There should be at most one row; take the first. - val raw = arr.optJSONObject(0) - ?.optJSONObject("title") - ?.optString("Direct3D versions") - ?.trim() ?: "" + fun updateOrModifyLocalConfig(imageFs: ImageFs, container: Container, appId: String, steamUserId64: String) { + try { + val exeCommandLine = container.execArgs - Timber.i("[DX Fetch] Raw fetchDirect3DMajor for raw=%s", raw) + val steamPath = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") - // Extract highest DX major number present. - val dx = Regex("\\b(9|10|11|12)\\b") - .findAll(raw) - .map { it.value.toInt() } - .maxOrNull() ?: -1 + // Create necessary directories + val userDataPath = File(steamPath, "userdata/$steamUserId64") + val configPath = File(userDataPath, "config") + configPath.mkdirs() - Timber.i("[DX Fetch] dx fetchDirect3DMajor is dx=%d", dx) + val localConfigFile = File(configPath, "localconfig.vdf") - callback(dx) + if (localConfigFile.exists()) { + val vdfContent = FileUtils.readFileAsString(localConfigFile.absolutePath) + val vdfData = KeyValue.loadFromString(vdfContent!!)!! + val app = vdfData["Software"]["Valve"]["Steam"]["apps"][appId] + val option = app.children.firstOrNull { it.name == "LaunchOptions" } + if (option != null) { + option.value = exeCommandLine.orEmpty() + } else { + app.children.add(KeyValue("LaunchOptions", exeCommandLine)) } + + vdfData.saveToFile(localConfigFile, false) + } else { + val vdfData = KeyValue(name = "UserLocalConfigStore") + val option = KeyValue("LaunchOptions", exeCommandLine) + val software = KeyValue("Software") + val valve = KeyValue("Valve") + val steam = KeyValue("Steam") + val apps = KeyValue("apps") + val app = KeyValue(appId) + + app.children.add(option) + apps.children.add(app) + steam.children.add(apps) + valve.children.add(steam) + software.children.add(valve) + vdfData.children.add(software) + + vdfData.saveToFile(localConfigFile, false) } - }) + } catch (e: Exception) { + Timber.e(e, "Failed to update or modify local config") + } } fun getSteamId64(): Long? { @@ -944,3 +1169,4 @@ object SteamUtils { return SteamService.userSteamId?.accountID?.toLong() } } + diff --git a/app/src/main/java/app/gamenative/utils/StorageUtils.kt b/app/src/main/java/app/gamenative/utils/StorageUtils.kt index 31b20a9b08..9127bcc1ce 100644 --- a/app/src/main/java/app/gamenative/utils/StorageUtils.kt +++ b/app/src/main/java/app/gamenative/utils/StorageUtils.kt @@ -21,6 +21,10 @@ import java.nio.file.Path object StorageUtils { fun getAvailableSpace(path: String): Long { + val file = File(path) + if (!file.exists()) { + throw IllegalArgumentException("Invalid path: $path") + } val stat = StatFs(path) return stat.blockSizeLong * stat.availableBlocksLong } diff --git a/app/src/main/java/com/winlator/core/DefaultVersion.java b/app/src/main/java/com/winlator/core/DefaultVersion.java index d1382e09cc..356de88857 100644 --- a/app/src/main/java/com/winlator/core/DefaultVersion.java +++ b/app/src/main/java/com/winlator/core/DefaultVersion.java @@ -8,7 +8,7 @@ public abstract class DefaultVersion { public static final String BOX86 = "0.3.2"; public static final String BOX64 = "0.3.6"; - public static final String FEXCORE = "2511"; + public static final String FEXCORE = "2512"; public static String WRAPPER = "System"; public static final String TURNIP = "25.2.0"; public static final String ZINK = "22.2.5"; diff --git a/app/src/main/java/com/winlator/core/WineUtils.java b/app/src/main/java/com/winlator/core/WineUtils.java index f1e4ae75a2..3bebea53bf 100644 --- a/app/src/main/java/com/winlator/core/WineUtils.java +++ b/app/src/main/java/com/winlator/core/WineUtils.java @@ -170,9 +170,8 @@ public static void applySystemTweaks(Context context, WineInfo wineInfo) { setWindowMetrics(registryEditor); } - File wineRoot = wineInfo.path != null ? new File(wineInfo.path) : new File(rootDir, "/opt/wine"); - File wineSystem32Dir = new File(wineRoot, "lib/wine/x86_64-windows"); - File wineSysWoW64Dir = new File(wineRoot, "lib/wine/i386-windows"); + File wineSystem32Dir = new File(rootDir, "/opt/wine/lib/wine/x86_64-windows"); + File wineSysWoW64Dir = new File(rootDir, "/opt/wine/lib/wine/i386-windows"); File containerSystem32Dir = new File(rootDir, ImageFs.WINEPREFIX+"/drive_c/windows/system32"); File containerSysWoW64Dir = new File(rootDir, ImageFs.WINEPREFIX+"/drive_c/windows/syswow64"); diff --git a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java index d83059ef5e..b99ef60a80 100644 --- a/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/InputControlsManager.java @@ -86,6 +86,12 @@ private void copyAssetProfilesIfNeeded() { FileUtils.copy(context, assetPath, targetFile); } } + + // Fix if controls-0.icp not exists + File file = ControlsProfile.getProfileFile(context, 0); + if (!file.isFile()) { + FileUtils.copy(context, "inputcontrols/profiles/controls-0.icp", file); + } } catch (IOException e) {} } diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index e5a71b8f7d..15ae1d5a88 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -60,8 +60,6 @@ public class InputControlsView extends View { private final Bitmap[] icons = new Bitmap[17]; private Timer mouseMoveTimer; private final PointF mouseMoveOffset = new PointF(); - private Vibrator vibrator; - private VibrationEffect effect; private boolean showTouchscreenControls = true; @SuppressLint("ResourceType") @@ -73,9 +71,6 @@ public InputControlsView(Context context) { setBackgroundColor(0x00000000); setPointerIcon(PointerIcon.load(getResources(), R.drawable.hidden_pointer_arrow)); setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - - vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); - effect = VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE); } public void setEditMode(boolean editMode) { @@ -389,9 +384,7 @@ public boolean onTouchEvent(MotionEvent event) { touchpadView.setPointerButtonLeftEnabled(true); for (ControlElement element : profile.getElements()) { if (element.handleTouchDown(pointerId, x, y)) { - if (vibrator != null) { - vibrator.vibrate(effect); - } + performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY); handled = true; } if (element.getBindingAt(0) == Binding.MOUSE_LEFT_BUTTON) { diff --git a/app/src/main/java/com/winlator/xenvironment/XEnvironment.java b/app/src/main/java/com/winlator/xenvironment/XEnvironment.java index 179822b055..410223b2bc 100644 --- a/app/src/main/java/com/winlator/xenvironment/XEnvironment.java +++ b/app/src/main/java/com/winlator/xenvironment/XEnvironment.java @@ -26,30 +26,6 @@ public class XEnvironment implements Iterable { private boolean winetricksRunning = false; - private final AudioManager audioManager; - private boolean audioCallbackRegistered = false; - private final AudioDeviceCallback audioDeviceCallback = new AudioDeviceCallback() { - @Override - public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { - // Handle newly added audio devices (e.g., headphones connected) - for (AudioDeviceInfo device : addedDevices) { - if (device.isSink()) { - restartAudioComponent(); - } - } - } - - @Override - public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { - // Handle removed audio devices (e.g., headphones disconnected) - for (AudioDeviceInfo device : removedDevices) { - if (device.isSink()) { - restartAudioComponent(); - } - } - } - }; - public synchronized boolean isWinetricksRunning() { return winetricksRunning; } @@ -61,9 +37,6 @@ public synchronized void setWinetricksRunning(boolean running) { public XEnvironment(Context context, ImageFs imageFs) { this.context = context; this.imageFs = imageFs; - this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - this.audioManager.registerAudioDeviceCallback(audioDeviceCallback, null); - this.audioCallbackRegistered = true; } public Context getContext() { @@ -110,11 +83,6 @@ public void stopEnvironmentComponents() { } public void onPause() { - if (audioCallbackRegistered) { - audioManager.unregisterAudioDeviceCallback(audioDeviceCallback); - audioCallbackRegistered = false; - } - GuestProgramLauncherComponent guestProgramLauncherComponent = getComponent(GuestProgramLauncherComponent.class); if (guestProgramLauncherComponent != null) guestProgramLauncherComponent.suspendProcess(); GlibcProgramLauncherComponent glibcProgramLauncherComponent = getComponent(GlibcProgramLauncherComponent.class); @@ -124,11 +92,6 @@ public void onPause() { } public void onResume() { - if (!audioCallbackRegistered) { - audioManager.registerAudioDeviceCallback(audioDeviceCallback, null); - audioCallbackRegistered = true; - } - GuestProgramLauncherComponent guestProgramLauncherComponent = getComponent(GuestProgramLauncherComponent.class); if (guestProgramLauncherComponent != null) guestProgramLauncherComponent.resumeProcess(); GlibcProgramLauncherComponent glibcProgramLauncherComponent = getComponent(GlibcProgramLauncherComponent.class); @@ -136,18 +99,4 @@ public void onResume() { BionicProgramLauncherComponent bionicProgramLauncherComponent = getComponent(BionicProgramLauncherComponent.class); if (bionicProgramLauncherComponent != null) bionicProgramLauncherComponent.resumeProcess(); } - - private void restartAudioComponent() { - final ALSAServerComponent alsaServerComponent = getComponent(ALSAServerComponent.class); - if (alsaServerComponent != null) { - alsaServerComponent.stop(); - alsaServerComponent.start(); - } - - final PulseAudioComponent pulseAudioComponent = getComponent(PulseAudioComponent.class); - if (pulseAudioComponent != null) { - //pulseAudioComponent.stop(); stop is already called inside start function - pulseAudioComponent.start(); - } - } } diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index 034a238606..394aaf6084 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -404,27 +404,23 @@ private void extractEmulatorsDlls() { Log.d("Extraction", "box64Version in use: " + wowbox64Version); Log.d("Extraction", "fexcoreVersion in use: " + fexcoreVersion); - if (!wowbox64Version.equals(container.getExtra("box64Version")) || !container.getWineVersion().equals(imageFs.getArch())) { - ContentProfile profile = contentsManager.getProfileByEntryName("wowbox64-" + wowbox64Version); - if (profile != null) - contentsManager.applyContent(profile); - else - Log.d("Extraction", "Extracting box64Version: " + wowbox64Version); - TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, environment.getContext(), "wowbox64/wowbox64-" + wowbox64Version + ".tzst", system32dir); - container.putExtra("box64Version", wowbox64Version); - containerDataChanged = true; - } - - if (!fexcoreVersion.equals(container.getExtra("fexcoreVersion")) || !container.getWineVersion().equals(imageFs.getArch())) { - ContentProfile profile = contentsManager.getProfileByEntryName("fexcore-" + fexcoreVersion); - if (profile != null) - contentsManager.applyContent(profile); - else - Log.d("Extraction", "Extracting fexcoreVersion: " + fexcoreVersion); - TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, environment.getContext(), "fexcore/fexcore-" + fexcoreVersion + ".tzst", system32dir); - container.putExtra("fexcoreVersion", fexcoreVersion); - containerDataChanged = true; - } + ContentProfile wowboxprofile = contentsManager.getProfileByEntryName("wowbox64-" + wowbox64Version); + if (wowboxprofile != null) + contentsManager.applyContent(wowboxprofile); + else + Log.d("Extraction", "Extracting box64Version: " + wowbox64Version); + TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, environment.getContext(), "wowbox64/wowbox64-" + wowbox64Version + ".tzst", system32dir); + container.putExtra("box64Version", wowbox64Version); + containerDataChanged = true; + + ContentProfile fexprofile = contentsManager.getProfileByEntryName("fexcore-" + fexcoreVersion); + if (fexprofile != null) + contentsManager.applyContent(fexprofile); + else + Log.d("Extraction", "Extracting fexcoreVersion: " + fexcoreVersion); + TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, environment.getContext(), "fexcore/fexcore-" + fexcoreVersion + ".tzst", system32dir); + container.putExtra("fexcoreVersion", fexcoreVersion); + containerDataChanged = true; if (containerDataChanged) container.saveData(); } @@ -446,6 +442,10 @@ private void addBox64EnvVars(EnvVars envVars, boolean enableLogs) { } public String execShellCommand(String command) { + return execShellCommand(command, true); + } + + public String execShellCommand(String command, boolean includeStderr) { Context context = environment.getContext(); ImageFs imageFs = ImageFs.find(context); File rootDir = imageFs.getRootDir(); @@ -501,15 +501,18 @@ public String execShellCommand(String command) { while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } - while ((line = errorReader.readLine()) != null) { - output.append(line).append("\n"); + if (includeStderr) { + while ((line = errorReader.readLine()) != null) { + output.append(line).append("\n"); + } } process.waitFor(); } catch (Exception e) { output.append("Error: ").append(e.getMessage()); } - return output.toString(); + // Format output: trim trailing whitespace/newlines + return output.toString().trim(); } public void restartWineServer() { diff --git a/app/src/main/java/com/winlator/xenvironment/components/GlibcProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/GlibcProgramLauncherComponent.java index 904fd5b876..452bc86b02 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/GlibcProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/GlibcProgramLauncherComponent.java @@ -229,17 +229,15 @@ private void extractBox64Files() { String currentBox64Version = PrefManager.getString("current_box64_version", ""); File rootDir = imageFs.getRootDir(); - if (!box64Version.equals(currentBox64Version) || !container.getWineVersion().equals(imageFs.getArch())) { - ContentProfile profile = contentsManager.getProfileByEntryName("box64-" + box64Version); - if (profile != null) { - contentsManager.applyContent(profile); - } - else { - Log.d("Extraction", "exctracting box64 with box64Version " + box64Version); - TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, context.getAssets(), "box86_64/box64-" + box64Version + ".tzst", rootDir); - } - PrefManager.putString("current_box64_version", box64Version); + ContentProfile profile = contentsManager.getProfileByEntryName("box64-" + box64Version); + if (profile != null) { + contentsManager.applyContent(profile); } + else { + Log.d("Extraction", "exctracting box64 with box64Version " + box64Version); + TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, context.getAssets(), "box86_64/box64-" + box64Version + ".tzst", rootDir); + } + PrefManager.putString("current_box64_version", box64Version); } private void addBox64EnvVars(EnvVars envVars, boolean enableLogs) { @@ -263,6 +261,10 @@ private void addBox64EnvVars(EnvVars envVars, boolean enableLogs) { } public String execShellCommand(String command) { + return execShellCommand(command, true); + } + + public String execShellCommand(String command, boolean includeStderr) { Context context = environment.getContext(); ImageFs imageFs = ImageFs.find(context); File rootDir = imageFs.getRootDir(); @@ -308,14 +310,17 @@ public String execShellCommand(String command) { while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } - while ((line = errorReader.readLine()) != null) { - output.append(line).append("\n"); + if (includeStderr) { + while ((line = errorReader.readLine()) != null) { + output.append(line).append("\n"); + } } process.waitFor(); } catch (Exception e) { output.append("Error: ").append(e.getMessage()); } - return output.toString(); + // Format output: trim trailing whitespace/newlines + return output.toString().trim(); } } diff --git a/app/src/main/java/com/winlator/xenvironment/components/GuestProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/GuestProgramLauncherComponent.java index c5e15d3555..ad018f02a6 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/GuestProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/GuestProgramLauncherComponent.java @@ -379,6 +379,10 @@ public void resumeProcess() { } public String execShellCommand(String command){ + return execShellCommand(command, true); + } + + public String execShellCommand(String command, boolean includeStderr){ return ""; } } diff --git a/app/src/main/jniLibs/arm64-v8a/libextras.so b/app/src/main/jniLibs/arm64-v8a/libextras.so index 10db2b70e5..b22364e927 100755 Binary files a/app/src/main/jniLibs/arm64-v8a/libextras.so and b/app/src/main/jniLibs/arm64-v8a/libextras.so differ diff --git a/app/src/main/res/drawable/ic_gog.png b/app/src/main/res/drawable/ic_gog.png new file mode 100644 index 0000000000..fdf49bcbd8 Binary files /dev/null and b/app/src/main/res/drawable/ic_gog.png differ diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 867383db91..1fca92946b 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -17,6 +17,10 @@ Slet Afinstallér Spil + Afinstallér spil + Er du sikker på, at du vil afinstallere %1$s? Denne handling kan ikke fortrydes. + Download spil + Appen der installeres har følgende pladskrav. Vil du fortsætte?\n\n\tDownload-størrelse: %1$s\n\tTilgængelig plads: %2$s Installér app Slet app Annullér download @@ -725,6 +729,7 @@ Appen der installeres har følgende pladskrav. Vil du fortsætte?\n\n\tDownload-størrelse: %1$s\n\tStørrelse på disk: %2$s\n\tTilgængelig plads: %3$s + Download-størrelse: %1$s\nStørrelse på disk: %2$s\nTilgængelig plads: %3$s Appen der installeres har brug for %1$s plads, men der er kun %2$s tilbage på denne enhed Er du sikker på, at du vil annullere download af appen? Slet alle downloadede data for dette spil? @@ -795,4 +800,36 @@ Eksporteret Kunne ikke eksportere: %s Eksport annulleret + + + GOG Integration (Alpha) + GOG Login + Log ind på din GOG-konto + Synkroniserer… + Fejl: %1$s + ✓ Synkroniseret %1$d spil + Hent dit GOG-spilbibliotek + Login lykkedes + Du er nu logget ind på GOG.\nVi vil nu synkronisere dit bibliotek i baggrunden. + + + Log ind på GOG + Tryk på \'Åbn GOG Login\' og log ind. Når du er logget ind, skal du kopiere URL\'en og indsætte nedenfor + Eksempel: https://embed.gog.com/on_login_success?origin=client&code=aaa + Åbn GOG Login + Godkendelseskode eller login-succes-URL + Indsæt kode eller url her + Log ind + Annuller + Kunne ikke åbne browser + + + Log ud + Log ud fra din GOG-konto + Log ud fra GOG? + Dette vil fjerne dine GOG-legitimationsoplysninger og rydde dit GOG-bibliotek fra denne enhed. Du kan logge ind igen når som helst. + Log ud + Logget ud fra GOG + Kunne ikke logge ud: %s + Logger ud fra GOG… diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000000..8d211c4036 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,906 @@ + + GameNative + Benutzeranmeldung + Zwei-Faktor-Authentifizierung + Startseite + Einstellungen + QR-Anmeldung + Bibliothek + Downloads + Unbekannte App + Vorheriger Code war falsch, bitte versuche es erneut. + Bitte bestätige die Anmeldung in der Steam Mobile App… + Bitte gib deinen Zwei-Faktor-Code aus deiner Authenticator-App ein. + Bitte gib den Authentifizierungscode ein, der an die E-Mail-Adresse %s gesendet wurde + Die App benötigt folgenden Speicherplatz. Möchtest du fortfahren?\n\n\tDownloadgröße: %1$s\n\tBelegter Speicher: %2$s\n\tVerfügbarer Speicher: %3$s + Diese App benötigt %1$s Speicherplatz, aber auf diesem Gerät sind nur noch %2$s frei + Bist du sicher, dass du den Download der App abbrechen möchtest? + Alle heruntergeladenen Daten für dieses Spiel löschen? + ImageFS herunterladen & installieren + Das Ubuntu-Image muss vor der Bearbeitung der Konfiguration heruntergeladen und installiert werden. Diese Aktion kann einige Minuten dauern. Möchtest du fortfahren? + ImageFS installieren + Das Ubuntu-Image muss vor der Bearbeitung der Konfiguration installiert werden. Diese Aktion kann einige Minuten dauern. Möchtest du fortfahren? + Container zurücksetzen + Dadurch wird dein Container auf die Voreinstellung zurückgesetzt. + Container zurücksetzen? + Zurücksetzen + Dateien überprüfen + Stelle bitte sicher, dass deine Spielstände vorher in die Cloud hochgeladen oder gesichert wurden, da sie sonst überschrieben werden können. + Aktualisieren + Stelle sicher, dass deine Speicherstände vor dem Aktualisieren in der Cloud gesichert oder gebackupt sind, um ein Überschreiben zu verhindern. + Cloud-Synchronisierung erfolgreich abgeschlossen + Spielstände sind bereits aktuell + Cloud-Synchronisierung fehlgeschlagen: %s + Du musst bei Steam angemeldet sein, um diese Funktion zu nutzen + Speicherberechtigung erforderlich + Container auf Werkseinstellungen zurückgesetzt + ImageFS installiert. Bitte Container erneut bearbeiten. + ImageFS konnte nicht installiert werden: %s + Spiel deinstallieren + Möchtest du %1$s wirklich deinstallieren? Das kann nicht rückgängig gemacht werden. + %1$s wurde deinstalliert + Spiel konnte nicht deinstalliert werden + Nie + Weiter + App-Kopfzeilenbild + Herunterladen + Installieren + Installieren + Löschen + Deinstallieren + Spielen + App installieren + Speicherbedarf wird berechnet … + App löschen + Download abbrechen + OK + Ja + Nein + Nicht genug Speicher + Fortfahren + Abbrechen + Unbenannt + Löschen bestätigen + Bibliothek + Downloads + Freunde + + Eigene Spiele + Keine Pfade hinzugefügt + Berechtigung erteilen + Aus Bibliothek hinzugefügt + Noch keine manuell hinzugefügten Spiele. + Pfad entfernen + Diesen Pfad nicht mehr durchsuchen? Ordnerinhalt bleibt erhalten. + Pfad entfernen + Pfad wurde entfernt. Der Inhalt wurde nicht gelöscht. + Manuelles Spiel entfernen + Diesen Ordner aus deiner Bibliothek entfernen? Dateien auf dem Datenträger bleiben erhalten. + Diesen Ordner entfernen + Manueller Ordner aus der Bibliothek entfernt. + ⚠ Kein Zugriff möglich (prüfe, ob der Pfad existiert) + ⚠ Berechtigung verweigert + 0 Ordner gefunden + %d Ordner gefunden + Ordner in diesen Pfaden werden nach .exe-Dateien durchsucht und als eigene Spiele gelistet. Das kann den App-Start verlangsamen. + Ordnerpfad konnte nicht ermittelt werden + Auswahl erforderlich + Für dieses Spiel gibt es mehrere ausführbare Dateien. Bitte wähle in den Einstellungen aus, welche gestartet werden soll. + Eigene Spiele-Einstellungen + Spiel löschen + Bist du sicher, dass du %1$s deinstallieren möchtest? + Unbekannt + + Deaktiviert + Kopieren + Stabilität + Kompatibilität + Mittel + Leistung + Unity + Unity Mono Bleeding Edge + Ubuntu FS + + Direct3D + DirectSound + DirectMusic + DirectPlay + DirectShow + DirectX + Visual C++ 2010 + Windows Media Decoder + OpenGL + DXVK-Version + Starten + Bearbeiten + Entfernen + Hinzufügen + Löschen + Kopieren von + OK + Speicherinformation + Duplizieren + Neu konfigurieren + Inhaltsinfo + Spiel nicht installiert + %s ist nicht installiert. Bitte installiere das Spiel, bevor du es startest. + Container-Konfiguration speichern? + Temporäre Änderungen wurden für diesen Start übernommen. Möchtest du sie speichern? + Synchronisierungsfehler + Speichern + Verwerfen + Verknüpfungen + Container + Box64 RCFile + Inhalte + Über + Datei öffnen + Datei herunterladen + Prozessor-Zuweisung + In den Vordergrund bringen + Prozess beenden + Neue Datei + Zum Startbildschirm hinzufügen + Vollbild umschalten + Ausrichtung wechseln + Task-Manager + Lupe + Logs + Beenden + Spiel beenden + Tastatur + Extra + Suche leeren + Tasten + Fronttasten + Schultertasten + Menütasten + Stick-Tasten + Linker Stick + Rechter Stick + D-Pad + D-Pad hoch + D-Pad runter + D-Pad links + D-Pad rechts + On-Screen-Controller + On-Screen-Controller bearbeiten + Physische Controller bearbeiten + Verbindung getrennt + On-Screen-Steuerung zurücksetzen + Navigationsmenü öffnen + Touchpad-Hilfe + On-Screen-Controller bei Gamepad ausblenden + On-Screen-Steuerung automatisch ausblenden, wenn ein physischer Controller verbunden ist + + Belegung auswählen + Belege: %1$s + Aktuell: %1$s + Belegung suchen… + Suche… + Suche + Kategorie + Tastatur + Maus + Gamepad + Maus + Gamepad + Keine + Belegung löschen + Keine Belegung gefunden + + %1$s bearbeiten + Position: (%1$d, %2$d) • Größe: %3$.2fx + Aussehen + %.2fx + Beschriftungstext + Eigener Text für diese Taste + Element-Typ + Art der Steuerung ändern + Form + Optische Darstellung + %1$s ist auf die Form %2$s beschränkt + Belegung + Belegungen (automatisch erzeugt) + Bereichs-Tastenbelegung werden automatisch erzeugt + Primäre Aktion + Sekundäre Aktion + Hoch + Rechts + Runter + Links + (Maus) + Slot %1$d + Tasten können bis zu 2 Belegungen haben: Primär (Drücken) und Sekundär (Gedrückt halten) + Eigenschaften + Position + X: %1$d, Y: %2$d + Ungespeicherte Änderungen + Du hast ungespeicherte Änderungen. Möchtest du sie speichern oder verwerfen? + Größe anpassen + Zurücksetzen + Fertig + Größe von Element übernehmen + Keine weiteren Elemente zum Kopieren + Größe übernommen: %.2fx + On-Screen-Steuerung auf Standard zurückgesetzt + Schnell-Vorlagen + WASD + Pfeiltasten + Maus + D-Pad + Linker Stick + Rechter Stick + + Physische Controller-Belegung + Tastenbelegung deines Controllers bearbeiten + Fronttasten + A-Taste + Untere Fronttaste (Bestätigung) + B-Taste + Rechte Fronttaste (Zurück) + X-Taste + Linke Fronttaste + Y-Taste + Obere Fronttaste + Schultertasten + L1 / LB + Linker Bumper + R1 / RB + Rechter Bumper + L2 / LT + Linker Trigger + R2 / RT + Rechter Trigger + Menütasten + Start / Optionen + Select / View / Share + Analogsticks + Stick-Tasten + L3 (Linker Stick-Klick) + R3 (Rechter Stick-Klick) + Linker Analogstick + Linker Stick hoch + Linker Stick runter + Linker Stick links + Linker Stick rechts + Rechter Analogstick + Rechter Stick hoch + Rechter Stick runter + Rechter Stick links + Rechter Stick rechts + Sonstiges + Home / Guide / PS + Auf Standardbelegung zurücksetzen + Nicht gesetzt + + Steuerung zurückgesetzt + Logcat konnte nicht gespeichert werden + + Speichert einen Logcat-Snapshot nur für diese App + Logcat speichern +
+ 0 : Behandelt CALL/RET als ob nie Flags benötigt werden (schneller, instabil)
+ 1 : Meistens benötigt RET Flags, meist nicht bei CALL
+ 2 : Jeder CALL/RET benötigt Flags (langsamer) + ]]>
+ Erzeugung von -NAN wie am x86 + Erzeugung von exaktem x86-Runden + Verwendung von Float/Double für x87-Emulation +
+ 0 : Baue Block nicht so groß wie möglich
+ 1 : Baue Dynarec-Block so groß wie möglich
+ 2 : Baue Dynarec-Block größer (bei Überlagerung nur bei ELF-Speicher)
+ 3 : Baue Dynarec-Block noch größer (alle Speicherarten) + ]]>
+
+ 0 : Keine speziellen Einstellungen
+ 1 : Füge Memory Barrier beim Schreiben in den Speicher hinzu (einige MOV-Opcode)
+ 2 : Wie 1, aber Memory Barrier bei jedem Schreibzugriff mit MOV
+ 3 : Wie 2, plus Barrier auch beim Lesen aus dem Speicher und für einzelne SSE/SSE2-Opcode + ]]>
+
+ 0 : Reguläre sichere Barrieren verwenden
+ 1 : Schwächere Barrieren für etwas mehr Leistung
+ 2 : Zusätzlich letzte Schreibbarrieren deaktivieren]]>
+
+ 0 : Unausgerichtete Atomics-Handhabungscode generieren.
+ 1 : Nur ausgerichtete Atomics generieren – schneller und weniger Code, kann jedoch SIGBUS verursachen bei nicht ausgerichteten Adressen.]]>
+
+ 0 : Deferred Flags deaktivieren.
+ 1 : Deferred Flags aktivieren.]]>
+
+ 0 : Nicht fortsetzen bei ungeschütztem Block.
+ 1 : Dynablock weiterausführen, auch wenn Daten geschrieben werden, was zu besserer Performance (aber zu Abstürzen führen) kann.
+ 2 : HotPage als NEVERCLEAN markieren, nicht schreibschützen, Block immer testen. Kann schneller sein, aber SMC kann nicht vollständig erkannt werden.]]>
+
+ 0 : Keine nativen Flags nutzen.
+ 1 : Wenn möglich native Flags verwenden.]]>
+
+ 0 : PAUSE-Befehl ignorieren.
+ 1 : Mit YIELD emulieren.
+ 2 : Mit WFI emulieren.
+ 3 : Mit SEVL+WFE emulieren. + ]]>
+
+ 0 : AVX deaktivieren
+ 1 : AVX, BMI1, F16C und VAES aktivieren.
+ 2 : Ebenfalls AVX2, BMI2, FMA, ADX, VPCLMULQDQ und RDRAND aktivieren.
\ + ]]>
+ Maximale Zahl an CPUs, die von box64 emuliert werden + UnityPlayer.dll erkennen und strongmem-Einstellungen anwenden + Alle Speicherzuweisungen im 32-Bit-Adressraum erzwingen + Definiert den maximal erlaubten Forward-Wert beim Blockbau + Optimierung von CALL/RET-Opcode + Legt fest, ob Dynarec auf fertige FillBlocks wartet + Aktiviert TSO IR-Operationen (benötigt für Multithread-Apps). + Macht Vektor-Load/Stores atomar, wenn TSO aktiviert ist. + Verwendet Half-Barrier-Atomics für nicht-ausgerichtete Loads/Stores bei TSO. + Macht REP MOVS / STOS atomar unter TSO. + Aktiviert Multiblock-Codekompilierung. Kann längere Kompilierzeiten und Ruckler verursachen. + Steuert CPU-Feature-Emulation/Erzwingung. + TSC auf langsamen Systemen skalieren. + Checks für sich selbst modifizierenden Code (SMC). + Verwendet volatile Metadaten aus PE-Dateien für TSO, falls verfügbar. + Spezielle SMC+JIT-Block-Hacks für Mono-Erkennung. + Hypervisor-Bit in CPUID verstecken (bei Crashs von Apps mit Hypervisor). + Deaktiviert FEXCore JIT L2-Cache, spart RAM aber macht Stottern wahrscheinlicher. + Wechselt FEXCore JIT L1-Cache auf dynamische Größe – spart RAM, kann aber Stottern verursachen. + X87-Gleitkomma-Präzision auf 64 Bit reduzieren. Minderung der Emulationsgenauigkeit & evtl. Grafikfehler. + Maximale Instruktionsmenge pro Übersetzungsblock. Höhere Werte = bessere Performance, aber evtl. instabiler. + Fortsetzen + Pausieren + + Verknüpfung erstellen + Beschriftung + Icon + Erstellen + Jetzt aktualisieren + Dateien werden verschoben + Internetverbindung erforderlich + Installiere nur über WLAN + Installationsfortschritt + Wird heruntergeladen… + Wird berechnet… + Download fehlgeschlagen. Bitte nochmal versuchen. + Update verfügbar + Spieldetails + Status + Größe + Speicherort + Entwickler + Veröffentlichungsdatum + Installiert + Wird installiert + Nicht installiert + Öffnen + Familienfreigabe + Spiele + Spielzeit der letzten 2 Wochen: %s Std + Gesamtspielzeit: %s Std + Eigenes Spiel hinzufügen + Eigenes Spiel hinzufügen + Bitte wähle den Ordner aus, der die Spieldateien enthält, die du als eigenes Spiel hinzufügen möchtest. + Diesen Hinweis nicht mehr anzeigen + Verknüpfung erstellt + Verknüpfung konnte nicht erstellt werden: %s + Bilder erfolgreich geladen + Spielordner nicht gefunden + Bilder konnten nicht geladen werden: %s + Exportiert + Export fehlgeschlagen: %s + Export abgebrochen + + Installiert + Spiel + Anwendung + Tool + Demo + Familie + + Keine Verbindung zu Steam + Steam-Verbindung erneut versuchen + Offline fortfahren + + Wie lief das Spiel? + Gab es Probleme? (Bitte auswählen): + + Hilfe & Support + Hall of Fame + Online gehen + Offline gehen + Abmelden + Schließen + + Neue Umgebungsvariable + Name + Wert + Keine weiteren bekannten Variablen + Container-Variante + Wine-Version + Startparameter + Beispiel: -dx11 + Sprache + Bildschirmgröße + Breite + Höhe + Audiotreiber + FPS anzeigen + Möchtest du den Treiber "%s" wirklich entfernen? Dies kann nicht rückgängig gemacht werden. + + Legacy DRM verwenden + DLC erzwingen + Nur aktivieren, falls DLCs nicht erkannt werden oder Spielstände mit DLC nicht funktionieren + Steam-Client starten (Beta) + Reduziert Performance und verlängert den Start\nErmöglicht Online-Spiel und behebt DRM- und Controller-Probleme\nNicht alle Spiele funktionieren + Steam-Updates erlauben + Aktualisiert Steam auf die neueste Version. Reduziert die Leistung spürbar. + Steam-Typ + + Grafiktreiber + Grafiktreiber-Version + Sichtbare Vulkan-Erweiterungen + Max. Gerätespeicher + Adrenotools Turnip verwenden + Vulkan-Version + Bildcache-Größe + DX-Wrapper + VKD3D-Featurelevel + DRI3 verwenden + Deaktivieren kann Grafikprobleme auf manchen Geräten beheben + Jedes Frame synchronisieren + KHR_present_wait deaktivieren + Präsentationsmodi + Speicherressourcen-Typ + BCn-Emulation + BCn-Emulationstyp + BCn-Emulations-Cache + Schärfe-Boost + Schärfegrad + Schärfe-Entrauschen + + FEXCore-Version + FEXCore-Voreinstellung + TSO-Modus + x87-Modus + Multiblock + 64-Bit-Emulator + 32-Bit-Emulator + Box64-Version + Box64-Voreinstellung + Box64-Voreinstellungen + FEXCore-Voreinstellungen + FEXCore-Voreinstellungen anzeigen, ändern oder neue erstellen + Name der Voreinstellung + + SDL-API verwenden + XInput-API aktivieren + DirectInput-API aktivieren + DirectInput-Mapper-Typ + Mauseingabe deaktivieren + Touchscreen-Modus + Direkte Zeigerbewegung (AN) vs. Touchpad-ähnliche relative Bewegung (AUS) + Mit versteckten On-Screen-Controls starten + On-Screen-Controls sind beim Spielstart ausgeblendet. Über das Menü ein-/ausblendbar. + Tastatur und Maus emulieren + Linker Stick = WASD, rechter Stick = Maus. L2 = Linksklick, R2 = Rechtsklick. + + Renderer + GPU-Name + Offscreen-Rendering-Modus + Videospeichergröße + CSMT (Command Stream Multi-Thread) aktivieren + Strikte Shader-Mathematik aktivieren + Eigener Mausbezugspunkt + + Umgebungsvariablen + Keine Umgebungsvariablen + Keine Laufwerke + Startauswahl + Prozessorbindung (32-Bit-Apps) + Pfad zur Ausführbaren Datei + z.B. pfad\\zu\\exe + + Erlaubte Ausrichtungen + Wine-Debug-Kanäle wählen + CPU%d + Beschreibe, was passiert ist + Support über Discord erhalten + + Treiberverwaltung + Treiber auswählen + ZIP vom Gerät importieren + + Inhaltsverwaltung + .wcp vom Gerät importieren + In Bearbeitung… + Ausgewählt + Ausgewählter Inhalt + Installierte Inhalte + Typ auswählen + Nicht vertrauenswürdige Dateien erkannt + Trotzdem installieren + Inhalt entfernen? + Bist du sicher, dass du %1$s (%2$s) entfernen möchtest? + + Sprache + Sprache auswählen + Neustart erforderlich + Die Änderung der Sprache erfordert einen Neustart der App. Möchtest du fortfahren? + Neustart + Sprache wird geändert und Neustart durchgeführt… + + Emulation + Erlaubte Ausrichtungen + Wähle, in welche Richtungen während des Spiels gedreht werden kann + Standardkonfiguration ändern + Die Grundeinstellungen für neue Container (betrifft bereits bestehende Spiele nicht) + Standard-Container-Konfiguration + Box64-Voreinstellungen + Voreinstellungen anzeigen, ändern oder neu erstellen + Treiberverwaltung + Benutzerdefinierte Grafiktreiber installieren oder entfernen + Inhaltsverwaltung + Zusätzliche Komponenten (.wcp) installieren + Wine/Proton-Manager + Importiere eigene Wine/Proton-Versionen (nur Bionic) + + Debug + Wine-Debug-Kanäle auswählen + Wine-Debug-Logs aktivieren + Wine-Debug-Ausgaben in Datei schreiben + Box86/64-Logs aktivieren + Box86 & Box64-Debug-Ausgaben in Datei schreiben + Letzten Absturz anzeigen + Spiel-Log anzeigen + Einstellungen zurücksetzen + [App wird beendet] Client abmelden und lokale Daten löschen. + Lokale Datenbank leeren + [App wird beendet] Kann helfen, Probleme mit Bibliothek oder Nachrichten zu beheben. + Bildercache leeren + Alle geladenen Bilder entfernen. + + Info + Trinkgeld senden + Unterstütze die Weiterentwicklung + Nach Start Trinkgeld erfragen + Blendet die Trinkgeld-Frage aus + Quellcode + Projektquellcode ansehen + Verwendete Bibliotheken + Welche Technologien GameNative möglich machen + Datenschutzerklärung + Öffnet die Datenschutzerklärung von GameNative + + Oberfläche + Weblinks extern öffnen + Links werden im Standardbrowser geöffnet + Statusleiste außerhalb der Spiele verbergen + Android-Statusleiste in Spieleliste, Einstellungen etc. ausblenden (Neustart nötig). + Iconstil + Nur über WLAN herunterladen + Verhindert Downloads über mobile Daten + Auf externen Speicher schreiben + Kein externer Speicher erkannt + Spiele auf externem Speicher sichern + Speichervolumen + Steam-Downloadserver + Neustart erforderlich + + Downloads + + GameNative + Datenschutzerklärung + Willkommen zurück + Melde dich an, um auf deine Steam-Bibliothek zuzugreifen + Benutzername + Passwort + Angemeldet bleiben + Anmelden + QR-Code fehlgeschlagen + QR-Code erneut versuchen + Öffne die Steam Mobile App und scanne diesen QR-Code, um dich sofort anzumelden + + Fortfahren + Einstellungen + + Verbindung zu Servern wird hergestellt… + + Die App benötigt %1$s, aber es sind nur noch %2$s frei + Die App benötigt folgenden Speicherplatz. Fortfahren?\n\n\tDownloadgröße: %1$s\n\tBelegter Speicher: %2$s\n\tVerfügbar: %3$s + Soll der Download der App wirklich abgebrochen werden? + Sollen alle Downloads für dieses Spiel gelöscht werden? + App wirklich löschen? + ImageFS herunterladen & installieren + Das Ubuntu-Image muss erst heruntergeladen werden. Dieser Vorgang dauert einige Minuten. Fortfahren? + ImageFS installieren + Das Ubuntu-Image muss installiert werden. Dies dauert einige Minuten. Fortfahren? + Container zurücksetzen + Dadurch wird dein Container auf die Voreinstellung zurückgesetzt. + Dateien überprüfen + Stelle sicher, dass du deine Speicherstände in die Cloud geladen oder gesichert hast, da diese sonst überschrieben werden können. + Update + Achte darauf, Speicherstände vor dem Update zu sichern. Sie werden sonst evtl. überschrieben. + %s Konfiguration + + Speicherberechtigung erforderlich + Exportiert + Export fehlgeschlagen: %s + Export abgebrochen + Verknüpfung erstellt + Verknüpfung konnte nicht erstellt werden: %s + Cloud-Synchronisierung erfolgreich + Spielstände sind aktuell + Cloud-Synchronisierung fehlgeschlagen: %s + + Internetverbindung erforderlich + Installieren nur über WLAN/LAN aktiviert + Datei %1$d von %2$d + + Update verfügbar + Eine neue Version (%1$s) ist verfügbar!%2$s + Update + Später + Update fehlgeschlagen + Update konnte nicht heruntergeladen oder installiert werden. Bitte später erneut versuchen. + + Kürzlicher Absturz + Tut uns leid!\nTeile uns gern mit, was passiert ist.\nDu kannst das letzte Crash-Log im App-Setup anzeigen/exportieren und bei Github als Issue einreichen.\nDer Link ist auch in den Einstellungen! + Danke, dass du GameNative nutzt! + Unterstütze Open-Source-PC-Gaming auf Android, indem du die App weiterempfiehlst oder Mitglied auf Ko-fi wirst. + Auf Ko-fi unterstützen + Teilen + Hat das Spiel funktioniert? + Tritt dem Discord bei, um Hilfe für dein Spiel oder Performance zu erhalten. + Discord öffnen + + Steam wird heruntergeladen… + GLIBC-Komponenten werden installiert… + Bionic-Komponenten werden installiert… + Lädt… + + App läuft + Du bist auf einem anderen Gerät bereits mit %s angemeldet. Du kannst trotzdem spielen, aber das beendet die andere Steam-Sitzung. + Du bist auf einem anderen Gerät (%1$s) mit %2$s (%3$s) angemeldet, und dieser Spielstand wurde noch nicht in der Cloud gespeichert. Du kannst trotzdem spielen, doch wird dadurch die andere Sitzung getrennt und es kann zu Speicherstandskonflikten kommen, wenn die andere Sitzung synchronisiert wird. + Trotzdem starten + + Speicherstandkonflikt + Es wurden ein neuer lokaler und ein neuer Cloud-Spielstand gefunden. Welchen möchtest du behalten?\n\nLokal:\n\t%1$s\nCloud:\n\t%2$s + Lokal behalten + Cloud behalten + + Die Synchronisierung dauert zu lange. Bitte versuche, das Spiel später erneut zu starten. + Synchronisierung läuft. Bitte versuche es später noch einmal. + Speicherstände konnten nicht synchronisiert werden: %s. + + Upload läuft + %1$s wurde auf Gerät %2$s (%3$s) gespielt, aber der Speicherstand wird noch hochgeladen.\nBitte später erneut versuchen. + Ausstehender Upload + %1$s wurde auf Gerät %2$s (%3$s) gespielt, aber dieser Speicherstand ist noch nicht in der Cloud (Upload nicht gestartet). Du kannst trotzdem spielen, es kann jedoch zu Konflikten kommen, wenn dein vorheriger Speicherstand noch hochgeladen wird. + App-Sitzung unterbrochen. Bitte die App neu starten. + Empfangene ausstehende Remote-Operationen mit \'none\'. Bitte App neu starten. + Mehrere ausstehende Remote-Operationen, später erneut versuchen. Bitte App neu starten. + + Container konfigurieren + Ungespeicherte Änderungen + Bist du sicher, dass du deine Änderungen verwerfen möchtest? + Allgemein + Grafik + Emulation + Controller + Wine + Win-Komponenten + Umgebung + Laufwerke + Erweitert + VKD3D-Version + Pfad zur Ausführbaren Datei + z.B. pfad\\zu\\exe + + Verwendete Bibliotheken + Pluvia - github.com/oxters168/Pluvia\nJavaSteam - github.com/Longi94/JavaSteam\nWinlator & Vortek - github.com/brunodev85/winlator\nWinlator Cmod - github.com/coffincolors/winlator\nWrapper - https://github.com/leegao/bionic-vulkan-wrapper & https://github.com/pipetto-crypto/\nUbuntu RootFs - releases.ubuntu.com/focal + + Kunst Credits + App-Icon: Hachi + Alternatives App-Icon: rhapsody_mdr + Lade Unterstützer… + Mitglieder + Unterstützer + Noch keine Unterstützer. + Anonym + + Die Chatfunktion wird noch entwickelt.\nBitte melde Fehler im Projekt-Repo. + Keine Chatverlauf + Nachricht senden + Senden + Emoticons + Sticker + + Öffnen + Installiert + Wird installiert + Nicht installiert + Familienfreigabe + Kompatibel + Kompatibilität unbekannt + Nicht kompatibel + + Bekannte Konfiguration funktioniert auf deinem Gerät + Bekannte Konfiguration sollte funktionieren + Bekannte Konfiguration könnte funktionieren + Keine bekannte Konfiguration + + Beste Konfiguration erfolgreich übernommen + Bekannte Konfiguration ungültig + Keine optimale Konfiguration verfügbar + Konfiguration konnte nicht übernommen werden: %s + App-Typ + App-Status + Layout + Liste + Kapsel + Hero + Durchsuche deine Spiele… + Suchen + Suche leeren + Keine Einträge zur Auswahl + Filter + %1$d Spiele • %2$d installiert + Steam + Eigene Spiele + Layout + + Anmelden + Bestätigungscode + 5-stelligen Code eingeben + + Inhalt wird überprüft… + Datei kann nicht erkannt werden + Profil nicht im Inhalt enthalten + Profil kann nicht erkannt werden + Inhalt existiert bereits + Inhalt ist unvollständig + Inhalt ist nicht vertrauenswürdig + Nicht genug Speicherplatz + Inhalt konnte nicht installiert werden + Dieser Inhalt enthält Dateien außerhalb des vertrauenswürdigen Sets. + Zusätzliche Komponenten installieren (.wcp: tar.xz/zst) + Typ + Version + Code + Beschreibung + Alle Dateien sind vertrauenswürdig. Installation bereit. + Kein Inhalt dieses Typs installiert. + Löschen + Dieser Inhalt enthält Dateien außerhalb des Vertrauensbereichs. Überprüfe und bestätige, um fortzufahren. + %1$s entfernt + + Treiber-Manifest konnte nicht geladen werden: %1$d + Fehler beim Laden des Treibers: %1$s + Zeitüberschreitung. Prüfe Netzwerk und versuche es erneut. + Netzwerkfehler: %1$s + + Standard + Alternativ + Langsam + Mittel + Schnell + Sehr schnell + Download-Geschwindigkeit + Höhere Geschwindigkeit kann zu Hitzeentwicklung führen + Standard + Einstellungen werden gespeichert und App neu gestartet… + + Einstellungen + + Inhalt erfolgreich installiert + Inhalt konnte nicht installiert werden + Installationsfehler: %1$s + + Bereit + + Wine/Proton-Manager + Nur Bionic-Images + Importiere benutzerdefinierte Wine- oder Proton-Versionen für Bionic-Container. Dateiname muss mit \'wine\' oder \'proton\' beginnen (Groß/Kleinschreibung egal). Pakete müssen bin/, lib/, und prefixPack.txz enthalten. Nur bionic-kompatible Importe. + Beispiel: "proton-10.0-ARM64ec.wcp" + Wine/Proton-Paket importieren + Wähle eine .wcp-Datei (Dateiname beginnt mit \'wine\' oder \'proton\') + .wcp-Paket importieren + Verarbeite… + Paketdetails + Typ + Version + Versionscode + Bin-Pfad + Lib-Pfad + Beschreibung + ✓ Alle Dateien sind vertrauenswürdig. Bereit zur Installation. + Paket installieren + Installierte Wine/Proton-Versionen + Keine Wine- oder Proton-Versionen gefunden. + Löschen + Dieses Paket enthält nicht vertrauenswürdige Dateien. Überprüfe und bestätige die Installation. + Nicht vertrauenswürdige Dateien: + Wine/Proton-Version entfernen + Möchtest du %1$s %2$s (%3$d) wirklich entfernen? Container mit dieser Version funktionieren danach nicht mehr. + %s entfernt + Fehler beim Entfernen: %s + Import abbrechen? + Es läuft gerade ein Import. Beim Abbrechen werden alle entpackten Dateien verworfen. Nochmal importieren nötig.\n\nImport wirklich abbrechen? + Ja, abbrechen + Nein, Import behalten + + Paket wird extrahiert und geprüft (kann bei großen Dateien 2-3 Minuten dauern)… + Dateiname muss mit \'wine\' oder \'proton\' beginnen + Datei ist leer oder kann nicht gelesen werden + Datei kann nicht geöffnet werden + Dateiauswahl konnte nicht geöffnet werden: %s + Datei kann nicht als gültiges Archiv erkannt werden + profile.json nicht im Paket gefunden + profile.json ist ungültig + Wine/Proton-Version existiert bereits + Im Paket fehlen benötigte Dateien (bin/, lib/ oder prefixPack.txz) + Paket nicht vertrauenswürdig + Nicht genug Speicher + Unbekannter Fehler + Wine/Proton-Paket konnte nicht installiert werden + Paket ist kein Wine/Proton (Typ: %s) + Dateiname deutet %1$s an, im Paket ist %2$s + Dieses Paket enthält Dateien außerhalb der Vertrauenszone. + Wine/Proton-Version existiert bereits + Installationsfehler: %s + Installationsfehler: %s + %1$s %2$s erfolgreich installiert + Dieses Wine/Proton benötigt GLIBC-Container und ist mit GameNative nicht kompatibel. Bitte nur ARM64/Bionic-Builds verwenden. + Container, die diese Version verwenden: + Kein Container nutzt diese Version. + Diese Container funktionieren danach nicht mehr: + + + GOG-Integration (Alpha) + GOG-Anmeldung + Bei deinem GOG-Konto anmelden + Synchronisiere… + Fehler: %1$s + ✓ %1$d Spiele synchronisiert + GOG-Spielebibliothek abrufen + Anmeldung erfolgreich + Du bist jetzt bei GOG angemeldet.\nWir synchronisieren deine Bibliothek nun im Hintergrund. + + + Bei GOG anmelden + Tippe auf \'GOG-Anmeldung öffnen\' und melde dich an. Nach der Anmeldung kopiere bitte die URL und füge sie unten ein + Beispiel: https://embed.gog.com/on_login_success?origin=client&code=aaa + GOG-Anmeldung öffnen + Autorisierungscode oder Anmelde-Erfolgs-URL + Code oder URL hier einfügen + Anmelden + Abbrechen + Browser konnte nicht geöffnet werden + + + Abmelden + Von deinem GOG-Konto abmelden + Von GOG abmelden? + Dies entfernt deine GOG-Anmeldedaten und löscht deine GOG-Bibliothek von diesem Gerät. Du kannst dich jederzeit wieder anmelden. + Abmelden + Erfolgreich von GOG abgemeldet + Abmeldung fehlgeschlagen: %s + Melde von GOG ab… + + + Spiel deinstallieren + Möchtest du %1$s wirklich deinstallieren? Diese Aktion kann nicht rückgängig gemacht werden. + Spiel herunterladen + Die App benötigt folgenden Speicherplatz. Möchtest du fortfahren?\n\n\tDownloadgröße: %1$s\n\tVerfügbarer Speicher: %2$s +
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000000..43114ad958 --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,970 @@ + + GameNative + Connexion utilisateur + Authentification à deux facteurs + Accueil + Paramètres + Connexion QR + Bibliothèque + Téléchargements + Application inconnue + Le code précédent était incorrect, veuillez réessayer. + Utilisez l\'application mobile Steam pour confirmer votre connexion… + Veuillez entrer votre code d\'authentification à deux facteurs depuis votre application d\'authentification. + Veuillez entrer le code d\'authentification envoyé à l\'adresse %s + L\'application en cours d\'installation nécessite l\'espace suivant. Voulez-vous continuer ?\n\n\tTaille du téléchargement : %1$s\n\tTaille sur le disque : %2$s\n\tEspace disponible : %3$s + Taille du téléchargement : %1$s\nTaille sur le disque : %2$s\nEspace disponible : %3$s + L\'application en cours d\'installation nécessite %1$s d\'espace mais il ne reste que %2$s sur cet appareil + Êtes-vous sûr de vouloir annuler le téléchargement de l\'application ? + Supprimer toutes les données téléchargées pour ce jeu ? + Télécharger et installer ImageFS + L\'image Ubuntu doit être téléchargée et installée avant de pouvoir modifier la configuration. Cette opération peut prendre quelques minutes. Voulez-vous continuer ? + Installer ImageFS + L\'image Ubuntu doit être installée avant de pouvoir modifier la configuration. Cette opération peut prendre quelques minutes. Voulez-vous continuer ? + Réinitialiser le conteneur + Cela réinitialisera votre conteneur à la configuration par défaut. + Réinitialiser le conteneur ? + Réinitialiser + Vérifier les fichiers + Veuillez vous assurer que vos sauvegardes sont téléchargées dans le cloud ou sauvegardées avant de vérifier, car elles peuvent être écrasées. + Mettre à jour + Veuillez vous assurer que vos sauvegardes sont téléchargées dans le cloud ou sauvegardées avant de mettre à jour, car elles peuvent être écrasées. + Synchronisation cloud terminée avec succès + Les fichiers de sauvegarde sont déjà à jour + Échec de la synchronisation cloud : %s + Vous devez être connecté à Steam pour utiliser cette fonctionnalité + Autorisation de stockage requise + Conteneur réinitialisé aux paramètres par défaut + ImageFS installé. Veuillez réessayer de modifier le conteneur. + Échec de l\'installation d\'ImageFS : %s + Désinstaller le jeu + Êtes-vous sûr de vouloir désinstaller %1$s ? Cette action ne peut pas être annulée. + %1$s a été désinstallé + Échec de la désinstallation du jeu + Désinstaller le jeu + Êtes-vous sûr de vouloir désinstaller %1$s ? Cette action ne peut pas être annulée. + Télécharger le jeu + L\'application en cours d\'installation nécessite l\'espace suivant. Voulez-vous continuer ?\n\n\tTaille du téléchargement : %1$s\n\tEspace disponible : %2$s + Jamais + Continuer + Image d\'en-tête de l\'application + Télécharger + Installer + Installer + Supprimer + Désinstaller + Jouer + Installer l\'application + Calcul des besoins en espace… + Supprimer l\'application + Annuler le téléchargement + OK + Oui + Non + Pas assez d\'espace + Continuer + Annuler + Sans titre + Confirmer la suppression + Bibliothèque + Téléchargements + Amis + + + Jeux personnalisés + Aucun chemin ajouté + Accorder l\'autorisation + Ajouté depuis la bibliothèque + Aucun jeu ajouté manuellement. + Supprimer le chemin + Supprimer ce chemin de l\'analyse ? Le contenu du dossier restera sur le disque. + Supprimer le chemin + Chemin supprimé de la liste. Le contenu n\'a pas été supprimé. + Supprimer le jeu manuel + Supprimer ce dossier ajouté manuellement de votre bibliothèque ? Cela ne supprime pas les fichiers sur le disque. + Supprimer le dossier manuel + Dossier manuel supprimé de la bibliothèque. + ⚠ Impossible d\'accéder (vérifiez si le chemin existe) + ⚠ Autorisation refusée + 0 dossiers trouvés + %d dossiers trouvés + Les dossiers dans ces chemins sont analysés pour les fichiers .exe et listés comme jeux personnalisés. Cela peut ralentir le démarrage de l\'application. + Échec de la résolution du chemin du dossier + Sélection d\'exécutable requise + Ce jeu possède plusieurs exécutables. Veuillez ouvrir les paramètres de jeu personnalisé pour sélectionner celui à lancer. + Paramètres du jeu personnalisé + Supprimer le jeu + Êtes-vous sûr de vouloir désinstaller %1$s ? + Inconnu + + + Désactivé + Copier + Stabilité + Compatibilité + Intermédiaire + Performance + Unity + Unity Mono Bleeding Edge + Ubuntu FS + + + Direct3D + DirectSound + DirectMusic + DirectPlay + DirectShow + DirectX + Visual C++ 2010 + Décodeur Windows Media + OpenGL + Version DXVK + Exécuter + Modifier + Supprimer + Ajouter + Supprimer + Copier depuis + Ok + Informations de stockage + Dupliquer + Reconfigurer + Informations sur le contenu + Jeu non installé + %s n\'est pas installé. Veuillez installer le jeu avant de le lancer. + Enregistrer la configuration du conteneur ? + Des modifications temporaires de la configuration du conteneur ont été appliquées pour ce lancement. Voulez-vous les enregistrer ? + Erreur de synchronisation + Enregistrer + Abandonner + Raccourcis + Conteneurs + Fichier RC Box64 + Contenus + À propos + Ouvrir le fichier + Télécharger le fichier + Affinité du processeur + Mettre au premier plan + Terminer le processus + Nouveau fichier + Ajouter à l\'écran d\'accueil + Basculer en plein écran + Basculer l\'orientation + Gestionnaire de tâches + Loupe + Journaux + Quitter + Quitter le jeu + Clavier + Extra + Effacer la recherche + Boutons + Boutons de face + Gâchettes + Boutons de menu + Boutons de joystick + Joystick gauche + Joystick droit + Croix directionnelle + D-Pad Haut + D-Pad Bas + D-Pad Gauche + D-Pad Droite + Contrôleur à l\'écran + Modifier le contrôleur à l\'écran + Modifier le contrôleur physique + Déconnecté + Réinitialiser les contrôles à l\'écran + Ouvrir le menu de navigation + Aide du pavé tactile + Masquer les contrôles à l\'écran avec manette + Masquer automatiquement les contrôles à l\'écran lorsqu\'un contrôleur physique est connecté + + + Sélectionner l\'affectation + Affecter : %1$s + Actuel : %1$s + Rechercher des affectations… + Rechercher… + Rechercher + Catégorie + Clavier + Souris + Manette + Souris + Manette + Aucune + Effacer l\'affectation + Aucune affectation trouvée + + + Modifier %1$s + Position : (%1$d, %2$d) • Taille : %3$.2fx + Apparence + %.2fx + Texte de l\'étiquette + Texte personnalisé pour ce bouton + Type d\'élément + Changer le type de ce contrôle + Forme + Apparence visuelle + %1$s est limité à la forme %2$s + Affectations + Affectations (générées automatiquement) + Les affectations des boutons de plage sont générées automatiquement + Action principale + Action secondaire + Haut + Droite + Bas + Gauche + (Souris) + Emplacement %1$d + Les boutons prennent en charge jusqu\'à 2 affectations : primaire (pression) et secondaire (pression longue) + Propriétés + Position + X : %1$d, Y : %2$d + Modifications non enregistrées + Vous avez des modifications non enregistrées. Voulez-vous les enregistrer ou les abandonner ? + Ajuster la taille + Réinitialiser + Terminé + Copier la taille depuis un élément + Aucun autre élément disponible pour copier la taille + Taille copiée : %.2fx + Contrôles à l\'écran réinitialisés par défaut + Préréglages rapides + WASD + Flèches + Souris + D-Pad + Joystick gauche + Joystick droit + + + Éditeur d\'affectations du contrôleur physique + Configurer les affectations de boutons pour votre contrôleur physique + Boutons de face + Bouton A + Bouton de face inférieur (Confirmer) + Bouton B + Bouton de face droit (Retour) + Bouton X + Bouton de face gauche + Bouton Y + Bouton de face supérieur + Gâchettes + L1 / LB + Gâchette gauche + R1 / RB + Gâchette droite + L2 / LT + Détente gauche + R2 / RT + Détente droite + Boutons de menu + Start / Options + Select / View / Share + Joysticks analogiques + Boutons de joystick + L3 (Clic joystick gauche) + R3 (Clic joystick droit) + Joystick analogique gauche + Joystick gauche Haut + Joystick gauche Bas + Joystick gauche Gauche + Joystick gauche Droite + Joystick analogique droit + Joystick droit Haut + Joystick droit Bas + Joystick droit Gauche + Joystick droit Droite + Autre + Home / Guide / PS + Réinitialiser aux affectations par défaut + Non défini + + + Contrôles réinitialisés + Échec de l\'enregistrement du logcat vers la destination + + + Enregistre un instantané du logcat uniquement pour le PID de cette application + Enregistrer le logcat + +
+ 0 : Traite CALL/RET comme s\'il n\'avait jamais besoin de drapeaux (plus rapide, instable)
+ 1 : La plupart des RET auront besoin de drapeaux, la plupart des CALLS non
+ 2 : Tous les CALL/RET auront besoin de drapeaux (plus lent) + ]]>
+ Génération de -NAN comme sur x86 + Génération d\'un arrondi x86 précis + Utilisation de Float/Double pour l\'émulation x87 +
+ 0 : Ne pas essayer de construire des blocs aussi grands que possible
+ 1 : Construire des blocs Dynarec aussi grands que possible
+ 2 : Construire des blocs Dynarec plus grands (continuer quand les blocs se chevauchent, mais uniquement pour les blocs en mémoire elf)
+ 3 : Construire des blocs Dynarec plus grands (continuer quand les blocs se chevauchent, pour tous les types de mémoire) + ]]>
+
+ 0 : Ne rien essayer de spécial
+ 1 : Activer certaines barrières mémoire lors de l\'écriture en mémoire (sur certains opcodes MOV)
+ 2 : Tout 1 plus une barrière mémoire à chaque écriture en mémoire utilisant MOV
+ 3 : Tout 2 plus une barrière mémoire lors de la lecture de la mémoire et sur certains opcodes SSE/SSE2 + ]]>
+
+ 0 : Utiliser des barrières sûres régulières
+ 1 : Utiliser des barrières faibles pour un léger gain de performance
+ 2 : Utiliser des barrières faibles, désactiver en plus les dernières barrières d\'écriture]]>
+
+ 0 : Générer du code de gestion des atomiques non alignés.
+ 1 : Générer uniquement des atomiques alignés, ce qui est plus rapide et a une taille de code plus petite, mais causera SIGBUS pour les opcodes préfixés LOCK opérant sur des adresses de données alignées.]]>
+
+ 0 : Désactiver l\'utilisation des drapeaux différés.
+ 1 : Activer l\'utilisation des drapeaux différés.]]>
+
+ 0 : Ne pas permettre de continuer à exécuter un bloc non protégé et potentiellement sale.
+ 1 : Permettre de continuer à exécuter un dynablock qui écrit des données dans la même page que le code. Cela peut accélérer le temps de chargement de certains jeux mais peut aussi provoquer des plantages inattendus.
+ 2 : Également, lorsqu\'une HotPage est détectée, marquer cette page comme NEVERCLEAN, elle ne sera donc pas protégée en écriture mais les blocs construits à partir de cette page seront toujours testés. Cela peut être plus rapide de cette façon (mais certains cas SMC pourraient ne pas être capturés).]]>
+
+ 0 : Ne pas utiliser les drapeaux natifs.
+ 1 : Utiliser les drapeaux natifs lorsque possible.]]>
+
+ 0 : Ignorer l\'instruction PAUSE x86.
+ 1 : Utiliser YIELD pour émuler l\'instruction PAUSE x86.
+ 2 : Utiliser WFI pour émuler l\'instruction PAUSE x86.
+ 3 : Utiliser SEVL+WFE pour émuler l\'instruction PAUSE x86. + ]]>
+
+ 0 : Désactiver l\'extension AVX
+ 1 : Activer les extensions AVX, BMI1, F16C et VAES.
+ 2 : Tout 1 plus activer AVX2, BMI2, FMA, ADX, VPCLMULQDQ et RDRAND.
\ + ]]>
+ Nombre maximum de CPUs présentés aux programmes par box64 + Détecter UnityPlayer.dll et appliquer les paramètres strongmem + Forcer chaque allocation mémoire dans les espaces d\'adresses 32 bits + Définit la valeur avant maximale autorisée lors de la construction de blocs + Optimisation des opcodes CALL/RET + Définit si Dynarec attendra que le FillBlock soit prêt + Active les ops IR TSO (requis pour les applications multithread). + Rend les chargements/stockages vectoriels atomiques lorsque TSO est activé. + Utilise des atomiques à demi-barrière pour les chargements/stockages non alignés sous TSO. + Rend REP MOVS / REP STOS atomiques sous TSO. + Active la compilation de code multibloc. Peut causer une compilation JIT plus longue et des saccades. + Contrôle le forçage des fonctionnalités CPU. + Met à l\'échelle TSC sur les systèmes à basse fréquence. + Vérifications de code auto-modifiable. + Utilise les métadonnées volatiles des fichiers PE pour TSO lorsqu\'elles sont disponibles. + Hacks spéciaux SMC + bloc JIT pour la détection Mono. + Masque le bit hyperviseur CPUID (utile pour les applications qui plantent avec). + Désactive la recherche du cache L2 JIT de FEXCore, économisant de la mémoire mais introduisant des saccades. + Bascule le cache L1 JIT de FEXCore pour qu\'il soit dimensionné dynamiquement, économisant de la mémoire mais introduisant des saccades. + Émule la virgule flottante X87 en utilisant une précision 64 bits. Cela réduit la précision d\'émulation et peut entraîner des bugs de rendu. + Budget d\'instructions maximum par bloc de traduction. Des valeurs plus élevées peuvent améliorer les performances mais peuvent réduire la stabilité. + + Reprendre + Pause + + + Créer un raccourci + Étiquette + Icône + Créer + Mettre à jour maintenant + Déplacement des fichiers + Connexion internet nécessaire pour installer + Installation sur WiFi uniquement activée + Progression de l\'installation + Téléchargement… + Calcul… + Échec du téléchargement. Veuillez réessayer. + Mise à jour disponible + Informations sur le jeu + Statut + Taille + Emplacement + Développeur + Date de sortie + Installé + Installation en cours + Non installé + Ouvrir + Partagé en famille + Jeux + Temps de jeu ces 2 dernières semaines : %s h + Temps de jeu total : %s h + Ajouter un jeu personnalisé + Ajouter un jeu personnalisé + Veuillez sélectionner le dossier contenant les fichiers du jeu que vous souhaitez ajouter comme jeu personnalisé. + Ne plus afficher ce dialogue + Raccourci créé + Échec de la création du raccourci : %s + Images récupérées avec succès + Dossier du jeu introuvable + Échec de la récupération des images : %s + Exporté + Échec de l\'exportation : %s + Exportation annulée + + + + Installé + Jeu + Application + Outil + Démo + Famille + + + Pas de connexion à Steam + Réessayer la connexion Steam + Continuer hors ligne + + + + Comment le jeu s\'est-il exécuté ? + Sélectionnez les problèmes rencontrés : + + + + Aide et support + Temple de la renommée + Se connecter + Se déconnecter + Déconnexion + Fermer + + + Nouvelle variable d\'environnement + Nom + Valeur + Plus de variables connues + Variante du conteneur + Version Wine + Arguments d\'exécution + Exemple : -dx11 + Langue + Taille de l\'écran + Largeur + Hauteur + Pilote audio + Afficher les FPS + Êtes-vous sûr de vouloir supprimer le pilote « %s » ? Cette action ne peut pas être annulée + + + Utiliser le DRM hérité + Forcer les DLC + N\'activer que si les DLC ne sont pas détectés ou si les sauvegardes avec DLC ne fonctionnent pas + Lancer le client Steam (Bêta) + Réduit les performances et ralentit le lancement\nPermet le jeu en ligne et corrige les problèmes de DRM et de contrôleur\nTous les jeux ne fonctionnent pas + Autoriser les mises à jour Steam + Met à jour Steam vers la dernière version. Réduit considérablement les performances. + Type Steam + + + Pilote graphique + Version du pilote graphique + Extensions Vulkan exposées + Mémoire maximale de l\'appareil + Utiliser Adrenotools Turnip + Version Vulkan + Taille du cache d\'images + Wrapper DX + Niveau de fonctionnalité VKD3D + Utiliser DRI3 + Désactiver peut corriger les problèmes graphiques sur certains appareils + Synchroniser chaque image + Désactiver KHR_present_wait + Modes de présentation + Type de ressource mémoire + Émulation BCn + Type d\'émulation BCn + Cache d\'émulation BCn + Amélioration de la netteté + Niveau de netteté + Débruitage de la netteté + + + Version FEXCore + Préréglage FEXCore + Mode TSO + Mode x87 + Multibloc + Émulateur 64 bits + Émulateur 32 bits + Version Box64 + Préréglage Box64 + Préréglages Box64 + Préréglages FEXCore + Voir, modifier et créer des préréglages FEXCore + Nom du préréglage + + + Utiliser l\'API SDL + Activer l\'API XInput + Activer l\'API DirectInput + Type de mappeur DirectInput + Désactiver l\'entrée souris + Mode écran tactile + Mouvement tactile direct vers le curseur (ON) vs mouvement relatif style pavé tactile (OFF) + Démarrer avec les contrôles à l\'écran masqués + Les contrôles à l\'écran seront masqués au démarrage du jeu. Basculez via le menu de navigation. + Émuler clavier et souris + Joystick gauche = WASD, Joystick droit = souris. L2 = clic gauche, R2 = clic droit. + + + Moteur de rendu + Nom du GPU + Mode de rendu hors écran + Taille de la mémoire vidéo + Activer CSMT (Command Stream Multi-Thread) + Activer les mathématiques de shader strictes + Remplacement de warp de souris + + + Variables d\'environnement + Aucune variable d\'environnement + Aucun lecteur + Sélection de démarrage + Affinité du processeur (apps 32 bits) + Chemin de l\'exécutable + ex. : chemin\\vers\\exe + + + Orientations autorisées + Sélectionner les canaux de débogage Wine + CPU%d + Décrivez ce qui s\'est passé + Obtenir de l\'aide sur Discord + + + Gestionnaire de pilotes + Sélectionner un pilote + Importer un ZIP depuis l\'appareil + + + Gestionnaire de contenus + Importer .wcp depuis l\'appareil + En cours… + Sélectionné + Contenu sélectionné + Contenus installés + Sélectionner le type + Fichiers non fiables détectés + Installer quand même + Supprimer le contenu ? + Êtes-vous sûr de vouloir supprimer %1$s (%2$s) ? + + + Langue + Sélectionner la langue + Redémarrage requis + Le changement de langue nécessite un redémarrage de l\'application. Voulez-vous continuer ? + Redémarrer + Changement de langue et redémarrage… + + + Émulation + Orientations autorisées + Choisir les orientations vers lesquelles pivoter en jeu + Modifier la configuration par défaut + Les paramètres de conteneur initiaux pour chaque jeu (n\'affecte pas les jeux déjà installés) + Configuration de conteneur par défaut + Préréglages Box64 + Voir, modifier et créer des préréglages Box64 + Gestionnaire de pilotes + Installer ou supprimer des packages de pilotes graphiques personnalisés + Gestionnaire de contenus + Installer des composants supplémentaires (.wcp) + Gestionnaire Wine/Proton + Importer des versions Wine/Proton personnalisées (Bionic uniquement) + + + Débogage + Sélectionner les canaux de débogage Wine + Activer les journaux de débogage Wine + Écrire la sortie de débogage Wine dans un fichier + Activer les journaux Box86/64 + Écrire la sortie de débogage Box86 & Box64 dans un fichier + Voir le dernier plantage + Voir le journal de débogage du jeu + Effacer les préférences + [Ferme l\'app] Déconnecte le client et efface les données de préférence locales. + Effacer la base de données locale + [Ferme l\'app] Peut aider à résoudre les problèmes avec les éléments de bibliothèque ou les messages. + Effacer le cache d\'images + Supprimer toutes les images qui ont été chargées. + + + Informations + Envoyer un pourboire + Contribuer au développement continu + Demander un pourboire au démarrage + Empêche le message de pourboire d\'apparaître + Code source + Voir le code source de ce projet + Bibliothèques utilisées + Voir les technologies qui rendent GameNative possible + Politique de confidentialité + Ouvre un lien vers la politique de confidentialité de GameNative + + + Interface + Ouvrir les liens web à l\'extérieur + Les liens s\'ouvrent avec votre navigateur web principal + Masquer la barre d\'état hors jeu + Masquer la barre d\'état Android dans la liste de jeux, les paramètres, etc. L\'application redémarrera lors du changement. + Style d\'icône + Télécharger uniquement via Wi-Fi + Empêcher les téléchargements sur données cellulaires + Écrire sur le stockage externe + Aucun stockage externe détecté + Enregistrer les jeux sur le stockage externe + Volume de stockage + Serveur de téléchargement Steam + Redémarrage requis + + + Téléchargements + + + GameNative + Politique de confidentialité + Bienvenue + Connectez-vous pour accéder à votre bibliothèque Steam + Nom d\'utilisateur + Mot de passe + Se souvenir de la session + Se connecter + Échec du code QR + Réessayer le code QR + Ouvrez l\'application mobile Steam et scannez ce code QR pour vous connecter instantanément + + + Continuer + Paramètres + + + + Connexion aux serveurs distants… + + + L\'application en cours d\'installation nécessite %1$s d\'espace mais il ne reste que %2$s sur cet appareil + L\'application en cours d\'installation nécessite l\'espace suivant. Voulez-vous continuer ?\n\n\tTaille du téléchargement : %1$s\n\tTaille sur le disque : %2$s\n\tEspace disponible : %3$s + Êtes-vous sûr de vouloir annuler le téléchargement de l\'application ? + Supprimer toutes les données téléchargées pour ce jeu ? + Êtes-vous sûr de vouloir supprimer cette application ? + Télécharger et installer ImageFS + L\'image Ubuntu doit être téléchargée et installée avant de pouvoir modifier la configuration. Cette opération peut prendre quelques minutes. Voulez-vous continuer ? + Installer ImageFS + L\'image Ubuntu doit être installée avant de pouvoir modifier la configuration. Cette opération peut prendre quelques minutes. Voulez-vous continuer ? + Réinitialiser le conteneur + Cela réinitialisera votre conteneur à la configuration par défaut. + Vérifier les fichiers + Veuillez vous assurer que vos sauvegardes sont téléchargées dans le cloud ou sauvegardées avant de vérifier, car elles peuvent être écrasées. + Mettre à jour + Veuillez vous assurer que vos sauvegardes sont téléchargées dans le cloud ou sauvegardées avant de mettre à jour, car elles peuvent être écrasées. + Configuration %s + + + Autorisation de stockage requise + Exporté + Échec de l\'exportation : %s + Exportation annulée + Raccourci créé + Échec de la création du raccourci : %s + Synchronisation cloud terminée avec succès + Les fichiers de sauvegarde sont déjà à jour + Échec de la synchronisation cloud : %s + + + Connexion internet nécessaire pour installer + Installation sur Wi-Fi/LAN uniquement activée + Fichier %1$d sur %2$d + + + Mise à jour disponible + Une nouvelle version (%1$s) est disponible !%2$s + Mettre à jour + Plus tard + Échec de la mise à jour + Échec du téléchargement ou de l\'installation de la mise à jour. Veuillez réessayer plus tard. + + + Plantage récent + Désolé pour ça !\nIl serait utile de connaître le problème récent que vous avez rencontré.\nVous pouvez consulter et exporter le journal de plantage le plus récent dans les paramètres de l\'application et le joindre comme problème Github dans le dépôt du projet.\nLe lien vers le dépôt Github se trouve également dans les paramètres ! + Merci d\'utiliser GameNative ! + Soutenez le jeu PC open source sur Android en partageant l\'application avec vos amis ou en devenant membre sur Ko-fi. + Rejoindre sur Ko-fi + Partager + Le jeu a-t-il fonctionné ? + Rejoignez le Discord pour obtenir de l\'aide pour réparer votre jeu ou améliorer les performances. + Ouvrir Discord + + + Téléchargement de Steam… + Installation des composants glibc… + Installation des composants Bionic… + Chargement… + + + Application en cours d\'exécution + Vous êtes connecté sur un autre appareil jouant déjà à %s. \nVous pouvez toujours jouer à ce jeu, mais cela déconnectera l\'autre session de Steam. + Vous êtes connecté sur un autre appareil (%1$s) jouant déjà à %2$s (%3$s), et cette sauvegarde n\'est pas encore dans le cloud. \nVous pouvez toujours jouer à ce jeu, mais cela déconnectera l\'autre session de Steam et peut créer un conflit de sauvegarde lorsque la progression de cette session sera synchronisée + Jouer quand même + + + Conflit de sauvegarde + Il y a une nouvelle sauvegarde distante et une nouvelle sauvegarde locale, laquelle voulez-vous conserver ?\n\nSauvegarde locale :\n\t%1$s\nSauvegarde distante :\n\t%2$s + Garder locale + Garder distante + + + L\'opération de synchronisation prend trop de temps. Veuillez réessayer de lancer le jeu dans un moment. + La synchronisation est en cours. Veuillez réessayer dans un moment. + Échec de la synchronisation des fichiers de sauvegarde : %s. + + + Téléversement en cours + Vous avez joué à %1$s sur l\'appareil %2$s (%3$s) et la sauvegarde de cette session est toujours en cours de téléversement.\nRéessayez plus tard. + Téléversement en attente + Vous avez joué à %1$s sur l\'appareil %2$s (%3$s), et cette sauvegarde n\'est pas encore dans le cloud. (téléversement non démarré)\nVous pouvez toujours jouer à ce jeu, mais cela peut créer un conflit lorsque votre progression de jeu précédente sera téléversée avec succès. + Session d\'application suspendue. Veuillez redémarrer l\'application. + Opérations distantes en attente reçues dont l\'opération était \'none\'. Veuillez redémarrer l\'application. + Plusieurs opérations distantes en attente, réessayez plus tard. Veuillez redémarrer l\'application. + + + Configurer le conteneur + Modifications non enregistrées + Êtes-vous sûr de vouloir abandonner vos modifications ? + Général + Graphiques + Émulation + Contrôleur + Wine + Composants Win + Environnement + Lecteurs + Avancé + Version VKD3D + Chemin de l\'exécutable + ex. : chemin\\vers\\exe + + + Bibliothèques utilisées + Pluvia - github.com/oxters168/Pluvia\nJavaSteam - github.com/Longi94/JavaSteam\nWinlator & Vortek - github.com/brunodev85/winlator\nWinlator Cmod - github.com/coffincolors/winlator\nWrapper - https://github.com/leegao/bionic-vulkan-wrapper & https://github.com/pipetto-crypto/\nUbuntu RootFs - releases.ubuntu.com/focal + + + Crédits artistiques + Icône de l\'app : Hachi + Icône alternative de l\'app : rhapsody_mdr + Chargement des supporters… + Membres + Supporters + Aucun supporter pour le moment. + Anonyme + + + Le chat est encore une fonctionnalité en développement.\nVeuillez signaler tout problème dans le dépôt du projet. + Aucun historique de chat + Envoyer un message + Envoyer + Émoticônes + Autocollants + + + Ouvrir + Installé + Installation en cours + Non installé + Partagé en famille + Compatible + Compatibilité inconnue + Non compatible + + + Configuration connue fonctionne sur votre appareil + Configuration connue devrait fonctionner sur votre appareil + Configuration connue pourrait fonctionner sur votre appareil + Aucune configuration connue + + + Meilleure configuration appliquée avec succès + Configuration connue invalide + Aucune meilleure configuration disponible pour ce jeu + Échec de l\'application de la configuration : %s + Type d\'app + Statut de l\'app + Disposition + Liste + Capsule + Héros + Rechercher vos jeux… + Rechercher + Effacer la recherche + Aucun élément listé avec la sélection + Filtres + %1$d jeux • %2$d installés + Steam + Jeux personnalisés + Disposition + + + Connexion + Code de vérification + Entrer le code à 5 caractères + + + Validation du contenu… + Le fichier ne peut pas être reconnu + Profil introuvable dans le contenu + Le profil ne peut pas être reconnu + Le contenu existe déjà + Le contenu est incomplet + Le contenu ne peut pas être fiable + Pas assez d\'espace + Impossible d\'installer le contenu + Ce contenu inclut des fichiers en dehors de l\'ensemble de confiance. + Installer des composants supplémentaires (.wcp : tar.xz/zst) + Type + Version + Code + Description + Tous les fichiers sont fiables. Prêt à installer. + Aucun contenu installé pour ce type. + Supprimer + Ce contenu inclut des fichiers en dehors de l\'ensemble de confiance. Vérifiez et confirmez pour continuer. + %1$s supprimé + + + Échec du chargement du manifeste du pilote : %1$d + Erreur de chargement du manifeste du pilote : %1$s + Délai de connexion dépassé. Veuillez vérifier votre réseau et réessayer. + Erreur réseau : %1$s + + + Par défaut + Alternatif + Lent + Moyen + Rapide + Très rapide + Vitesse de téléchargement + Des vitesses plus élevées peuvent causer une augmentation de la chaleur de l\'appareil pendant les téléchargements + Par défaut + Enregistrement des paramètres et redémarrage… + + + Paramètres + + + Contenu installé avec succès + Échec de l\'installation du contenu + Erreur d\'installation : %1$s + + + Prêt + + Gestionnaire Wine/Proton + Images Bionic uniquement + Importer des versions Wine ou Proton personnalisées pour les conteneurs Bionic. Le nom de fichier doit commencer par \'wine\' ou \'proton\' (insensible à la casse). Les packages doivent inclure bin/, lib/ et prefixPack.txz. Toutes les importations sont compatibles bionic uniquement. + Par exemple : « proton-10.0-ARM64ec.wcp » + Importer un package Wine/Proton + Sélectionner un fichier .wcp (avec un nom de fichier commençant par \'wine\' ou \'proton\') + Importer un package .wcp + Traitement… + Détails du package + Type + Version + Code de version + Chemin Bin + Chemin Lib + Description + ✓ Tous les fichiers sont fiables. Prêt à installer. + Installer le package + Versions Wine/Proton installées + Aucune version Wine ou Proton installée trouvée. + Supprimer + Ce package inclut des fichiers en dehors de l\'ensemble de confiance. Vérifiez et confirmez pour continuer l\'installation. + Fichiers non fiables : + Supprimer la version Wine/Proton + Êtes-vous sûr de vouloir supprimer %1$s %2$s (%3$d) ? Les conteneurs utilisant cette version ne fonctionneront plus. + %s supprimé + Échec de la suppression : %s + Annuler l\'importation ? + Une importation est en cours. L\'annulation supprimera tous les fichiers extraits et vous devrez recommencer l\'importation.\n\nÊtes-vous sûr de vouloir annuler ? + Oui, annuler l\'importation + Non, continuer l\'importation + + + Extraction et validation du package (cela peut prendre 2-3 minutes pour les gros fichiers)… + Le nom de fichier doit commencer par \'wine\' ou \'proton\' (insensible à la casse) + Le fichier est vide ou ne peut pas être lu + Impossible d\'ouvrir le fichier + Échec de l\'ouverture du sélecteur de fichiers : %s + Le fichier ne peut pas être reconnu comme une archive valide + profile.json introuvable dans le package + profile.json invalide + Cette version Wine/Proton existe déjà + Le package manque de fichiers requis (bin/, lib/ ou prefixPack.txz) + Le package ne peut pas être fiable + Pas assez d\'espace de stockage + Une erreur inconnue s\'est produite + Impossible d\'installer le package Wine/Proton + Le package n\'est ni Wine ni Proton (type : %s) + Le nom de fichier indique %1$s mais le package contient %2$s + Ce package inclut des fichiers en dehors de l\'ensemble de confiance. + La version Wine/Proton existe déjà + Échec de l\'installation : %s + Erreur d\'installation : %s + %1$s %2$s installé avec succès + Cette build Wine/Proton nécessite des conteneurs GLIBC et n\'est pas compatible avec GameNative. Veuillez utiliser uniquement des builds ARM64/bionic. + Conteneurs utilisant cette version : + Aucun conteneur n\'utilise actuellement cette version. + Ces conteneurs ne fonctionneront plus si vous continuez : + + + Intégration GOG (Alpha) + Connexion GOG + Connectez-vous à votre compte GOG + Synchronisation… + Erreur : %1$s + ✓ %1$d jeux synchronisés + Récupérer votre bibliothèque de jeux GOG + Connexion réussie + Vous êtes maintenant connecté à GOG.\nNous allons maintenant synchroniser votre bibliothèque en arrière-plan. + + + Se connecter à GOG + Appuyez sur \'Ouvrir la connexion GOG\' et connectez-vous. Une fois connecté, veuillez copier l\'URL et coller ci-dessous + Exemple : https://embed.gog.com/on_login_success?origin=client&code=aaa + Ouvrir la connexion GOG + Code d\'autorisation ou URL de réussite de connexion + Collez le code ou l\'url ici + Se connecter + Annuler + Impossible d\'ouvrir le navigateur + + + Déconnexion + Se déconnecter de votre compte GOG + Se déconnecter de GOG ? + Cela supprimera vos identifiants GOG et effacera votre bibliothèque GOG de cet appareil. Vous pouvez vous reconnecter à tout moment. + Déconnexion + Déconnecté de GOG avec succès + Échec de la déconnexion : %s + Déconnexion de GOG… +
+ + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml new file mode 100644 index 0000000000..0585f2c5b5 --- /dev/null +++ b/app/src/main/res/values-it/strings.xml @@ -0,0 +1,972 @@ + + GameNative + Login Utente + Due Fattori + Home + Impostazioni + Login QR + Libreria + Download + App Sconosciuta + Il codice precedente non era corretto, riprova. + Usa l\'app mobile di Steam per confermare l\'accesso… + Inserisci il codice di autenticazione a due fattori dalla tua app di autenticazione. + Inserisci il codice di autenticazione inviato all\'email %s + L\'app che stai installando ha i seguenti requisiti di spazio. Vuoi procedere?\n\n\tDimensione Download: %1$s\n\tDimensione su Disco: %2$s\n\tSpazio Disponibile: %3$s + Dimensione Download: %1$s\nSpazio su Disco: %2$s\nSpazio Disponibile: %3$s + L\'app che stai installando richiede %1$s di spazio ma sono rimasti solo %2$s su questo dispositivo + Sei sicuro di voler annullare il download dell\'app? + Eliminare tutti i dati scaricati per questo gioco? + Scarica e Installa ImageFS + L\'immagine Ubuntu deve essere scaricata e installata prima di poter modificare la configurazione. Questa operazione potrebbe richiedere alcuni minuti. Vuoi continuare? + Installa ImageFS + L\'immagine Ubuntu deve essere installata prima di poter modificare la configurazione. Questa operazione potrebbe richiedere alcuni minuti. Vuoi continuare? + Reimposta Container + Questo reimposterà il tuo container alla configurazione predefinita. + Reimpostare Container? + Reimposta + Verifica File + Assicurati che i tuoi salvataggi siano caricati sul cloud o sottoposti a backup prima della verifica, altrimenti potrebbero essere sovrascritti. + Aggiorna + Assicurati che i tuoi salvataggi siano caricati sul cloud o sottoposti a backup prima dell\'aggiornamento, altrimenti potrebbero essere sovrascritti. + Sincronizzazione cloud completata con successo + I file di salvataggio sono già aggiornati + Sincronizzazione cloud fallita: %s + Devi aver effettuato l\'accesso a Steam per utilizzare questa funzione + Permesso di archiviazione richiesto + Container reimpostato ai valori predefiniti + ImageFS installato. Prova a modificare nuovamente il container. + Impossibile installare ImageFS: %s + Disinstalla Gioco + Sei sicuro di voler disinstallare %1$s? Questa azione non può essere annullata. + %1$s è stato disinstallato + Impossibile disinstallare il gioco + Disinstalla Gioco + Sei sicuro di voler disinstallare %1$s? Questa azione non può essere annullata. + Scarica Gioco + L\'app che stai installando ha i seguenti requisiti di spazio. Vuoi procedere?\n\n\tDimensione Download: %1$s\n\tSpazio Disponibile: %2$s + Mai + Continua + Immagine intestazione app + Scarica + Installa + Installa + Elimina + Disinstalla + Gioca + Installa App + Calcolo requisiti di spazio... + Elimina App + Annulla Download + OK + + No + Spazio Insufficiente + Procedi + Annulla + Senza titolo + Conferma Eliminazione + Libreria + Download + Amici + + + Giochi Personalizzati + Nessun percorso aggiunto + Concedi Permesso + Aggiunto dalla Libreria + Nessun gioco aggiunto manualmente. + Rimuovi Percorso + Rimuovere questo percorso dalla scansione? Il contenuto della cartella rimarrà sul disco. + Rimuovi percorso + Percorso rimosso dall\'elenco. Il contenuto non è stato eliminato. + Rimuovi Gioco Manuale + Rimuovere questa cartella aggiunta manualmente dalla tua libreria? Questo non elimina i file sul disco. + Rimuovi cartella manuale + Cartella manuale rimossa dalla libreria. + ⚠ Impossibile accedere (controlla se il percorso esiste) + ⚠ Permesso negato + 0 cartelle trovate + %d cartelle trovate + Le cartelle in questi percorsi vengono scansionate per file .exe ed elencate come giochi personalizzati. Questo potrebbe rallentare l\'avvio dell\'app. + Impossibile risolvere il percorso della cartella + Selezione Eseguibile Richiesta + Questo gioco ha più eseguibili. Apri le Impostazioni Gioco Personalizzato per selezionare quale avviare. + Impostazioni Gioco Personalizzato + Elimina Gioco + Sei sicuro di voler disinstallare %1$s? + Sconosciuto + + + Disabilitato + Copia + Stabilità + Compatibilità + Intermedio + Prestazioni + Unity + Unity Mono Bleeding Edge + Ubuntu FS + + + Direct3D + DirectSound + DirectMusic + DirectPlay + DirectShow + DirectX + Visual C++ 2010 + Windows Media Decoder + OpenGL + Versione DXVK + Esegui + Modifica + Rimuovi + Aggiungi + Elimina + Copia Da + Ok + Imposta Risoluzione Personalizzata + x + Larghezza e altezza devono essere maggiori di 0 + La larghezza deve essere maggiore dell\'altezza + Info Archiviazione + Duplica + Riconfigura + Info Contenuto + Gioco Non Installato + %s non è installato. Installa prima il gioco per avviarlo. + Salvare Configurazione Container? + Sono state applicate modifiche temporanee alla configurazione del container per questo avvio. Vuoi salvarle? + Errore Sincronizzazione + Salva + Scarta + Scorciatoie + Container + File RC Box64 + Contenuti + Info + Apri File + Scarica File + Affinità Processore + Porta in Primo Piano + Termina Processo + Nuovo File + Aggiungi a Schermata Home + Attiva/Disattiva Schermo Intero + Cambia Orientamento + Gestione Attività + Lente d\'ingrandimento + Log + Esci + Esci dal Gioco + Tastiera + Extra + Cancella Ricerca + Pulsanti + Pulsanti Frontali + Pulsanti Dorsali + Pulsanti Menu + Pulsanti Levette + Levetta Sinistra + Levetta Destra + D-Pad + D-Pad Su + D-Pad Giù + D-Pad Sinistra + D-Pad Destra + Controller a schermo + Modifica Controller a schermo + Modifica Controller Fisico + Disconnesso + Reimposta Controlli a Schermo + Apri Menu Navigazione + Aiuto Touchpad + Nascondi controlli a schermo con controller + Nascondi automaticamente i controlli a schermo quando è connesso un controller fisico + + + Seleziona Associazione + Associa: %1$s + Attuale: %1$s + Cerca associazioni… + Cerca… + Cerca + Categoria + Tastiera + Mouse + Gamepad + Mouse + Gamepad + Nessuno + Cancella Associazione + Nessuna associazione trovata + + + Modifica %1$s + Posizione: (%1$d, %2$d) • Dimensione: %3$.2fx + Aspetto + %.2fx + Testo Etichetta + Testo personalizzato per questo pulsante + Tipo Elemento + Cambia il tipo di questo controllo + Forma + Aspetto visivo + %1$s è limitato alla forma %2$s + Associazioni + Associazioni (Generate automaticamente) + Le associazioni dei pulsanti di intervallo sono generate automaticamente + Azione Primaria + Azione Secondaria + Su + Destra + Giù + Sinistra + (Mouse) + Slot %1$d + I pulsanti supportano fino a 2 associazioni: primaria (pressione) e secondaria (pressione prolungata) + Proprietà + Posizione + X: %1$d, Y: %2$d + Modifiche Non Salvate + Hai modifiche non salvate. Vuoi salvarle o scartarle? + Regola Dimensione + Reimposta + Fatto + Copia Dimensione Da Elemento + Nessun altro elemento disponibile da cui copiare la dimensione + Dimensione copiata: %.2fx + Controlli a schermo reimpostati ai valori predefiniti + Preset Rapidi + WASD + Frecce + Mouse + D-Pad + Levetta Sinistra + Levetta Destra + + + Editor Associazioni Controller Fisico + Configura mappature pulsanti per il tuo controller fisico + Pulsanti Frontali + Pulsante A + Pulsante frontale inferiore (Conferma) + Pulsante B + Pulsante frontale destro (Indietro) + Pulsante X + Pulsante frontale sinistro + Pulsante Y + Pulsante frontale superiore + Pulsanti Dorsali + L1 / LB + Dorsale sinistro + R1 / RB + Dorsale destro + L2 / LT + Grilletto sinistro + R2 / RT + Grilletto destro + Pulsanti Menu + Start / Opzioni + Select / Visualizza / Condividi + Levette Analogiche + Pulsanti Levette + L3 (Click Levetta Sinistra) + R3 (Click Levetta Destra) + Levetta Analogica Sinistra + Levetta Sinistra Su + Levetta Sinistra Giù + Levetta Sinistra Sinistra + Levetta Sinistra Destra + Levetta Analogica Destra + Levetta Destra Su + Levetta Destra Giù + Levetta Destra Sinistra + Levetta Destra Destra + Altro + Home / Guida / PS + Reimposta ad Associazioni Predefinite + Non Impostato + + + Controlli reimpostati + Impossibile salvare logcat nella destinazione + + + Salva un\'istantanea del logcat solo per il PID di questa app + Salva logcat + +
+ 0 : Tratta CALL/RET come se non avessero mai bisogno di flag (più veloce, instabile)
+ 1 : La maggior parte dei RET avrà bisogno di flag, la maggior parte delle CALL no
+ 2 : Tutti i CALL/RET avranno bisogno di flag (più lento) + ]]>
+ Generazione di -NAN come su x86 + Generazione di arrotondamento x86 preciso + Uso di Float/Double per emulazione x87 +
+ 0 : Non provare a costruire blocchi il più grandi possibile
+ 1 : Costruisci blocchi Dynarec il più grandi possibile
+ 2 : Costruisci blocchi Dynarec più grandi (continua quando il blocco si sovrappone, ma solo per blocchi in memoria elf)
+ 3 : Costruisci blocchi Dynarec più grandi (continua quando il blocco si sovrappone, per tutti i tipi di memoria) + ]]>
+
+ 0 : Non provare nulla di speciale
+ 1 : Abilita alcune Barriere di Memoria durante la scrittura in memoria (su alcuni opcode MOV)
+ 2 : Tutto 1 più una Barriera di Memoria su ogni scrittura in memoria usando MOV
+ 3 : Tutto 2 più Barriera di Memoria durante la lettura dalla memoria e su alcuni opcode SSE/SSE2 + ]]>
+
+ 0 : Usa barriere sicure regolari
+ 1 : Usa barriere deboli per un leggero aumento delle prestazioni
+ 2 : Usa barriere deboli, disabilita inoltre le ultime barriere di scrittura]]>
+
+ 0 : Genera codice di gestione atomiche non allineate.
+ 1 : Genera solo atomiche allineate, che è più veloce e di dimensioni ridotte, ma causerà SIGBUS per opcode con prefisso LOCK che operano su indirizzi dati allineati.]]>
+
+ 0 : Disabilita l\'uso dei flag differiti.
+ 1 : Abilita l\'uso dei flag differiti.]]>
+
+ 0 : Non consentire di continuare l\'esecuzione di un blocco non protetto e potenzialmente sporco.
+ 1 : Consenti di continuare a eseguire un dynablock che scrive dati nella stessa pagina del codice. Può essere più veloce nel caricamento di alcuni giochi ma può anche causare crash imprevisti.
+ 2 : Inoltre, quando rileva una HotPage, contrassegna quella pagina come NEVERCLEAN, quindi non sarà protetta da scrittura ma il blocco costruito da quella pagina sarà sempre testato. Può essere più veloce in questo modo (ma alcuni casi SMC potrebbero non essere intercettati).]]>
+
+ 0 : Non usare flag nativi.
+ 1 : Usa flag nativi quando possibile.]]>
+
+ 0 : Ignora istruzione x86 PAUSE.
+ 1 : Usa YIELD per emulare istruzione x86 PAUSE.
+ 2 : Usa WFI per emulare istruzione x86 PAUSE.
+ 3 : Usa SEVL+WFE per emulare istruzione x86 PAUSE. + ]]>
+
+ 0 : Disabilita estensione AVX
+ 1 : abilita estensione AVX, BMI1, F16C e VAES.
+ 2 : Tutto 1 più abilita AVX2, BMI2, FMA, ADX, VPCLMULQDQ e RDRAND. + ]]>
+ Numero massimo di CPU presentate ai programmi da box64 + Rileva UnityPlayer.dll e applica impostazioni strongmem + Forza ogni allocazione di memoria in spazi di indirizzi a 32 bit + Definisce il valore massimo di avanzamento consentito durante la costruzione del Blocco + Ottimizzazione degli opcode CALL/RET + Definisce se Dynarec attenderà o meno che il FillBlock sia pronto + Abilita ops IR TSO (richiesto per app multithread). + Rende i load/store vettoriali atomici quando TSO è abilitato. + Usa atomiche half-barrier per load/store non allineati sotto TSO. + Rende REP MOVS / REP STOS atomici sotto TSO. + Abilita compilazione codice multiblocco. Può causare compilazione JIT più lunga e stuttering. + Controlla forzatura funzionalità CPU. + Scala TSC su sistemi a bassa frequenza. + Controlli Codice Auto-Modificante (SMC). + Usa metadati volatili dai file PE per TSO quando disponibili. + Hack speciali blocco SMC + JIT per rilevamento Mono. + Nasconde bit hypervisor CPUID (utile per app che crashano con esso). + Disabilita lookup cache L2 JIT FEXCore, risparmiando memoria ma introducendo stuttering. + Passa cache L1 JIT FEXCore a dimensione dinamica, risparmiando memoria ma introducendo stuttering. + Emula virgola mobile X87 usando precisione a 64 bit. Questo riduce la precisione dell\'emulazione e può causare bug di rendering. + Budget massimo istruzioni per blocco di traduzione. Valori più alti possono migliorare le prestazioni ma ridurre la stabilità. + + Riprendi + Pausa + + + Crea scorciatoia + Etichetta + Icona + Crea + Aggiorna Ora + Spostamento File + Serve internet per installare + Installa solo tramite Wi-Fi abilitato + Progresso Installazione + Scaricamento... + Calcolo... + Download fallito. Riprova. + Aggiornamento Disponibile + Informazioni Gioco + Stato + Dimensione + Posizione + Sviluppatore + Data di Rilascio + Installato + Installazione + Non Installato + Apri + Condiviso in Famiglia + Giochi + Tempo di gioco ultime 2 settimane: %s ore + Tempo di gioco totale: %s ore + Aggiungi gioco personalizzato + Aggiungi Gioco Personalizzato + Seleziona la cartella contenente i file del gioco che vuoi aggiungere come gioco personalizzato. + Non mostrare più questa finestra + Scorciatoia creata + Impossibile creare scorciatoia: %s + Immagini recuperate con successo + Cartella gioco non trovata + Impossibile recuperare immagini: %s + Esportato + Impossibile esportare: %s + Esportazione annullata + + + + Installato + Gioco + Applicazione + Strumento + Demo + Famiglia + + + Nessuna connessione a Steam + Riprova Connessione Steam + Continua Offline + + + + Come ha girato il gioco? + Seleziona eventuali problemi riscontrati: + + + + Aiuto & Supporto + Hall of Fame + Vai Online + Vai Offline + Disconnetti + Chiudi + + + Nuova Variabile d\'Ambiente + Nome + Valore + Nessuna altra variabile nota + Variante Container + Versione Wine + Argomenti Esecuzione + Esempio: -dx11 + Lingua + Dimensione Schermo + Larghezza + Altezza + Driver Audio + Mostra FPS + Sei sicuro di voler rimuovere il driver "%s"? Questo non può essere annullato + + + Usa DRM Legacy + Forza DLC + Abilita solo se i DLC non vengono rilevati o i salvataggi con DLC non funzionano + Avvia Client Steam (Beta) + Riduce le prestazioni e rallenta l\'avvio\nConsente il gioco online e corregge problemi di DRM e controller\nNon tutti i giochi funzionano + Consenti aggiornamenti Steam + Aggiorna Steam all\'ultima versione. Riduce significativamente le prestazioni. + Tipo Steam + + + Driver Grafico + Versione Driver Grafico + Estensioni Vulkan Esposte + Memoria Dispositivo Max + Usa Adrenotools Turnip + Versione Vulkan + Dimensione Cache Immagini + Wrapper DX + Livello Funzionalità VKD3D + Usa DRI3 + Disabilitare può correggere glitch grafici su alcuni dispositivi + Sincronizza Ogni Fotogramma + Disabilita KHR_present_wait + Modalità Presentazione + Tipo Risorsa Memoria + Emulazione BCn + Tipo Emulazione BCn + Cache Emulazione BCn + Boost Nitidezza + Livello Nitidezza + Denoise Nitidezza + + + Versione FEXCore + Preset FEXCore + Modalità TSO + Modalità x87 + Multiblocco + Emulatore 64-bit + Emulatore 32-bit + Versione Box64 + Preset Box64 + Preset Box64 + Preset FEXCore + Visualizza, modifica e crea preset FEXCore + Nome preset + + + Usa API SDL + Abilita API XInput + Abilita API DirectInput + Tipo Mapper DirectInput + Disabilita Input Mouse + Modalità Touchscreen + Movimento tocco-cursore diretto (ON) vs movimento relativo stile touchpad (OFF) + Avvia con Controlli a Schermo Nascosti + I controlli a schermo saranno nascosti all\'avvio del gioco. Attiva/disattiva tramite il menu di navigazione. + Emula tastiera e mouse + Levetta sinistra = WASD, Levetta destra = mouse. L2 = click sinistro, R2 = click destro. + + + Renderer + Nome GPU + Modalità Rendering Offscreen + Dimensione Memoria Video + Abilita CSMT (Command Stream Multi-Thread) + Abilita Matematica Shader Rigorosa + Override Mouse Warp + + + Variabili d\'Ambiente + Nessuna variabile d\'ambiente + Nessuna unità + Selezione Avvio + Affinità Processore (app 32-bit) + Percorso Eseguibile + es. percorso\\a\\exe + + + Orientamenti Consentiti + Seleziona Canali Debug Wine + CPU%d + Descrivi cosa è successo + Ottieni supporto su Discord + + + Gestione Driver + Seleziona un driver + Importa ZIP dal dispositivo + + + Gestione Contenuti + Importa .wcp dal dispositivo + Elaborazione... + Selezionato + Contenuto selezionato + Contenuti installati + Seleziona tipo + File Non Attendibili Rilevati + Installa Comunque + Rimuovere contenuto? + Sei sicuro di voler rimuovere %1$s (%2$s)? + + + Lingua + Seleziona Lingua + Riavvio Richiesto + Cambiare la lingua richiede il riavvio dell\'app. Vuoi continuare? + Riavvia + Cambio lingua e riavvio... + + + Emulazione + Orientamenti Consentiti + Scegli quali orientamenti possono essere ruotati durante il gioco + Modifica Config Predefinita + Le impostazioni iniziali del container per ogni gioco (non influisce sui giochi già installati) + Config Container Predefinita + Preset Box64 + Visualizza, modifica e crea preset Box64 + Gestione Driver + Installa o rimuovi pacchetti driver grafici personalizzati + Gestione Contenuti + Installa componenti aggiuntivi (.wcp) + Gestione Wine/Proton + Importa versioni Wine/Proton personalizzate (solo Bionic) + + + Debug + Seleziona Canali Debug Wine + Abilita Log Debug Wine + Scrivi output debug Wine su file + Abilita Log Box86/64 + Scrivi output debug Box86 & Box64 su file + Visualizza ultimo crash + Visualizza log debug gioco + Cancella Preferenze + [Chiude App] Disconnette il client e cancella i dati delle preferenze locali. + Cancella Database Locale + [Chiude app] Può aiutare a risolvere problemi con elementi della libreria o messaggi. + Cancella Cache Immagini + Rimuovi tutte le immagini caricate. + + + Info + Invia mancia + Contribuisci allo sviluppo in corso + Chiedi mancia all\'avvio + Impedisce la comparsa del messaggio mancia + Codice sorgente + Visualizza il codice sorgente di questo progetto + Librerie Usate + Vedi quali tecnologie rendono possibile GameNative + Informativa sulla Privacy + Apre un link all\'informativa sulla privacy di GameNative + + + Interfaccia + Apri link web esternamente + I link si aprono con il tuo browser web principale + Nascondi barra di stato quando non in gioco + Nascondi barra di stato Android nell\'elenco giochi, impostazioni, ecc. L\'app si riavvierà se modificato. + Stile icona + Scarica solo tramite Wi-Fi + Impedisci download su rete cellulare + Scrivi su memoria esterna + Nessuna memoria esterna rilevata + Salva giochi su memoria esterna + Volume archiviazione + Server Download Steam + Riavvio Richiesto + + + Download + + + GameNative + Informativa sulla Privacy + Bentornato + Accedi per accedere alla tua libreria Steam + Nome utente + Password + Ricorda sessione + Accedi + Codice QR Fallito + Riprova Codice QR + Apri l\'app mobile di Steam e scansiona questo codice QR per accedere istantaneamente + + + Continua + Impostazioni + + + + Connessione ai server remoti... + + + L\'app che stai installando richiede %1$s di spazio ma sono rimasti solo %2$s su questo dispositivo + L\'app che stai installando ha i seguenti requisiti di spazio. Vuoi procedere?\n\n\tDimensione Download: %1$s\n\tDimensione su Disco: %2$s\n\tSpazio Disponibile: %3$s + Sei sicuro di voler annullare il download dell\'app? + Eliminare tutti i dati scaricati per questo gioco? + Sei sicuro di voler eliminare questa app? + Scarica e Installa ImageFS + L\'immagine Ubuntu deve essere scaricata e installata prima di poter modificare la configurazione. Questa operazione potrebbe richiedere alcuni minuti. Vuoi continuare? + Installa ImageFS + L\'immagine Ubuntu deve essere installata prima di poter modificare la configurazione. Questa operazione potrebbe richiedere alcuni minuti. Vuoi continuare? + Reimposta Container + Questo reimposterà il tuo container alla configurazione predefinita. + Verifica File + Assicurati che i tuoi salvataggi siano caricati sul cloud o sottoposti a backup prima della verifica, altrimenti potrebbero essere sovrascritti. + Aggiorna + Assicurati che i tuoi salvataggi siano caricati sul cloud o sottoposti a backup prima dell\'aggiornamento, altrimenti potrebbero essere sovrascritti. + %s Config + + + Permesso di archiviazione richiesto + Esportato + Impossibile esportare: %s + Esportazione annullata + Scorciatoia creata + Impossibile creare scorciatoia: %s + Sincronizzazione cloud completata con successo + I file di salvataggio sono già aggiornati + Sincronizzazione cloud fallita: %s + + + Serve internet per installare + Installa solo tramite Wi-Fi/LAN abilitato + File %1$d di %2$d + + + Aggiornamento Disponibile + È disponibile una nuova versione (%1$s)!%2$s + Aggiorna + Più tardi + Aggiornamento Fallito + Impossibile scaricare o installare l\'aggiornamento. Riprova più tardi. + + + Crash Recente + Ci dispiace!\nSarebbe utile conoscere il problema recente che hai avuto.\nPuoi visualizzare ed esportare il log del crash più recente nelle impostazioni dell\'app e allegarlo come issue GitHub nel repository del progetto.\nIl link al repository Github è anche nelle impostazioni! + Grazie per aver usato GameNative! + Supporta il gaming PC open-source su Android condividendo l\'app con i tuoi amici o diventando membro su Ko-fi. + Unisciti su Ko-fi + Condividi + Il gioco ha funzionato? + Unisciti al Discord per ottenere supporto per correggere il tuo gioco o migliorare le prestazioni. + Apri Discord + + + Scaricamento Steam... + Installazione componenti glibc... + Installazione componenti Bionic... + Caricamento... + + + App in Esecuzione + Sei connesso su un altro dispositivo che sta già giocando a %s. \nPuoi comunque giocare a questo gioco, ma ciò disconnetterà l\'altra sessione da Steam. + Sei connesso su un altro dispositivo (%1$s) che sta già giocando a %2$s (%3$s), e quel salvataggio non è ancora nel cloud. \nPuoi comunque giocare a questo gioco, ma ciò disconnetterà l\'altra sessione da Steam e potrebbe creare un conflitto di salvataggio quando il progresso di quella sessione verrà sincronizzato + Gioca comunque + + + Conflitto Salvataggio + C\'è un nuovo salvataggio remoto e un nuovo salvataggio locale, quale vuoi mantenere?\n\nSalvataggio locale:\n\t%1$s\nSalvataggio remoto:\n\t%2$s + Mantieni locale + Mantieni remoto + + + L\'operazione di sincronizzazione sta impiegando troppo tempo. Prova ad avviare nuovamente il gioco tra un momento. + La sincronizzazione è attualmente in corso. Riprova tra un momento. + Impossibile sincronizzare i file di salvataggio: %s. + + + Caricamento in Corso + Hai giocato a %1$s sul dispositivo %2$s (%3$s) e il salvataggio di quella sessione è ancora in caricamento.\nRiprova più tardi. + Caricamento in Sospeso + Hai giocato a %1$s sul dispositivo %2$s (%3$s), e quel salvataggio non è ancora nel cloud. (caricamento non iniziato)\nPuoi comunque giocare a questo gioco, ma ciò potrebbe creare un conflitto quando il tuo progresso di gioco precedente verrà caricato con successo. + Sessione app sospesa. Riavvia l\'app. + Ricevute operazioni remote in sospeso la cui operazione era \'none\'. Riavvia l\'app. + Molteplici operazioni remote in sospeso, riprova più tardi. Riavvia l\'app. + + + Configura Container + Modifiche Non Salvate + Sei sicuro di voler scartare le tue modifiche? + Generale + Grafica + Emulazione + Controller + Wine + Componenti Win + Ambiente + Unità + Avanzate + Versione VKD3D + Percorso Eseguibile + es. percorso\\a\\exe + + + Librerie Usate + Pluvia - github.com/oxters168/Pluvia\nJavaSteam - github.com/Longi94/JavaSteam\nWinlator & Vortek - github.com/brunodev85/winlator\nWinlator Cmod - github.com/coffincolors/winlator\nWrapper - https://github.com/leegao/bionic-vulkan-wrapper & https://github.com/pipetto-crypto/\nUbuntu RootFs - releases.ubuntu.com/focal + + + Crediti Artistici + Icona App: Hachi + Icona App Alternativa: rhapsody_mdr + Caricamento sostenitori… + Membri + Sostenitori + Nessun sostenitore ancora. + Anonimo + + + La chat è ancora una funzione sperimentale.\nSegnala eventuali problemi nel repo del progetto. + Nessuna cronologia chat + Invia un messaggio + Invia + Emoticon + Adesivi + + + Apri + Installato + Installazione + Non installato + Condiviso in Famiglia + Compatibile + Compatibilità Sconosciuta + Non Compatibile + + + Config nota funzionante sul tuo dispositivo + Config nota dovrebbe funzionare sul tuo dispositivo + Config nota potrebbe funzionare sul tuo dispositivo + Nessuna config nota + + + Miglior config applicata con successo + Config nota non valida + Nessuna miglior config disponibile per questo gioco + Impossibile applicare config: %s + Tipo App + Stato App + Layout + Elenco + Capsula + Eroe + Cerca i tuoi giochi… + Cerca + Cancella ricerca + Nessun elemento elencato con la selezione + Filtri + %1$d giochi • %2$d installati + Steam + Giochi Personalizzati + Layout + + + Login + Codice di Verifica + Inserisci codice a 5 caratteri + + + Convalida contenuto… + File non riconosciuto + Profilo non trovato nel contenuto + Profilo non riconosciuto + Contenuto già esistente + Contenuto incompleto + Contenuto non attendibile + Spazio insufficiente + Impossibile installare contenuto + Questo contenuto include file al di fuori del set attendibile. + Installa componenti aggiuntivi (.wcp: tar.xz/zst) + Tipo + Versione + Codice + Descrizione + Tutti i file sono attendibili. Pronto per l\'installazione. + Nessun contenuto installato per questo tipo. + Elimina + Questo contenuto include file al di fuori del set attendibile. Rivedi e conferma per procedere. + Rimosso %1$s + + + Impossibile caricare manifesto driver: %1$d + Errore caricamento manifesto driver: %1$s + Connessione scaduta. Controlla la tua rete e riprova. + Errore di rete: %1$s + + + Predefinito + Alternativo + Lento + Medio + Veloce + Rapidissimo + Velocità download + Velocità più elevate possono causare un aumento del calore del dispositivo durante i download + Predefinito + Salvataggio impostazioni e riavvio… + + + Impostazioni + + + Contenuto installato con successo + Impossibile installare contenuto + Errore installazione: %1$s + + + Pronto + + Gestione Wine/Proton + Solo Immagini Bionic + Importa versioni Wine o Proton personalizzate per container Bionic. Il nome del file deve iniziare con \'wine\' o \'proton\' (non case-sensitive). I pacchetti devono includere bin/, lib/ e prefixPack.txz. Tutte le importazioni sono compatibili solo con bionic. + Per esempio: "proton-10.0-ARM64ec.wcp" + Importa Pacchetto Wine/Proton + Seleziona un file .wcp (con nome file che inizia con \'wine\' o \'proton\') + Importa Pacchetto .wcp + Elaborazione... + Dettagli Pacchetto + Tipo + Versione + Codice Versione + Percorso Bin + Percorso Lib + Descrizione + ✓ Tutti i file sono attendibili. Pronto per l\'installazione. + Installa Pacchetto + Versioni Wine/Proton Installate + Nessuna versione Wine o Proton installata trovata. + Elimina + Questo pacchetto include file al di fuori del set attendibile. Rivedi e conferma per procedere con l\'installazione. + File non attendibili: + Rimuovi Versione Wine/Proton + Sei sicuro di voler rimuovere %1$s %2$s (%3$d)? I container che usano questa versione non funzioneranno più. + Rimosso %s + Impossibile rimuovere: %s + Annullare Importazione? + Un\'importazione è attualmente in corso. Annullare scarterà tutti i file estratti e dovrai ricominciare l\'importazione.\n\nSei sicuro di voler annullare? + Sì, Annulla Importazione + No, Mantieni Importazione + + + Estrazione e convalida pacchetto (potrebbe richiedere 2-3 minuti per file grandi)... + Il nome del file deve iniziare con \'wine\' o \'proton\' (non case-sensitive) + Il file è vuoto o non può essere letto + Impossibile aprire il file + Impossibile aprire selettore file: %s + Il file non può essere riconosciuto come archivio valido + profile.json non trovato nel pacchetto + profile.json non valido + Questa versione Wine/Proton esiste già + Il pacchetto manca di file richiesti (bin/, lib/, o prefixPack.txz) + Il pacchetto non può essere considerato attendibile + Spazio di archiviazione insufficiente + Si è verificato un errore sconosciuto + Impossibile installare pacchetto Wine/Proton + Il pacchetto non è Wine o Proton (tipo: %s) + Il nome file indica %1$s ma il pacchetto contiene %2$s + Questo pacchetto include file al di fuori del set attendibile. + Versione Wine/Proton già esistente + Impossibile installare: %s + Errore installazione: %s + %1$s %2$s installato con successo + Questa build Wine/Proton richiede container GLIBC e non è compatibile con GameNative. Usa solo build ARM64/bionic. + Container che usano questa versione: + Nessun container sta attualmente usando questa versione. + Questi container non funzioneranno più se procedi: + + + Integrazione GOG (Alpha) + Login GOG + Accedi al tuo account GOG + Sincronizzazione… + Errore: %1$s + ✓ Sincronizzati %1$d giochi + Recupera la tua libreria giochi GOG + Login Riuscito + Ora sei connesso a GOG.\nSincronizzeremo la tua libreria in background. + + + Accedi a GOG + Tocca \'Apri Login GOG\' e accedi. Una volta effettuato l\'accesso, copia l\'URL e incollalo qui sotto + Esempio: https://embed.gog.com/on_login_success?origin=client&code=aaa + Apri Login GOG + Codice Autorizzazione o URL successo login + Incolla codice o url qui + Login + Annulla + Impossibile aprire browser + + + Logout + Disconnettiti dal tuo account GOG + Logout da GOG? + Questo rimuoverà le tue credenziali GOG e cancellerà la tua libreria GOG da questo dispositivo. Puoi accedere nuovamente in qualsiasi momento. + Logout + Disconnesso da GOG con successo + Impossibile disconnettersi: %s + Disconnessione da GOG… +
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6264a98efd..0cf9c81162 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -17,6 +17,10 @@ Deletar Desinstalar Jogar + Desinstalar Jogo + Tem certeza que deseja desinstalar %1$s? Esta ação não pode ser desfeita. + Baixar Jogo + O aplicativo sendo instalado tem os seguintes requisitos de espaço. Deseja continuar?\n\n\tTamanho do download: %1$s\n\tEspaço disponível: %2$s Instalar App Deletar App Cancelar Download @@ -725,6 +729,7 @@ O aplicativo sendo instalado tem os seguintes requisitos de espaço. Deseja continuar?\n\n\tTamanho do download: %1$s\n\tTamanho no disco: %2$s\n\tEspaço disponível: %3$s + Tamanho do download: %1$s\nTamanho no disco: %2$s\nEspaço disponível: %3$s O aplicativo sendo instalado precisa de %1$s de espaço, mas há apenas %2$s restante neste dispositivo Tem certeza de que deseja cancelar o download do aplicativo? Excluir todos os dados baixados para este jogo? @@ -795,4 +800,36 @@ Exportado Falha ao exportar: %s Exportação cancelada + + + Integração GOG (Alpha) + Login GOG + Entre na sua conta GOG + Sincronizando… + Erro: %1$s + ✓ %1$d jogos sincronizados + Buscar sua biblioteca de jogos GOG + Login bem-sucedido + Você está conectado ao GOG.\nVamos sincronizar sua biblioteca em segundo plano. + + + Entrar no GOG + Toque em \'Abrir Login GOG\' e entre. Após fazer login, copie a URL e cole abaixo + Exemplo: https://embed.gog.com/on_login_success?origin=client&code=aaa + Abrir Login GOG + Código de autorização ou URL de sucesso do login + Cole o código ou url aqui + Entrar + Cancelar + Não foi possível abrir o navegador + + + Sair + Desconectar da sua conta GOG + Sair do GOG? + Isso removerá suas credenciais GOG e limpará sua biblioteca GOG deste dispositivo. Você pode entrar novamente a qualquer momento. + Sair + Desconectado do GOG com sucesso + Falha ao sair: %s + Saindo do GOG… diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000000..6c7416ea81 --- /dev/null +++ b/app/src/main/res/values-ro/strings.xml @@ -0,0 +1,973 @@ + + GameNative + Autentificare utilizator + Autentificare în doi pași + Acasă + Setări + Autentificare QR + Bibliotecă + Descărcări + Aplicație necunoscută + Codul anterior a fost incorect, încearcă din nou. + Folosește aplicația Steam Mobile pentru a confirma autentificarea… + Introdu codul de autentificare din aplicația ta de autentificare. + Introdu codul trimis la adresa de email %s + Aplicația ce urmează să fie instalată are următoarele cerințe de spațiu. Vrei să continui?\n\n\tDimensiune descărcare: %1$s\n\tSpațiu pe disc: %2$s\n\tSpațiu disponibil: %3$s + Dimensiune descărcare: %1$s\nSpațiu pe disc: %2$s\nSpațiu disponibil: %3$s + Aplicația necesită %1$s spațiu, dar pe dispozitiv sunt disponibile doar %2$s + Sigur vrei să anulezi descărcarea aplicației? + Ștergi toate datele descărcate pentru acest joc? + Descărcare & Instalare ImageFS + Imaginea Ubuntu trebuie descărcată și instalată înainte de a putea edita configurația. Această operațiune poate dura câteva minute. Vrei să continui? + Instalare ImageFS + Imaginea Ubuntu trebuie instalată înainte de a putea edita configurația. Această operațiune poate dura câteva minute. Vrei să continui? + Resetare Container + Aceasta va reseta containerul la configurația implicită. + Resetezi containerul? + Resetează + Verificare fișiere + Asigură-te că salvările sunt încărcate în cloud sau copiate în altă parte înainte de verificare, deoarece pot fi suprascrise. + Actualizare + Asigură-te că salvările sunt încărcate în cloud sau copiate în altă parte înainte de actualizare, deoarece pot fi suprascrise. + Sincronizarea cloud s-a încheiat cu succes + Fișierele de salvare sunt deja actualizate + Sincronizare cloud eșuată: %s + Trebuie să fii autentificat în Steam pentru a folosi această funcție + Este necesară permisiunea de stocare + Container resetat la setările implicite + ImageFS a fost instalat. Încearcă din nou editarea containerului. + Instalarea ImageFS a eșuat: %s + Dezinstalare joc + Sigur vrei să dezinstalezi %1$s? Această acțiune nu poate fi anulată. + %1$s a fost dezinstalat + Dezinstalarea jocului a eșuat + Dezinstalare joc + Sigur vrei să dezinstalezi %1$s? Această acțiune nu poate fi anulată. + Descărcare joc + Aplicația ce urmează să fie instalată are următoarele cerințe de spațiu. Vrei să continui?\n\n\tDimensiune descărcare: %1$s\n\tSpațiu disponibil: %2$s + Niciodată + Continuă + Imagine antet aplicație + Descărcare + Instalare + Instalează + Șterge + Dezinstalează + Pornește + Instalare aplicație + Se calculează cerințele de spațiu... + Ștergere aplicație + Anulare descărcare + OK + Da + Nu + Spațiu insuficient + Continuă + Anulează + Fără titlu + Confirmă ștergerea + Bibliotecă + Descărcări + Prieteni + + + Jocuri personalizate + Nicio cale adăugată + Acordă permisiunea + Adăugat din Bibliotecă + Niciun joc adăugat manual încă. + Elimină calea + Elimini această cale din scanare? Conținutul folderului va rămâne pe disc. + Elimină calea + Calea a fost eliminată din listă. Conținutul nu a fost șters. + Elimină jocul manual + Elimini acest folder adăugat manual din bibliotecă? Aceasta nu șterge fișierele de pe disc. + Elimină folderul manual + Folderul manual a fost eliminat din bibliotecă. + ⚠ Nu se poate accesa (verifică dacă calea există) + ⚠ Permisiune refuzată + 0 foldere găsite + %d foldere găsite + Folderele din aceste căi sunt scanate pentru fișiere .exe și listate ca jocuri personalizate. Acest lucru poate încetini pornirea aplicației. + Nu s-a putut rezolva calea folderului + Selectarea executabilului necesară + Acest joc are mai multe executabile. Deschide Setările Jocului Personalizat pentru a alege pe care să-l lansezi. + Setări joc personalizat + Ștergere joc + Sigur vrei să dezinstalezi %1$s? + Necunoscut + + Dezactivat + Copiază + Stabilitate + Compatibilitate + Intermediar + Performanță + Unity + Unity Mono Bleeding Edge + Ubuntu FS + + Direct3D + DirectSound + DirectMusic + DirectPlay + DirectShow + DirectX + Visual C++ 2010 + Windows Media Decoder + OpenGL + Versiune DXVK + Rulează + Editează + Elimină + Adaugă + Șterge + Copiază din + OK + Setează rezoluție personalizată + x + Lățimea și înălțimea trebuie să fie mai mari decât 0 + Lățimea trebuie să fie mai mare decât înălțimea + Informații stocare + Duplică + Reconfigurează + Informații conținut + Joc neinstalat + %s nu este instalat. Instalează jocul înainte de a-l porni. + Salvezi configurația containerului? + Modificările temporare ale configurației au fost aplicate pentru această lansare. Vrei să le salvezi? + Eroare de sincronizare + Salvează + Renunță + Scurtături + Containere + Fișier RC Box64 + Conținut + Despre + Deschide fișier + Descarcă fișier + Afiinitate procesor + Adu în față + Închide procesul + Fișier nou + Adaugă pe ecranul principal + Comută pe ecran complet + Comută orientarea + Manager de activități + Lupă + Loguri + Ieșire + Ieșire din joc + Tastatură + Extra + Șterge căutarea + Butoane + Butoane frontale + Butoane shoulder + Butoane meniu + Butoane thumbstick + Stick stânga + Stick dreapta + D‑Pad + D‑Pad Sus + D‑Pad Jos + D‑Pad Stânga + D‑Pad Dreapta + Controller pe ecran + Editează controllerul pe ecran + Editează controllerul fizic + Deconectat + Resetează controalele pe ecran + Deschide meniul de navigare + Ajutor touchpad + Ascunde controalele pe ecran când există controller + Ascunde automat controalele pe ecran când un controller fizic este conectat + + + Selectează acțiunea + Asociere: %1$s + Curent: %1$s + Caută acțiuni… + Caută… + Căutare + Categorie + Tastatură + Mouse + Gamepad + Mouse + Gamepad + Nimic + Șterge asocierea + Nicio asociere găsită + + + Editează %1$s + Poziție: (%1$d, %2$d) • Mărime: %3$.2fx + Aspect + %.2fx + Text etichetă + Text personalizat pentru acest buton + Tip element + Schimbă tipul acestui control + Formă + Aspect vizual + %1$s este limitat la forma %2$s + Asocieri + Asocieri (generate automat) + Asocierile pentru butoanele de tip „range” sunt generate automat + Acțiune principală + Acțiune secundară + Sus + Dreapta + Jos + Stânga + (Mouse) + Slot %1$d + Butoanele suportă până la 2 asocieri: principală (apăsare) și secundară (apăsare lungă) + Proprietăți + Poziție + X: %1$d, Y: %2$d + Modificări nesalvate + Ai modificări nesalvate. Vrei să le salvezi sau să renunți la ele? + Ajustează mărimea + Resetează + Gata + Copiază mărimea de la element + Nu există alte elemente de la care să copiezi mărimea + Mărime copiată: %.2fx + Controalele pe ecran au fost resetate la valorile implicite + Presetări rapide + WASD + Săgeți + Mouse + D‑Pad + Stick stânga + Stick dreapta + + + Editor asocieri controller fizic + Configurează mapările butoanelor pentru controllerul fizic + Butoane frontale + Buton A + Buton frontal inferior (Confirmare) + Buton B + Buton frontal dreapta (Înapoi) + Buton X + Buton frontal stânga + Buton Y + Buton frontal superior + Butoane shoulder + L1 / LB + Bumper stânga + R1 / RB + Bumper dreapta + L2 / LT + Trigger stânga + R2 / RT + Trigger dreapta + Butoane meniu + Start / Options + Select / View / Share + Stick‑uri analogice + Butoane thumbstick + L3 (Click stick stânga) + R3 (Click stick dreapta) + Stick analogic stânga + Stick stânga sus + Stick stânga jos + Stick stânga stânga + Stick stânga dreapta + Stick analogic dreapta + Stick dreapta sus + Stick dreapta jos + Stick dreapta stânga + Stick dreapta dreapta + Altele + Home / Guide / PS + Resetează la asocierile implicite + Nesetat + + + Controalele au fost resetate + Nu s-a putut salva logcat la destinație + + + Salvează un snapshot al logcat doar pentru PID-ul acestei aplicații + Salvează logcat +
+ 0 : Tratează CALL/RET ca și cum nu ar avea nevoie de flag-uri (mai rapid, instabil)
+ 1 : Majoritatea RET vor necesita flag-uri, majoritatea CALL nu
+ 2 : Toate CALL/RET vor necesita flag-uri (mai lent) + ]]>
+ Generarea valorilor -NAN ca pe x86 + Generarea rotunjirii precise ca pe x86 + Utilizarea Float/Double pentru emularea x87 +
+ 0 : Nu încerca să construiești blocul cât mai mare
+ 1 : Construiește blocul Dynarec cât mai mare
+ 2 : Construiește blocuri mai mari (continuă când blocurile se suprapun, doar pentru memorie ELF)
+ 3 : Construiește blocuri mai mari (continuă când se suprapun, pentru orice tip de memorie) + ]]>
+
+ 0 : Nu aplica nimic special
+ 1 : Activează unele Memory Barrier la scriere (pe unele opcode MOV)
+ 2 : Ca 1, plus Memory Barrier la fiecare scriere MOV
+ 3 : Ca 2, plus Memory Barrier la citire și pe unele opcode SSE/SSE2 + ]]>
+
+ 0 : Folosește barrier‑e sigure
+ 1 : Folosește barrier‑e slabe pentru un mic boost
+ 2 : Folosește barrier‑e slabe și dezactivează ultimele write barrier‑e + ]]>
+
+ 0 : Generează cod pentru atomici nealiniați.
+ 1 : Generează doar atomici aliniați (mai rapid, cod mai mic), dar poate cauza SIGBUS pentru LOCK pe adrese aliniate. + ]]>
+
+ 0 : Dezactivează deferred flags.
+ 1 : Activează deferred flags. + ]]>
+
+ 0 : Nu permite continuarea rulării unui bloc dirty.
+ 1 : Permite rularea unui dynablock care scrie în aceeași pagină cu codul (poate accelera încărcarea, dar poate cauza crash-uri).
+ 2 : Detectează HotPage, marchează pagina ca NEVERCLEAN și nu o protejează la scriere; blocurile vor fi testate mereu. + ]]>
+
+ 0 : Nu folosi flag‑uri native.
+ 1 : Folosește flag‑uri native când este posibil. + ]]>
+
+ 0 : Ignoră PAUSE.
+ 1 : Emulează PAUSE cu YIELD.
+ 2 : Emulează PAUSE cu WFI.
+ 3 : Emulează PAUSE cu SEVL+WFE. + ]]>
+
+ 0 : Dezactivează AVX
+ 1 : Activează AVX, BMI1, F16C și VAES
+ 2 : Ca 1, plus AVX2, BMI2, FMA, ADX, VPCLMULQDQ și RDRAND + ]]>
+ Numărul maxim de CPU-uri prezentate programelor de box64 + Detectează UnityPlayer.dll și aplică setările strongmem + Forțează toate alocările de memorie în spațiu 32‑bit + Definește valoarea maximă forward permisă la construirea blocurilor + Optimizarea opcode‑urilor CALL/RET + Definește dacă Dynarec va aștepta FillBlock + Activează operațiile TSO IR (necesare pentru aplicații multithread) + Face load/store‑urile vectoriale atomice când TSO este activ + Folosește half‑barrier pentru load/store nealiniate sub TSO + Face REP MOVS / REP STOS atomice sub TSO + Activează compilarea multiblock (poate cauza stutter) + Controlează forțarea capabilităților CPU + Scalează TSC pe sisteme cu frecvență redusă + Verificări pentru Self‑Modifying Code + Folosește metadata volatilă din fișiere PE pentru TSO + Hack‑uri speciale SMC + JIT pentru detectarea Mono + Ascunde bitul CPUID hypervisor (util pentru aplicații care crash‑uiesc cu el) + Dezactivează cache‑ul L2 al JIT‑ului FEXCore (economisește memorie, dar introduce stutter) + Face cache‑ul L1 al JIT‑ului FEXCore dinamic (economisește memorie, dar introduce stutter) + Emulează X87 cu precizie 64‑bit (reduce acuratețea și poate cauza bug‑uri vizuale) + Numărul maxim de instrucțiuni per bloc de traducere (mai mare = performanță mai bună, stabilitate mai mică) + Reia + Pauză + + + Creează shortcut + Etichetă + Icon + Creează + Actualizează acum + Mutare fișiere + Este necesară conexiune la internet pentru instalare + Instalarea doar prin WiFi este activată + Progres instalare + Se descarcă... + Se calculează... + Descărcarea a eșuat. Încearcă din nou. + Actualizare disponibilă + Informații joc + Stare + Dimensiune + Locație + Dezvoltator + Data lansării + Instalat + Se instalează + Neinstalat + Deschide + Partajat în familie + Jocuri + Timp jucat în ultimele 2 săptămâni: %s ore + Timp total jucat: %s ore + Adaugă joc personalizat + Adaugă joc personalizat + Selectează folderul care conține fișierele jocului pe care vrei să-l adaugi ca joc personalizat. + Nu mai arăta acest dialog + Shortcut creat + Nu s-a putut crea shortcut-ul: %s + Imaginile au fost preluate cu succes + Folderul jocului nu a fost găsit + Nu s-au putut prelua imaginile: %s + Exportat + Exportul a eșuat: %s + Export anulat + + + + Instalat + Joc + Aplicație + Instrument + Demo + Familie + + + Fără conexiune la Steam + Reîncearcă conectarea la Steam + Continuă offline + + + + Cum a rulat jocul? + Selectează orice probleme întâlnite: + + + + Ajutor & Suport + Hall of Fame + Mergi online + Mergi offline + Deconectare + Închide + + + Variabilă de mediu nouă + Nume + Valoare + Nu mai există variabile cunoscute + Variantă container + Versiune Wine + Argumente exec + Exemplu: -dx11 + Limbă + Dimensiune ecran + Lățime + Înălțime + Driver audio + Afișează FPS + Sigur vrei să elimini driverul „%s”? Această acțiune nu poate fi anulată + + + Folosește DRM vechi + Forțează DLC + Activează doar dacă DLC‑urile nu sunt detectate sau salvările cu DLC nu funcționează + Pornește Steam Client (Beta) + Reduce performanța și încetinește lansarea\nPermite joc online și rezolvă probleme DRM și controller\nNu toate jocurile funcționează + Permite actualizările Steam + Actualizează Steam la ultima versiune. Reduce semnificativ performanța. + Tip Steam + + + Driver grafic + Versiune driver grafic + Extensii Vulkan expuse + Memorie maximă dispozitiv + Folosește Adrenotools Turnip + Versiune Vulkan + Dimensiune cache imagini + Wrapper DX + Nivel funcțional VKD3D + Folosește DRI3 + Dezactivarea poate rezolva artefacte grafice pe unele dispozitive + Sincronizează fiecare cadru + Dezactivează KHR_present_wait + Moduri de prezentare + Tip resursă de memorie + Emulare BCn + Tip emulare BCn + Cache emulare BCn + Boost claritate + Nivel claritate + Reducere zgomot claritate + + + Versiune FEXCore + Presetare FEXCore + Mod TSO + Mod x87 + Multiblock + Emulator 64‑bit + Emulator 32‑bit + Versiune Box64 + Presetare Box64 + Presetări Box64 + Presetări FEXCore + Vizualizează, modifică și creează presetări FEXCore + Nume presetare + + + Folosește API SDL + Activează API XInput + Activează API DirectInput + Tip mapper DirectInput + Dezactivează input-ul de mouse + Mod touchscreen + Mișcare directă touch-la-cursor (ON) vs mișcare relativă tip touchpad (OFF) + Pornește cu controalele pe ecran ascunse + Controalele pe ecran vor fi ascunse la pornirea jocului. Le poți comuta din meniul de navigare. + Emulează tastatura și mouse-ul + Stick stânga = WASD, stick dreapta = mouse. L2 = click stânga, R2 = click dreapta. + + + Renderer + Nume GPU + Mod randare offscreen + Dimensiune memorie video + Activează CSMT (Command Stream Multi-Thread) + Activează calcule stricte pentru shadere + Override pentru mouse warp + + + Variabile de mediu + Nu există variabile de mediu + Nu există unități + Selecție la pornire + Afilieri procesor (aplicații 32-bit) + Cale executabil + ex.: path\\to\\exe + + + Orientări permise + Selectează canalele de debug Wine + CPU%d + Descrie ce s-a întâmplat + Obține suport pe Discord + + + Manager drivere + Selectează un driver + Importă ZIP de pe dispozitiv + + + Manager conținut + Importă .wcp de pe dispozitiv + Se lucrează... + Selectat + Conținut selectat + Conținut instalat + Selectează tipul + Fișiere nesigure detectate + Instalează oricum + Elimini conținutul? + Sigur vrei să elimini %1$s (%2$s)? + + + Limbă + Selectează limba + Este necesară repornirea + Schimbarea limbii necesită repornirea aplicației. Vrei să continui? + Repornește + Se schimbă limba și se repornește... + + + Emulare + Orientări permise + Alege orientările în care se poate roti în timpul jocului + Modifică configurația implicită + Setările inițiale ale containerului pentru fiecare joc (nu afectează jocurile deja instalate) + Config container implicit + Presetări Box64 + Vizualizează, modifică și creează presetări Box64 + Manager drivere + Instalează sau elimină pachete de drivere grafice personalizate + Manager conținut + Instalează componente suplimentare (.wcp) + Manager Wine/Proton + Importă versiuni personalizate de Wine/Proton (doar Bionic) + + + Depanare + Selectează canalele de debug Wine + Activează logurile de debug Wine + Scrie ieșirea de debug Wine în fișier + Activează logurile Box86/64 + Scrie ieșirea de debug Box86 & Box64 în fișier + Vezi ultimul crash + Vezi logul de debug al jocului + Șterge preferințele + [Închide aplicația] Deconectează clientul și șterge datele locale de preferințe. + Șterge baza de date locală + [Închide aplicația] Poate ajuta la rezolvarea problemelor cu elementele din bibliotecă sau mesaje. + Șterge cache-ul de imagini + Elimină toate imaginile încărcate. + + + Informații + Trimite un tip + Contribuie la dezvoltarea continuă + Cere tip la pornire + Oprește afișarea mesajului de tip + Cod sursă + Vezi codul sursă al acestui proiect + Biblioteci utilizate + Vezi tehnologiile care fac GameNative posibil + Politica de confidențialitate + Deschide linkul către politica de confidențialitate GameNative + + + Interfață + Deschide linkurile web extern + Linkurile se deschid în browserul principal + Ascunde bara de stare în afara jocului + Ascunde bara de stare Android în lista de jocuri, setări etc. Aplicația se va reporni după modificare. + Stil iconițe + Descarcă doar prin Wi‑Fi + Previne descărcările pe date mobile + Scrie pe stocare externă + Nu a fost detectată stocare externă + Salvează jocurile pe stocare externă + Volum de stocare + Server de descărcare Steam + Este necesară repornirea + + + Descărcări + + + GameNative + Politica de confidențialitate + Bine ai revenit + Autentifică-te pentru a accesa biblioteca ta Steam + Nume utilizator + Parolă + Ține-mă minte + Autentificare + Cod QR eșuat + Reîncearcă codul QR + Deschide aplicația Steam pe mobil și scanează acest cod QR pentru autentificare instantă + + + Continuă + Setări + + + Se conectează la serverele remote... + + + Aplicația care se instalează necesită %1$s spațiu, dar doar %2$s sunt disponibili pe acest dispozitiv + Aplicația care se instalează are următoarele cerințe de spațiu. Vrei să continui?\n\n\tDimensiune descărcare: %1$s\n\tDimensiune pe disc: %2$s\n\tSpațiu disponibil: %3$s + Sigur vrei să anulezi descărcarea aplicației? + Ștergi toate datele descărcate pentru acest joc? + Sigur vrei să ștergi această aplicație? + Descarcă & Instalează ImageFS + Imaginea Ubuntu trebuie descărcată și instalată înainte de a putea edita configurația. Această operațiune poate dura câteva minute. Vrei să continui? + Instalează ImageFS + Imaginea Ubuntu trebuie instalată înainte de a putea edita configurația. Această operațiune poate dura câteva minute. Vrei să continui? + Resetează containerul + Aceasta va reseta containerul la configurația implicită. + Verifică fișierele + Asigură-te că salvările sunt încărcate în cloud sau copiate înainte de verificare, altfel pot fi suprascrise. + Actualizare + Asigură-te că salvările sunt încărcate în cloud sau copiate înainte de actualizare, altfel pot fi suprascrise. + Config %s + + + Permisiune de stocare necesară + Exportat + Export eșuat: %s + Export anulat + Shortcut creat + Nu s-a putut crea shortcut-ul: %s + Sincronizare cloud finalizată cu succes + Fișierele de salvare sunt deja la zi + Sincronizare cloud eșuată: %s + + + Este necesar internet pentru instalare + Instalare doar prin Wi‑Fi/LAN activată + Fișier %1$d din %2$d + + + Actualizare disponibilă + O nouă versiune (%1$s) este disponibilă!%2$s + Actualizează + Mai târziu + Actualizare eșuată + Nu s-a putut descărca sau instala actualizarea. Încearcă din nou mai târziu. + + + Crash recent + Ne pare rău!\nAr fi util să știm ce problemă ai întâmpinat.\nPoți vizualiza și exporta cel mai recent crash log din setările aplicației și îl poți atașa ca issue pe Github în repository-ul proiectului.\nLinkul către repo este tot în setări! + Mulțumim că folosești GameNative! + Susține gaming-ul open‑source pe Android distribuind aplicația prietenilor sau devenind membru pe Ko‑fi. + Alătură-te pe Ko‑fi + Distribuie + A funcționat jocul? + Alătură-te serverului de Discord pentru suport și optimizare. + Deschide Discord + + + Se descarcă Steam... + Se instalează componente glibc... + Se instalează componente Bionic... + Se încarcă... + + + Aplicația rulează + Ești autentificat pe un alt dispozitiv care deja rulează %s.\nPoți juca în continuare, dar asta va deconecta cealaltă sesiune din Steam. + Ești autentificat pe un alt dispozitiv (%1$s) care rulează deja %2$s (%3$s), iar salvarea nu este încă în cloud.\nPoți juca în continuare, dar asta va deconecta cealaltă sesiune și poate crea conflicte de salvare când progresul este sincronizat. + Joacă oricum + + + Conflict de salvare + Există o salvare nouă locală și una nouă remote. Pe care vrei să o păstrezi?\n\nSalvare locală:\n\t%1$s\nSalvare remote:\n\t%2$s + Păstrează locală + Păstrează remote + + + Operația de sincronizare durează prea mult. Încearcă să lansezi jocul din nou peste puțin timp. + Sincronizarea este în desfășurare. Încearcă din nou peste puțin timp. + Sincronizarea fișierelor de salvare a eșuat: %s. + + + Upload în curs + Ai jucat %1$s pe dispozitivul %2$s (%3$s), iar salvarea acelei sesiuni încă se încarcă.\nÎncearcă mai târziu. + Upload în așteptare + Ai jucat %1$s pe dispozitivul %2$s (%3$s), iar salvarea nu este încă în cloud (upload neînceput).\nPoți juca în continuare, dar pot apărea conflicte când progresul anterior se va încărca. + Sesiunea aplicației a fost suspendată. Repornește aplicația. + Au fost primite operații remote cu tipul „none”. Repornește aplicația. + Există multiple operații remote în așteptare. Încearcă mai târziu. Repornește aplicația. + + + Configurează containerul + Modificări nesalvate + Sigur vrei să renunți la modificări? + General + Grafică + Emulare + Controller + Wine + Componente Win + Mediu + Unități + Avansat + Versiune VKD3D + Cale executabil + ex.: path\\to\\exe + + + Biblioteci utilizate + Pluvia - github.com/oxters168/Pluvia\nJavaSteam - github.com/Longi94/JavaSteam\nWinlator & Vortek - github.com/brunodev85/winlator\nWinlator Cmod - github.com/coffincolors/winlator\nWrapper - https://github.com/leegao/bionic-vulkan-wrapper & https://github.com/pipetto-crypto/\nUbuntu RootFs - releases.ubuntu.com/focal + + + Credite artă + Icon aplicație: Hachi + Icon alternativ: rhapsody_mdr + Se încarcă susținătorii… + Membri + Susținători + Niciun susținător încă. + Anonim + + + Funcția de chat este încă într-un stadiu incipient.\nTe rugăm să raportezi orice problemă în repo-ul proiectului. + Nu există istoric de chat + Trimite un mesaj + Trimite + Emoticoane + Stickere + + + Deschide + Instalat + Se instalează + Neinstalat + Partajat în familie + Compatibil + Necunoscut + Necompatibil + + + Config cunoscut funcționează pe GPU-ul tău + Config cunoscut ar trebui să funcționeze pe GPU-ul tău + Config cunoscut poate funcționa pe GPU-ul tău + Nicio configurație cunoscută + + + Cea mai bună configurație a fost aplicată cu succes + Config cunoscut invalid + Nu există o configurație optimă pentru acest joc + Aplicarea configurației a eșuat: %s + Tip aplicație + Stare aplicație + Aspect + Listă + Capsulă + Hero + Caută jocurile tale… + Căutare + Șterge căutarea + Niciun element pentru selecția curentă + Filtre + %1$d jocuri • %2$d instalate + Steam + Jocuri personalizate + Aspect + + + Autentificare + Cod de verificare + Introdu codul de 5 caractere + + + Se validează conținutul… + Fișierul nu poate fi recunoscut + Profilul nu a fost găsit în conținut + Profilul nu poate fi recunoscut + Conținutul există deja + Conținut incomplet + Conținutul nu poate fi considerat sigur + Spațiu insuficient + Nu s-a putut instala conținutul + Acest conținut include fișiere în afara setului de încredere. + Instalează componente suplimentare (.wcp: tar.xz/zst) + Tip + Versiune + Cod + Descriere + Toate fișierele sunt sigure. Gata de instalare. + Nu există conținut instalat pentru acest tip. + Șterge + Acest conținut include fișiere în afara setului de încredere. Revizuiește și confirmă pentru a continua. + Eliminat %1$s + + + Nu s-a putut încărca manifestul driverului: %1$d + Eroare la încărcarea manifestului driverului: %1$s + Conexiunea a expirat. Verifică rețeaua și încearcă din nou. + Eroare de rețea: %1$s + + + Implicit + Alternativ + Încet + Mediu + Rapid + Foarte rapid + Viteză descărcare + Vitezele mari pot cauza încălzirea dispozitivului în timpul descărcărilor + Implicit + Se salvează setările și se repornește… + + + Setări + + + Conținut instalat cu succes + Instalarea conținutului a eșuat + Eroare la instalare: %1$s + + + Gata + + + Manager Wine/Proton + Doar imagini Bionic + Importă versiuni Wine sau Proton personalizate pentru containere Bionic. Numele fișierului trebuie să înceapă cu „wine” sau „proton” (fără diferență între majuscule/minuscule). Pachetele trebuie să includă bin/, lib/ și prefixPack.txz. Toate importurile sunt compatibile doar cu Bionic. + De exemplu: "proton-10.0-ARM64ec.wcp" + Importă pachet Wine/Proton + Selectează un fișier .wcp (cu numele începând cu „wine” sau „proton”) + Importă pachet .wcp + Se procesează... + Detalii pachet + Tip + Versiune + Cod versiune + Cale bin + Cale lib + Descriere + ✓ Toate fișierele sunt sigure. Gata de instalare. + Instalează pachetul + Versiuni Wine/Proton instalate + Nu au fost găsite versiuni Wine sau Proton instalate. + Șterge + Acest pachet include fișiere în afara setului de încredere. Revizuiește și confirmă pentru a continua instalarea. + Fișiere nesigure: + Elimină versiunea Wine/Proton + Sigur vrei să elimini %1$s %2$s (%3$d)? Containerele care folosesc această versiune nu vor mai funcționa. + Eliminat %s + Eliminare eșuată: %s + Anulezi importul? + Un import este în desfășurare. Anularea va șterge toate fișierele extrase și va trebui să reiei importul.\n\nSigur vrei să anulezi? + Da, anulează importul + Nu, păstrează importul + + + Se extrage și validează pachetul (poate dura 2-3 minute pentru fișiere mari)... + Numele fișierului trebuie să înceapă cu „wine” sau „proton” (fără diferență între majuscule/minuscule) + Fișierul este gol sau nu poate fi citit + Nu se poate deschide fișierul + Nu s-a putut deschide selectorul de fișiere: %s + Fișierul nu poate fi recunoscut ca arhivă validă + profile.json nu a fost găsit în pachet + profile.json este invalid + Această versiune Wine/Proton există deja + Pachetul nu conține fișierele necesare (bin/, lib/ sau prefixPack.txz) + Pachetul nu poate fi considerat sigur + Spațiu de stocare insuficient + A apărut o eroare necunoscută + Nu s-a putut instala pachetul Wine/Proton + Pachetul nu este Wine sau Proton (tip: %s) + Numele indică %1$s dar pachetul conține %2$s + Acest pachet include fișiere în afara setului de încredere. + Versiunea Wine/Proton există deja + Instalare eșuată: %s + Eroare la instalare: %s + %1$s %2$s instalat cu succes + Această versiune Wine/Proton necesită containere GLIBC și nu este compatibilă cu GameNative. Folosește doar build-uri ARM64/Bionic. + Containere care folosesc această versiune: + Niciun container nu folosește această versiune. + Aceste containere nu vor mai funcționa dacă continui: + + + Integrare GOG (Alpha) + Autentificare GOG + Autentifică-te în contul tău GOG + Se sincronizează… + Eroare: %1$s + ✓ S-au sincronizat %1$d jocuri + Preia biblioteca ta de jocuri GOG + Autentificare reușită + Ești acum autentificat în GOG.\nBiblioteca ta va fi sincronizată în fundal. + + + Autentificare GOG + Apasă „Open GOG Login” și autentifică-te. După autentificare, copiază URL-ul și lipește-l mai jos + Exemplu: https://embed.gog.com/on_login_success?origin=client&code=aaa + Open GOG Login + Cod de autorizare sau URL de succes + Lipește codul sau URL-ul aici + Autentificare + Anulează + Nu s-a putut deschide browserul + + + Deconectare + Deconectează-te din contul tău GOG + Te deconectezi de la GOG? + Aceasta va elimina datele tale GOG și va șterge biblioteca GOG de pe acest dispozitiv. Te poți autentifica din nou oricând. + Deconectare + Deconectare reușită de la GOG + Deconectare eșuată: %s + Se deconectează de la GOG… +
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000000..d56e51ba4c --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,973 @@ + + GameNative + Вхід користувача + Двофакторна автентифікація + Домівка + Налаштування + Вхід за допомогою QR-коду + Бібліотека + Завантаження + Невідомий застосунок + Невірний код, спробуйте знову. + Підтвердіть вхід, за допомогою мобільного застосунку Steam… + Введіть код двофакторної автентифікації. + Введіть код, відправлений на пошту: %s + Застосунок, що інсталюється, має такі вимоги до обсягу пам\'яті. Продовжити?\n\n\tРозмір завантаження: %1$s\n\tРозмір на диску: %2$s\n\tДоступне місце: %3$s + Розмір завантаження: %1$s\nРозмір на диску: %2$s\nДоступне місце: %3$s + Для інсталяції застосунка потрібно %1$s вільного місця, але на цьому пристрої доступно лише %2$s + Ви впевнені, що хочете скасувати завантаження застосунка? + Видалити всі завантажені дані цієї гри? + Завантажити та інсталювати ImageFS + Образ Ubuntu необхідно завантажити та інсталювати перед редагуванням конфігурації. Ця операція може тривати кілька хвилин. Продовжити? + Інсталювати ImageFS + Образ Ubuntu необхідно інсталювати перед редагуванням конфігурації. Ця операція може тривати кілька хвилин. Продовжити? + Скинути контейнер + Це скине налаштування Вашого контейнера до стандартної конфігурації. + Скинути контейнер? + Скинути + Перевірити файли + Будь ласка, переконайтеся, що ваші збереження завантажено у хмару або створено їхню резервну копію перед перевіркою, оскільки інакше вони можуть бути перезаписані. + Оновити + Будь ласка, переконайтеся, що ваші збереження завантажено у хмару або створено їхню резервну копію перед оновленням, оскільки інакше вони можуть бути перезаписані. + Успішна синхронізація з хмарою + Файли збереження вже актуальні + Помилка синхронізації з хмарою: %s + Ви повинні увійти в Steam, щоб використовувати цю функцію + Необхідний дозвіл на доступ до сховища + Контейнер скинуто до стандартних налаштувань + ImageFS інстальовано. Будь ласка, спробуйте редагувати контейнер знову. + Не вдалося інсталювати ImageFS: %s + Деінсталювати гру + Ви впевнені, що хочете деінсталювати %1$s? Цю дію не можна скасувати. + %1$s деінстальовано + Помилка деінсталяції гри + Ніколи + Продовжити + Зображення заголовка застосунку + Завантажити + Інсталювати + Інсталювати + Видалити + Деінсталювати + Грати + Деінсталювати гру + Ви впевнені, що хочете деінсталювати %1$s? Цю дію не можна скасувати. + Завантажити гру + Гра має наступні вимоги до простору. Бажаєте продовжити?\n\n\tРозмір завантаження: %1$s\n\tДоступний простір: %2$s + Інсталювати застосунок + Розрахунок необхідного місця... + Деінсталювати застосунок + Скасувати завантаження + ОК + Так + Ні + Недостатньо місця + Продовжити + Скасувати + Без назви + Підтвердити видалення + Бібліотека + Завантаження + Друзі + + + Власні ігри + Шляхи не додано + Надати дозвіл + Додано з бібліотеки + Ще немає власних ігор. + Видалити шлях + Видалити цей шлях зі сканування? Вміст папки залишиться на диску. + Видалити шлях + Шлях видалено зі списку. Вміст не було видалено. + Видалити власну гру + Видалити цю додану вручну папку з вашої бібліотеки? Це не видалить файли на диску. + Видалити папку, додану вручну + Папку, додану вручну, видалено з бібліотеки. + ⚠ Немає доступу (перевірте, чи існує шлях) + ⚠ У доступі відмовлено + Знайдено 0 папок + Знайдено папок: %d + Папки за цими шляхами скануються на наявність .exe файлів і відображаються як власні ігри. Це може сповільнити запуск застосунку. + Не вдалося визначити шлях до папки + Потрібно вибрати виконуваний файл + Ця гра має кілька виконуваних файлів. Відкрийте налаштування користувацької гри, щоб вибрати, який з них запускати. + Налаштування власної гри + Видалити гру + Ви впевнені, що хочете видалити %1$s? + Невідомий + + + Вимкнено + Копіювати + Стабільність + Сумісність + Збалансований + Продуктивність + Unity + Unity Mono Bleeding Edge + Ubuntu FS + + + Direct3D + DirectSound + DirectMusic + DirectPlay + DirectShow + DirectX + Visual C++ 2010 + Декодер Windows Media + OpenGL + Версія DXVK + Запустити + Змінити + Видалити + Додати + Видалити + Копіювати з + ОК + Встановити власну роздільну здатність + x + Ширина та висота повинні бути більшими за 0 + Ширина повинна бути більшою за висоту + Інформація про сховище + Дублювати + Переналаштувати + Інформація про вміст + Гра не інстальована + Гра %s не інстальована. Будь ласка, інсталюйте гру перед запуском. + Зберегти налаштування контейнера? + Тимчасові налаштування були застосовані для цього запуску. Зберегти їх? + Помилка синхронізації + Зберегти + Вийти + Ярлики + Контейнери + Box64 RCFile + Вміст + Про програму + Відкрити файл + Завантажити файл + Спорідненість процесора + Перенести на передній план + Завершити процес + Новий файл + Додати на головний екран + Перемкнути повноекранний режим + Перемкнути орієнтацію екрана + Диспетчер завдань + Лупа + Журнали + Вийти з гри + Вийти + Клавіатура + Додатково + Очистити пошук + Кнопки + Основні кнопки + Плечові кнопки + Кнопки меню + Кнопки стіків + Лівий стік + Правий стік + D-Pad + D-Pad вгору + D-Pad вниз + D-Pad вліво + D-Pad вправо + Елементи керування введенням + Редагувати екранний контролер + Редагувати фізичний контролер + Від\'єднано + Скинути екранні елементи керування + Відкрити меню навігації + Довідка сенсорної панелі + Приховувати екранні елементи з контролером + Автоматично приховувати екранні елементи керування, коли під\'єднано фізичний контролер + + + Вибрати призначення + Призначити: %1$s + Поточне: %1$s + Пошук призначень… + Пошук… + Пошук + Категорія + Клавіатура + Миша + Геймпад + Миша + Геймпад + Немає + Очистити призначення + Призначень не знайдено + + + Редагувати %1$s + Позиція: (%1$d, %2$d) • Розмір: %3$.2fx + Вигляд + %.2fx + Текст підпису + Власний текст для цієї кнопки + Тип елемента + Змінити тип цього елемента керування + Форма + Зовнішній вигляд + %1$s обмежено формою %2$s + Призначення + Призначення (Автоматичні) + Призначення кнопок діапазону генеруються автоматично + Основна дія + Додаткова дія + Вгору + Вправо + Вниз + Вліво + (Миша) + Слот %1$d + Кнопки підтримують до 2 призначень: основне (натискання) та додаткове (довге натискання) + Властивості + Позиція + X: %1$d, Y: %2$d + Незбережені зміни + У вас є незбережені зміни. Ви хочете зберегти чи скасувати їх? + Налаштувати розмір + Скинути + Готово + Копіювати розмір з елемента + Немає інших елементів для копіювання розміру + Скопійовано розмір: %.2fx + Екранні елементи керування скинуто до стандартних + Швидкі пресети + WASD + Стрілки + Миша + D-Pad + Лівий стік + Правий стік + + + Редактор призначень фізичного контролера + Налаштувати призначення кнопок для вашого фізичного контролера + Основні кнопки + Кнопка A + Нижня кнопка (Підтвердити) + Кнопка B + Права кнопка (Назад) + Кнопка X + Ліва кнопка + Кнопка Y + Верхня кнопка + Плечові кнопки + L1 / LB + Лівий бампер + R1 / RB + Правий бампер + L2 / LT + Лівий тригер + R2 / RT + Правий тригер + Кнопки меню + Start / Options + Select / View / Share + Аналогові стіки + Кнопки стіків + L3 (Натискання лівого стіка) + R3 (Натискання правого стіка) + Лівий аналоговий стік + Лівий стік вгору + Лівий стік вниз + Лівий стік вліво + Лівий стік вправо + Правий аналоговий стік + Правий стік вгору + Правий стік вниз + Правий стік вліво + Правий стік вправо + Інше + Home / Guide / PS + Скинути до стандартних призначень + Не задано + + + Елементи керування скинуто + Не вдалося зберегти logcat у місце призначення + + + Зберігає знімок logcat лише для PID цього застосунку + Зберегти logcat + +
+ 0 : Обробляє CALL/RET так, ніби їм ніколи не потрібні прапорці (швидше, нестабільно)
+ 1 : Більшості RET потрібні прапорці, більшості CALL — ні
+ 2 : Усім CALL/RET потрібні прапорці (повільніше) + ]]>
+ Створює -NAN, як на x86 + Створює точне округлення x86 + Використовує Float/Double для x87 емуляції +
+ 0 : Не намагатися будувати блок максимально великим
+ 1 : Будувати блок Dynarec максимально великим
+ 2 : Будувати ще більший блок Dynarec (продовжувати при накладанні блоків, лише для пам\'яті elf)
+ 3 : Будувати ще більший блок Dynarec (продовжувати при накладанні блоків, для всіх типів пам\'яті) + ]]>
+
+ 0 : Не робити нічого особливого
+ 1 : Увімкнути деякі бар\'єри пам\'яті при записі в пам\'ять (на деяких опкодах MOV)
+ 2 : Все з пункту 1, плюс бар\'єр пам\'яті при кожному записі в пам\'ять через MOV
+ 3 : Все з пункту 2, плюс бар\'єр пам\'яті при читанні з пам\'яті та на деяких опкодах SSE/SSE2 + ]]>
+
+ 0 : Використовувати звичайні безпечні бар\'єри
+ 1 : Використовувати слабкі бар\'єри для невеликого приросту продуктивності
+ 2 : Використовувати слабкі бар\'єри, додатково вимкнути бар\'єри останнього запису]]>
+
+ 0 : Генерувати код обробки невирівняних атоміків.
+ 1 : Генерувати лише вирівняні атоміки (швидший і менший код, але викличе SIGBUS для опкодів з префіксом LOCK на вирівняних адресах).]]>
+
+ 0 : Вимкнути використання відкладених прапорців.
+ 1 : Увімкнути використання відкладених прапорців.]]>
+
+ 0 : Не дозволяти виконання.
+ 1 : Дозволити продовжити виконання dynablock, що пише дані на ту саму сторінку, що й код. Пришвидшує завантаження деяких ігор, але може викликати збої.
+ 2 : Також, при виявленні HotPage, позначає її як NEVERCLEAN (вона не буде захищена від запису, але блоки з цієї сторінки завжди перевірятимуться). Це може бути швидше (але деякі випадки SMC можуть не перехоплюватися).]]>
+
+ 0 : Не використовувати рідні прапорці.
+ 1 : Використовувати рідні прапорці, коли це можливо.]]>
+
+ 0 : Ігнорувати інструкцію x86 PAUSE.
+ 1 : Використовувати YIELD для емуляції x86 PAUSE.
+ 2 : Використовувати WFI для емуляції x86 PAUSE.
+ 3 : Використовувати SEVL+WFE для емуляції x86 PAUSE. + ]]>
+
+ 0 : Вимкнути розширення AVX
+ 1 : Увімкнути розширення AVX, BMI1, F16C та VAES.
+ 2 : Все з пункту 1, плюс увімкнути AVX2, BMI2, FMA, ADX, VPCLMULQDQ та RDRAND.
+ ]]>
+ Максимальна кількість процесорів, що відображається для програм через box64 + Виявляти UnityPlayer.dll і застосовувати налаштування strongmem + Примусово розміщувати всі виділення пам\'яті в 32-бітному адресному просторі + Визначає максимальне значення випередження при побудові блоку (Block) + Оптимізація опкодів CALL/RET + Визначає, чи чекатиме Dynarec на готовність FillBlock + Вмикає операції TSO IR (потрібно для багатопотокових програм). + Робить векторні завантаження/збереження атомарними, коли увімкнено TSO. + Використовує напівбар\'єрні атоміки для невирівняних завантажень/збережень при TSO. + Робить REP MOVS / REP STOS атомарними при TSO. + Вмикає компіляцію мультиблоків (multiblock). Може збільшити час JIT-компіляції та спричинити ривки. + Керує примусовим увімкненням функцій CPU. + Масштабує TSC на системах з низькою частотою. + Перевірки коду, що модифікує сам себе (SMC). + Використовує volatile метадані з PE-файлів для TSO, коли це можливо. + Спеціальні хаки SMC + JIT блоків для виявлення Mono. + Приховує біт гіпервізора в CPUID (корисно для програм, що вилітають через нього). + Вимикає пошук у L2-кеші FEXCore JIT; економить пам\'ять, але викликає ривки. + Робить розмір L1-кешу FEXCore JIT динамічним; економить пам\'ять, але викликає ривки. + Емулює операції з плаваючою комою X87 з 64-бітною точністю. Це знижує точність емуляції та може спричинити помилки рендерингу. + Максимальний ліміт інструкцій на блок трансляції. Вищі значення можуть покращити продуктивність, але знизити стабільність. + + Продовжити + Призупинити + + + Створити ярлик + Позначка + Значок + Створити + Оновити зараз + Переміщення файлів + Потрібне інтернет-з\'єднання для інсталяції + Увімкнено інсталяцію лише через Wi-Fi + Прогрес інсталювання + Завантаження... + Розрахунок... + Помилка завантаження. Спробуйте знову. + Доступне оновлення + Інформація про гру + Статус + Розмір + Розташування + Видавець + Дата виходу + Інстальовано + Інсталяція + Не інстальовано + Відкрити + Сімейна бібліотека + Ігри + Награно за останні два тижні: %s годин + Всього награно: %s годин + Додати власну гру + Додати власну гру + Будь ласка, виберіть папку з файлами гри, яку ви хочете додати як власну гру. + Більше не показувати + Ярлик створено + Не вдалося створити ярлик: %s + Зображення успішно отримано + Папку гри не знайдено + Не вдалося отримати зображення: %s + Вивантажено + Не вдалося вивантажити: %s + Вивантаження скасовано + + + + Інстальовані + Ігри + Програми + Інструменти + Демо + Сімейна бібліотека + + + Немає з\'єднання зі Steam + Спробувати знову + Продовжити офлайн + + + + Як працювала гра? + Оберіть знайдені проблеми: + + + + Довідка та підтримка + Зал слави + Увійти в мережу + Вийти з мережі + Вийти з акаунта + Закрити + + + Нова змінна середовища + Назва + Значення + Більше немає відомих змінних + Варіант контейнера + Версія Wine + Параметри запуску + Наприклад: -dx11 + Мова + Розмір екрана + Ширина + Висота + Аудіо драйвер + Показати FPS + Ви впевнені, що хочете видалити драйвер «%s»? Цю дію не можна скасувати. + + + Попередній метод DRM + Примусити DLC + Увімкнути лише тоді, якщо не виявлено DLC або не працюють збереження з DLC + Запустити клієнт Steam (Бета) + Зменшує продуктивність та сповільнює запуск.\nДозволяє онлайн гру, а також вирішує проблему DRM та контролеру.\nПрацює не з усіма іграми + Дозволити оновлення клієнту Steam + Оновлює клієнт Steam до останньої версії. Значно зменшує продуктивність. + Версія Steam + + + Графічний драйвер + Версія графічного драйвера + Відкриті розширення Vulkan + Максимальна пам\'ять пристрою + Використовувати Adrenotools Turnip + Версія Vulkan + Розмір кешу + DX Wrapper + Рівень функцій VKD3D + Використовувати DRI3 + Вимкнення параметра може виправити графічні проблеми на деяких пристроях + Синхронізувати кожен кадр + Вимкнути KHR_present_wait + Режими відображення + Тип ресурсу пам\'яті + Емуляція BCn + Тип емуляції BCn + Кеш емуляції BCn + Підсилення чіткості + Рівень чіткості + Зменшення шуму + + + Версія FEXCore + Пресет FEXCore + Режим TSO + Режим x87 + Мультиблок + 64-бітний емулятор + 32-бітний емулятор + Версія Box64 + Пресет Box64 + Пресети Box64 + Пресети FEXCore + Перегляд, зміна та створення пресетів FEXCore + Назва пресета + + + Використовувати SDL API + Увімкнути XInput API + Увімкнути DirectInput API + Тип мапера DirectInput + Вимкнути введення мишею + Режим сенсорного екрану + Пряме переміщення курсора (УВІМК) або відносне переміщення, як на тачпаді (ВИМК) + Запускати з прихованими елементами керування + Екранні елементи будуть приховані під час запуску гри. Перемикайте через меню навігації. + Емуляція клавіатури та миші + Лівий стік = WASD, Правий стік = Миша. L2 = ЛКМ, R2 = ПКМ. + + + Рушій візуалізації + Назва GPU + Режим позаекранного рендерингу + Розмір відеопам\'яті + Увімкнути CSMT (Command Stream Multi-Thread) + Увімкнути сувору математику шейдерів + Примусове позиціонування курсора + + + Змінні середовища + Немає змінних середовища + Немає дисків + Налаштування при запуску + Спорідненість процесора (32-bit програми) + Шлях .exe файлу + Наприклад path\\to\\exe + + + Дозволені орієнтації екрану + Обрати канали налагодження Wine + CPU%d + Опишіть, що сталось + Отримати допомогу в Discord + + + Менеджер драйверів + Обрати драйвер + Імпортувати ZIP-файл з пристрою + + + Менеджер вмісту + Імпортувати .wcp з пристрою + Виконання... + Обраний + Обраний вміст + Інстальований вміст + Обрати тип файлу + Помічено ненадійні файли + Всеодно інсталювати + Видалити вміст? + Ви впевнені, що хочете видалити %1$s (%2$s)? + + + Мова + Обрати мову + Необхідний перезапуск + Зміна мови потребує перезапуску. Продовжити? + Перезапустити + Зміна мови та перезапуск... + + + Емуляція + Дозволені орієнтації екрану + Виберіть можливі орієнтації екрана під час гри. + Змінити стандартну конфігурацію + Стандартні налаштування контейнера для кожної гри (не впливає на вже інстальовані ігри) + Стандартні налаштування контейнера + Box64 Пресети + Перегляд, зміна та створення пресетів Box64 + Менеджер драйвера + Інсталювати або видалити пакети власних графічних драйверів + Менеджер вмісту + Інсталяція додаткових компонентів (.wcp) + Менеджер Wine/Proton + Імпорт власних версій Wine/Proton (лише Bionic) + + + Налагодження + Обрати канали налагодження Wine + Увімкнути журнали налагодження Wine + Записувати виведення налагодження Wine у файл + Увімкнути журнали Box86/64 + Записувати виведення налагодження Box86 та Box64 у файл + Переглянути останній збій + Переглянути журнал налагодження гри + Очистити налаштування + [Закриває застосунок] Вихід із клієнта та видалення локальних налаштувань. + Очистити локальну базу даних + [Закриває застосунок] Може допомогти виправити проблеми з елементами бібліотеки чи повідомленнями. + Очистити кеш зображень + Видалити всі завантажені зображення. + + + Інформація + Підтримати розробників + Сприяйте подальшому розвитку + Пропонувати підтримку розробників при запуску + (ВИМК) Приховує повідомлення з пропозицією підтримки + Вихідний код + Переглянути вихідний код застосунку + Використані бібліотеки + Дізнайтеся, завдяки яким технологіям працює GameNative + Політика конфіденційності + Відкриває посилання на Політику конфіденційності GameNative + + + Інтерфейс + Відкривати вебпосилання у зовнішньому браузері + Посилання відкриваються у вашому основному браузері + Приховувати рядок стану, коли гра не запущена + Приховує рядок стану Android у списку ігор, налаштуваннях тощо. Застосунок перезапуститься після зміни. + Стиль значку + Завантаження тільки через Wi-Fi + Запобігає завантаженню через мобільні дані + Записувати на зовнішнє сховище + Не виявлено зовнішнього сховища + Зберігати ігри на зовнішнє сховище + Обсяг сховища + Сервер завантаження Steam + Потрібен перезапуск + + + Завантаження + + + GameNative + Політика конфіденційності + З поверненням + Увійдіть, щоб отримати доступ до бібліотеки Steam + Ім\'я користувача + Пароль + Запам\'ятати мене + Увійти + Помилка QR-коду + Спробувати ще раз + Відкрийте мобільний застосунок Steam і відскануйте цей QR-код, щоб миттєво увійти. + + + Продовжити + Налаштування + + + + Підключення до віддалених серверів... + + + Для інсталяції застосунку потрібно %1$s пам\'яті, але на пристрої доступно лише %2$s. + Для інсталяції застосунку потрібні такі вимоги до місця. Бажаєте продовжити?\n\n\tРозмір завантаження: %1$s\n\tМісце на диску: %2$s\n\tДоступне місце: %3$s + Ви впевнені, що хочете скасувати завантаження цього застосунку? + Видалити всі завантажені дані для цієї гри? + Ви впевнені, що хочете видалити цей застосунок? + Завантажити та інсталювати ImageFS + Образ Ubuntu потрібно завантажити та інсталювати, перш ніж Ви зможете редагувати конфігурацію. Ця операція може тривати кілька хвилин. Бажаєте продовжити? + Інсталювати ImageFS + Образ Ubuntu потрібно інсталювати, перш ніж Ви зможете редагувати конфігурацію. Ця операція може тривати кілька хвилин. Бажаєте продовжити? + Скинути контейнер + Це скине налаштування Вашого контейнера до стандартної конфігурації. + Перевірити файли + Будь ласка, переконайтеся, що ваші збереження завантажено у хмару або створено їхню резервну копію, оскільки інакше вони можуть бути перезаписані. + Оновити + Будь ласка, переконайтеся, що ваші збереження завантажено у хмару або створено їхню резервну копію перед оновленням, оскільки інакше вони можуть бути перезаписані. + Конфігурація %s + + + Необхідний дозвіл на доступ до сховища + Вивантажено + Помилка вивантаження: %s + Вивантаження скасовано + Створено ярлик + Помилка створення ярлика: %s + Успішна синхронізація з хмарою + Файли збереження вже актуальні + Помилка синхронізації з хмарою: %s + + + Потрібне інтернет-з\'єднання для інсталяції + Увімкнено інсталяцію лише через Wi-Fi/LAN + Файл %1$d з %2$d + + + Доступне оновлення + Нова версія (%1$s) доступна!%2$s + Оновити + Пізніше + Помилка оновлення + Помилка завантаження або інсталяції оновлення. Будь ласка, спробуйте знову. + + + Нещодавній збій + Перепрошуємо!\nБуло б добре дізнатися про проблему, з якою Ви нещодавно зіткнулися.\nВи можете переглянути та експортувати останній журнал збою в налаштуваннях застосунку і додати його як проблему (issue) на GitHub у репозиторії проєкту.\nПосилання на репозиторій GitHub також знаходиться в налаштуваннях! + Дякуємо за користування GameNative! + Підтримайте open-source ПК-ігри на Android, поділившись застосунком із друзями або ставши учасником на Ko-fi. + Стати учасником на Ko-fi + Поділитися + Чи працювала гра? + Долучайтесь до нашого Discord, щоб отримати підтримку у виправленні чи покращенні роботи ігор. + Відкрити Discord + + + Завантаження Steam... + Інсталяція компонентів glibc... + Інсталяція компонентів Bionic... + Завантаження... + + + Застосунок працює + Ви авторизовані на іншому пристрої, де граєте у %s. Ви все одно можете грати в цю гру, але це завершить інший сеанс у Steam. + Ви авторизовані на іншому пристрої (%1$s), де граєте у %2$s (%3$s), і прогрес цієї гри ще не синхронізовано з хмарою. \nВи все одно можете грати в цю гру, але це завершить інший сеанс у Steam і може створити конфлікт збережень, коли прогрес того сеансу синхронізуватиметься + Все одно грати + + + Конфлікт збережень + Знайдено нове віддалене та нове локальне збереження. Яке з них залишити?\n\nЛокальне збереження:\n\t%1$s\nВіддалене збереження:\n\t%2$s + Залишити локальне + Залишити віддалене + + + Операція синхронізації триває занадто довго. Спробуйте запустити гру ще раз через деякий час. + Наразі триває синхронізація. Спробуйте ще раз за мить. + Помилка синхронізації файлів збереження: %s. + + + Триває вивантаження + Ви грали у %1$s на пристрої %2$s (%3$s), і збереження цього сеансу ще завантажуються.\nСпробуйте пізніше. + Очікування вивантаження + Ви грали у %1$s на пристрої %2$s (%3$s), і збереження цього сеансу ще не синхронізовано з хмарою (вивантаження не розпочато).\nВи все одно можете грати в цю гру, але це може створити конфлікт, коли ваш попередній прогрес успішно вивантажиться. + Сеанс застосунку призупинено. Будь ласка, перезапустіть застосунок. + Отримано відкладені віддалені операції з типом \'none\'. Будь ласка, перезапустіть застосунок. + Виявлено кілька відкладених віддалених операцій. Спробуйте пізніше. Будь ласка, перезапустіть застосунок. + + + Налаштувати контейнер + Незбережені зміни + Бажаєте вийти без збереження змін? + Загальні + Графіка + Емуляція + Контролер + Wine + Компоненти Windows + Середовище + Диски + Додаткові + Версія VKD3D + Шлях до виконуваного файлу + напр., path\\to\\exe + + + Використані бібліотеки + Pluvia - github.com/oxters168/Pluvia\nJavaSteam - github.com/Longi94/JavaSteam\nWinlator та Vortek - github.com/brunodev85/winlator\nWinlator Cmod - github.com/coffincolors/winlator\nWrapper - https://github.com/leegao/bionic-vulkan-wrapper та https://github.com/pipetto-crypto/\nUbuntu RootFs - releases.ubuntu.com/focal + + + Автори ілюстрацій + Іконка застосунку: Hachi + Альтернативна іконка застосунку: rhapsody_mdr + Завантаження прихильників… + Учасники + Прихильники + Прихильників поки немає. + Анонімно + + + Чат знаходиться у ранньому доступі\nБудь ласка, повідомляйте про проблеми у репозиторії проєкту. + Історія чату відсутня + Надіслати повідомлення + Надіслати + Смайли + Стікери + + + Відкрити + Інстальовано + Інсталяція + Не інстальовано + Сімейна бібліотека + Сумісно + Сумісність невідома + Несумісно + + + Відома конфігурація працює на вашому пристрої + Відома конфігурація має працювати на вашому пристрої + Відома конфігурація може працювати на вашому пристрої + Немає відомої конфігурації + + + Найкращу конфігурацію успішно застосовано + Відома конфігурація недійсна + Немає доступної найкращої конфігурації для цієї гри + Не вдалося застосувати конфігурацію: %s + Тип застосунку + Статус застосунку + Бібліотека + Список + Капсула + Герой + Шукати серед ваших ігор… + Пошук + Очистити пошук + Немає елементів, що відповідають вибору + Фільтри + %1$d ігор • %2$d інстальовано + Steam + Власні ігри + Макет + + + Вхід + Код підтвердження + Введіть 5-символьний код + + + Перевірка вмісту… + Файл не розпізнано + Профіль не знайдено у вмісті + Профіль не розпізнано + Вміст уже існує + Вміст неповний + Вміст є недовіреним + Недостатньо місця + Не вдається інсталювати пакет + Цей пакет містить файли, що не входять до надійного набору. + Інсталювати додаткові компоненти (.wcp: tar.xz/zst) + Тип + Версія + Код + Опис + Усі файли є надійними. Готово до інсталяції. + Немає інстальованого вмісту цього типу. + Видалити + Цей вміст містить файли, що не входять до надійного набору. Перегляньте та підтвердьте для продовження. + Видалено %1$s + + + Не вдалося завантажити маніфест драйвера: %1$d + Помилка завантаження маніфесту драйвера: %1$s + Час очікування з\'єднання вичерпано. Перевірте мережу та спробуйте ще раз. + Помилка мережі: %1$s + + + Стандартний + Альтернативний + Повільна + Середня + Швидка + Блискавична + Швидкість завантаження + Вищі швидкості можуть спричинити надмірний нагрів пристрою під час завантаження. + Стандартний + Збереження налаштувань та перезапуск… + + + Налаштування + + + Вміст успішно інстальовано + Не вдалося інсталювати вміст + Помилка інсталяції: %1$s + + + Готова + + Менеджер Wine/Proton + Лише образи Bionic + Імпортуйте власні версії Wine або Proton для контейнерів Bionic. Ім\'я файлу має починатися з \'wine\' або \'proton\' (без урахування регістру). Пакети повинні містити bin/, lib/ та prefixPack.txz. Усі імпортовані файли сумісні лише з bionic. + Наприклад: «proton-10.0-ARM64ec.wcp» + Імпортувати пакет Wine/Proton + Виберіть файл .wcp (ім\'я файлу має починатися з \'wine\' або \'proton\') + Імпортувати пакет .wcp + Обробка... + Деталі пакета + Тип + Версія + Код версії + Шлях до Bin + Шлях до Lib + Опис + ✓ Усі файли є надійними. Готово до інсталяції. + Інсталювати пакет + Інстальовані версії Wine/Proton + Не знайдено інстальованих версій Wine або Proton. + Видалити + Цей пакет містить файли, що не входять до набору надійних. Перегляньте та підтвердьте для продовження інсталяції. + Ненадійні файли: + Видалити версію Wine/Proton + Ви впевнені, що хочете видалити %1$s %2$s (%3$d)? Контейнери, що використовують цю версію, більше не працюватимуть. + Видалено %s + Не вдалося видалити: %s + Скасувати імпорт? + Наразі триває імпорт. Скасування призведе до видалення всіх видобутих файлів, і вам доведеться розпочати імпорт знову.\n\nВи впевнені, що хочете скасувати? + Так, скасувати імпорт + Ні, продовжити імпорт + + + Розпакування та перевірка пакета (це може зайняти 2-3 хвилини для великих файлів)... + Ім\'я файлу має починатися з \'wine\' або \'proton\' (без урахування регістру) + Файл порожній або його неможливо прочитати + Не вдалося відкрити файл + Не вдалося відкрити вибір файлів: %s + Файл не розпізнано як дійсний архів + profile.json не знайдено в пакеті + Недійсний profile.json + Ця версія Wine/Proton вже існує + У пакеті відсутні необхідні файли (bin/, lib/ або prefixPack.txz) + Пакет є недовіреним + Недостатньо місця в сховищі + Сталася невідома помилка + Неможливо інсталювати пакет Wine/Proton + Пакет не є Wine або Proton (type: %s) + Ім\'я файлу вказує на %1$s, але пакет містить %2$s + Цей пакет містить файли, що не входять до надійного набору. + Версія Wine/Proton вже існує + Не вдалося інсталювати: %s + Помилка інсталяції: %s + %1$s %2$s успішно інстальовано + Ця збірка Wine/Proton вимагає контейнерів GLIBC і несумісна з GameNative. Будь ласка, використовуйте лише збірки ARM64/bionic. + Контейнери, що використовують цю версію: + Наразі жоден контейнер не використовує цю версію. + Ці контейнери більше не працюватимуть, якщо ви продовжите: + + + Інтеграція GOG (Альфа) + Вхід у GOG + Увійдіть у свій обліковий запис GOG + Синхронізація… + Помилка: %1$s + ✓ Синхронізовано %1$d ігор + Отримати вашу бібліотеку ігор GOG + Вхід успішний + Ви увійшли в GOG.\nМи тепер синхронізуємо вашу бібліотеку у фоновому режимі. + + + Увійти в GOG + Натисніть \'Відкрити вхід GOG\' і увійдіть. Після входу скопіюйте URL-адресу та вставте нижче + Приклад: https://embed.gog.com/on_login_success?origin=client&code=aaa + Відкрити вхід GOG + Код авторизації або URL успішного входу + Вставте код або url сюди + Увійти + Скасувати + Не вдалося відкрити браузер + + + Вийти + Вийти з облікового запису GOG + Вийти з GOG? + Це видалить ваші облікові дані GOG та очистить бібліотеку GOG на цьому пристрої. Ви можете увійти знову в будь-який час. + Вийти + Успішно вийшли з GOG + Не вдалося вийти: %s + Вихід з GOG… +
+ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5b5eed1167..d5c295e2ac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,47 +1,52 @@ GameNative - 登入 + 登录 双重验证 主页 - 设定 - 扫码登入 + 设置 + 扫码登录 游戏库 下载 未知应用 - 输入错误的验证码, 请再试一次 - 使用Steam行动应用程式确认登入… - 请输入您的验证器应用程式中的双重验证码 - 请输入发送至邮件信箱 %s 的验证码 - 正在安装的应用程式有以下空间需求:\n\n\t下载大小: %1$s\n\t磁碟占用空间: %2$s\n\t可用空间: %3$s - 正在安装的应用程式需要 %1$s 的空间, 但此设备上只剩下 %2$s 的空间 - 您确定要取消下载此应用程式吗? - 删除此游戏的所有已下载数据? + 验证码错误,请重试 + 正在通过Steam移动应用确认登录… + 请输入您验证器应用中的双重验证码 + 请输入发送至邮箱 %s 的验证码 + 要安装的应用有以下空间要求:\n\n\t下载大小:%1$s\n\t磁盘占用空间:%2$s\n\t可用空间:%3$s + 下载大小:%1$s\n\t磁盘占用空间:%2$s\n\t可用空间:%3$s + 要安装的应用需要 %1$s 空间,但设备仅剩 %2$s 可用空间 + 确定要取消下载此应用吗? + 删除此游戏的所有已下载数据? 下载并安装 ImageFS - 必须先下载并安装 Ubuntu 映像, 然后才能编辑配置. 此操作可能需要几分钟, 是否继续? + 必须先下载并安装 Ubuntu 镜像才能编辑配置。此操作可能需要几分钟,是否继续? 安装 ImageFS - 必须先下载并安装 Ubuntu 映像, 然后才能编辑配置. 此操作可能需要几分钟, 是否继续? + 必须先下载并安装 Ubuntu 镜像才能编辑配置。此操作可能需要几分钟,是否继续? 重置容器 - 这将把容器重置为预设配置 - 重置容器? + 这将把容器重置为默认配置 + 重置容器? 重置 - 验证档案 - 请确保您的存档已上传至云端或已备份, 然后再进行验证, 否则存档可能会被覆盖 + 验证文件 + 验证前请确保存档已上传至云端或已备份,否则存档可能会被覆盖 更新 - 请确保您的存档已上传至云端或已备份, 然后再进行验证, 否则存档可能会被覆盖 - 云端同步已成功完成 + 更新前请确保存档已上传至云端或已备份,否则存档可能会被覆盖 + 云同步已成功完成 游戏存档已是最新版本 - 云端同步失败: %s + 云同步失败:%s 需要存储权限 - 容器重设为预设设定 - 已安装 ImageFS, 请尝试再次编辑容器 - ImageFS 安装失败: %s + 容器已重置为默认设置 + ImageFS 已安装,请重试编辑容器 + ImageFS 安装失败:%s 卸载游戏 - 您确定要卸载 %1$s 吗? 此操作无法撤回 + 确定要卸载 %1$s 吗?此操作无法撤销 %1$s 已卸载 卸载游戏失败 + 卸载游戏 + 您确定要卸载 %1$s 吗? 此操作无法撤回 + 下载游戏 + 正在安装的应用程式有以下空间需求:\n\n\t下载大小: %1$s\n\t可用空间: %2$s 从不 继续 - App header image + 应用头图 下载 安装 安装 @@ -49,7 +54,7 @@ 卸载 开始游戏 安装应用 - 计算空间需求… + 计算空间需求中… 删除应用 取消下载 确定 @@ -64,45 +69,45 @@ 下载 好友 - - 自订游戏 - 没有添加路径 - 授予许可 + + 自定义游戏 + 未添加路径 + 授予权限 从游戏库添加 尚未手动添加游戏 - 删除路径 - 从扫描中删除此路径? 文件夹内容将保留在磁盘上 - 删除路径 - 已从列表中删除路径. 文件夹内容尚未删除 - 删除自订游戏 - 从您的游戏库中删除这个手动添加的文件夹吗? 这不会删除磁盘上的文件 - 删除文件夹 - 已从游戏库中删除文件夹 - ⚠ 无法访问 (检查路径是否存在) + 移除路径 + 从扫描中移除此路径?文件夹内容将保留在磁盘上 + 移除路径 + 路径已从列表中移除。文件夹内容未删除 + 移除自定义游戏 + 从游戏库中移除此手动添加的文件夹吗?这不会删除磁盘上的文件 + 移除文件夹 + 文件夹已从游戏库中移除 + ⚠ 无法访问(检查路径是否存在) ⚠ 没有权限 找到 0 个文件夹 找到 %d 个文件夹 - 将扫描这些路径中的文件夹中的 .exe 文件并将其列为自定义游戏, 这可能会减慢应用程序的启动时间 + 将扫描这些路径中文件夹内的 .exe 文件并将其列为自定义游戏,可能会减慢应用启动速度 无法解析文件夹路径 - 需要选择可执行程序 - 此游戏有多个可执行文件, 请打开 自定义游戏设置 以选择要启动的游戏 + 需要选择可执行文件 + 此游戏有多个可执行文件,请打开 自定义游戏设置 以选择要启动的游戏 自定义游戏设置 删除游戏 - 您确定要卸载 %1$s 吗? + 确定要卸载 %1$s 吗? 未知 - + 已禁用 复制 稳定 - 相容性 + 兼容性 平衡 高性能 Unity Unity Mono Bleeding Edge Ubuntu FS - + Direct3D DirectSound DirectMusic @@ -113,41 +118,41 @@ Windows Media Decoder OpenGL DXVK 版本 - 执行 + 运行 编辑 - 删除 + 移除 添加 删除 - 从...复制 + 从…复制 确认 存储信息 复制 重新配置 内容信息 游戏未安装 - %s 未安装, 请在启动前先安装游戏 - 保存容器配置? - 此游戏运行已应用修改的配置, 您想保存容器配置吗? + %s 未安装,请在启动前先安装游戏 + 保存容器配置? + 此游戏运行已应用修改的配置,是否保存容器配置? 同步错误 保存 取消 - 捷径 + 快捷方式 容器 Box64 RCFile 内容 关于 打开文件 下载文件 - 处理器关联 - 移到最前面 - 结束工作 - 新增文件 - 添加到主画面 + 处理器关联性 + 置顶 + 结束进程 + 新建文件 + 添加到主屏幕 切换全屏 - 切换萤幕方向 - 工作管理员 + 切换屏幕方向 + 任务管理器 放大镜 - 应用程序日志 + 应用日志 退出 退出游戏 键盘 @@ -165,20 +170,20 @@ 方向键 下 方向键 左 方向键 右 - 萤幕控制器 - 编辑萤幕控制器 + 屏幕控制器 + 编辑屏幕控制器 编辑实体控制器 已断开 - 重置萤幕控制器 + 重置屏幕控制器 打开导航菜单 - 触摸板支援 - 使用控制器时隐藏萤幕控制器 - 当实体控制器连接时自动隐藏萤幕控制器 + 触摸板支持 + 使用控制器时隐藏屏幕控制器 + 当实体控制器连接时自动隐藏屏幕控制器 - + 选择绑定 - 绑定: %1$s - 当前: %1$s + 绑定:%1$s + 当前:%1$s 搜索绑定… 搜索… 搜索 @@ -192,20 +197,20 @@ 清除绑定 找不到绑定 - + 编辑 %1$s - 位置: (%1$d, %2$d) • 大小: %3$.2fx + 位置:(%1$d, %2$d)• 大小:%3$.2fx 外观 %.2fx 标签文字 此按钮的自定义文字 元素类型 - 变更此控件的类型 + 更改此控件的类型 形状 视觉外观 %1$s 仅限于 %2$s 形状 绑定 - 绑定 (自动生成) + 绑定(自动生成) 范围按钮绑定会自动生成 主要动作 次要动作 @@ -213,21 +218,21 @@ - (鼠标) - 插槽 %1$d - 按钮最多支持 2 个绑定: 主要 (按压) 和次要 (长按) + (鼠标) + 槽位 %1$d + 按钮最多支持 2 个绑定:主要(按压)和次要(长按) 属性 位置 - X: %1$d, Y: %2$d - 未保存的变更 - 您有未保存的变更。您要保存还是舍弃它们? + X:%1$d,Y:%2$d + 未保存的更改 + 您有未保存的更改。要保存还是放弃它们? 调整大小 重置 完成 从元素复制大小 - 没有其他可用的元素可复制大小 - 已复制大小: %.2fx - 萤幕控制器已重置为默认值 + 没有其他可用元素可复制大小 + 已复制大小:%.2fx + 屏幕控制器已重置为默认值 快速预设 WASD 方向键 @@ -236,14 +241,14 @@ 左摇杆 右摇杆 - + 实体控制器绑定编辑器 - 设置实体控制器的按钮对应 + 设置实体控制器的按钮映射 正面按钮 A 按钮 - 底部正面按钮 (确认) + 底部正面按钮(确认) B 按钮 - 右侧正面按钮 (返回) + 右侧正面按钮(返回) X 按钮 左侧正面按钮 Y 按钮 @@ -260,16 +265,16 @@ 菜单按钮 Start / Options Select / View / Share - 类比摇杆 + 模拟摇杆 摇杆按钮 - L3 (左摇杆点击) - R3 (右摇杆点击) - 左类比摇杆 + L3(左摇杆按下) + R3(右摇杆按下) + 左模拟摇杆 左摇杆 上 左摇杆 下 左摇杆 左 左摇杆 右 - 右类比摇杆 + 右模拟摇杆 右摇杆 上 右摇杆 下 右摇杆 左 @@ -279,652 +284,678 @@ 重置为默认绑定 未设置 - + 控制器已重置 无法将 logcat 保存到目标 - - 保存此应用程序 PID 的 logcat 快照 + + 保存此应用 PID 的 logcat 快照 保存 logcat
- 0 : 将 CALL/RET 视为从不需要处理 (更快, 但不稳定)
- 1 : 大多数 RET 需要 flags, 大多数 CALLS 不需要 flags
- 2 : 所有 CALL/RET 都需要 flags 处理 (速度较慢) - ]]>
+ CALL/RET 操作码的标志处理方式

+ 0:将 CALL/RET 视为从不需要处理(更快,但不稳定)
+ 1:大多数 RET 需要标志,大多数 CALLS 不需要标志
+ 2:所有 CALL/RET 都需要标志处理(速度较慢) + ]]>
产生类似 x86 架构上的 -NAN 值 - 产生精确的 x86 舍入 / 进位结果 + 产生精确的 x86 舍入/进位结果 在 x87 模拟中使用的 Float/Double 浮点类型
- 0 : 不要尝试建立尽可能大的程式码块
- 1 : 建立尽可能大的 Dynarec 程式码块
- 2 : 建立更大的 Dynarec 块 (允许重叠, 只适用于 ELF 记忆体)
- 3 : 建立更大的 Dynarec 块 (允许重叠, 适用于所有记忆体类型) - ]]>
+ 0:不尝试建立尽可能大的代码块
+ 1:建立尽可能大的 Dynarec 代码块
+ 2:建立更大的 Dynarec 块(允许重叠,仅适用于 ELF 内存)
+ 3:建立更大的 Dynarec 块(允许重叠,适用于所有内存类型) + ]]>

- 0 : 不使用特殊处理
- 1 : 启用写入记忆体时的部分 Memory Barrier (针对某些 MOV opcode)
- 2 : 包含选项1, 再加上每次使用 MOV 指令写入记忆体时都加入记忆体屏障
- 3 : 包含选项1, 再加上从记忆体读取时,以及在某些 SSE/SSE2 opcodes 上使用 Memory Barrier - ]]>
+ 强内存模型模拟

+ 0:不使用特殊处理
+ 1:启用写入内存时的部分内存屏障(针对某些 MOV 操作码)
+ 2:包含选项1,再加上每次使用 MOV 指令写入内存时都加入内存屏障
+ 3:包含选项1,再加上从内存读取时,以及在某些 SSE/SSE2 操作码上使用内存屏障 + ]]>

- 0 : 使用常规安全屏障
- 1 : 采用弱屏障以获得轻微的性能提升
- 2 : 使用弱屏障,并额外禁用最后写入屏障]]>
+ 调整内存屏障以减少强内存模拟对性能的影响

+ 0:使用常规安全屏障
+ 1:采用弱屏障以获得轻微的性能提升
+ 2:使用弱屏障,并额外禁用最后写入屏障]]>

- 0 : 产生非对齐原子操作的处理程式码
- 1 : 仅产生对齐的原子操作,此方式速度更快且程式码体积更小,但会导致以 LOCK 为前缀的操作码在对齐资料位址上运作时触发 SIGBUS 异常。]]>
+ 仅产生对齐原子操作(目前仅适用于 Arm64 架构)

+ 0:产生非对齐原子操作的处理代码
+ 1:仅产生对齐的原子操作,此方式速度更快且代码体积更小,但会导致以 LOCK 为前缀的操作码在对齐数据地址上运作时触发 SIGBUS 异常。]]>

- 0 : 停用延迟标记的使用
- 1 : 启用延迟标记的使用]]>
+ 启用或停用延迟标志的使用

+ 0:停用延迟标志的使用
+ 1:启用延迟标志的使用]]>

- 0 : 不允许继续执行未受保护且可能已遭窜改的区块
- 1 : 允许继续执行「动态区块」,该机制将资料写入与程式码相同的页面。此做法虽能加快某些游戏的载入速度,但也可能导致意外当机。
- 2 : 当侦测到 HotPage 时,亦会将该页面标记为 NEVERCLEAN,因此该页面不会被写入保护,但从该页面建立的区块仍会持续接受测试。此方式可能更为迅速(但某些 SMC 案例可能无法被拦截)。]]>
+ 允许继续执行未受保护且可能已遭篡改的区块

+ 0:不允许继续执行未受保护且可能已遭篡改的区块
+ 1:允许继续执行“动态区块”,该机制将数据写入与代码相同的页面。此做法虽能加快某些游戏的加载速度,但也可能导致意外崩溃。
+ 2:当检测到 HotPage 时,亦会将该页面标记为 NEVERCLEAN,因此该页面不会被写入保护,但从该页面建立的区块仍会持续接受测试。此方式可能更为迅速(但某些 SMC 案例可能无法被拦截)。]]>

- 0 : 不使用原生标志
- 1 : 尽可能使用原生标志]]>
+ 0:不使用原生标志
+ 1:尽可能使用原生标志]]>

- 0 : 忽略 x86 PAUSE 指令
- 1 : 使用 YIELD 模拟 x86 的 PAUSE 指令
- 2 : 使用 WFI 模拟 x86 PAUSE 指令
- 3 : 使用 SEVL+WFE 模拟 x86 PAUSE 指令 + 启用 x86 PAUSE 模拟功能,可能有助于提升自旋锁的性能

+ 0:忽略 x86 PAUSE 指令
+ 1:使用 YIELD 模拟 x86 的 PAUSE 指令
+ 2:使用 WFI 模拟 x86 PAUSE 指令
+ 3:使用 SEVL+WFE 模拟 x86 PAUSE 指令 ]]>

- 0 : 停用 AVX 扩展功能
- 1 : 启用 AVX、BMI1、F16C 及 VAES 扩展功能
- 2 : 选项 1 加上启用 AVX2、BMI2、FMA、ADX、VPCLMULQDQ 及 RDRAND
\ + 已实现 AVX 与 AVX2,并包含 BMI1、BMI2、ADX、FMA、F16C 及 RDANDR 扩展功能

+ 0:禁用 AVX 扩展功能
+ 1:启用 AVX、BMI1、F16C 及 VAES 扩展功能
+ 2:选项 1 加上启用 AVX2、BMI2、FMA、ADX、VPCLMULQDQ 及 RDRAND
]]>
- box64 向程式呈现的最大处理器核心数 - 侦测 UnityPlayer.dll 并套用 strongmem 设定 - 强制所有记忆体分配在 32 位元位址空间中执行 + box64 向程序呈现的最大处理器核心数 + 检测 UnityPlayer.dll 并应用 strongmem 设置 + 强制所有内存分配在 32 位地址空间中执行 定义建立区块时允许的最大向前位移 - 最佳化 CALL/RET 操作码 + 优化 CALL/RET 操作码 定义 Dynarec 是否需等待 FillBlock 准备就绪 - 启用 TSO IR 操作 (多执行绪应用程式所需) - 当 TSO 启用时, 使向量载入/储存指令成为原子操作 - 在 TSO 环境下, 使用半屏障原子操作处理未对齐的载入/储存操作 + 启用 TSO IR 操作(多线程应用程序所需) + 当 TSO 启用时,使向量加载/存储指令成为原子操作 + 在 TSO 环境下,使用半屏障原子操作处理未对齐的加载/存储操作 使 REP MOVS / REP STOS 在 TSO 环境下成为原子操作 - 启用多区块程式码编译, 可能导致JIT编译时间延长及画面卡顿 + 启用多区块代码编译,可能导致JIT编译时间延长及画面卡顿 控制 CPU 功能强制执行 调整低频系统中TSC的系数 - 自我修改程式码检查 - 在可用的情况下, 使用PE档案中的挥发性元资料进行TSO作业 - 针对 Mono 应用程式侦测的特殊处理 (SMC + JIT block hacks) - 隐藏 CPUID 虚拟化管理程式位元 (适用于因该位元导致崩溃的应用程式) - 停用 FEXCore JIT L2 快取查询功能,节省记忆体但会导致卡顿 - 将 FEXCore JIT L1 快取切换为动态大小, 节省记忆体但会导致卡顿 - 采用64位元精度模拟X87浮点运算, 此举将降低模拟精度, 并可能导致渲染错误 - 每个翻译区块的最大指令预算, 较高的数值可提升效能, 但可能降低稳定性 + 自我修改代码检查 + 在可用的情况下,使用PE文件中的易失性元数据进行TSO作业 + 针对 Mono 应用程序检测的特殊处理(SMC + JIT block hacks) + 隐藏 CPUID 虚拟机管理器位(适用于因该位导致崩溃的应用程序) + 禁用 FEXCore JIT L2 缓存查询功能,节省内存但会导致卡顿 + 将 FEXCore JIT L1 缓存切换为动态大小,节省内存但会导致卡顿 + 采用64位精度模拟X87浮点运算,此举将降低模拟精度,并可能导致渲染错误 + 每个翻译区块的最大指令预算,较高的数值可提升性能,但可能降低稳定性 继续下载 暂停下载 - - 创建捷径 + + 创建快捷方式 标签 - 图示 + 图标 创建 立即更新 - 移动文件 - 需要网络连线才能安装 + 移动文件中 + 需要网络连接才能安装 仅启用通过 WiFi 安装 安装进度 下载中... 计算中... - 下载失败, 请再试一次 - 可用更新 - 游戏资讯 + 下载失败,请重试 + 有可用更新 + 游戏信息 状态 大小 - 档案位置 + 文件位置 开发者 发布日期 已安装 安装中 未安装 - 开启 + 打开 家庭共享 游戏 - 过去2周的游戏时间: %s 小时 - 总游戏时间: %s 小时 + 过去2周的游戏时间:%s 小时 + 总游戏时间:%s 小时 添加自定义游戏 添加自定义游戏 - 请选择包含您要添加为自定义游戏的游戏的文件夹 + 请选择包含您要添加为自定义游戏的文件夹 不再显示 - 已创建捷径 - 无法创建捷径:%s - 映像获取成功 + 快捷方式已创建 + 无法创建快捷方式:%s + 图像获取成功 找不到游戏文件夹 - 无法获取映像: %s + 无法获取图像:%s 已导出 - 无法导出: %s + 无法导出:%s 取消导出 - - + 已安装 游戏 - 应用程式 + 应用程序 工具 试玩版 家庭共享 - - 没有连接到 Steam - 重试连接到 Steam + + 未连接到 Steam + 重试连接 Steam 离线使用 + + 游戏运行情况如何? + 选择您遇到的任何问题: - - 游戏的运行状况如何? - 选择您遇到的任何问题: - - - - 帮助 & 支援 + + 帮助与支持 名人堂 进入在线模式 进入离线模式 登出 关闭 - - 新增环境变数 + + 新建环境变量 名称 - 没有更多已知环境变数 + 没有更多已知环境变量 容器变体 Wine版本 执行参数 - 例子: -dx11 + 例如:-dx11 语言 - 萤幕大小 + 屏幕大小 宽度 高度 - 音讯驱动程式 + 音频驱动程序 显示帧率 - 您确定要移除驱动程式 "%s"? 此操作无法复原 + 确定要移除驱动程序“%s”?此操作无法撤销 - + 使用旧版 DRM 强制开启DLC - 仅在未侦测到 DLC 或包含 DLC 的存档无法运作时启用 - 启动 Steam 用户端 (Beta) - 降低效能并减慢启动速度\n允许线上游玩并修复 DRM 和控制器问题\n并非所有游戏都适用 + 仅在未检测到 DLC 或包含 DLC 的存档无法运作时启用 + 启动 Steam 客户端(Beta) + 降低性能并减慢启动速度\n允许在线游玩并修复 DRM 和控制器问题\n并非所有游戏都适用 允许 Steam 更新 - 将 Steam 更新至最新版本, 会显著降低效能 + 将 Steam 更新至最新版本,会显著降低性能 Steam 类型 - - 图形驱动程式 - 图形驱动程式版本 - 启用 Vulkan 扩充功能 + + 图形驱动程序 + 图形驱动程序版本 + 启用 Vulkan 扩展功能 最大内存容量 - 影格同步 - Use Adrenotools Turnip + 帧同步 + 使用 Adrenotools Turnip Vulkan 版本 - 图像快取大小 - DX Wrapper - VKD3D Feature Level + 图像缓存大小 + DX 封装器 + VKD3D 功能级别 使用 DRI3 - 在某些装置上停用此功能或许能修复图形故障 - 同步每帧影像 - 停用 KHR_present_wait - 当前模式 - 记忆体资源类型 + 在某些设备上禁用此功能或许能修复图形故障 + 同步每帧图像 + 禁用 KHR_present_wait + 呈现模式 + 内存资源类型 BCn 模拟 BCn 模拟类型 - BCn 模拟快取 - 锐利度增强 - 锐利度等级 - 锐利度去噪 + BCn 模拟缓存 + 锐度增强 + 锐度等级 + 锐度去噪 - + FEXCore 版本 - FEXCore Preset + FEXCore 预设 TSO 模式 x87 模式 - Multiblock - 64-bit 模拟器 - 32-bit 模拟器 + 多区块 + 64位 模拟器 + 32位 模拟器 Box64 版本 Box64 预设集 Box64 预设集 FEXCore 预设集 - 检视、修改及建立 FEXCore 预设集 + 查看、修改及创建 FEXCore 预设集 预设集名称 - + 使用 SDL API 启用 XInput API 启用 DirectInput API - DirectInput Mapper 类型 - 停用滑鼠输入 + DirectInput 映射器类型 + 禁用鼠标输入 触屏模式 - 直接触控到光标移动 (开启) vs 触控板式相对移动 (关闭) - 开始时隐藏萤幕控制器 - 游戏开始时萤幕控制器将被隐藏。可通过导航菜单切换。 - 模拟键盘与滑鼠 - 左摇杆 = WASD, 右摇杆 = 滑鼠, L2 = 滑鼠左键, R2 = 滑鼠右键 - - - 渲染器 + 直接触控到光标移动(开启) vs 触摸板式相对移动(关闭) + 开始时隐藏屏幕控制器 + 游戏开始时屏幕控制器将被隐藏。可通过导航菜单切换。 + 模拟键盘与鼠标 + 左摇杆 = WASD,右摇杆 = 鼠标,L2 = 鼠标左键,R2 = 鼠标右键 + + + 渲染器 GPU 名称 - 萤幕外渲染模式 - 显示记忆体大小 - 启用 CSMT (Command Stream Multi-Thread) - 启用 Strict Shader Math - 滑鼠跃迁覆写 - - - 环境变数 - 没有环境变数 - 未挂载任何储存装置 + 屏幕外渲染模式 + 显存大小 + 启用 CSMT(命令流多线程) + 启用严格着色器数学 + 鼠标跳转覆盖 + + + 环境变量 + 没有环境变量 + 未挂载任何存储设备 启动选项 - 处理器关联 (32 位元应用程式) + 处理器关联性(32 位应用程序) 执行路径 - e.g., path\\to\\exe + 例如:path\\to\\exe - - 允许的萤幕方向 - 选择 Wine 侦错通道 + + 允许的屏幕方向 + 选择 Wine 调试通道 CPU%d 描述发生了什么 - 在 Discord 上获取支援 + 在 Discord 上获取支持 - - 驱动程式管理 - 选择驱动程式 - 从装置汇入ZIP + + 驱动程序管理 + 选择驱动程序 + 从设备导入ZIP - + 内容管理 - 从装置汇入 .wcp + 从设备导入 .wcp 正在处理... 已选择 选定的内容 已安装的内容 选择类型 - 侦测到不受信任的档案 + 检测到不受信任的文件 继续安装 - 移除内容? - 您确定要移除 %1$s (%2$s)? + 移除内容? + 确定要移除 %1$s(%2$s)? - + 语言 选择语言 需要重新启动 - 更改语言需要重新启动应用程式. 是否继续? + 更改语言需要重新启动应用程序。是否继续? 重新启动 切换语言并重新启动中... - - 模拟设定 - 允许的萤幕方向 - 选择游戏中可使用的萤幕方向 - 修改预设配置 - 每款游戏的初始容器设定 (不影响已安装游戏) - 预设容器设定 + + 模拟设置 + 允许的屏幕方向 + 选择游戏中可使用的屏幕方向 + 修改默认配置 + 每款游戏的初始容器设置(不影响已安装游戏) + 默认容器设置 Box64 预设集 - 检视、修改及建立 Box64 预设集 - 驱动程式管理员 - 安装或移除自订显示驱动程式套件 + 查看、修改及创建 Box64 预设集 + 驱动程序管理器 + 安装或移除自定义显示驱动程序包 内容管理 - 安装附加元件 (.wcp) - Wine/Proton 管理员 - 汇入自订的 Wine/Proton 版本 (仅限 Bionic) - - - 除错 - 选择 Wine 除错频道 - 启用 Wine 除错记录 - 将 Wine 除错输出写入档案 + 安装附加组件(.wcp) + Wine/Proton 管理器 + 导入自定义的 Wine/Proton 版本(仅限 Bionic) + + + 调试 + 选择 Wine 调试通道 + 启用 Wine 调试日志 + 将 Wine 调试输出写入文件 启用 Box86/64 日志 - 将 Box86 与 Box64 的除错输出写入档案 - 检视最新崩溃日志 - 检视游戏除错记录 - 清除偏好设定 - [关闭应用程式] 登出客户端并清除本地偏好设定资料 - 清除本地资料库 - [关闭应用程式] 可能有助于修复游戏库项目或讯息相关的问题 - 清除影像缓存 - 移除所有已载入的图片 - - - 资讯 + 将 Box86 与 Box64 的调试输出写入文件 + 查看最新崩溃日志 + 查看游戏调试日志 + 清除偏好设置 + [关闭应用程序] 登出客户端并清除本地偏好设置数据 + 清除本地数据库 + [关闭应用程序] 可能有助于修复游戏库项目或信息相关的问题 + 清除图像缓存 + 移除所有已加载的图片 + + + 信息 捐款支持 - 为持续的应用程式开发贡献力量 - 启动时显示捐款讯息 - 停止显示捐款讯息 - 原始码 - 检视此专案的原始码 - 使用的函式库 - 了解哪些技术让GameNative成为可能 - 隐私权政策 - 开启连结至 GameNative 的隐私权政策 - - - 介面 - 在外部开启网页连结 - 连结将以您的主要网页浏览器开启 - 在非游戏状态时隐藏状态列 - 在游戏清单、设定等介面隐藏 Android 状态列。变更后应用程式将重新启动。 - 仅限透过 Wi-Fi 下载 - 禁止使用行动数据下载 - 写入外部储存装置 - 未侦测到外部储存装置 - 将游戏存档储存至外部储存装置 - 储存容量 - Steam 下载伺服器 + 为持续的应用程序开发贡献力量 + 启动时显示捐款信息 + 停止显示捐款信息 + 源代码 + 查看此项目的源代码 + 使用的库 + 了解哪些技术让 GameNative 成为可能 + 隐私政策 + 打开链接至 GameNative 的隐私政策 + + + 界面 + 在外部打开网页链接 + 链接将以您的主要网页浏览器打开 + 在非游戏状态时隐藏状态栏 + 在游戏列表、设置等界面隐藏 Android 状态栏。更改后应用程序将重新启动。 + 仅限通过 Wi-Fi 下载 + 禁止使用移动数据下载 + 写入外部存储设备 + 未检测到外部存储设备 + 将游戏存档存储至外部存储设备 + 存储容量 + Steam 下载服务器 需要重新启动 - + 下载 - + GameNative - 隐私权政策 + 隐私政策 欢迎回来 - 登入以存取您的 Steam 游戏库 - 用户名称 + 登录以访问您的 Steam 游戏库 + 用户名 密码 - 记住登入状态 - 登入 + 记住登录状态 + 登录 QR 码扫描失败 - 重新尝试 QR 码扫描 - 开启 Steam 行动应用程式,扫描此 QR 码即可立即登入 + 重试 QR 码扫描 + 打开 Steam 移动应用程序,扫描此 QR 码即可立即登录 - + 继续 - 设定 - + 设置 - - 正在连接至远端伺服器... + + 正在连接到远程服务器... - - 正在安装的应用程式需要 %1$s 的空间, 但此装置仅剩 %2$s 可用空间 - 正在安装的应用程式需要以下储存空间. 是否继续安装? \n\n\t下载大小: %1$s\n\t磁碟占用空间: %2$s\n\t可用空间: %3$s - 您确定要取消应用程式的下载? - 删除此游戏的所有已下载资料? - 您确定要删除此应用程式? + + 要安装的应用程序需要 %1$s 的空间,但此设备仅剩 %2$s 可用空间 + 要安装的应用程序有以下存储空间需求。是否继续安装?\n\n\t下载大小:%1$s\n\t磁盘占用空间:%2$s\n\t可用空间:%3$s + 确定要取消应用程序的下载? + 删除此游戏的所有已下载数据? + 确定要删除此应用程序? 下载并安装 ImageFS - 在编辑设定档之前,需先下载并安装 Ubuntu 映像档. 此操作可能需要数分钟时间. 您要继续吗? + 在编辑配置文件之前,需先下载并安装 Ubuntu 镜像文件。此操作可能需要数分钟时间。您要继续吗? 安装 ImageFS - 在编辑设定前, 需先安装 Ubuntu 映像档. 此操作可能需要数分钟时间. 您要继续吗? + 在编辑设置前,需先安装 Ubuntu 镜像文件。此操作可能需要数分钟时间。您要继续吗? 重置容器 - 此操作将您的容器重设为预设配置 - 验证档案 - 请确保您的存档已上传至云端或完成备份后再进行验证, 否则存档可能会被覆写 + 此操作将您的容器重置为默认配置 + 验证文件 + 请确保您的存档已上传至云端或完成备份后再进行验证,否则存档可能会被覆盖 更新 - 请确保您的存档已上传至云端或完成备份后再进行验证, 否则存档可能会被覆写 - %s 设定档 - - - 需要储存权限 - 已汇出 - 汇出失败:%s - 汇出已取消 - 捷径已建立 - 建立捷径失败: %s - 云端同步完成 - 储存档案已更新至最新版本 - 云端同步失败: %s - - - 需连网安装 - 已启用仅透过 Wi-Fi/区域网路安装 - 档案 %1$d / %2$d 个 - - - 可用更新 - 新版本 (%1$s) 已推出! %2$s + 请确保您的存档已上传至云端或完成备份后再进行验证,否则存档可能会被覆盖 + %s 配置文件 + + + 需要存储权限 + 已导出 + 导出失败:%s + 导出已取消 + 快捷方式已创建 + 创建快捷方式失败:%s + 云同步完成 + 存档文件已更新至最新版本 + 云同步失败:%s + + + 需联网安装 + 已启用仅通过 Wi-Fi/局域网安装 + 文件 %1$d / %2$d 个 + + + 有可用更新 + 新版本(%1$s)已推出!%2$s 更新 稍后更新 更新失败 - 更新下载或安装失败, 请稍后再试 - - - 最近应用程式当机 - 若能了解您最近遇到的问题, 将有助于我们改善应用程式.\n您可在应用程式设定中检视并汇出最近的崩溃日志, 将其附加至专案原始码储存库的 Github Issue中\nGithub 原始码储存库的连结亦位于设定页面内 - 感谢您使用GameNative! - 透过与朋友分享应用程式或在Ko-fi成为会员,支持Android平台的开源PC游戏 - 加入Ko-fi + 更新下载或安装失败,请稍后再试 + + + 最近应用程序崩溃 + 若能了解您最近遇到的问题,将有助于我们改善应用程序。\n您可在应用程序设置中查看并导出最近的崩溃日志,将其附加至项目源代码仓库的 Github Issue中\nGithub 源代码仓库的链接亦位于设置页面内 + 感谢您使用 GameNative! + 通过与朋友分享应用程序或在 Ko-fi 成为会员,支持 Android 平台的开源 PC 游戏 + 加入 Ko-fi 分享 - 游戏运作正常吗? - 加入 Discord 社群, 获取游戏修复与效能优化的支援 - 开启 Discord + 游戏运行正常吗? + 加入 Discord 社区,获取游戏修复与性能优化的支持 + 打开 Discord - + 正在下载 Steam... 安装 glibc 组件... - 安装Bionic元件中... - 载入中... + 安装Bionic组件中... + 加载中... - - 应用程式运行中 - 您已在另一台装置登入并正在游玩 %s。\n您仍可继续游玩此游戏,但此操作将导致 Steam 断开另一装置的连线。 - 您已在另一台装置 (%1$s) 上登入并正在游玩 %2$s (%3$s),该存档尚未同步至云端。\n您仍可继续游玩此游戏,但此操作将使另一装置的 Steam 连线中断,且当该装置的进度同步时可能产生存档冲突。 + + 应用程序运行中 + 您已在另一台设备登录并正在游玩 %s。\n您仍可继续游玩此游戏,但此操作将导致 Steam 断开另一设备的连接。 + 您已在另一台设备(%1$s)上登录并正在游玩 %2$s(%3$s),该存档尚未同步至云端。\n您仍可继续游玩此游戏,但此操作将使另一设备的 Steam 连接中断,且当该设备的进度同步时可能产生存档冲突。 仍要继续 - + 存档冲突 - 远端存档与本地存档发生冲突,请选择要保留哪个?\n\n本地存档:\n\t%1$s\n远端存档:\n\t%2$s + 远程存档与本地存档发生冲突,请选择要保留哪个?\n\n本地存档:\n\t%1$s\n远程存档:\n\t%2$s 保留本地存档 - 保留远端存档 + 保留远程存档 - - 同步操作耗时过久, 请稍后重新启动游戏 - 同步目前正在进行中, 请稍后再试 - 同步存档案失败:%s + + 同步操作耗时过久,请稍后重新启动游戏 + 同步目前正在进行中,请稍后再试 + 同步存档失败:%s - + 上传中 - 您在装置 %2$s (%3$s) 上游玩了 %1$s, 该次游玩进度的储存档案仍在上传中, 请稍后再试 + 您在设备 %2$s(%3$s)上游玩了 %1$s,该次游玩进度的存档文件仍在上传中,请稍后再试 待上传 - 您在装置 %2$s (%3$s) 上游玩了 %1$s, 该存档尚未上传至云端 (尚未开始上传)\n您仍可继续游玩此游戏, 但先前游戏进度成功上传时可能产生冲突 - 应用程式工作阶段已暂停, 请重新启动应用程式 - 收到待处理的远端操作, 其操作类型为 \'none\', 请重新启动应用程式 - 有多项远端操作正在处理中, 请稍后再试. 请重新启动应用程式 - - - 设定容器 - 未储存的变更 - 您确定要舍弃您的变更吗? - 一般 + 您在设备 %2$s(%3$s)上游玩了 %1$s,该存档尚未上传至云端(尚未开始上传)\n您仍可继续游玩此游戏,但先前游戏进度成功上传时可能产生冲突 + 应用程序会话已暂停,请重新启动应用程序 + 收到待处理的远程操作,其操作类型为“none”,请重新启动应用程序 + 有多项远程操作正在处理中,请稍后再试。请重新启动应用程序 + + + 配置容器 + 未保存的更改 + 确定要放弃您的更改吗? + 通用 图形 模拟 控制器 Wine - Win Components + Win 组件 环境 - 磁碟机 - 进阶 + 驱动器 + 高级 VKD3D 版本 - 可执行档路径 - e.g., path\\to\\exe + 可执行文件路径 + 例如:path\\to\\exe - - 使用的函式库 + + 使用的库 Pluvia - github.com/oxters168/Pluvia\nJavaSteam - github.com/Longi94/JavaSteam\nWinlator & Vortek - github.com/brunodev85/winlator\nWinlator Cmod - github.com/coffincolors/winlator\nWrapper - https://github.com/leegao/bionic-vulkan-wrapper & https://github.com/pipetto-crypto/\nUbuntu RootFs - releases.ubuntu.com/focal - - Art Credits - App Icon: Hachi - Alternate App Icon: rhapsody_mdr - Loading supporters… - Members - Supporters - No supporters yet. - Anonymous - - - 聊天功能仍处于早期阶段, 请将任何问题回报至专案储存库 + + 美术鸣谢 + 应用图标:Hachi + 备用应用图标:rhapsody_mdr + 正在加载支持者… + 会员 + 支持者 + 尚无支持者。 + 匿名 + + + 聊天功能仍处于早期阶段,请将任何问题报告至项目仓库 没有聊天记录 - 发送讯息 + 发送消息 发送 - Emoticons - Stickers + 表情 + 贴纸 - - 开启 + + 打开 已安装 安装中 未安装 家庭共享 - - 已知配置可在您的装置上运作 - 已知配置应该可在您的装置上运作 - 已知配置可能可在您的装置上运作 + + 已知配置可在您的设备上运行 + 已知配置应该在您的设备上运行 + 已知配置可能在您的设备上运行 没有已知配置 - - 最佳配置已成功套用 + + 最佳配置已成功应用 已知配置无效 此游戏没有可用的最佳配置 - 套用配置失败: %s + 应用配置失败:%s - 应用程式类型 - 应用程式状态 - 版面配置 - 清单 + 应用程序类型 + 应用程序状态 + 布局 + 列表 胶囊 - Hero - 搜寻您的游戏... - 搜寻 - 清除搜寻 + 英雄 + 搜索您的游戏... + 搜索 + 清除搜索 没有符合选择条件的项目 过滤器 %1$d 款游戏 • %2$d 款已安装 Steam - 自订游戏 - 版面配置 + 自定义游戏 + 布局 - - 登入 + + 登录 验证码 输入5位数验证码 - - 验证内容中... - 无法识别档案 - 内容中未找到Profile - 无法识别Profile + + 正在验证内容... + 无法识别文件 + 内容中未找到配置文件 + 无法识别配置文件 内容已存在 内容不完整 内容不可信 空间不足 无法安装内容 - 此内容包含可信文件以外的档案 - 安装附加元件 (.wcp: tar.xz/zst) + 此内容包含受信文件以外的文件 + 安装附加组件(.wcp: tar.xz/zst) 类型 版本 代码 描述 - 所有档案均受信任, 已准备好安装 + 所有文件均受信任,已准备好安装 此类型无已安装内容 删除 - 此内容包含可信文件外的档案, 请确认后再继续操作 + 此内容包含受信文件外的文件,请确认后再继续操作 已移除 %1$s - - 载入驱动程式清单失败: %1$d - 载入驱动程式清单时发生错误: %1$s - 连线超时, 请检查您的网路并重新尝试 - 网路错误: %1$s + + 加载驱动程序清单失败:%1$d + 加载驱动程序清单时发生错误:%1$s + 连接超时,请检查您的网络并重新尝试 + 网络错误:%1$s - - 预设 + + 默认 替代 缓慢 中等 快速 极速 下载速度 - 较高的下载速度可能会导致装置发热 - 预设 - 储存设定并重新启动中... + 较高的下载速度可能会导致设备发热 + 默认 + 正在保存设置并重新启动... - - 设定 + + 设置 - + 内容已成功安装 安装内容失败 - 安装错误: %1$s + 安装错误:%1$s - + 准备就绪 - - Wine/Proton 管理员 - 仅限 Bionic 映像档 - 为 Bionic 容器导入自订 Wine 或 Proton 版本. 档案名称必须以 wine 或 proton 开头 (不区分大小写) 套件必须包含 bin/, lib/ 目录及 prefixPack.txz 档案. 所有导入版本仅相容于 Bionic 环境 - 例子: "proton-10.0-ARM64ec.wcp" - 汇入 Wine/Proton 套件 - 请选择一个 .wcp 档案 (档案名称需以 wine 或 proton 开头) - 汇入 .wcp 套件 + + + Wine/Proton 管理器 + 仅限 Bionic 镜像 + 为 Bionic 容器导入自定义 Wine 或 Proton 版本。文件名称必须以 wine 或 proton 开头(不区分大小写)。包必须包含 bin/、lib/ 目录及 prefixPack.txz 文件。所有导入版本仅兼容于 Bionic 环境 + 例如:“proton-10.0-ARM64ec.wcp” + 导入 Wine/Proton 包 + 请选择一个 .wcp 文件(文件名称需以 wine 或 proton 开头) + 导入 .wcp 包 处理中... - 套件详情 + 包详情 类型 版本 版本代码 Bin 路径 Lib 路径 描述 - ✓ 所有档案皆属可信, 已准备好进行安装 - 安装套件 + ✓ 所有文件皆属可信,已准备好进行安装 + 安装包 已安装的 Wine/Proton 版本 未发现已安装的 Wine 或 Proton 版本 删除 - 此内容包含可信文件外的档案, 请确认后再继续操作 - 未受信任的档案: + 此内容包含受信文件外的文件,请确认后再继续操作 + 未受信任的文件: 移除 Wine/Proton 版本 - 您确定要移除 %1$s %2$s (%3$d) 吗? 使用此版本的容器将无法继续运作 + 确定要移除 %1$s %2$s(%3$d)吗?使用此版本的容器将无法继续运行 已移除 %s - 移除失败: %s - 取消汇入? - 汇入作业正在进行中, 取消操作将丢弃所有已提取的档案, 您需要重新开始汇入程序\n\n您确定要取消吗? - 是, 取消汇入 - 不, 保留汇入 - - - 正在解压缩及验证套件 (大型档案可能需要 2 至 3 分钟)... - 档案名称必须以 wine 或 proton 开头 (不区分大小写) - 档案为空或无法读取 - 无法开启档案 - 无法开启档案选择器: %s - 无法识别此档案为有效的压缩档 - 在套件中找不到 profile.json - profile.json 档案无效 + 移除失败:%s + 取消导入? + 导入作业正在进行中,取消操作将丢弃所有已提取的文件,您需要重新开始导入程序\n\n确定要取消吗? + 是,取消导入 + 不,保留导入 + + + 正在解压缩及验证包(大型文件可能需要 2 至 3 分钟)... + 文件名称必须以 wine 或 proton 开头(不区分大小写) + 文件为空或无法读取 + 无法打开文件 + 无法打开文件选择器:%s + 无法识别此文件为有效的压缩文件 + 在包中找不到 profile.json + profile.json 文件无效 Wine/Proton 版本已存在 - 套件缺少必需档案 (bin/、lib/ 或 prefixPack.txz) - 此套件无法信任 - 储存空间不足 + 包缺少必需文件(bin/、lib/ 或 prefixPack.txz) + 此包无法信任 + 存储空间不足 发生未知错误 - 无法安装 Wine/Proton 套件 - 此套件并非 Wine 或 Proton (类型: %s) - 档案名称显示 %1$s, 但套件包含 %2$s - 此套件包含可信套件以外的档案 + 无法安装 Wine/Proton 包 + 此包并非 Wine 或 Proton(类型:%s) + 文件名称显示 %1$s,但包包含 %2$s + 此包包含受信包以外的文件 Wine/Proton 版本已存在 - 安装失败: %s - 安装错误: %s + 安装失败:%s + 安装错误:%s %1$s %2$s 已安装成功 - 此 Wine/Proton 版本需搭配 GLIBC 容器运行, 且不兼容 GameNative. - 请使用 ARM64/bionic 版本. - 使用此版本的容器: + 此 Wine/Proton 版本需搭配 GLIBC 容器运行,且不兼容 GameNative。\n请使用 ARM64/bionic 版本。 + 使用此版本的容器: 目前没有容器使用此版本 - 若您继续操作, 这些容器将无法继续运作: + 若您继续操作,这些容器将无法继续运行: + + + GOG 集成 (Alpha) + GOG 登录 + 登录到您的 GOG 账户 + 同步中… + 错误: %1$s + ✓ 已同步 %1$d 个游戏 + 获取您的 GOG 游戏库 + 登录成功 + 您现已登录到 GOG。\n我们将在后台同步您的游戏库。 + + + 登录到 GOG + 点击\'打开 GOG 登录\'并登录。登录后, 请复制 URL 并粘贴到下方 + 示例: https://embed.gog.com/on_login_success?origin=client&code=aaa + 打开 GOG 登录 + 授权码或登录成功 URL + 在此粘贴代码或 url + 登录 + 取消 + 无法打开浏览器 + + + 注销 + 从您的 GOG 账户登出 + 从 GOG 注销? + 这将删除您的 GOG 凭据并清除此设备上的 GOG 库。您可以随时重新登录。 + 注销 + 成功从 GOG 注销 + 注销失败: %s + 正在从 GOG 注销… - - diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8207e6ccd2..483e7aaf52 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -13,6 +13,7 @@ 請輸入您的驗證器應用程式中的雙重驗證碼 請輸入發送至郵件信箱 %s 的驗證碼 正在安裝的應用程式有以下空間需求:\n\n\t下載大小: %1$s\n\t磁碟佔用空間: %2$s\n\t可用空間: %3$s + 下載大小: %1$s\n磁碟佔用空間: %2$s\n可用空間: %3$s 正在安裝的應用程式需要 %1$s 的空間, 但此設備上只剩下 %2$s 的空間 您確定要取消下載此應用程式嗎? 刪除此遊戲的所有已下載數據? @@ -39,6 +40,10 @@ 您確定要卸載 %1$s 嗎? 此操作無法撤回 %1$s 已卸載 卸載遊戲失敗 + 卸載遊戲 + 您確定要卸載 %1$s 嗎? 此操作無法撤回 + 下載遊戲 + 正在安裝的應用程式有以下空間需求:\n\n\t下載大小: %1$s\n\t可用空間: %2$s 從不 繼續 App header image @@ -925,6 +930,38 @@ 使用此版本的容器: 目前沒有容器使用此版本 若您繼續操作, 這些容器將無法繼續運作: + + + GOG 整合 (Alpha) + GOG 登入 + 登入您的 GOG 帳戶 + 同步中… + 錯誤: %1$s + ✓ 已同步 %1$d 個遊戲 + 獲取您的 GOG 遊戲庫 + 登入成功 + 您現已登入到 GOG。\n我們將在背景中同步您的遊戲庫。 + + + 登入到 GOG + 點擊\'開啟 GOG 登入\'並登入。登入後, 請複製 URL 並貼到下方 + 範例: https://embed.gog.com/on_login_success?origin=client&code=aaa + 開啟 GOG 登入 + 授權碼或登入成功 URL + 在此貼上代碼或 url + 登入 + 取消 + 無法開啟瀏覽器 + + + 登出 + 從您的 GOG 帳戶登出 + 從 GOG 登出? + 這將刪除您的 GOG 憑證並清除此裝置上的 GOG 遊戲庫。您可以隨時重新登入。 + 登出 + 成功從 GOG 登出 + 登出失敗: %s + 正在從 GOG 登出… diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 5b0a6c2fb2..633f8078c9 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -27,6 +27,7 @@ Wrapper Wrapper-v2 Wrapper-leegao + Wrapper-legacy System @@ -38,6 +39,7 @@ turnip25.3.0_R6_Gmem 25.3.0_R11 26.0.0_R4 + 26.0.0_R8 25.1.0 @@ -95,6 +97,7 @@ 2.12 2.13 2.14.1 + 3.0b 0 (Default) @@ -167,11 +170,13 @@ 0.3.7 (Default) + 0.4.0 0.3.8 0.3.6 0.3.2 + 0.4.0 0.3.7 0.3.4 @@ -179,6 +184,8 @@ 2507 2508 2511 + 2512 + 2601 Standard (Old Gamepads) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cdd445dbb1..92e923f34e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ Please enter your 2-factor auth code from your authenticator app. Please enter the auth code sent to the email at %s The app being installed has the following space requirements. Would you like to proceed?\n\n\tDownload Size: %1$s\n\tSize on Disk: %2$s\n\tAvailable Space: %3$s + Download Size: %1$s\nSize on Disk: %2$s\nAvailable Space: %3$s The app being installed needs %1$s of space but there is only %2$s left on this device Are you sure you want to cancel the download of the app? Delete all downloaded data for this game? @@ -40,6 +41,10 @@ Are you sure you want to uninstall %1$s? This action cannot be undone. %1$s has been uninstalled Failed to uninstall game + Uninstall Game + Are you sure you want to uninstall %1$s? This action cannot be undone. + Download Game + The app being installed has the following space requirements. Would you like to proceed?\n\n\tDownload Size: %1$s\n\tAvailable Space: %2$s Never Continue App header image @@ -129,6 +134,10 @@ Delete Copy From Ok + Set Custom Resolution + x + Width and height must be greater than 0 + Width must be greater than height Storage Info Duplicate Reconfigure @@ -822,14 +831,14 @@ Family Shared Shared Compatible - GPU Compatible Unknown + GPU Compatible Not Compatible - Known config works on your device - Known config should work on your device - Known config may work on your device + Known config works on your GPU + Known config should work on your GPU + Known config may work on your GPU No known config @@ -1008,6 +1017,37 @@ Containers using this version: No containers are currently using this version. These containers will no longer work if you proceed: - + + GOG Integration (Alpha) + GOG Login + Sign in to your GOG account + Syncing… + Error: %1$s + ✓ Synced %1$d games + Fetch your GOG games library + Login Successful + You are now signed in to GOG.\nWe will now sync your library in the background. + + + Sign in to GOG + Tap \'Open GOG Login\' and sign in. Once logged in, please copy the URL and paste below + Example: https://embed.gog.com/on_login_success?origin=client&code=aaa + Open GOG Login + Authorization Code or login success URL + Paste code or url here + Login + Cancel + Could not open browser + + + Logout + Sign out from your GOG account + Logout from GOG? + This will remove your GOG credentials and clear your GOG library from this device. You can sign in again at any time. + Logout + Logged out from GOG successfully + Failed to logout: %s + Logging out from GOG… + diff --git a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt new file mode 100644 index 0000000000..0765bec9bc --- /dev/null +++ b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt @@ -0,0 +1,853 @@ +package app.gamenative.service + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import app.gamenative.data.ConfigInfo +import app.gamenative.data.FileChangeLists +import app.gamenative.data.PostSyncInfo +import app.gamenative.data.SaveFilePattern +import app.gamenative.data.SteamApp +import app.gamenative.data.UFS +import app.gamenative.db.PluviaDatabase +import app.gamenative.enums.AppType +import app.gamenative.enums.OS +import app.gamenative.enums.PathType +import app.gamenative.enums.ReleaseState +import app.gamenative.enums.SaveLocation +import app.gamenative.service.DownloadService +import app.gamenative.service.SteamService +import com.winlator.container.Container +import com.winlator.xenvironment.ImageFs +import `in`.dragonbra.javasteam.steam.handlers.steamcloud.AppFileChangeList +import `in`.dragonbra.javasteam.steam.handlers.steamcloud.AppFileInfo +import `in`.dragonbra.javasteam.steam.handlers.steamcloud.SteamCloud +import `in`.dragonbra.javasteam.steam.handlers.steamcloud.FileDownloadInfo +import `in`.dragonbra.javasteam.steam.steamclient.configuration.SteamConfiguration +import app.gamenative.enums.SyncResult +import `in`.dragonbra.javasteam.util.crypto.CryptoHelper +import kotlinx.coroutines.runBlocking +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody +import java.util.Date +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import io.mockk.every +import io.mockk.mockk +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.lang.reflect.Field +import java.util.EnumSet +import java.util.concurrent.CompletableFuture + +@RunWith(RobolectricTestRunner::class) +class SteamAutoCloudTest { + + private lateinit var context: Context + private lateinit var tempDir: File + private lateinit var saveFilesDir: File + private lateinit var db: PluviaDatabase + private lateinit var mockSteamService: SteamService + private lateinit var mockSteamCloud: SteamCloud + private val testAppId = "STEAM_123456" + private val steamAppId = 123456 + private val clientId = 1L + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + tempDir = File.createTempFile("steam_autocloud_test_", null) + tempDir.delete() + tempDir.mkdirs() + + // Set up DownloadService paths + DownloadService.populateDownloadService(context) + File(SteamService.internalAppInstallPath).mkdirs() + SteamService.externalAppInstallPath.takeIf { it.isNotBlank() }?.let { File(it).mkdirs() } + + // Set up ImageFs + val imageFs = ImageFs.find(context) + val homeDir = File(imageFs.rootDir, "home") + homeDir.mkdirs() + + val containerDir = File(homeDir, "${ImageFs.USER}-${testAppId}") + containerDir.mkdirs() + + // Create container + val container = Container(testAppId) + container.setRootDir(containerDir) + container.name = "Test Container" + container.saveData() + + // Create save files directory structure matching Windows path + // %WinMyDocuments%My Games/TestGame/Steam/76561198025127569 + val wineprefix = File(imageFs.wineprefix) + wineprefix.mkdirs() + val dosDevices = File(wineprefix, "dosdevices") + dosDevices.mkdirs() + val cDrive = File(dosDevices, "c:") + cDrive.mkdirs() + val users = File(cDrive, "users") + users.mkdirs() + val xuser = File(users, "xuser") + xuser.mkdirs() + val documents = File(xuser, "Documents") + documents.mkdirs() + val myGames = File(documents, "My Games") + myGames.mkdirs() + val testGame = File(myGames, "TestGame") + testGame.mkdirs() + val steam = File(testGame, "Steam") + steam.mkdirs() + val steamId = File(steam, "76561198025127569") + steamId.mkdirs() + val saveGames = File(steamId, "SaveGames") + saveGames.mkdirs() + saveFilesDir = saveGames + + // Set up in-memory database + db = Room.inMemoryDatabaseBuilder(context, PluviaDatabase::class.java) + .allowMainThreadQueries() + .build() + + // Create test SteamApp with 3 patterns sharing the same prefix + val saveFilePatterns = listOf( + SaveFilePattern( + root = PathType.WinMyDocuments, + path = "My Games/TestGame/Steam/76561198025127569", + pattern = "Capture*.sav", + ), + SaveFilePattern( + root = PathType.WinMyDocuments, + path = "My Games/TestGame/Steam/76561198025127569", + pattern = "*SaveData*.sav", + ), + SaveFilePattern( + root = PathType.WinMyDocuments, + path = "My Games/TestGame/Steam/76561198025127569", + pattern = "SystemData_0.sav", + ), + ) + + val testApp = SteamApp( + id = steamAppId, + name = "Test Game", + config = ConfigInfo(installDir = "123456"), + type = AppType.game, + osList = EnumSet.of(OS.windows), + releaseState = ReleaseState.released, + ufs = UFS(saveFilePatterns = saveFilePatterns), + ) + + runBlocking { + db.steamAppDao().insert(testApp) + } + + // Create test files + // Pattern 2: *SaveData*.sav should match 4 files + File(saveGames, "AutoSaveData.sav").writeBytes("autosave content".toByteArray()) + File(saveGames, "SaveData_0.sav").writeBytes("savedata0 content".toByteArray()) + File(saveGames, "ContinueSaveData.sav").writeBytes("continue content".toByteArray()) + File(saveGames, "SaveData_1.sav").writeBytes("savedata1 content".toByteArray()) + + // Pattern 3: SystemData_0.sav should match 1 file + File(saveGames, "SystemData_0.sav").writeBytes("systemdata content".toByteArray()) + + // Pattern 1: Capture*.sav should match 0 files (none created) + + // Mock SteamService + mockSteamService = mock() + whenever(mockSteamService.appDao).thenReturn(db.steamAppDao()) + whenever(mockSteamService.fileChangeListsDao).thenReturn(db.appFileChangeListsDao()) + whenever(mockSteamService.changeNumbersDao).thenReturn(db.appChangeNumbersDao()) + whenever(mockSteamService.db).thenReturn(db) + + val mockSteamClient = mock<`in`.dragonbra.javasteam.steam.steamclient.SteamClient>() + val mockSteamID = mock<`in`.dragonbra.javasteam.types.SteamID>() + whenever(mockSteamService.steamClient).thenReturn(mockSteamClient) + whenever(mockSteamClient.steamID).thenReturn(mockSteamID) + + // Set SteamService.instance using reflection + try { + val instanceField = SteamService::class.java.getDeclaredField("instance") + instanceField.isAccessible = true + instanceField.set(null, mockSteamService) + } catch (e: Exception) { + fail("Failed to set SteamService.instance: ${e.message}") + } + + // Use MockK for SteamCloud - handles Kotlin default parameters properly + mockSteamCloud = mockk(relaxed = true) + + // Mock empty AppFileChangeList (no cloud files) + val emptyAppFileChangeList = mock() + whenever(emptyAppFileChangeList.currentChangeNumber).thenReturn(0) + whenever(emptyAppFileChangeList.isOnlyDelta).thenReturn(false) + whenever(emptyAppFileChangeList.appBuildIDHwm).thenReturn(0) + whenever(emptyAppFileChangeList.pathPrefixes).thenReturn(emptyList()) + whenever(emptyAppFileChangeList.machineNames).thenReturn(emptyList()) + whenever(emptyAppFileChangeList.files).thenReturn(emptyList()) + + every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns + CompletableFuture.completedFuture(emptyAppFileChangeList) + + // Mock upload batch methods + val mockUploadBatchResponse = mock<`in`.dragonbra.javasteam.steam.handlers.steamcloud.AppUploadBatchResponse>() + whenever(mockUploadBatchResponse.batchID).thenReturn(1) + whenever(mockUploadBatchResponse.appChangeNumber).thenReturn(1) + + every { mockSteamCloud.beginAppUploadBatch(any(), any(), any(), any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(mockUploadBatchResponse) + + val mockFileUploadInfo = mock<`in`.dragonbra.javasteam.steam.handlers.steamcloud.FileUploadInfo>() + whenever(mockFileUploadInfo.blockRequests).thenReturn(emptyList()) + + every { mockSteamCloud.beginFileUpload(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(mockFileUploadInfo) + + every { mockSteamCloud.commitFileUpload(any(), any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(true) + + every { mockSteamCloud.completeAppUploadBatch(any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(Unit) + + // Initialize database: set change number to 0 to match cloud (so we test upload path) + // Insert an empty file-change-list row so getByAppId() is non-null and diff detects local changes + runBlocking { + db.appChangeNumbersDao().insert(app.gamenative.data.ChangeNumbers(steamAppId, 0)) + db.appFileChangeListsDao().insert(steamAppId, emptyList()) + } + } + + @After + fun tearDown() { + // Clean up ImageFs directory first (files created in wineprefix) + // This is critical because ImageFs uses context.getFilesDir() which is inside Robolectric's temp directory + try { + val imageFs = ImageFs.find(context) + val imageFsRoot = imageFs.rootDir + if (imageFsRoot.exists()) { + imageFsRoot.deleteRecursively() + } + + // Reset ImageFs singleton to prevent issues across tests + val instanceField = ImageFs::class.java.getDeclaredField("INSTANCE") + instanceField.isAccessible = true + instanceField.set(null, null) + } catch (e: Exception) { + // Ignore cleanup errors - files might be locked, but Robolectric will handle it + } + + // Clean up temp directory + try { + tempDir.deleteRecursively() + } catch (e: Exception) { + // Ignore cleanup errors + } + + // Close database + db.close() + + // Give file system a moment to release locks (especially important in CI) + Thread.sleep(50) + } + + @Test + fun testMultiplePatternsSamePrefix_returnsAllFiles() = runBlocking { + // Get the test app + val testApp = db.steamAppDao().findApp(steamAppId)!! + + // Create prefixToPath function that maps to our test directory structure + val prefixToPath: (String) -> String = { prefix -> + when { + prefix == "WinMyDocuments" -> { + val imageFs = ImageFs.find(context) + val wineprefix = File(imageFs.wineprefix) + val dosDevices = File(wineprefix, "dosdevices") + val cDrive = File(dosDevices, "c:") + val users = File(cDrive, "users") + val xuser = File(users, "xuser") + val documents = File(xuser, "Documents") + documents.absolutePath + } + else -> tempDir.absolutePath + } + } + + // Call syncUserFiles + val result = SteamAutoCloud.syncUserFiles( + appInfo = testApp, + clientId = clientId, + steamInstance = mockSteamService, + steamCloud = mockSteamCloud, + preferredSave = SaveLocation.None, + prefixToPath = prefixToPath, + ).await() + + // Verify result + assertNotNull("Result should not be null", result) + assertEquals("Should upload 5 files (4 from pattern 2 + 1 from pattern 3)", 5, result!!.filesUploaded) + assertTrue("Uploads should be completed", result.uploadsCompleted) + assertEquals("Should have 5 files managed", 5, result.filesManaged) + } + +// @Test + fun testDownloadCloudSavesOnFirstBoot() = runBlocking { + // Clear existing files and database state + saveFilesDir.listFiles()?.forEach { it.delete() } + runBlocking { + db.appChangeNumbersDao().deleteByAppId(steamAppId) + db.appFileChangeListsDao().deleteByAppId(steamAppId) + } + + // Set local change number to 0 (first boot scenario) + runBlocking { + db.appChangeNumbersDao().insert(app.gamenative.data.ChangeNumbers(steamAppId, 0)) + db.appFileChangeListsDao().insert(steamAppId, emptyList()) + } + + val testApp = db.steamAppDao().findApp(steamAppId)!! + + // Create cloud files to download + val cloudFile1Content = "cloud save file 1 content".toByteArray() + val cloudFile2Content = "cloud save file 2 content".toByteArray() + val cloudFile3Content = "cloud save file 3 content".toByteArray() + + val cloudFile1Sha = CryptoHelper.shaHash(cloudFile1Content) + val cloudFile2Sha = CryptoHelper.shaHash(cloudFile2Content) + val cloudFile3Sha = CryptoHelper.shaHash(cloudFile3Content) + + // Create mock AppFileInfo instances + val mockFile1 = mock() + whenever(mockFile1.filename).thenReturn("cloud_save_1.sav") + whenever(mockFile1.shaFile).thenReturn(cloudFile1Sha) + whenever(mockFile1.pathPrefixIndex).thenReturn(0) + whenever(mockFile1.timestamp).thenReturn(Date()) + whenever(mockFile1.rawFileSize).thenReturn(cloudFile1Content.size) + + val mockFile2 = mock() + whenever(mockFile2.filename).thenReturn("cloud_save_2.sav") + whenever(mockFile2.shaFile).thenReturn(cloudFile2Sha) + whenever(mockFile2.pathPrefixIndex).thenReturn(0) + whenever(mockFile2.timestamp).thenReturn(Date()) + whenever(mockFile2.rawFileSize).thenReturn(cloudFile2Content.size) + + val mockFile3 = mock() + whenever(mockFile3.filename).thenReturn("cloud_save_3.sav") + whenever(mockFile3.shaFile).thenReturn(cloudFile3Sha) + whenever(mockFile3.pathPrefixIndex).thenReturn(0) + whenever(mockFile3.timestamp).thenReturn(Date()) + whenever(mockFile3.rawFileSize).thenReturn(cloudFile3Content.size) + + // Create mock AppFileChangeList with cloud files + val cloudChangeNumber = 5 + val mockAppFileChangeList = mock() + whenever(mockAppFileChangeList.currentChangeNumber).thenReturn(cloudChangeNumber.toLong()) + whenever(mockAppFileChangeList.isOnlyDelta).thenReturn(false) + whenever(mockAppFileChangeList.appBuildIDHwm).thenReturn(0) + whenever(mockAppFileChangeList.pathPrefixes).thenReturn(listOf("%WinMyDocuments%/My Games/TestGame/Steam/76561198025127569")) + whenever(mockAppFileChangeList.machineNames).thenReturn(emptyList()) + whenever(mockAppFileChangeList.files).thenReturn(listOf(mockFile1, mockFile2, mockFile3)) + + every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns + CompletableFuture.completedFuture(mockAppFileChangeList) + + // Mock FileDownloadInfo for each file + val mockDownloadInfo1 = mock() + whenever(mockDownloadInfo1.urlHost).thenReturn("test.example.com") + whenever(mockDownloadInfo1.urlPath).thenReturn("/download/file1") + whenever(mockDownloadInfo1.useHttps).thenReturn(true) + whenever(mockDownloadInfo1.requestHeaders).thenReturn(emptyList()) + whenever(mockDownloadInfo1.fileSize).thenReturn(cloudFile1Content.size) + whenever(mockDownloadInfo1.rawFileSize).thenReturn(cloudFile1Content.size) + + val mockDownloadInfo2 = mock() + whenever(mockDownloadInfo2.urlHost).thenReturn("test.example.com") + whenever(mockDownloadInfo2.urlPath).thenReturn("/download/file2") + whenever(mockDownloadInfo2.useHttps).thenReturn(true) + whenever(mockDownloadInfo2.requestHeaders).thenReturn(emptyList()) + whenever(mockDownloadInfo2.fileSize).thenReturn(cloudFile2Content.size) + whenever(mockDownloadInfo2.rawFileSize).thenReturn(cloudFile2Content.size) + + val mockDownloadInfo3 = mock() + whenever(mockDownloadInfo3.urlHost).thenReturn("test.example.com") + whenever(mockDownloadInfo3.urlPath).thenReturn("/download/file3") + whenever(mockDownloadInfo3.useHttps).thenReturn(true) + whenever(mockDownloadInfo3.requestHeaders).thenReturn(emptyList()) + whenever(mockDownloadInfo3.fileSize).thenReturn(cloudFile3Content.size) + whenever(mockDownloadInfo3.rawFileSize).thenReturn(cloudFile3Content.size) + + // Mock clientFileDownload to return appropriate download info based on filename in the path + var downloadCallCount = 0 + every { mockSteamCloud.clientFileDownload(any(), any()) } answers { + downloadCallCount++ + when (downloadCallCount) { + 1 -> CompletableFuture.completedFuture(mockDownloadInfo1) + 2 -> CompletableFuture.completedFuture(mockDownloadInfo2) + 3 -> CompletableFuture.completedFuture(mockDownloadInfo3) + else -> CompletableFuture.completedFuture(mockDownloadInfo1) // fallback + } + } + + // Mock HTTP client to return file content + val mockHttpClient = mock() + val mockCall = mock() + whenever(mockHttpClient.newCall(any())).thenReturn(mockCall) + + // Create mock responses with file content + val responseBody1 = ResponseBody.create(null, cloudFile1Content) + val response1 = Response.Builder() + .request(okhttp3.Request.Builder().url("https://test.example.com/download/file1").build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody1) + .build() + + val responseBody2 = ResponseBody.create(null, cloudFile2Content) + val response2 = Response.Builder() + .request(okhttp3.Request.Builder().url("https://test.example.com/download/file2").build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody2) + .build() + + val responseBody3 = ResponseBody.create(null, cloudFile3Content) + val response3 = Response.Builder() + .request(okhttp3.Request.Builder().url("https://test.example.com/download/file3").build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody3) + .build() + + // Return responses in order + var callCount = 0 + whenever(mockCall.execute()).thenAnswer { + callCount++ + when (callCount) { + 1 -> response1 + 2 -> response2 + 3 -> response3 + else -> response3 + } + } + + // Set up HTTP client on the existing mock steam client + val mockSteamClient = mockSteamService.steamClient!! + val mockConfig = mock() + whenever(mockSteamClient.configuration).thenReturn(mockConfig) + whenever(mockConfig.httpClient).thenReturn(mockHttpClient) + + // Create prefixToPath function + val prefixToPath: (String) -> String = { prefix -> + when { + prefix == "WinMyDocuments" -> { + val imageFs = ImageFs.find(context) + val wineprefix = File(imageFs.wineprefix) + val dosDevices = File(wineprefix, "dosdevices") + val cDrive = File(dosDevices, "c:") + val users = File(cDrive, "users") + val xuser = File(users, "xuser") + val documents = File(xuser, "Documents") + documents.absolutePath + } + else -> tempDir.absolutePath + } + } + + // Call syncUserFiles + val result = SteamAutoCloud.syncUserFiles( + appInfo = testApp, + clientId = clientId, + steamInstance = mockSteamService, + steamCloud = mockSteamCloud, + preferredSave = SaveLocation.None, + prefixToPath = prefixToPath, + ).await() + + // Verify result + assertNotNull("Result should not be null", result) + assertEquals("Should download 3 files", 3, result!!.filesDownloaded) + assertEquals("Sync result should be Success", SyncResult.Success, result.syncResult) + assertTrue("Bytes downloaded should be > 0", result.bytesDownloaded > 0) + + // Verify files were written to disk + val expectedFile1 = File(saveFilesDir, "cloud_save_1.sav") + val expectedFile2 = File(saveFilesDir, "cloud_save_2.sav") + val expectedFile3 = File(saveFilesDir, "cloud_save_3.sav") + + assertTrue("File 1 should exist", expectedFile1.exists()) + assertTrue("File 2 should exist", expectedFile2.exists()) + assertTrue("File 3 should exist", expectedFile3.exists()) + + assertEquals("File 1 content should match", cloudFile1Content.contentToString(), expectedFile1.readBytes().contentToString()) + assertEquals("File 2 content should match", cloudFile2Content.contentToString(), expectedFile2.readBytes().contentToString()) + assertEquals("File 3 content should match", cloudFile3Content.contentToString(), expectedFile3.readBytes().contentToString()) + + // Verify database change number was updated + val changeNumber = db.appChangeNumbersDao().getByAppId(steamAppId) + assertNotNull("Change number should exist", changeNumber) + assertEquals("Change number should match cloud", cloudChangeNumber, changeNumber!!.changeNumber) + } + + @Test + fun testUploadOnSubsequentBoots() = runBlocking { + val testApp = db.steamAppDao().findApp(steamAppId)!! + + // Set local change number to match cloud (e.g., both 5) + val matchingChangeNumber = 5 + runBlocking { + db.appChangeNumbersDao().deleteByAppId(steamAppId) + db.appFileChangeListsDao().deleteByAppId(steamAppId) + db.appChangeNumbersDao().insert(app.gamenative.data.ChangeNumbers(steamAppId, matchingChangeNumber.toLong())) + + // Insert old file state into database (different from current local files) + val oldFileContent = "old file content".toByteArray() + val oldFileSha = CryptoHelper.shaHash(oldFileContent) + val oldUserFile = app.gamenative.data.UserFileInfo( + root = PathType.WinMyDocuments, + path = "My Games/TestGame/Steam/76561198025127569", + filename = "SaveData_0.sav", + timestamp = System.currentTimeMillis() - 10000, + sha = oldFileSha + ) + db.appFileChangeListsDao().insert(steamAppId, listOf(oldUserFile)) + } + + // Create new local files that differ from database state + saveFilesDir.listFiles()?.forEach { it.delete() } + val newFile1Content = "new save data 1".toByteArray() + val newFile2Content = "new save data 2".toByteArray() + File(saveFilesDir, "SaveData_0.sav").writeBytes(newFile1Content) + File(saveFilesDir, "SaveData_New.sav").writeBytes(newFile2Content) + + // Mock AppFileChangeList with matching change number (no new cloud files) + val mockAppFileChangeList = mock() + whenever(mockAppFileChangeList.currentChangeNumber).thenReturn(matchingChangeNumber.toLong()) + whenever(mockAppFileChangeList.isOnlyDelta).thenReturn(false) + whenever(mockAppFileChangeList.appBuildIDHwm).thenReturn(0) + whenever(mockAppFileChangeList.pathPrefixes).thenReturn(listOf("%WinMyDocuments%/My Games/TestGame/Steam/76561198025127569")) + whenever(mockAppFileChangeList.machineNames).thenReturn(emptyList()) + whenever(mockAppFileChangeList.files).thenReturn(emptyList()) + + every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns + CompletableFuture.completedFuture(mockAppFileChangeList) + + // Mock upload batch response + val mockUploadBatchResponse = mock<`in`.dragonbra.javasteam.steam.handlers.steamcloud.AppUploadBatchResponse>() + whenever(mockUploadBatchResponse.batchID).thenReturn(1) + whenever(mockUploadBatchResponse.appChangeNumber).thenReturn((matchingChangeNumber + 1).toLong()) + + every { mockSteamCloud.beginAppUploadBatch(any(), any(), any(), any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(mockUploadBatchResponse) + + val mockFileUploadInfo = mock<`in`.dragonbra.javasteam.steam.handlers.steamcloud.FileUploadInfo>() + whenever(mockFileUploadInfo.blockRequests).thenReturn(emptyList()) + + every { mockSteamCloud.beginFileUpload(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(mockFileUploadInfo) + + every { mockSteamCloud.commitFileUpload(any(), any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(true) + + every { mockSteamCloud.completeAppUploadBatch(any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(Unit) + + // Create prefixToPath function + val prefixToPath: (String) -> String = { prefix -> + when { + prefix == "WinMyDocuments" -> { + val imageFs = ImageFs.find(context) + val wineprefix = File(imageFs.wineprefix) + val dosDevices = File(wineprefix, "dosdevices") + val cDrive = File(dosDevices, "c:") + val users = File(cDrive, "users") + val xuser = File(users, "xuser") + val documents = File(xuser, "Documents") + documents.absolutePath + } + else -> tempDir.absolutePath + } + } + + // Call syncUserFiles + val result = SteamAutoCloud.syncUserFiles( + appInfo = testApp, + clientId = clientId, + steamInstance = mockSteamService, + steamCloud = mockSteamCloud, + preferredSave = SaveLocation.None, + prefixToPath = prefixToPath, + ).await() + + // Verify result + assertNotNull("Result should not be null", result) + assertTrue("Uploads should be required", result!!.uploadsRequired) + assertTrue("Uploads should be completed", result.uploadsCompleted) + assertEquals("Should upload 2 files (1 modified + 1 new)", 2, result.filesUploaded) + assertEquals("Sync result should be Success", SyncResult.Success, result.syncResult) + + // Verify database was updated with new change number + val changeNumber = db.appChangeNumbersDao().getByAppId(steamAppId) + assertNotNull("Change number should exist", changeNumber) + assertEquals("Change number should be updated", (matchingChangeNumber + 1).toLong(), changeNumber!!.changeNumber) + } + + @Test + fun testPrefixResolution() = runBlocking { + val testApp = db.steamAppDao().findApp(steamAppId)!! + + // Create test files in multiple path types + val imageFs = ImageFs.find(context) + val wineprefix = File(imageFs.wineprefix) + val dosDevices = File(wineprefix, "dosdevices") + val cDrive = File(dosDevices, "c:") + val users = File(cDrive, "users") + val xuser = File(users, "xuser") + + // WinMyDocuments: Documents/My Games/TestGame/save1.sav + val documents = File(xuser, "Documents") + val myGames = File(documents, "My Games") + val testGameDocs = File(myGames, "TestGame") + testGameDocs.mkdirs() + val docSaveFile = File(testGameDocs, "save1.sav") + val docSaveContent = "documents save".toByteArray() + docSaveFile.writeBytes(docSaveContent) + + // WinAppDataLocal: AppData/Local/TestGame/save2.sav + val appData = File(xuser, "AppData") + val local = File(appData, "Local") + val testGameLocal = File(local, "TestGame") + testGameLocal.mkdirs() + val localSaveFile = File(testGameLocal, "save2.sav") + val localSaveContent = "local save".toByteArray() + localSaveFile.writeBytes(localSaveContent) + + // SteamUserData: Create structure for Steam userdata + val programFiles = File(cDrive, "Program Files (x86)") + val steam = File(programFiles, "Steam") + val userdata = File(steam, "userdata") + val accountId = File(userdata, "76561198025127569") + val appIdDir = File(accountId, steamAppId.toString()) + val remote = File(appIdDir, "remote") + remote.mkdirs() + val steamSaveFile = File(remote, "save3.sav") + val steamSaveContent = "steam save".toByteArray() + steamSaveFile.writeBytes(steamSaveContent) + + // Update test app with patterns for all three path types + val saveFilePatterns = listOf( + SaveFilePattern( + root = PathType.WinMyDocuments, + path = "My Games/TestGame", + pattern = "save1.sav", + ), + SaveFilePattern( + root = PathType.WinAppDataLocal, + path = "TestGame", + pattern = "save2.sav", + ), + SaveFilePattern( + root = PathType.SteamUserData, + path = "", + pattern = "save3.sav", + ), + ) + + val updatedApp = testApp.copy(ufs = UFS(saveFilePatterns = saveFilePatterns)) + runBlocking { + db.steamAppDao().update(updatedApp) + } + + // Clear existing database state + runBlocking { + db.appChangeNumbersDao().deleteByAppId(steamAppId) + db.appFileChangeListsDao().deleteByAppId(steamAppId) + db.appChangeNumbersDao().insert(app.gamenative.data.ChangeNumbers(steamAppId, 0)) + db.appFileChangeListsDao().insert(steamAppId, emptyList()) + } + + // Mock empty cloud (no cloud files) + val mockAppFileChangeList = mock() + whenever(mockAppFileChangeList.currentChangeNumber).thenReturn(0) + whenever(mockAppFileChangeList.isOnlyDelta).thenReturn(false) + whenever(mockAppFileChangeList.appBuildIDHwm).thenReturn(0) + whenever(mockAppFileChangeList.pathPrefixes).thenReturn(emptyList()) + whenever(mockAppFileChangeList.machineNames).thenReturn(emptyList()) + whenever(mockAppFileChangeList.files).thenReturn(emptyList()) + + every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns + CompletableFuture.completedFuture(mockAppFileChangeList) + + // Create prefixToPath function that maps all path types + val prefixToPath: (String) -> String = { prefix -> + when (prefix) { + "WinMyDocuments" -> documents.absolutePath + "WinAppDataLocal" -> local.absolutePath + "SteamUserData" -> remote.absolutePath + else -> tempDir.absolutePath + } + } + + // Call syncUserFiles + val result = SteamAutoCloud.syncUserFiles( + appInfo = updatedApp, + clientId = clientId, + steamInstance = mockSteamService, + steamCloud = mockSteamCloud, + preferredSave = SaveLocation.None, + prefixToPath = prefixToPath, + ).await() + + // Verify result + assertNotNull("Result should not be null", result) + assertEquals("Should upload 3 files (one from each path type)", 3, result!!.filesUploaded) + assertTrue("Uploads should be completed", result.uploadsCompleted) + assertEquals("Should have 3 files managed", 3, result.filesManaged) + + // Verify files were found in correct locations + assertTrue("Documents save file should exist", docSaveFile.exists()) + assertTrue("Local save file should exist", localSaveFile.exists()) + assertTrue("Steam save file should exist", steamSaveFile.exists()) + } + + @Test + fun testSaveFileDepthDiscovery() = runBlocking { + val testApp = db.steamAppDao().findApp(steamAppId)!! + + // Create nested directory structure up to depth 7 + val basePath = saveFilesDir + basePath.listFiles()?.forEach { it.deleteRecursively() } + + // Depth 0 + File(basePath, "level0.sav").writeBytes("level0".toByteArray()) + + // Depth 1 + val subdir1 = File(basePath, "subdir1") + subdir1.mkdirs() + File(subdir1, "level1.sav").writeBytes("level1".toByteArray()) + + // Depth 2 + val subdir2 = File(subdir1, "subdir2") + subdir2.mkdirs() + File(subdir2, "level2.sav").writeBytes("level2".toByteArray()) + + // Depth 3 + val subdir3 = File(subdir2, "subdir3") + subdir3.mkdirs() + File(subdir3, "level3.sav").writeBytes("level3".toByteArray()) + + // Depth 4 + val subdir4 = File(subdir3, "subdir4") + subdir4.mkdirs() + File(subdir4, "level4.sav").writeBytes("level4".toByteArray()) + + // Depth 5 + val subdir5 = File(subdir4, "subdir5") + subdir5.mkdirs() + File(subdir5, "level5.sav").writeBytes("level5".toByteArray()) + + // Depth 6 (should NOT be found - beyond maxDepth=5) + val subdir6 = File(subdir5, "subdir6") + subdir6.mkdirs() + File(subdir6, "level6.sav").writeBytes("level6".toByteArray()) + + // Depth 7 (should NOT be found - beyond maxDepth=5) + val subdir7 = File(subdir6, "subdir7") + subdir7.mkdirs() + File(subdir7, "level7.sav").writeBytes("level7".toByteArray()) + + // Update test app with pattern that matches all .sav files + val saveFilePatterns = listOf( + SaveFilePattern( + root = PathType.WinMyDocuments, + path = "My Games/TestGame/Steam/76561198025127569", + pattern = "*.sav", + ), + ) + + val updatedApp = testApp.copy(ufs = UFS(saveFilePatterns = saveFilePatterns)) + runBlocking { + db.steamAppDao().update(updatedApp) + } + + // Clear existing database state + runBlocking { + db.appChangeNumbersDao().deleteByAppId(steamAppId) + db.appFileChangeListsDao().deleteByAppId(steamAppId) + db.appChangeNumbersDao().insert(app.gamenative.data.ChangeNumbers(steamAppId, 0)) + db.appFileChangeListsDao().insert(steamAppId, emptyList()) + } + + // Mock empty cloud (no cloud files) + val mockAppFileChangeList = mock() + whenever(mockAppFileChangeList.currentChangeNumber).thenReturn(0) + whenever(mockAppFileChangeList.isOnlyDelta).thenReturn(false) + whenever(mockAppFileChangeList.appBuildIDHwm).thenReturn(0) + whenever(mockAppFileChangeList.pathPrefixes).thenReturn(emptyList()) + whenever(mockAppFileChangeList.machineNames).thenReturn(emptyList()) + whenever(mockAppFileChangeList.files).thenReturn(emptyList()) + + every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns + CompletableFuture.completedFuture(mockAppFileChangeList) + + // Create prefixToPath function + val prefixToPath: (String) -> String = { prefix -> + when { + prefix == "WinMyDocuments" -> { + val imageFs = ImageFs.find(context) + val wineprefix = File(imageFs.wineprefix) + val dosDevices = File(wineprefix, "dosdevices") + val cDrive = File(dosDevices, "c:") + val users = File(cDrive, "users") + val xuser = File(users, "xuser") + val documents = File(xuser, "Documents") + documents.absolutePath + } + else -> tempDir.absolutePath + } + } + + // Call syncUserFiles + val result = SteamAutoCloud.syncUserFiles( + appInfo = updatedApp, + clientId = clientId, + steamInstance = mockSteamService, + steamCloud = mockSteamCloud, + preferredSave = SaveLocation.None, + prefixToPath = prefixToPath, + ).await() + + // Verify result - should find files at depths 0-5 (6 files), but not depths 6-7 + assertNotNull("Result should not be null", result) + assertEquals("Should upload 5 files (depths 0-5, maxDepth=5)", 5, result!!.filesUploaded) + assertTrue("Uploads should be completed", result.uploadsCompleted) + assertEquals("Should have 5 files managed", 5, result.filesManaged) + + // Verify files at depths 0-5 exist + assertTrue("Level 0 file should exist", File(basePath, "level0.sav").exists()) + assertTrue("Level 1 file should exist", File(subdir1, "level1.sav").exists()) + assertTrue("Level 2 file should exist", File(subdir2, "level2.sav").exists()) + assertTrue("Level 3 file should exist", File(subdir3, "level3.sav").exists()) + assertTrue("Level 4 file should exist", File(subdir4, "level4.sav").exists()) + + // Verify files at depths 6-7 exist on disk but were NOT included in upload + assertTrue("Level 6 file should exist on disk", File(subdir6, "level6.sav").exists()) + assertTrue("Level 7 file should exist on disk", File(subdir7, "level7.sav").exists()) + // But they should not be in the managed files count (verified by filesManaged == 6) + } +} + diff --git a/app/src/test/java/app/gamenative/service/gog/GOGAuthManagerTest.kt b/app/src/test/java/app/gamenative/service/gog/GOGAuthManagerTest.kt new file mode 100644 index 0000000000..61aaa3e3f7 --- /dev/null +++ b/app/src/test/java/app/gamenative/service/gog/GOGAuthManagerTest.kt @@ -0,0 +1,251 @@ +package app.gamenative.service.gog + +import android.content.Context +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import timber.log.Timber +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config( + manifest = Config.NONE, + application = android.app.Application::class +) +class GOGAuthManagerTest { + @Mock + private lateinit var context: Context + + private lateinit var mockWebServer: MockWebServer + private lateinit var closeable: AutoCloseable + private lateinit var tempDir: File + + companion object { + @JvmStatic + @BeforeClass + fun setUpClass() { + // Silence Timber logging in all tests + Timber.uprootAll() + } + } + + @Before + fun setUp() { + closeable = MockitoAnnotations.openMocks(this) + mockWebServer = MockWebServer() + mockWebServer.start() + tempDir = createTempDir("gogtest") + `when`(context.filesDir).thenReturn(tempDir) + } + + @After + fun tearDown() { + mockWebServer.shutdown() + closeable.close() + tempDir.deleteRecursively() + } + + @Test + fun testAuthenticateWithCode_success() = runTest { + // Arrange + val code = "good_code" + val json = JSONObject().apply { + put("access_token", "token123") + put("refresh_token", "refresh123") + put("user_id", "user123") + put("expires_in", 3600) + }.toString() + + mockWebServer.enqueue(MockResponse() + .setResponseCode(200) + .setBody(json) + .addHeader("Content-Type", "application/json")) + + // Act + val result = withMockedHttpClient(mockWebServer.url("/token").toString()) { + GOGAuthManager.authenticateWithCode(context, code) + } + + // Assert + assertTrue(result.isSuccess) + val creds = result.getOrNull()!! + assertEquals("token123", creds.accessToken) + assertEquals("refresh123", creds.refreshToken) + assertEquals("user123", creds.userId) + + // Verify request + val request = mockWebServer.takeRequest() + assertTrue(request.path?.startsWith("/token?") == true) + assertTrue(request.path?.contains("authorization_code") == true) + } + + @Test + fun testAuthenticateWithCode_failure() = runTest { + // Arrange + val code = "bad_code" + val json = JSONObject().apply { + put("error", "invalid_grant") + put("error_description", "Invalid code") + }.toString() + + mockWebServer.enqueue(MockResponse() + .setResponseCode(400) + .setBody(json) + .addHeader("Content-Type", "application/json")) + + // Act + val result = withMockedHttpClient(mockWebServer.url("/token").toString()) { + GOGAuthManager.authenticateWithCode(context, code) + } + + // Assert + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("Invalid code") == true) + } + + @Test + fun testGetStoredCredentials_success() = runTest { + val authJson = JSONObject().apply { + put(GOGConstants.GOG_CLIENT_ID, JSONObject().apply { + put("access_token", "token123") + put("refresh_token", "refresh123") + put("user_id", "user123") + put("expires_in", 3600) + put("loginTime", System.currentTimeMillis() / 1000.0 + 1000) + }) + }.toString() + val authFile = File(tempDir, "gog_auth.json") + authFile.writeText(authJson) + + val result = GOGAuthManager.getStoredCredentials(context) + assertTrue(result.isSuccess) + val creds = result.getOrNull()!! + assertEquals("token123", creds.accessToken) + } + + @Test + fun testGetStoredCredentials_expired_refreshSuccess() = runTest { + // Arrange + val authJson = JSONObject().apply { + put(GOGConstants.GOG_CLIENT_ID, JSONObject().apply { + put("access_token", "old_token") + put("refresh_token", "refresh123") + put("user_id", "user123") + put("expires_in", 1) + put("loginTime", 0) + }) + }.toString() + val authFile = File(tempDir, "gog_auth.json") + authFile.writeText(authJson) + + // Mock refresh token response + val refreshJson = JSONObject().apply { + put("access_token", "new_token") + put("refresh_token", "refresh123") + put("user_id", "user123") + put("expires_in", 3600) + }.toString() + + mockWebServer.enqueue(MockResponse() + .setResponseCode(200) + .setBody(refreshJson) + .addHeader("Content-Type", "application/json")) + + // Act + val result = withMockedHttpClient(mockWebServer.url("/token").toString()) { + GOGAuthManager.getStoredCredentials(context) + } + + // Assert + assertTrue(result.isSuccess) + val creds = result.getOrNull()!! + assertEquals("new_token", creds.accessToken) + + // Verify refresh request + val request = mockWebServer.takeRequest() + assertTrue(request.path?.contains("refresh_token") == true) + } + + @Test + fun testValidateCredentials_success() = runTest { + val authJson = JSONObject().apply { + put(GOGConstants.GOG_CLIENT_ID, JSONObject().apply { + put("access_token", "token123") + put("refresh_token", "refresh123") + put("user_id", "user123") + put("expires_in", 3600) + put("loginTime", System.currentTimeMillis() / 1000.0 + 1000) + }) + }.toString() + val authFile = File(tempDir, "gog_auth.json") + authFile.writeText(authJson) + + val result = GOGAuthManager.validateCredentials(context) + assertTrue(result.isSuccess) + assertTrue(result.getOrNull() == true) + } + + @Test + fun testValidateCredentials_failure() = runTest { + // No file created, so should fail + val result = GOGAuthManager.validateCredentials(context) + assertTrue(result.isSuccess) + assertTrue(result.getOrNull() == false) + } + + @Test + fun testExtractCodeFromInput_withFullUrl() { + val url = "https://embed.gog.com/on_login_success?code=ABC123XYZ&origin=client" + val result = GOGAuthManager.extractCodeFromInput(url) + assertEquals("ABC123XYZ", result) + } + + @Test + fun testExtractCodeFromInput_withUrlMultipleParams() { + val url = "https://embed.gog.com/on_login_success?code=DEF456&origin=client&state=test" + val result = GOGAuthManager.extractCodeFromInput(url) + assertEquals("DEF456", result) + } + + @Test + fun testExtractCodeFromInput_withUrlNoCode() { + val url = "https://embed.gog.com/on_login_success?origin=client" + val result = GOGAuthManager.extractCodeFromInput(url) + assertEquals("", result) + } + + @Test + fun testExtractCodeFromInput_withPlainCode() { + val code = "PLAIN_CODE_123" + val result = GOGAuthManager.extractCodeFromInput(code) + assertEquals("PLAIN_CODE_123", result) + } + + // --- Helpers --- + private suspend fun withMockedHttpClient(testTokenUrl: String, block: suspend () -> T): T { + // Override the token URL to point to MockWebServer + val originalTokenUrl = GOGAuthManager.tokenUrl + GOGAuthManager.tokenUrl = testTokenUrl + + try { + return block() + } finally { + GOGAuthManager.tokenUrl = originalTokenUrl + } + } +} diff --git a/app/src/test/java/app/gamenative/service/gog/GOGConstantsTest.kt b/app/src/test/java/app/gamenative/service/gog/GOGConstantsTest.kt new file mode 100644 index 0000000000..1ea3fe0ccd --- /dev/null +++ b/app/src/test/java/app/gamenative/service/gog/GOGConstantsTest.kt @@ -0,0 +1,51 @@ +package app.gamenative.service.gog + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import app.gamenative.PrefManager +import java.io.File +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito + +class GOGConstantsTest { + @Before + fun setUp() { + // Create mock DataStore that returns empty preferences + val mockDataStore = Mockito.mock(DataStore::class.java) as DataStore + Mockito.`when`(mockDataStore.data).thenReturn(flowOf(emptyPreferences())) + + // Use reflection to set dataStore without calling init() + val dataStoreField = PrefManager::class.java.getDeclaredField("dataStore") + dataStoreField.isAccessible = true + dataStoreField.set(PrefManager, mockDataStore) + + // Mock context for GOGConstants + val context = Mockito.mock(Context::class.java) + val filesDir = File("/tmp/internal") + filesDir.mkdirs() + Mockito.`when`(context.filesDir).thenReturn(filesDir) + Mockito.`when`(context.applicationContext).thenReturn(context) + + PrefManager.init(context) + GOGConstants.init(context) + } + + @Test + fun testGetGameInstallPath_pathStructure() { + val path = GOGConstants.getGameInstallPath("Another Game 2026") + assertEquals(path, "/tmp/internal/GOG/games/common/Another Game 2026") + } + + @Test + fun testSanitizationSpecialChars() { + val path = GOGConstants.getGameInstallPath("G%ame@With^Special*Chars") + assertEquals(path, "/tmp/internal/GOG/games/common/GameWithSpecialChars") + } +} diff --git a/app/src/test/java/app/gamenative/service/gog/api/GOGManifestParserTest.kt b/app/src/test/java/app/gamenative/service/gog/api/GOGManifestParserTest.kt new file mode 100644 index 0000000000..a2017be593 --- /dev/null +++ b/app/src/test/java/app/gamenative/service/gog/api/GOGManifestParserTest.kt @@ -0,0 +1,535 @@ +package app.gamenative.service.gog.api + +import java.io.ByteArrayOutputStream +import java.util.zip.Deflater +import java.util.zip.GZIPOutputStream +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Comprehensive tests for GOGManifestParser + * Uses real data classes and JSON parsing + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class GOGManifestParserTest { + private lateinit var parser: GOGManifestParser + + @Before + fun setUp() { + parser = GOGManifestParser() + } + + + // Functions to create dependency items + private fun createTestChunk( + md5: String = "aabbccdd11223344", + size: Long = 1000L, + compressedSize: Long? = 500L, + ) = FileChunk( + compressedMd5 = md5, + size = size, + compressedSize = compressedSize, + md5 = md5, + ) + + private fun createTestDepotFile( + path: String = "game.exe", + productId: String? = null, + chunks: List = listOf(createTestChunk()), + flags: List = emptyList(), + ) = DepotFile( + path = path, + productId = productId, + chunks = chunks, + md5 = null, + sha256 = null, + flags = flags, + ) + + private fun createTestDepot( + languages: List = listOf("en-US"), + manifest: String = "depot_manifest_url", + ) = Depot( + languages = languages, + size = 1000000, + manifest = manifest, + productId = "1", + compressedSize = 5, + osBitness = emptyList(), + ) + + private fun createTestProduct(productId: String = "12345") = Product( + productId = productId, + name = "Test Game", + ) + + private fun createTestBuild( + buildId: String = "build123", + generation: Int = 2, + ) = GOGBuild( + buildId = buildId, + productId = "12345", + platform = "windows", + generation = generation, + versionName = "1.0.0", + branch = "master", + link = "https://cdn.gog.com/manifest", + legacyBuildId = null, + ) + + + + // Tests here + + @Test + fun testSelectBuild_returnsGen2Build() { + val gen1 = createTestBuild(buildId = "old", generation = 1) + val gen2 = createTestBuild(buildId = "new", generation = 2) + val builds = listOf(gen1, gen2) + + val result = parser.selectBuild(builds) + + assertNotNull(result) + assertEquals(2, result?.generation) + assertEquals("new", result?.buildId) + } + + @Test + fun testSelectBuild_returnsNullWhenEmpty() { + val result = parser.selectBuild(emptyList()) + assertNull(result) + } + + @Test + fun testSelectBuild_returnsNullWhenNoGen2() { + val gen1Only = listOf(createTestBuild(generation = 1)) + val result = parser.selectBuild(gen1Only) + assertNull(result) + } + + @Test + fun testFilterDepotsByLanguage() { + val enDepot = createTestDepot(languages = listOf("en-US")) + val frDepot = createTestDepot(languages = listOf("fr-FR")) + val manifest = GOGManifestMeta( + baseProductId = "12345", + installDirectory = "game", + depots = listOf(enDepot, frDepot), + dependencies = emptyList(), + products = emptyList(), + ) + + val result = parser.filterDepotsByLanguage(manifest, "en-US") + + assertEquals(1, result.size) + assertTrue(result[0].languages.contains("en-US")) + } + + @Test + fun testFilterDepotsByBitness() { + val depot64 = createTestDepot().copy(osBitness = listOf("64")) + val depot32 = createTestDepot().copy(osBitness = listOf("32")) + val depotBoth = createTestDepot().copy(osBitness = listOf("32", "64")) + val depots = listOf(depot64, depot32, depotBoth) + + val result = parser.filterDepotsByBitness(depots, "64") + + assertEquals(2, result.size) + assertTrue(result.any { it.osBitness?.contains("64") == true }) + } + + @Test + fun testFilterDepotsByBitness_nullBitnessIncluded() { + val depotWithBitness = createTestDepot().copy(osBitness = listOf("64")) + val depotNoBitness = createTestDepot().copy(osBitness = null) + val depots = listOf(depotWithBitness, depotNoBitness) + + val result = parser.filterDepotsByBitness(depots, "64") + + assertEquals(2, result.size) // Both should be included + } + + @Test + fun testFilterDepotsByOwnership() { + val ownedDepot = createTestDepot().copy(productId = "12345") + val unownedDepot = createTestDepot().copy(productId = "67890") + val depots = listOf(ownedDepot, unownedDepot) + val ownedProductIds = setOf("12345") + + val result = parser.filterDepotsByOwnership(depots, ownedProductIds) + + assertEquals(1, result.size) + assertEquals("12345", result[0].productId) + } + + @Test + fun testSeparateBaseDLC() { + val baseFile = createTestDepotFile(path = "base.exe", productId = null) + val dlcFile = createTestDepotFile(path = "dlc.dat", productId = "dlc_123") + val files = listOf(baseFile, dlcFile) + + val (base, dlc) = parser.separateBaseDLC(files, "12345") + + assertEquals(1, base.size) + assertEquals(1, dlc.size) + assertEquals("base.exe", base[0].path) + assertEquals("dlc.dat", dlc[0].path) + } + + @Test + fun testSeparateSupportFiles() { + val gameFile = createTestDepotFile(path = "game.exe", flags = emptyList()) + val supportFile = createTestDepotFile(path = "__support/redist/vcredist.exe", flags = listOf("support")) + val files = listOf(gameFile, supportFile) + + val (game, support) = parser.separateSupportFiles(files) + + assertEquals(1, game.size) + assertEquals(1, support.size) + assertEquals("__support/redist/vcredist.exe", support[0].path) + assertTrue(support[0].flags.contains("support")) + } + + @Test + fun testCalculateTotalSize_usesCompressedSize() { + val chunk1 = createTestChunk(size = 1000, compressedSize = 500) + val chunk2 = createTestChunk(size = 2000, compressedSize = 800) + val file = createTestDepotFile(chunks = listOf(chunk1, chunk2)) + + val result = parser.calculateTotalSize(listOf(file)) + + assertEquals(1300L, result) // 500 + 800 + } + + @Test + fun testCalculateTotalSize_fallsBackToSizeWhenNoCompression() { + val chunk = createTestChunk(size = 1000, compressedSize = null) + val file = createTestDepotFile(chunks = listOf(chunk)) + + val result = parser.calculateTotalSize(listOf(file)) + + assertEquals(1000L, result) + } + + @Test + fun testCalculateUncompressedSize() { + val chunk1 = createTestChunk(size = 1000, compressedSize = 500) + val chunk2 = createTestChunk(size = 2000, compressedSize = 800) + val file = createTestDepotFile(chunks = listOf(chunk1, chunk2)) + + val result = parser.calculateUncompressedSize(listOf(file)) + + assertEquals(3000L, result) // 1000 + 2000 + } + + @Test + fun testFindDLCProducts() { + val baseProduct = createTestProduct("12345") + val dlcProduct = createTestProduct("67890") + val manifest = GOGManifestMeta( + baseProductId = "12345", + installDirectory = "game", + depots = emptyList(), + dependencies = emptyList(), + products = listOf(baseProduct, dlcProduct), + ) + + val result = parser.findDLCProducts(manifest) + + assertEquals(1, result.size) + assertEquals("67890", result[0].productId) + } + + @Test + fun testHasDLC_true() { + val manifest = GOGManifestMeta( + baseProductId = "12345", + installDirectory = "game", + depots = emptyList(), + dependencies = emptyList(), + products = listOf(createTestProduct("12345"), createTestProduct("dlc")), + ) + + assertTrue(parser.hasDLC(manifest)) + } + + @Test + fun testHasDLC_false() { + val manifest = GOGManifestMeta( + baseProductId = "12345", + installDirectory = "game", + depots = emptyList(), + dependencies = emptyList(), + products = listOf(createTestProduct("12345")), + ) + + assertFalse(parser.hasDLC(manifest)) + } + + @Test + fun testBuildChunkUrlMap() { + val chunks = listOf("aabbccdd11223344", "11223344aabbccdd") + val baseUrls = listOf("https://cdn.gog.com/content") + + val result = parser.buildChunkUrlMap(chunks, baseUrls) + + assertEquals(2, result.size) + assertEquals("https://cdn.gog.com/content/aa/bb/aabbccdd11223344", result["aabbccdd11223344"]) + assertEquals("https://cdn.gog.com/content/11/22/11223344aabbccdd", result["11223344aabbccdd"]) + } + + @Test + fun testBuildChunkUrlMap_emptyUrls() { + val result = parser.buildChunkUrlMap(listOf("hash"), emptyList()) + assertTrue(result.isEmpty()) + } + + @Test + fun testBuildChunkUrlMap_shortHash() { + val chunks = listOf("abc") + val baseUrls = listOf("https://cdn.gog.com") + + val result = parser.buildChunkUrlMap(chunks, baseUrls) + + assertEquals("https://cdn.gog.com/abc", result["abc"]) + } + + @Test + fun testBuildChunkUrlMapWithProducts() { + val chunks = listOf("aabbccdd11223344", "11223344aabbccdd") + val chunkToProductMap = mapOf( + "aabbccdd11223344" to "12345", + "11223344aabbccdd" to "67890" + ) + val productUrlMap = mapOf( + "12345" to listOf("https://cdn1.gog.com/product1"), + "67890" to listOf("https://cdn2.gog.com/product2") + ) + + val result = parser.buildChunkUrlMapWithProducts(chunks, chunkToProductMap, productUrlMap) + + assertEquals(2, result.size) + assertEquals("https://cdn1.gog.com/product1/aa/bb/aabbccdd11223344", result["aabbccdd11223344"]) + assertEquals("https://cdn2.gog.com/product2/11/22/11223344aabbccdd", result["11223344aabbccdd"]) + } + + @Test + fun testBuildChunkUrlMapWithProducts_missingProduct() { + val chunks = listOf("aabbccdd11223344") + val chunkToProductMap = mapOf("aabbccdd11223344" to "12345") + val productUrlMap = emptyMap>() + + val result = parser.buildChunkUrlMapWithProducts(chunks, chunkToProductMap, productUrlMap) + + assertTrue(result.isEmpty()) + } + + @Test + fun testExtractChunkHashes_preservesOrder() { + val chunk1 = createTestChunk("aaaa") + val chunk2 = createTestChunk("bbbb") + val chunk3 = createTestChunk("cccc") + val file1 = createTestDepotFile(chunks = listOf(chunk1, chunk2)) + val file2 = createTestDepotFile(chunks = listOf(chunk3)) + + val result = parser.extractChunkHashes(listOf(file1, file2)) + + assertEquals(listOf("aaaa", "bbbb", "cccc"), result) + } + + @Test + fun testExtractChunkHashes_removesDuplicates() { + val chunk1 = createTestChunk("aaaa") + val chunk2 = createTestChunk("aaaa") // duplicate + val file = createTestDepotFile(chunks = listOf(chunk1, chunk2)) + + val result = parser.extractChunkHashes(listOf(file)) + + assertEquals(1, result.size) + assertEquals("aaaa", result[0]) + } + + @Test + fun testDetectGeneration() { + val gen2Build = createTestBuild(generation = 2) + assertEquals(2, parser.detectGeneration(gen2Build)) + + val gen1Build = createTestBuild(generation = 1) + assertEquals(1, parser.detectGeneration(gen1Build)) + } + + // ========== JSON Parsing Tests ========== + + @Test + fun testParseBuilds() { + val json = """ + { + "total_count": 1, + "count": 1, + "items": [{ + "build_id": "123", + "product_id": "456", + "os": "windows", + "generation": 2, + "version_name": "1.0", + "branch": "master", + "link": "https://cdn.gog.com/manifest" + }] + } + """.trimIndent() + + val result = parser.parseBuilds(json) + + assertEquals(1, result.totalCount) + assertEquals(1, result.count) + assertEquals(1, result.items.size) + assertEquals("123", result.items[0].buildId) + assertEquals(2, result.items[0].generation) + assertEquals("456", result.items[0].productId) + } + + @Test + fun testParseManifest() { + val json = """ + { + "baseProductId": "12345", + "installDirectory": "game", + "depots": [], + "dependencies": [], + "products": [{ + "productId": "12345", + "name": "Test Game" + }] + } + """.trimIndent() + + val result = parser.parseManifest(json) + + assertEquals("12345", result.baseProductId) + assertEquals("game", result.installDirectory) + assertEquals(1, result.products.size) + } + + @Test + fun testParseDepotManifest() { + val json = """ + { + "depot": { + "items": [ + { + "type": "DepotFile", + "path": "game.exe", + "chunks": [], + "flags": [] + } + ] + } + } + """.trimIndent() + + val result = parser.parseDepotManifest(json) + + assertEquals(1, result.files.size) + assertEquals("game.exe", result.files[0].path) + } + + @Test + fun testParseDependencyManifest() { + val json = """ + { + "depots": [{ + "dependencyId": "vcredist2015", + "manifest": "manifest_url", + "compressedSize": 1000, + "size": 2000, + "languages": ["*"], + "readableName": "Visual C++ 2015", + "signature": "sig123", + "internal": false + }] + } + """.trimIndent() + + val result = parser.parseDependencyManifest(json) + + assertEquals(1, result.depots.size) + assertEquals("vcredist2015", result.depots[0].dependencyId) + assertEquals("Visual C++ 2015", result.depots[0].readableName) + } + + @Test + fun testParseSecureLinks() { + val json = """ + { + "urls": [{ + "url_format": "https://secure-cdn.gog.com/{path}?token={token}", + "parameters": { + "path": "content", + "token": "xyz123" + } + }] + } + """.trimIndent() + + val result = parser.parseSecureLinks(json) + + assertEquals(1, result.urls.size) + assertTrue(result.urls[0].contains("secure-cdn.gog.com")) + assertTrue(result.urls[0].contains("xyz123")) + } + + // ========== Decompression Tests ========== + + @Test + fun testDecompressManifest_gzip() { + val plainText = "Hello GOG World" + val compressed = ByteArrayOutputStream().use { baos -> + GZIPOutputStream(baos).use { gzipOut -> + gzipOut.write(plainText.toByteArray()) + } + baos.toByteArray() + } + + val result = parser.decompressManifest(compressed) + + assertEquals(plainText, result) + } + + @Test + fun testDecompressManifest_zlib() { + val plainText = "Hello GOG World" + val compressed = ByteArrayOutputStream().use { baos -> + val deflater = Deflater() + try { + val buffer = ByteArray(1024) + deflater.setInput(plainText.toByteArray()) + deflater.finish() + while (!deflater.finished()) { + val count = deflater.deflate(buffer) + baos.write(buffer, 0, count) + } + } finally { + deflater.end() + } + baos.toByteArray() + } + + val result = parser.decompressManifest(compressed) + + assertEquals(plainText, result) + } + + @Test + fun testDecompressManifest_plainText() { + val plainText = "Hello GOG World" + val result = parser.decompressManifest(plainText.toByteArray()) + assertEquals(plainText, result) + } +} diff --git a/app/src/test/java/app/gamenative/ui/component/dialog/ContainerConfigDialogContainerUpdateTest.kt b/app/src/test/java/app/gamenative/ui/component/dialog/ContainerConfigDialogContainerUpdateTest.kt index 11705558f2..a38124ac99 100644 --- a/app/src/test/java/app/gamenative/ui/component/dialog/ContainerConfigDialogContainerUpdateTest.kt +++ b/app/src/test/java/app/gamenative/ui/component/dialog/ContainerConfigDialogContainerUpdateTest.kt @@ -1,20 +1,32 @@ package app.gamenative.ui.component.dialog import android.content.Context +import androidx.room.Room import androidx.test.core.app.ApplicationProvider +import app.gamenative.data.ConfigInfo +import app.gamenative.data.SteamApp +import app.gamenative.db.PluviaDatabase +import app.gamenative.enums.AppType +import app.gamenative.enums.OS +import app.gamenative.enums.ReleaseState import app.gamenative.service.DownloadService import app.gamenative.service.SteamService import app.gamenative.utils.ContainerUtils import com.winlator.container.Container import com.winlator.container.ContainerData +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import java.io.File +import java.lang.reflect.Field +import java.util.EnumSet @RunWith(RobolectricTestRunner::class) class ContainerConfigDialogContainerUpdateTest { @@ -36,6 +48,43 @@ class ContainerConfigDialogContainerUpdateTest { File(SteamService.internalAppInstallPath).mkdirs() SteamService.externalAppInstallPath.takeIf { it.isNotBlank() }?.let { File(it).mkdirs() } + // Create app directory that SteamService.getAppDirPath will return + val appDir = File(SteamService.internalAppInstallPath, "123456") + appDir.mkdirs() + + // Set up in-memory database with SteamApp entry + val db = Room.inMemoryDatabaseBuilder(context, PluviaDatabase::class.java) + .allowMainThreadQueries() + .build() + + // Insert test SteamApp so getAppDirPath() can find it + val testApp = SteamApp( + id = 123456, + name = "Test Game", + config = ConfigInfo(installDir = "123456"), + type = AppType.game, + osList = EnumSet.of(OS.windows), + releaseState = ReleaseState.released, + ) + runBlocking { + db.steamAppDao().insert(testApp) + } + + // Create a mock SteamService instance and set it as SteamService.instance + val mockSteamService = mock() + whenever(mockSteamService.appDao).thenReturn(db.steamAppDao()) + + // Mock steamClient and steamID for userSteamId property + val mockSteamClient = mock<`in`.dragonbra.javasteam.steam.steamclient.SteamClient>() + val mockSteamID = mock<`in`.dragonbra.javasteam.types.SteamID>() + whenever(mockSteamService.steamClient).thenReturn(mockSteamClient) + whenever(mockSteamClient.steamID).thenReturn(mockSteamID) + + // Set the mock as SteamService.instance using reflection + val instanceField = SteamService::class.java.getDeclaredField("instance") + instanceField.isAccessible = true + instanceField.set(null, mockSteamService) + container = Container("STEAM_123456") container.setRootDir(tempDir) diff --git a/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt b/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt index 499ac2564f..5605f41166 100644 --- a/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt +++ b/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt @@ -4,11 +4,14 @@ import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import app.gamenative.data.ConfigInfo +import app.gamenative.data.SaveFilePattern import app.gamenative.data.SteamApp +import app.gamenative.data.UFS import app.gamenative.db.PluviaDatabase import app.gamenative.enums.AppType import app.gamenative.enums.Marker import app.gamenative.enums.OS +import app.gamenative.enums.PathType import app.gamenative.enums.ReleaseState import app.gamenative.service.DownloadService import app.gamenative.service.SteamService @@ -222,9 +225,13 @@ class SteamUtilsFileSearchTest { val dosDevicesPath = File(imageFs.wineprefix, "dosdevices/a:") dosDevicesPath.mkdirs() - // Create .original.exe file + // Create multiple .original.exe files in different folders val origExeFile = File(dosDevicesPath, "game.exe.original.exe") origExeFile.writeBytes("original exe content".toByteArray()) + val nestedDir = File(dosDevicesPath, "bin") + nestedDir.mkdirs() + val origExeFile2 = File(nestedDir, "game2.exe.original.exe") + origExeFile2.writeBytes("original exe content 2".toByteArray()) // Call the actual function SteamUtils.restoreOriginalExecutable(context, steamAppId) @@ -234,6 +241,10 @@ class SteamUtilsFileSearchTest { assertTrue("Should restore exe to original location", restoredFile.exists()) assertEquals("Restored content should match backup", "original exe content", restoredFile.readText()) + val restoredFile2 = File(nestedDir, "game2.exe") + assertTrue("Should restore exe to original location in subdirectory", restoredFile2.exists()) + assertEquals("Restored content should match backup for second exe", + "original exe content 2", restoredFile2.readText()) } @Test @@ -566,9 +577,31 @@ class SteamUtilsFileSearchTest { val steamClientDll = File(steamDir, "steamclient.dll") steamClientDll.writeBytes("fake steamclient.dll".toByteArray()) + // Create steam client files in wineprefix Steam directory for backup testing + val wineprefixSteamDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + wineprefixSteamDir.mkdirs() + val steamClientFiles = SteamUtils.steamClientFiles() + val originalSteamClientContents = mutableMapOf() + steamClientFiles.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + val content = "original $fileName content" + file.writeBytes(content.toByteArray()) + originalSteamClientContents[fileName] = content + } + // Step 2: Call replaceSteamClientDll (First Time) SteamUtils.replaceSteamclientDll(context, testAppId) + // Verify steam client files are backed up + val backupDir = File(wineprefixSteamDir, "steamclient_backup") + assertTrue("steamclient_backup directory should exist", backupDir.exists()) + steamClientFiles.forEach { fileName -> + val backupFile = File(backupDir, "$fileName.orig") + assertTrue("Backup file $fileName.orig should exist", backupFile.exists()) + assertEquals("Backup file $fileName.orig should contain original content", + originalSteamClientContents[fileName], backupFile.readText()) + } + // Verify steam_settings folder is created next to steamclient.dll in Steam directory val steamSettingsDir = File(steamDir, "steam_settings") assertTrue("steam_settings folder should exist in Steam directory", steamSettingsDir.exists()) @@ -587,7 +620,7 @@ class SteamUtilsFileSearchTest { assertTrue("configs.user.ini should contain account_name field", userIniContent.contains("account_name=")) assertTrue("configs.user.ini should contain account_steamid field", userIniContent.contains("account_steamid=")) assertTrue("configs.user.ini should contain language field", userIniContent.contains("language=")) - assertTrue("configs.user.ini should contain ticket field", userIniContent.contains("ticket=")) + assertFalse("configs.user.ini should not contain ticket field", userIniContent.contains("ticket=")) // Verify configs.app.ini contains expected content val appIniContent = configsAppIni.readText() @@ -620,7 +653,7 @@ class SteamUtilsFileSearchTest { // Verify game.exe is NOT overwritten after first replaceSteamClientDll call assertEquals("game.exe should be overwritten after replaceSteamClientDll", - "unpacked exe content", gameExe.readText()) + "original exe content", gameExe.readText()) // Verify marker was set assertTrue("Should add STEAM_COLDCLIENT_USED marker", @@ -666,7 +699,7 @@ class SteamUtilsFileSearchTest { appUserIniContent.contains("account_steamid=")) assertTrue("configs.user.ini in app directory should contain language field", appUserIniContent.contains("language=")) - assertTrue("configs.user.ini in app directory should contain ticket field", + assertFalse("configs.user.ini in app directory should not contain ticket field", appUserIniContent.contains("ticket=")) // Verify configs.app.ini contains expected content in app directory @@ -737,8 +770,8 @@ class SteamUtilsFileSearchTest { steamAppId.toString(), steamAppIdFile.readText().trim()) // Verify game.exe is NOT overwritten after second replaceSteamClientDll call - assertEquals("game.exe should be overwritten after second replaceSteamClientDll", - "unpacked exe content", gameExe.readText()) + assertEquals("game.exe should not be overwritten after second replaceSteamClientDll", + "original exe content", gameExe.readText()) // Verify marker was set assertTrue("Should add STEAM_COLDCLIENT_USED marker", @@ -784,9 +817,44 @@ class SteamUtilsFileSearchTest { val steamClientDll = File(steamDir, "steamclient.dll") steamClientDll.writeBytes("fake steamclient.dll".toByteArray()) + // Create steam client files in wineprefix Steam directory for backup/restore testing + val wineprefixSteamDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + wineprefixSteamDir.mkdirs() + val steamClientFiles = SteamUtils.steamClientFiles() + val originalSteamClientContents = mutableMapOf() + steamClientFiles.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + val content = "original $fileName content" + file.writeBytes(content.toByteArray()) + originalSteamClientContents[fileName] = content + } + + // Create extra_dlls directory to test deletion + val extraDllsDir = File(wineprefixSteamDir, "extra_dlls") + extraDllsDir.mkdirs() + File(extraDllsDir, "test.dll").writeBytes("test dll content".toByteArray()) + // Step 2: Call replaceSteamClientDll (First Time) SteamUtils.replaceSteamclientDll(context, testAppId) + // Verify steam client files are backed up + val backupDir = File(wineprefixSteamDir, "steamclient_backup") + assertTrue("steamclient_backup directory should exist", backupDir.exists()) + steamClientFiles.forEach { fileName -> + val backupFile = File(backupDir, "$fileName.orig") + assertTrue("Backup file $fileName.orig should exist", backupFile.exists()) + assertEquals("Backup file $fileName.orig should contain original content", + originalSteamClientContents[fileName], backupFile.readText()) + } + + // Modify original files to verify they get restored + steamClientFiles.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + if (file.exists()) { + file.writeBytes("modified $fileName content".toByteArray()) + } + } + // Verify steam_settings folder is created next to steamclient.dll in Steam directory val steamSettingsDir = File(steamDir, "steam_settings") assertTrue("steam_settings folder should exist in Steam directory", steamSettingsDir.exists()) @@ -811,7 +879,7 @@ class SteamUtilsFileSearchTest { // Verify game.exe is NOT overwritten after first replaceSteamClientDll call assertEquals("game.exe should not be overwritten after replaceSteamClientDll", - "unpacked exe content", gameExe.readText()) + "original exe content", gameExe.readText()) // Verify marker was set assertTrue("Should add STEAM_COLDCLIENT_USED marker", @@ -833,6 +901,18 @@ class SteamUtilsFileSearchTest { assertEquals("steam_api64.dll should remain the same after restoreSteamApi", originalDllContent, dllFile.readText()) + // Verify steam client files are restored from backup + steamClientFiles.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + assertTrue("Steam client file $fileName should exist after restore", file.exists()) + assertEquals("Steam client file $fileName should be restored to original content", + originalSteamClientContents[fileName], file.readText()) + } + + // Verify extra_dlls directory is deleted + assertFalse("extra_dlls directory should be deleted after restoreSteamclientFiles", + extraDllsDir.exists()) + // Verify marker was set assertTrue("Should add STEAM_DLL_RESTORED marker", MarkerUtils.hasMarker(appDir.absolutePath, Marker.STEAM_DLL_RESTORED)) @@ -845,8 +925,8 @@ class SteamUtilsFileSearchTest { SteamUtils.replaceSteamclientDll(context, testAppId) // Verify restoreUnpackedExecutable overwrites game.exe with game.exe.unpacked.exe content - assertEquals("game.exe should be overwritten with game.exe.unpacked.exe content after second replaceSteamClientDll", - "unpacked exe content", gameExe.readText()) + assertEquals("game.exe should be overwritten with game.exe.original.exe content after second replaceSteamClientDll", + "original exe content", gameExe.readText()) // Verify steam_settings folder still exists next to steamclient.dll in Steam directory assertTrue("steam_settings folder should still exist in Steam directory", @@ -863,7 +943,7 @@ class SteamUtilsFileSearchTest { assertTrue("configs.user.ini should contain account_name field", userIniContent.contains("account_name=")) assertTrue("configs.user.ini should contain account_steamid field", userIniContent.contains("account_steamid=")) assertTrue("configs.user.ini should contain language field", userIniContent.contains("language=")) - assertTrue("configs.user.ini should contain ticket field", userIniContent.contains("ticket=")) + assertFalse("configs.user.ini should not contain ticket field", userIniContent.contains("ticket=")) // Verify configs.app.ini contains expected content val appIniContent = configsAppIni.readText() @@ -923,9 +1003,40 @@ class SteamUtilsFileSearchTest { MarkerUtils.removeMarker(appDir.absolutePath, Marker.STEAM_DLL_RESTORED) MarkerUtils.removeMarker(appDir.absolutePath, Marker.STEAM_COLDCLIENT_USED) + // Create steam client files and backup in wineprefix Steam directory + // This simulates a previous replaceSteamclientDll call that created backups + val wineprefixSteamDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + wineprefixSteamDir.mkdirs() + val steamClientFiles = SteamUtils.steamClientFiles() + val originalSteamClientContents = mutableMapOf() + val backupDir = File(wineprefixSteamDir, "steamclient_backup") + backupDir.mkdirs() + + // Create backup files (simulating previous backup) + steamClientFiles.forEach { fileName -> + val content = "original $fileName content" + originalSteamClientContents[fileName] = content + val backupFile = File(backupDir, "$fileName.orig") + backupFile.writeBytes(content.toByteArray()) + } + + // Create modified steam client files (they should be restored during replaceSteamApi) + steamClientFiles.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + file.writeBytes("modified $fileName content".toByteArray()) + } + // Step 2: Call replaceSteamApi (First Time) SteamUtils.replaceSteamApi(context, testAppId) + // Verify restoreSteamclientFiles was called during replaceSteamApi (files should be restored from backup) + steamClientFiles.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + assertTrue("Steam client file $fileName should exist after replaceSteamApi", file.exists()) + assertEquals("Steam client file $fileName should be restored to original content during replaceSteamApi", + originalSteamClientContents[fileName], file.readText()) + } + // Verify steam_api64.dll gets overwritten with content from assets val expectedDllContent = loadTestAsset(context, "steampipe/steam_api64.dll") assertEquals("steam_api64.dll should be replaced with asset content", @@ -959,7 +1070,7 @@ class SteamUtilsFileSearchTest { appUserIniContent.contains("account_steamid=")) assertTrue("configs.user.ini should contain language field", appUserIniContent.contains("language=")) - assertTrue("configs.user.ini should contain ticket field", + assertFalse("configs.user.ini should not contain ticket field", appUserIniContent.contains("ticket=")) // Verify configs.app.ini contains expected content @@ -990,6 +1101,19 @@ class SteamUtilsFileSearchTest { assertTrue("Should add STEAM_DLL_REPLACED marker", MarkerUtils.hasMarker(appDir.absolutePath, Marker.STEAM_DLL_REPLACED)) + // Modify steam client files again to test restore during restoreSteamApi + steamClientFiles.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + if (file.exists()) { + file.writeBytes("modified again $fileName content".toByteArray()) + } + } + + // Create extra_dlls directory to test deletion + val extraDllsDir = File(wineprefixSteamDir, "extra_dlls") + extraDllsDir.mkdirs() + File(extraDllsDir, "test.dll").writeBytes("test dll content".toByteArray()) + // Step 3: Call restoreSteamApi // Remove markers to allow the function to run MarkerUtils.removeMarker(appDir.absolutePath, Marker.STEAM_COLDCLIENT_USED) @@ -1004,6 +1128,18 @@ class SteamUtilsFileSearchTest { assertEquals("game.exe should be overwritten with game.exe.original.exe content after restoreSteamApi", "original exe content", gameExe.readText()) + // Verify steam client files are restored from backup + steamClientFiles.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + assertTrue("Steam client file $fileName should exist after restoreSteamApi", file.exists()) + assertEquals("Steam client file $fileName should be restored to original content after restoreSteamApi", + originalSteamClientContents[fileName], file.readText()) + } + + // Verify extra_dlls directory is deleted + assertFalse("extra_dlls directory should be deleted after restoreSteamclientFiles", + extraDllsDir.exists()) + // Verify marker was set assertTrue("Should add STEAM_DLL_RESTORED marker", MarkerUtils.hasMarker(appDir.absolutePath, Marker.STEAM_DLL_RESTORED)) @@ -1054,4 +1190,535 @@ class SteamUtilsFileSearchTest { assertTrue("Should add STEAM_DLL_REPLACED marker again", MarkerUtils.hasMarker(appDir.absolutePath, Marker.STEAM_DLL_REPLACED)) } + + @Test + fun testGenerateCloudSaveConfig_withWindowsPatterns() = runBlocking { + // Update test app with Windows saveFilePatterns + val testApp = db.steamAppDao().findApp(steamAppId)!! + val updatedApp = testApp.copy( + ufs = UFS( + saveFilePatterns = listOf( + SaveFilePattern( + root = PathType.GameInstall, + path = "saves/game.dat", + pattern = "*.dat" + ), + SaveFilePattern( + root = PathType.WinAppDataLocal, + path = "MyGame/{64BitSteamID}/save.sav", + pattern = "*.sav" + ), + SaveFilePattern( + root = PathType.WinMyDocuments, + path = "MyGame/{Steam3AccountID}/config.ini", + pattern = "*.ini" + ) + ) + ) + ) + db.steamAppDao().update(updatedApp) + + // Ensure no marker exists + MarkerUtils.removeMarker(appDir.absolutePath, Marker.STEAM_DLL_REPLACED) + + // Create DLL file + val dllFile = File(appDir, "steam_api.dll") + dllFile.writeBytes("original dll content".toByteArray()) + + // Call replaceSteamApi to trigger ensureSteamSettings + SteamUtils.replaceSteamApi(context, testAppId) + + // Verify configs.app.ini exists and contains cloud save config + // steam_settings is created next to the DLL in app directory + val steamSettingsDir = File(appDir, "steam_settings") + val appIni = File(steamSettingsDir, "configs.app.ini") + assertTrue("configs.app.ini should exist", appIni.exists()) + + val appIniContent = appIni.readText() + assertTrue("configs.app.ini should contain [app::cloud_save::general] section", + appIniContent.contains("[app::cloud_save::general]")) + assertTrue("configs.app.ini should contain create_default_dir=1", + appIniContent.contains("create_default_dir=1")) + assertTrue("configs.app.ini should contain create_specific_dirs=1", + appIniContent.contains("create_specific_dirs=1")) + assertTrue("configs.app.ini should contain [app::cloud_save::win] section", + appIniContent.contains("[app::cloud_save::win]")) + + // Verify GameInstall is converted to gameinstall + assertTrue("configs.app.ini should contain gameinstall (lowercase)", + appIniContent.contains("{::gameinstall::}")) + assertFalse("configs.app.ini should not contain GameInstall (uppercase)", + appIniContent.contains("{::GameInstall::}")) + + // Verify placeholder replacements + assertTrue("configs.app.ini should contain {::64BitSteamID::}", + appIniContent.contains("{::64BitSteamID::}")) + assertTrue("configs.app.ini should contain {::Steam3AccountID::}", + appIniContent.contains("{::Steam3AccountID::}")) + assertFalse("configs.app.ini should not contain {64BitSteamID}", + appIniContent.contains("{64BitSteamID}")) + assertFalse("configs.app.ini should not contain {Steam3AccountID}", + appIniContent.contains("{Steam3AccountID}")) + + // Verify directory entries exist + assertTrue("configs.app.ini should contain dir1=", appIniContent.contains("dir1=")) + assertTrue("configs.app.ini should contain dir2=", appIniContent.contains("dir2=")) + assertTrue("configs.app.ini should contain dir3=", appIniContent.contains("dir3=")) + } + + @Test + fun testGenerateCloudSaveConfig_deduplication() = runBlocking { + // Update test app with duplicate Windows patterns + val testApp = db.steamAppDao().findApp(steamAppId)!! + val updatedApp = testApp.copy( + ufs = UFS( + saveFilePatterns = listOf( + SaveFilePattern( + root = PathType.GameInstall, + path = "saves/game.dat", + pattern = "*.dat" + ), + SaveFilePattern( + root = PathType.GameInstall, + path = "saves/game.dat", // Duplicate + pattern = "*.dat" + ), + SaveFilePattern( + root = PathType.WinAppDataLocal, + path = "MyGame/save.sav", + pattern = "*.sav" + ), + SaveFilePattern( + root = PathType.WinAppDataLocal, + path = "MyGame/save.sav", // Duplicate + pattern = "*.sav" + ) + ) + ) + ) + db.steamAppDao().update(updatedApp) + + // Ensure no marker exists + MarkerUtils.removeMarker(appDir.absolutePath, Marker.STEAM_DLL_REPLACED) + + // Create DLL file + val dllFile = File(appDir, "steam_api.dll") + dllFile.writeBytes("original dll content".toByteArray()) + + // Call replaceSteamApi + SteamUtils.replaceSteamApi(context, testAppId) + + // Verify configs.app.ini exists + // steam_settings is created next to the DLL in app directory + val steamSettingsDir = File(appDir, "steam_settings") + val appIni = File(steamSettingsDir, "configs.app.ini") + assertTrue("configs.app.ini should exist", appIni.exists()) + + val appIniContent = appIni.readText() + + // Verify only unique entries exist + val dirLines = appIniContent.lines().filter { it.startsWith("dir") && it.contains("=") } + val uniqueDirs = dirLines.toSet() + assertEquals("Should have 2 unique directory entries", 2, uniqueDirs.size) + + // Verify the directory strings are unique (no duplicates) + val dirValues = dirLines.map { it.substringAfter("=") }.toSet() + assertEquals("Should have 2 unique directory values", 2, dirValues.size) + } + + @Test + fun testGenerateCloudSaveConfig_noWindowsPatterns() = runBlocking { + // Update test app with only non-Windows patterns + val testApp = db.steamAppDao().findApp(steamAppId)!! + val updatedApp = testApp.copy( + ufs = UFS( + saveFilePatterns = listOf( + SaveFilePattern( + root = PathType.LinuxHome, + path = ".local/share/game", + pattern = "*.sav" + ), + SaveFilePattern( + root = PathType.MacHome, + path = "Library/Application Support/game", + pattern = "*.sav" + ) + ) + ) + ) + db.steamAppDao().update(updatedApp) + + // Ensure no marker exists + MarkerUtils.removeMarker(appDir.absolutePath, Marker.STEAM_DLL_REPLACED) + + // Create DLL file + val dllFile = File(appDir, "steam_api.dll") + dllFile.writeBytes("original dll content".toByteArray()) + + // Call replaceSteamApi + SteamUtils.replaceSteamApi(context, testAppId) + + // Verify configs.app.ini exists + // steam_settings is created next to the DLL in app directory + val steamSettingsDir = File(appDir, "steam_settings") + val appIni = File(steamSettingsDir, "configs.app.ini") + assertTrue("configs.app.ini should exist", appIni.exists()) + + val appIniContent = appIni.readText() + + // Verify cloud save sections are NOT present + assertFalse("configs.app.ini should not contain [app::cloud_save::general] section", + appIniContent.contains("[app::cloud_save::general]")) + assertFalse("configs.app.ini should not contain [app::cloud_save::win] section", + appIniContent.contains("[app::cloud_save::win]")) + assertFalse("configs.app.ini should not contain create_default_dir", + appIniContent.contains("create_default_dir")) + } + + @Test + fun testGenerateCloudSaveConfig_mixedPatterns() = runBlocking { + // Update test app with both Windows and non-Windows patterns + val testApp = db.steamAppDao().findApp(steamAppId)!! + val updatedApp = testApp.copy( + ufs = UFS( + saveFilePatterns = listOf( + SaveFilePattern( + root = PathType.GameInstall, + path = "saves/game.dat", + pattern = "*.dat" + ), + SaveFilePattern( + root = PathType.LinuxHome, + path = ".local/share/game", + pattern = "*.sav" + ), + SaveFilePattern( + root = PathType.WinAppDataLocal, + path = "MyGame/save.sav", + pattern = "*.sav" + ) + ) + ) + ) + db.steamAppDao().update(updatedApp) + + // Ensure no marker exists + MarkerUtils.removeMarker(appDir.absolutePath, Marker.STEAM_DLL_REPLACED) + + // Create DLL file + val dllFile = File(appDir, "steam_api.dll") + dllFile.writeBytes("original dll content".toByteArray()) + + // Call replaceSteamApi + SteamUtils.replaceSteamApi(context, testAppId) + + // Verify configs.app.ini exists + // steam_settings is created next to the DLL in app directory + val steamSettingsDir = File(appDir, "steam_settings") + val appIni = File(steamSettingsDir, "configs.app.ini") + assertTrue("configs.app.ini should exist", appIni.exists()) + + val appIniContent = appIni.readText() + + // Verify cloud save sections exist + assertTrue("configs.app.ini should contain [app::cloud_save::general] section", + appIniContent.contains("[app::cloud_save::general]")) + assertTrue("configs.app.ini should contain [app::cloud_save::win] section", + appIniContent.contains("[app::cloud_save::win]")) + + // Verify only Windows patterns appear (GameInstall and WinAppDataLocal) + assertTrue("configs.app.ini should contain gameinstall", + appIniContent.contains("{::gameinstall::}")) + assertTrue("configs.app.ini should contain WinAppDataLocal", + appIniContent.contains("{::WinAppDataLocal::}")) + + // Verify non-Windows patterns do NOT appear + assertFalse("configs.app.ini should not contain LinuxHome", + appIniContent.contains("{::LinuxHome::}")) + assertFalse("configs.app.ini should not contain MacHome", + appIniContent.contains("{::MacHome::}")) + + // Should have exactly 2 directory entries (only Windows patterns) + val dirLines = appIniContent.lines().filter { it.startsWith("dir") && it.contains("=") } + assertEquals("Should have 2 directory entries (only Windows patterns)", 2, dirLines.size) + } + + @Test + fun testLocalSavePath_noSaveFilePatterns() = runBlocking { + // Update test app with empty saveFilePatterns + val testApp = db.steamAppDao().findApp(steamAppId)!! + val updatedApp = testApp.copy( + ufs = UFS( + saveFilePatterns = emptyList() + ) + ) + db.steamAppDao().update(updatedApp) + + // Ensure no marker exists + MarkerUtils.removeMarker(appDir.absolutePath, Marker.STEAM_DLL_REPLACED) + + // Create DLL file + val dllFile = File(appDir, "steam_api.dll") + dllFile.writeBytes("original dll content".toByteArray()) + + // Call replaceSteamApi + SteamUtils.replaceSteamApi(context, testAppId) + + // Verify configs.user.ini exists + // steam_settings is created next to the DLL in app directory + val steamSettingsDir = File(appDir, "steam_settings") + val userIni = File(steamSettingsDir, "configs.user.ini") + assertTrue("configs.user.ini should exist", userIni.exists()) + + val userIniContent = userIni.readText() + + // Verify [user::saves] section exists + assertTrue("configs.user.ini should contain [user::saves] section", + userIniContent.contains("[user::saves]")) + + // Verify local_save_path exists with correct format + assertTrue("configs.user.ini should contain local_save_path", + userIniContent.contains("local_save_path=")) + + // Verify the path format (accountId will be 0L from mock, but format should be correct) + val accountId = SteamService.userSteamId?.accountID ?: 0L + val expectedPath = "C:\\Program Files (x86)\\Steam\\userdata\\$accountId" + assertTrue("configs.user.ini should contain correct local_save_path format", + userIniContent.contains("local_save_path=$expectedPath")) + } + + @Test + fun testLocalSavePath_withSaveFilePatterns() = runBlocking { + // Update test app with saveFilePatterns + val testApp = db.steamAppDao().findApp(steamAppId)!! + val updatedApp = testApp.copy( + ufs = UFS( + saveFilePatterns = listOf( + SaveFilePattern( + root = PathType.GameInstall, + path = "saves/game.dat", + pattern = "*.dat" + ) + ) + ) + ) + db.steamAppDao().update(updatedApp) + + // Ensure no marker exists + MarkerUtils.removeMarker(appDir.absolutePath, Marker.STEAM_DLL_REPLACED) + + // Create DLL file + val dllFile = File(appDir, "steam_api.dll") + dllFile.writeBytes("original dll content".toByteArray()) + + // Call replaceSteamApi + SteamUtils.replaceSteamApi(context, testAppId) + + // Verify configs.user.ini exists + // steam_settings is created next to the DLL in app directory + val steamSettingsDir = File(appDir, "steam_settings") + val userIni = File(steamSettingsDir, "configs.user.ini") + assertTrue("configs.user.ini should exist", userIni.exists()) + + val userIniContent = userIni.readText() + + // Verify [user::saves] section does NOT exist + assertFalse("configs.user.ini should not contain [user::saves] section", + userIniContent.contains("[user::saves]")) + + // Verify local_save_path does NOT exist + assertFalse("configs.user.ini should not contain local_save_path", + userIniContent.contains("local_save_path=")) + } + + @Test + fun test_backupSteamclientFiles_backsUpExistingFiles() { + val imageFs = ImageFs.find(context) + val wineprefixSteamDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + wineprefixSteamDir.mkdirs() + + // Create some (not all) of the steam client files + val steamClientFiles = SteamUtils.steamClientFiles() + val filesToCreate = steamClientFiles.take(3) // Create only first 3 files + val originalContents = mutableMapOf() + + filesToCreate.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + val content = "original $fileName content" + file.writeBytes(content.toByteArray()) + originalContents[fileName] = content + } + + // Call backupSteamclientFiles + SteamUtils.backupSteamclientFiles(context, steamAppId) + + // Verify backup directory is created + val backupDir = File(wineprefixSteamDir, "steamclient_backup") + assertTrue("steamclient_backup directory should exist", backupDir.exists()) + + // Verify only existing files are backed up + filesToCreate.forEach { fileName -> + val backupFile = File(backupDir, "$fileName.orig") + assertTrue("Backup file $fileName.orig should exist", backupFile.exists()) + assertEquals("Backup file $fileName.orig should contain original content", + originalContents[fileName], backupFile.readText()) + } + + // Verify non-existent files are NOT backed up + val filesNotCreated = steamClientFiles.drop(3) + filesNotCreated.forEach { fileName -> + val backupFile = File(backupDir, "$fileName.orig") + assertFalse("Backup file $fileName.orig should NOT exist for non-existent file", backupFile.exists()) + } + } + + @Test + fun test_backupSteamclientFiles_handlesNonExistentFiles() { + val imageFs = ImageFs.find(context) + val wineprefixSteamDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + wineprefixSteamDir.mkdirs() + + // Create only some of the steam client files + val steamClientFiles = SteamUtils.steamClientFiles() + val filesToCreate = listOf(steamClientFiles[0], steamClientFiles[2], steamClientFiles[4]) // Create 3 specific files + val originalContents = mutableMapOf() + + filesToCreate.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + val content = "original $fileName content" + file.writeBytes(content.toByteArray()) + originalContents[fileName] = content + } + + // Call backupSteamclientFiles + SteamUtils.backupSteamclientFiles(context, steamAppId) + + // Verify backup directory is created + val backupDir = File(wineprefixSteamDir, "steamclient_backup") + assertTrue("steamclient_backup directory should exist", backupDir.exists()) + + // Verify existing files are backed up + filesToCreate.forEach { fileName -> + val backupFile = File(backupDir, "$fileName.orig") + assertTrue("Backup file $fileName.orig should exist", backupFile.exists()) + assertEquals("Backup file $fileName.orig should contain original content", + originalContents[fileName], backupFile.readText()) + } + } + + @Test + fun test_restoreSteamclientFiles_restoresFromBackup() { + val imageFs = ImageFs.find(context) + val wineprefixSteamDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + wineprefixSteamDir.mkdirs() + + // Create backup files + val backupDir = File(wineprefixSteamDir, "steamclient_backup") + backupDir.mkdirs() + val steamClientFiles = SteamUtils.steamClientFiles() + val backupContents = mutableMapOf() + + steamClientFiles.forEach { fileName -> + val backupFile = File(backupDir, "$fileName.orig") + val content = "backup $fileName content" + backupFile.writeBytes(content.toByteArray()) + backupContents[fileName] = content + } + + // Modify or delete original files + steamClientFiles.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + if (fileName == steamClientFiles[0]) { + // Delete first file + if (file.exists()) file.delete() + } else { + // Modify other files + file.writeBytes("modified $fileName content".toByteArray()) + } + } + + // Call restoreSteamclientFiles + SteamUtils.restoreSteamclientFiles(context, steamAppId) + + // Verify files are restored from backup + steamClientFiles.forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + assertTrue("Steam client file $fileName should exist after restore", file.exists()) + assertEquals("Steam client file $fileName should be restored from backup", + backupContents[fileName], file.readText()) + } + } + + @Test + fun test_restoreSteamclientFiles_deletesExtraDlls() { + val imageFs = ImageFs.find(context) + val wineprefixSteamDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + wineprefixSteamDir.mkdirs() + + // Create backup files + val backupDir = File(wineprefixSteamDir, "steamclient_backup") + backupDir.mkdirs() + val steamClientFiles = SteamUtils.steamClientFiles() + steamClientFiles.forEach { fileName -> + val backupFile = File(backupDir, "$fileName.orig") + backupFile.writeBytes("backup content".toByteArray()) + } + + // Create extra_dlls directory with files + val extraDllsDir = File(wineprefixSteamDir, "extra_dlls") + extraDllsDir.mkdirs() + val testDll = File(extraDllsDir, "test.dll") + testDll.writeBytes("test dll content".toByteArray()) + val testDll2 = File(extraDllsDir, "test2.dll") + testDll2.writeBytes("test2 dll content".toByteArray()) + + assertTrue("extra_dlls directory should exist before restore", extraDllsDir.exists()) + + // Call restoreSteamclientFiles + SteamUtils.restoreSteamclientFiles(context, steamAppId) + + // Verify extra_dlls directory is deleted + assertFalse("extra_dlls directory should be deleted after restoreSteamclientFiles", + extraDllsDir.exists()) + } + + @Test + fun test_restoreSteamclientFiles_handlesMissingBackup() { + val imageFs = ImageFs.find(context) + val wineprefixSteamDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + wineprefixSteamDir.mkdirs() + + // Create some original files + val steamClientFiles = SteamUtils.steamClientFiles() + val originalContents = mutableMapOf() + steamClientFiles.take(2).forEach { fileName -> + val file = File(wineprefixSteamDir, fileName) + val content = "original $fileName content" + file.writeBytes(content.toByteArray()) + originalContents[fileName] = content + } + + // Ensure no backup directory exists + val backupDir = File(wineprefixSteamDir, "steamclient_backup") + if (backupDir.exists()) { + backupDir.deleteRecursively() + } + + // Call restoreSteamclientFiles - should not throw + try { + SteamUtils.restoreSteamclientFiles(context, steamAppId) + // Test passes if no exception is thrown + assertTrue("Should complete without error when backup missing", true) + } catch (e: Exception) { + fail("Should not throw exception when backup missing: ${e.message}") + } + + // Verify original files remain unchanged + originalContents.forEach { (fileName, content) -> + val file = File(wineprefixSteamDir, fileName) + assertTrue("Original file $fileName should still exist", file.exists()) + assertEquals("Original file $fileName should remain unchanged", + content, file.readText()) + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 26079599ac..691839f7d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ agp = "8.8.0" # https://mvnrepository.com/artifact/com.android.application/com.a apache-compress = "1.27.1" # https://mvnrepository.com/artifact/org.apache.commons/commons-compress apng = "3.0.2" # https://mvnrepository.com/artifact/com.github.penfeizhou.android.animation/apng composeBom = "2025.01.01" # https://mvnrepository.com/artifact/androidx.compose/compose-bom -material3Version = "1.3.1" # Minimum version for pull-to-refresh support coreKtx = "1.15.0" # https://mvnrepository.com/artifact/androidx.core/core-ktx coroutines = "1.10.1" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core dagger-hilt = "2.55" # https://mvnrepository.com/artifact/com.google.dagger/hilt-android @@ -12,6 +11,7 @@ dataStore = "1.1.2" # https://mvnrepository.com/artifact/androidx.datastore/data espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espresso/espresso-core feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose +javasteam = "1.8.0-6-SNAPSHOT" # https://mvnrepository.com/artifact/in.dragonbra/javasteam json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit @@ -20,24 +20,26 @@ kotlinter = "5.0.1" # https://plugins.gradle.org/plugin/org.jmailen.kotlinter ksp = "2.1.21-2.0.2" # https://mvnrepository.com/artifact/com.google.devtools.ksp/symbol-processing-api landscapistCoil = "2.4.6" # https://mvnrepository.com/artifact/com.github.skydoves/landscapist-coil lifecycleRuntimeKtx = "2.8.7" # https://mvnrepository.com/artifact/androidx.lifecycle/lifecycle-runtime-ktx +material = "1.12.0" # https://mvnrepository.com/artifact/com.google.android.material/material material3Adaptive = "1.0.0" # https://mvnrepository.com/artifact/androidx.compose.material3.adaptive/adaptive-layout material3AdaptiveNavSuite = "1.3.1" # https://mvnrepository.com/artifact/androidx.compose.material3/material3-adaptive-navigation-suite +material3Version = "1.3.1" # Minimum version for pull-to-refresh support materialKolor = "2.0.0" # https://mvnrepository.com/artifact/com.materialkolor/material-kolor-android +mockito = "5.14.2" # https://mvnrepository.com/artifact/org.mockito/mockito-core +mockitoKotlin = "5.3.1" # https://mvnrepository.com/artifact/org.mockito.kotlin/mockito-kotlin +mockk = "1.13.5" # https://mvnrepository.com/artifact/io.mockk/mockk +mockwebserver = "5.1.0" navigation-compose = "2.8.6" # https://mvnrepository.com/artifact/androidx.navigation/navigation-compose +orgJson = "20231013" protobuf = "4.31.1" # https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java -robolectric = "4.14" -mockito = "5.14.2" -mockitoKotlin = "5.3.1" -room-runtime = "2.8.0-rc02" # https://mvnrepository.com/artifact/androidx.room/room-runtime +robolectric = "4.14" # https://mvnrepository.com/artifact/org.robolectric/robolectric +room-runtime = "2.8.4" # https://mvnrepository.com/artifact/androidx.room/room-runtime runner = "1.6.2" # https://mvnrepository.com/artifact/androidx.test/runner settings = "2.10.0" # https://github.com/alorma/Compose-Settings/releases spongycastle = "1.58.0.0" # https://mvnrepository.com/artifact/com.madgag.spongycastle/prov -steamkit = "1.8.0-SNAPSHOT" # https://mvnrepository.com/artifact/in.dragonbra/javasteam timber = "5.0.1" # https://mvnrepository.com/artifact/com.jakewharton.timber/timber zstd-jni = "1.5.7-5" # https://mvnrepository.com/artifact/com.github.luben/zstd-jni -zxing = "3.5.3" -material = "1.12.0" -constraintlayoutComposeAndroid = "1.1.1" # https://mvnrepository.com/artifact/com.google.zxing/core +zxing = "3.5.3" # https://mvnrepository.com/artifact/com.google.zxing/core [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -54,35 +56,33 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room-runtime" } androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room-runtime" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room-runtime" } + # TODO: Remove 'version' once 1.8.0 is rolled into stable BOM, see https://developer.android.com/jetpack/androidx/releases/compose-ui # This fixes `Placement happened before lookahead` crash when animating lists items. androidx-ui = { group = "androidx.compose.ui", name = "ui", version = "1.8.0-beta01" } + androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -apng = { group = "com.github.penfeizhou.android.animation", name = "apng", version.ref = "apng" } apache-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "apache-compress" } +apng = { group = "com.github.penfeizhou.android.animation", name = "apng", version.ref = "apng" } compose-settings-ui = { module = "com.github.alorma.compose-settings:ui-tiles", version.ref = "settings" } compose-settings-ui-extended = { module = "com.github.alorma.compose-settings:ui-tiles-extended", version.ref = "settings" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "dataStore" } feature-delivery = { module = "com.google.android.play:feature-delivery-ktx", version.ref = "feature-delivery" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger-hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "dagger-hilt" } +javasteam = { group = "io.github.joshuatam", name = "javasteam", version.ref = "javasteam" } +javasteam-depotdownloader = { group = "io.github.joshuatam", name = "javasteam-depotdownloader", version.ref = "javasteam" } jetbrains-kotlinx-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "json" } kotlin-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } landscapist-coil = { module = "com.github.skydoves:landscapist-coil", version.ref = "landscapistCoil" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } material-kolor = { group = "com.materialkolor", name = "material-kolor", version.ref = "materialKolor" } navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" } +orgJson = { module = "org.json:json", version.ref = "orgJson" } protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" } spongycastle = { group = "com.madgag.spongycastle", name = "prov", version.ref = "spongycastle" } -#steamkit = { group = "in.dragonbra", name = "javasteam", version.ref = "steamkit" } -steamkit = { group = "io.github.utkarshdalal", name = "javasteam", version.ref = "steamkit" } -steamkit-tf = { group = "in.dragonbra", name = "javasteam-tf", version.ref = "steamkit" } -steamkit-dota2 = { group = "in.dragonbra", name = "javasteam-dota2", version.ref = "steamkit" } -#steamkit-depotdownloader = { group = "in.dragonbra", name = "javasteam-depotdownloader", version.ref = "steamkit" } -steamkit-depotdownloader = { group = "io.github.utkarshdalal", name = "javasteam-depotdownloader", version.ref = "steamkit" } -steamkit-deadlock = { group = "in.dragonbra", name = "javasteam-deadlock", version.ref = "steamkit" } -steamkit-cs = { group = "in.dragonbra", name = "javasteam-cs", version.ref = "steamkit" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } zstd-jni = { group = "com.github.luben", name = "zstd-jni", version.ref = "zstd-jni" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } @@ -94,20 +94,21 @@ androidx-runner = { group = "androidx.test", name = "runner", version.ref = "run androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } junit = { group = "junit", name = "junit", version.ref = "junit" } -robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockitoKotlin" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockwebserver" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } -# Dependencies when locally building JavaSteam +# Dependencies when locally building JavaSteam, check link below for current dependencies. +# https://github.com/Longi94/JavaSteam/blob/master/gradle/libs.versions.toml commons-io = { module = "commons-io:commons-io", version = "2.18.0" } # https://mvnrepository.com/artifact/commons-io/commons-io commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.17.0" } # https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 commons-validator = { module = "commons-validator:commons-validator", version = "1.9.0" } # https://mvnrepository.com/artifact/commons-validator/commons-validator -okhttp = { module = "com.squareup.okhttp3:okhttp", version = "5.1.0"} -okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines", version = "5.1.0"} ktor-engine = { module = "io.ktor:ktor-client-cio", version = "3.0.3" } # https://mvnrepository.com/artifact/io.ktor/ktor-client-cio -xz = { module = "org.tukaani:xz", version = "1.10" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } -androidx-constraintlayout-compose-android = { group = "androidx.constraintlayout", name = "constraintlayout-compose-android", version.ref = "constraintlayoutComposeAndroid" } # https://mvnrepository.com/artifact/org.tukaani/xz +okhttp = { module = "com.squareup.okhttp3:okhttp", version = "5.1.0"} # https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp +okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines", version = "5.1.0"} # https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp-coroutines +xz = { module = "org.tukaani:xz", version = "1.10" } # https://mvnrepository.com/artifact/org.tukaani/xz [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -120,6 +121,7 @@ jetbrains-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", ve kotlinter = { id = "org.jmailen.kotlinter", version.ref = "kotlinter" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } secrets-gradle = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version = "2.0.1" } +room = { id = "androidx.room", version.ref = "room-runtime" } [bundles] hilt = [ @@ -153,7 +155,7 @@ compose = [ winlator = [ "apache-compress", ] -steamkit-dev = [ +javasteam-dev = [ "commons-io", "commons-lang3", "commons-validator",