diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b497d89b5..bf7e559d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -226,6 +226,7 @@ dependencies { } } implementation(libs.spongycastle) + implementation(libs.kaml) // Split Modules implementation(libs.bundles.google) diff --git a/app/schemas/app.gamenative.db.PluviaDatabase/13.json b/app/schemas/app.gamenative.db.PluviaDatabase/13.json new file mode 100644 index 000000000..82656ca78 --- /dev/null +++ b/app/schemas/app.gamenative.db.PluviaDatabase/13.json @@ -0,0 +1,1060 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "18f34423f2c71a9a43aac74b04bd5238", + "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": "epic_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `catalog_id` TEXT NOT NULL, `app_name` TEXT NOT NULL, `title` TEXT NOT NULL, `namespace` TEXT NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `platform` TEXT NOT NULL, `version` TEXT NOT NULL, `executable` TEXT NOT NULL, `install_size` INTEGER NOT NULL, `download_size` INTEGER NOT NULL, `art_cover` TEXT NOT NULL, `art_square` TEXT NOT NULL, `art_logo` TEXT NOT NULL, `art_portrait` TEXT NOT NULL, `can_run_offline` INTEGER NOT NULL, `requires_ot` INTEGER NOT NULL, `cloud_save_enabled` INTEGER NOT NULL, `save_folder` TEXT NOT NULL, `third_party_managed_app` TEXT NOT NULL, `is_ea_managed` INTEGER NOT NULL, `is_dlc` INTEGER NOT NULL, `base_game_app_name` TEXT NOT NULL, `description` TEXT NOT NULL, `release_date` TEXT NOT NULL, `genres` TEXT NOT NULL, `tags` TEXT NOT NULL, `last_played` INTEGER NOT NULL, `play_time` INTEGER NOT NULL, `type` INTEGER NOT NULL, `eos_catalog_item_id` TEXT NOT NULL, `eos_app_id` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "catalogId", + "columnName": "catalog_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appName", + "columnName": "app_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "is_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installPath", + "columnName": "install_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "executable", + "columnName": "executable", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installSize", + "columnName": "install_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadSize", + "columnName": "download_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artCover", + "columnName": "art_cover", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artSquare", + "columnName": "art_square", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artLogo", + "columnName": "art_logo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artPortrait", + "columnName": "art_portrait", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canRunOffline", + "columnName": "can_run_offline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresOT", + "columnName": "requires_ot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cloudSaveEnabled", + "columnName": "cloud_save_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saveFolder", + "columnName": "save_folder", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thirdPartyManagedApp", + "columnName": "third_party_managed_app", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEAManaged", + "columnName": "is_ea_managed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDLC", + "columnName": "is_dlc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseGameAppName", + "columnName": "base_game_app_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "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": "eosCatalogItemId", + "columnName": "eos_catalog_item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eosAppId", + "columnName": "eos_app_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "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" + ] + } + }, + { + "tableName": "ludusavi_manifest_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `manifestYaml` TEXT NOT NULL, `lastUpdated` INTEGER NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manifestYaml", + "columnName": "manifestYaml", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "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, '18f34423f2c71a9a43aac74b04bd5238')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index df3ddc211..ecaa34e8c 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -463,6 +463,12 @@ object PrefManager { get() = getPref(EXTERNAL_DISPLAY_SWAP, false) set(value) { setPref(EXTERNAL_DISPLAY_SWAP, value) } + // Prefer Ludusavi manifest over Steam UFS for save detection + private val PREFER_LUDUSAVI = booleanPreferencesKey("prefer_ludusavi") + var preferLudusavi: Boolean + get() = getPref(PREFER_LUDUSAVI, false) + set(value) { setPref(PREFER_LUDUSAVI, value) } + // Disable Mouse Input (prevents external mouse events) private val DISABLE_MOUSE_INPUT = booleanPreferencesKey("disable_mouse_input") var disableMouseInput: Boolean diff --git a/app/src/main/java/app/gamenative/data/LudusaviManifestCache.kt b/app/src/main/java/app/gamenative/data/LudusaviManifestCache.kt new file mode 100644 index 000000000..c33957192 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/LudusaviManifestCache.kt @@ -0,0 +1,28 @@ +package app.gamenative.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Cached Ludusavi manifest data for offline access and performance + */ +@Entity(tableName = "ludusavi_manifest_cache") +data class LudusaviManifestCache( + @PrimaryKey + val id: Int = 1, // Single row cache + + /** + * Raw YAML manifest content from Ludusavi + */ + val manifestYaml: String, + + /** + * Timestamp when manifest was last fetched (System.currentTimeMillis()) + */ + val lastUpdated: Long, + + /** + * Manifest version/etag for cache invalidation + */ + val version: String? = null, +) diff --git a/app/src/main/java/app/gamenative/data/ludusavi/LudusaviManifest.kt b/app/src/main/java/app/gamenative/data/ludusavi/LudusaviManifest.kt new file mode 100644 index 000000000..190dfc178 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/ludusavi/LudusaviManifest.kt @@ -0,0 +1,95 @@ +package app.gamenative.data.ludusavi + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Root manifest structure from Ludusavi + * https://github.com/mtkennerly/ludusavi-manifest + */ +@Serializable +data class LudusaviManifest( + @SerialName("games") + val games: Map = emptyMap(), +) + +/** + * Individual game entry in the manifest + */ +@Serializable +data class LudusaviGame( + @SerialName("files") + val files: Map = emptyMap(), + + @SerialName("registry") + val registry: Map = emptyMap(), + + @SerialName("installDir") + val installDir: Map = emptyMap(), + + @SerialName("steam") + val steam: LudusaviSteamInfo? = null, + + @SerialName("gog") + val gog: LudusaviGogInfo? = null, + + @SerialName("cloud") + val cloud: LudusaviCloudInfo? = null, +) + +/** + * File or path entry with optional conditions + */ +@Serializable +data class LudusaviFileEntry( + @SerialName("tags") + val tags: List = emptyList(), + + @SerialName("when") + val conditions: List = emptyList(), +) + +/** + * Conditional requirements for a file/path entry + */ +@Serializable +data class LudusaviCondition( + @SerialName("os") + val os: String? = null, + + @SerialName("store") + val store: String? = null, +) + +/** + * Steam-specific metadata + */ +@Serializable +data class LudusaviSteamInfo( + @SerialName("id") + val id: Int, +) + +/** + * GOG-specific metadata + */ +@Serializable +data class LudusaviGogInfo( + @SerialName("id") + val id: Long, +) + +/** + * Cloud save support information + */ +@Serializable +data class LudusaviCloudInfo( + @SerialName("steam") + val steam: Boolean = false, + + @SerialName("gog") + val gog: Boolean = false, + + @SerialName("epic") + val epic: Boolean = false, +) diff --git a/app/src/main/java/app/gamenative/data/ludusavi/LudusaviPathMapper.kt b/app/src/main/java/app/gamenative/data/ludusavi/LudusaviPathMapper.kt new file mode 100644 index 000000000..5cfc9efb8 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/ludusavi/LudusaviPathMapper.kt @@ -0,0 +1,151 @@ +package app.gamenative.data.ludusavi + +import app.gamenative.data.SaveFilePattern +import app.gamenative.enums.PathType +import timber.log.Timber + +/** + * Maps Ludusavi path placeholders to GameNative PathType enum and SaveFilePattern. + * + * Ludusavi uses placeholders like winLocalAppData, winAppData, winDocuments, winSavedGames, game. + * GameNative uses PathType enum which maps to Wine prefix paths. + */ +object LudusaviPathMapper { + + /** + * Converts a Ludusavi path template to a SaveFilePattern. + * + * @param ludusaviPath The path template from Ludusavi manifest + * @return SaveFilePattern or null if path type is not supported + */ + fun translateToPattern(ludusaviPath: String): SaveFilePattern? { + // Handle base installation directory placeholder + if (ludusaviPath.contains("")) { + Timber.d("Skipping placeholder path: $ludusaviPath") + return null + } + + return when { + ludusaviPath.startsWith("") -> { + parseWindowsPath( + pathType = PathType.WinAppDataLocalLow, + ludusaviPath = ludusaviPath, + prefix = "", + ) + } + + ludusaviPath.startsWith("") -> { + parseWindowsPath( + pathType = PathType.WinAppDataRoaming, + ludusaviPath = ludusaviPath, + prefix = "", + ) + } + + ludusaviPath.startsWith("") -> { + parseWindowsPath( + pathType = PathType.WinMyDocuments, + ludusaviPath = ludusaviPath, + prefix = "", + ) + } + + ludusaviPath.startsWith("") -> { + parseWindowsPath( + pathType = PathType.WinSavedGames, + ludusaviPath = ludusaviPath, + prefix = "", + ) + } + + ludusaviPath.startsWith("") -> { + parseWindowsPath( + pathType = PathType.GameInstall, + ludusaviPath = ludusaviPath, + prefix = "", + ) + } + + // Skip Linux/Mac paths + ludusaviPath.startsWith("") || + ludusaviPath.startsWith("") || + ludusaviPath.startsWith("") -> { + Timber.d("Skipping non-Windows path: $ludusaviPath") + null + } + + else -> { + Timber.w("Unknown Ludusavi path type: $ludusaviPath") + null + } + } + } + + /** + * Parse a Windows-style Ludusavi path into SaveFilePattern components. + * + * @param pathType The PathType enum to use + * @param ludusaviPath Full path from Ludusavi + * @param prefix The placeholder prefix to remove + */ + private fun parseWindowsPath( + pathType: PathType, + ludusaviPath: String, + prefix: String, + ): SaveFilePattern { + // Remove prefix and leading slash + val relativePath = ludusaviPath.removePrefix(prefix).removePrefix("/") + + // Handle glob patterns like **/*.sav or Saves/* + val parts = relativePath.split("/") + + // Find where the glob pattern starts (contains * or **) + val firstGlobIndex = parts.indexOfFirst { it.contains("*") } + + val pathPart: String + val patternPart: String + + if (firstGlobIndex >= 0) { + // Everything before glob is path, glob itself is pattern + val pathComponents = parts.take(firstGlobIndex) + val patternComponents = parts.drop(firstGlobIndex) + + pathPart = pathComponents.joinToString("/") + patternPart = patternComponents.lastOrNull() ?: "*" + } else { + // No glob - treat entire path as directory with wildcard pattern + pathPart = relativePath + patternPart = "*" + } + + // Determine if recursive scanning is needed + val isRecursive = relativePath.contains("**") || parts.size > 2 + + return SaveFilePattern( + root = pathType, + path = pathPart, + pattern = patternPart, + recursive = if (isRecursive) 1 else 0, + ) + } + + /** + * Checks if a file entry should be processed based on OS and store conditions. + * + * @param entry The LudusaviFileEntry to check + * @return true if entry applies to Windows Steam games + */ + fun shouldProcessEntry(entry: LudusaviFileEntry): Boolean { + // If no conditions, entry applies to all platforms/stores + if (entry.conditions.isEmpty()) { + return true + } + + // Check if any condition matches Windows + Steam + return entry.conditions.any { condition -> + val osMatches = condition.os == null || condition.os == "windows" + val storeMatches = condition.store == null || condition.store == "steam" + osMatches && storeMatches + } + } +} diff --git a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt index 56921b635..efc7026b7 100644 --- a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt +++ b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt @@ -14,6 +14,7 @@ import app.gamenative.data.DownloadingAppInfo import app.gamenative.data.EncryptedAppTicket import app.gamenative.data.GOGGame import app.gamenative.data.EpicGame +import app.gamenative.data.LudusaviManifestCache import app.gamenative.db.converters.AppConverter import app.gamenative.db.converters.ByteArrayConverter import app.gamenative.db.converters.FriendConverter @@ -31,6 +32,7 @@ import app.gamenative.db.dao.DownloadingAppInfoDao import app.gamenative.db.dao.EncryptedAppTicketDao import app.gamenative.db.dao.GOGGameDao import app.gamenative.db.dao.EpicGameDao +import app.gamenative.db.dao.LudusaviManifestCacheDao const val DATABASE_NAME = "pluvia.db" @@ -45,9 +47,10 @@ const val DATABASE_NAME = "pluvia.db" SteamLicense::class, GOGGame::class, EpicGame::class, - DownloadingAppInfo::class + DownloadingAppInfo::class, + LudusaviManifestCache::class ], - version = 12, + version = 13, // 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 = [ @@ -55,7 +58,8 @@ const val DATABASE_NAME = "pluvia.db" AutoMigration(from = 8, to = 9), AutoMigration(from = 9, to = 10), AutoMigration(from = 10, to = 11), - AutoMigration(from = 11, to = 12) + AutoMigration(from = 11, to = 12), + AutoMigration(from = 12, to = 13) ] ) @TypeConverters( @@ -88,4 +92,6 @@ abstract class PluviaDatabase : RoomDatabase() { abstract fun epicGameDao(): EpicGameDao abstract fun downloadingAppInfoDao(): DownloadingAppInfoDao + + abstract fun ludusaviManifestCacheDao(): LudusaviManifestCacheDao } diff --git a/app/src/main/java/app/gamenative/db/dao/LudusaviManifestCacheDao.kt b/app/src/main/java/app/gamenative/db/dao/LudusaviManifestCacheDao.kt new file mode 100644 index 000000000..8c39d9fca --- /dev/null +++ b/app/src/main/java/app/gamenative/db/dao/LudusaviManifestCacheDao.kt @@ -0,0 +1,20 @@ +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.LudusaviManifestCache + +@Dao +interface LudusaviManifestCacheDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(cache: LudusaviManifestCache) + + @Query("SELECT * FROM ludusavi_manifest_cache WHERE id = 1") + suspend fun get(): LudusaviManifestCache? + + @Query("DELETE FROM ludusavi_manifest_cache") + suspend fun clear() +} diff --git a/app/src/main/java/app/gamenative/service/LudusaviService.kt b/app/src/main/java/app/gamenative/service/LudusaviService.kt new file mode 100644 index 000000000..aa06cda7f --- /dev/null +++ b/app/src/main/java/app/gamenative/service/LudusaviService.kt @@ -0,0 +1,224 @@ +package app.gamenative.service + +import app.gamenative.data.LudusaviManifestCache +import app.gamenative.data.SaveFilePattern +import app.gamenative.data.ludusavi.LudusaviPathMapper +import app.gamenative.db.PluviaDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.net.HttpURLConnection +import java.net.URL +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Service for fetching and parsing Ludusavi manifest data + * https://github.com/mtkennerly/ludusavi-manifest + */ +@Singleton +class LudusaviService @Inject constructor( + private val database: PluviaDatabase, +) { + + companion object { + // Ludusavi manifest is ~15MB YAML, too large for kaml's code point limit + // We'll download and parse it manually to extract only what we need + private const val MANIFEST_URL = "https://raw.githubusercontent.com/mtkennerly/ludusavi-manifest/master/data/manifest.yaml" + private const val CACHE_EXPIRY_MS = 7L * 24 * 60 * 60 * 1000 // 7 days + + /** + * Known broken games where Steam UFS data is incorrect + * Will always check Ludusavi for these games + */ + private val KNOWN_BROKEN_GAMES = setOf( + 1313140, // Cult of the Lamb + 814370, // Monster Sanctuary + // Add more as discovered + ) + } + + /** + * Check if a game is known to have broken Steam UFS data + */ + fun isKnownBrokenGame(steamAppId: Int): Boolean { + return steamAppId in KNOWN_BROKEN_GAMES + } + + /** + * Get save file patterns for a Steam game from Ludusavi manifest + * + * @param steamAppId The Steam App ID to look up + * @return List of SaveFilePattern or null if game not found or manifest unavailable + */ + suspend fun getPatterns(steamAppId: Int): List? = withContext(Dispatchers.IO) { + try { + // Download manifest text (don't parse the whole thing) + val manifestYaml = getManifestYaml() ?: return@withContext null + + // Extract just the game entry we need using string parsing + val gameSection = extractGameSection(manifestYaml, steamAppId) ?: run { + Timber.d("Game $steamAppId not found in Ludusavi manifest") + return@withContext null + } + + // Parse file paths from the extracted section + val patterns = parseFilePaths(gameSection) + + if (patterns.isEmpty()) { + Timber.d("No Windows patterns found for game $steamAppId") + return@withContext null + } + + Timber.i("Found ${patterns.size} save patterns for game $steamAppId from Ludusavi") + patterns.forEach { pattern -> + Timber.d(" - ${pattern.root.name}/${pattern.path}/${pattern.pattern}") + } + + patterns + } catch (e: Exception) { + Timber.e(e, "Failed to get patterns for game $steamAppId") + null + } + } + + /** + * Get the manifest YAML text (cached or downloaded) + */ + private suspend fun getManifestYaml(): String? = withContext(Dispatchers.IO) { + // Check database cache + val dao = database.ludusaviManifestCacheDao() + val cached = dao.get() + + if (cached != null && !isCacheExpired(cached.lastUpdated)) { + Timber.d("Loading Ludusavi manifest from database cache") + return@withContext cached.manifestYaml + } + + // Fetch from network + try { + Timber.i("Downloading Ludusavi manifest from $MANIFEST_URL") + val manifestYaml = downloadManifest() + + // Cache to database + dao.insert( + LudusaviManifestCache( + manifestYaml = manifestYaml, + lastUpdated = System.currentTimeMillis(), + ) + ) + + Timber.i("Successfully downloaded Ludusavi manifest (${manifestYaml.length} bytes)") + manifestYaml + } catch (e: Exception) { + Timber.e(e, "Failed to download Ludusavi manifest") + null + } + } + + /** + * Extract a game section from the YAML by finding its steam.id entry + */ + private fun extractGameSection(yaml: String, steamAppId: Int): String? { + // Find the steam.id line matching our app ID + // Pattern: " steam:\n id: {steamAppId}" + val steamIdPattern = Regex(""" steam:\s*\n\s+id:\s*$steamAppId\s*$""", RegexOption.MULTILINE) + val match = steamIdPattern.find(yaml) ?: return null + + // Find the start of this game entry (back to the game name at indent level 0) + val beforeMatch = yaml.substring(0, match.range.first) + // Game names are at root level (no indent) and start with a capital letter + val gameStartPattern = Regex("""^([A-Z0-9][^\n:]*):\s*$""", RegexOption.MULTILINE) + val gameMatches = gameStartPattern.findAll(beforeMatch).toList() + val gameNameMatch = gameMatches.lastOrNull() ?: return null + + val gameStart = gameNameMatch.range.first + + // Find the end of this game entry (next game at indent level 0) + val afterMatch = yaml.substring(match.range.last) + val nextGameMatch = Regex("""^[A-Z0-9]""", RegexOption.MULTILINE).find(afterMatch) + val gameEnd = if (nextGameMatch != null) { + match.range.last + nextGameMatch.range.first + } else { + yaml.length + } + + return yaml.substring(gameStart, gameEnd) + } + + /** + * Parse file paths from a game YAML section + */ + private fun parseFilePaths(gameYaml: String): List { + val patterns = mutableListOf() + + // Find the files: section + val filesMatch = Regex("""^\s+files:\s*$""", RegexOption.MULTILINE).find(gameYaml) ?: return patterns + val filesStart = filesMatch.range.last + + // Find where files section ends (next property at same or lower indent level) + val rest = gameYaml.substring(filesStart) + val nextSectionMatch = Regex("""^\s{2,4}[a-z]+:\s*$""", RegexOption.MULTILINE).find(rest) + val filesEnd = if (nextSectionMatch != null) { + filesStart + nextSectionMatch.range.first + } else { + gameYaml.length + } + + val filesSection = gameYaml.substring(filesStart, filesEnd) + + // Extract all file paths (keys under files: that start with placeholders) + // Pattern matches lines like: "/path/to/file": + val pathPattern = Regex("""^\s{4,}(["']?<.+?):\s*$""", RegexOption.MULTILINE) + pathPattern.findAll(filesSection).forEach { match -> + var path = match.groupValues[1].trim() + // Only process paths that start with < (placeholder paths) + if (path.startsWith("\"<") || path.startsWith("'<") || path.startsWith("<")) { + // Remove surrounding quotes if present + path = path.trim('"').trim('\'') + LudusaviPathMapper.translateToPattern(path)?.let { patterns.add(it) } + } + } + + return patterns + } + + /** + * Download manifest from GitHub (YAML format) + */ + private fun downloadManifest(): String { + val url = URL(MANIFEST_URL) + val connection = url.openConnection() as HttpURLConnection + + try { + connection.requestMethod = "GET" + connection.connectTimeout = 30000 // 30 seconds + connection.readTimeout = 60000 // 60 seconds + + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + throw IllegalStateException("HTTP error $responseCode") + } + + return connection.inputStream.bufferedReader().use { it.readText() } + } finally { + connection.disconnect() + } + } + + /** + * Check if cached manifest is expired + */ + private fun isCacheExpired(lastUpdated: Long): Boolean { + val age = System.currentTimeMillis() - lastUpdated + return age > CACHE_EXPIRY_MS + } + + /** + * Clear cached manifest (for testing/debugging) + */ + suspend fun clearCache() = withContext(Dispatchers.IO) { + database.ludusaviManifestCacheDao().clear() + Timber.i("Cleared Ludusavi manifest cache") + } +} diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index af6131cb4..fc4fb2f7e 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -1,6 +1,7 @@ package app.gamenative.service import androidx.room.withTransaction +import app.gamenative.PrefManager import app.gamenative.data.PostSyncInfo import app.gamenative.data.SaveFilePattern import app.gamenative.data.SteamApp @@ -36,6 +37,7 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -77,8 +79,10 @@ object SteamAutoCloud { steamInstance: SteamService, steamCloud: SteamCloud, preferredSave: SaveLocation = SaveLocation.None, + preferLudusavi: Boolean = false, parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO), prefixToPath: (String) -> String, + ludusaviService: LudusaviService? = null, overrideLocalChangeNumber: Long? = null, onProgress: ((message: String, progress: Float) -> Unit)? = null, ): Deferred = parentScope.async { @@ -213,7 +217,37 @@ object SteamAutoCloud { } val getLocalUserFilesAsPrefixMap: () -> Map> = { - val savePatterns = appInfo.ufs.saveFilePatterns.filter { userFile -> userFile.root.isWindows } + var savePatterns = appInfo.ufs.saveFilePatterns.filter { userFile -> userFile.root.isWindows } + + // Fallback to Ludusavi if no Steam UFS patterns, game is known to be broken, or user prefers Ludusavi + if (savePatterns.isEmpty() || + ludusaviService?.isKnownBrokenGame(appInfo.id) == true || + preferLudusavi) { + val reason = when { + preferLudusavi -> "user preference" + savePatterns.isEmpty() -> "no UFS patterns" + else -> "known broken game" + } + Timber.i("Attempting Ludusavi fallback for ${appInfo.name} (${appInfo.id}): $reason") + + ludusaviService?.let { service -> + parentScope.async { + service.getPatterns(appInfo.id) + }.let { deferred -> + // Block to get Ludusavi patterns synchronously within this lambda + runBlocking { + deferred.await()?.let { ludusaviPatterns -> + Timber.i("Using ${ludusaviPatterns.size} Ludusavi patterns for ${appInfo.name}") + savePatterns = ludusaviPatterns + } + } + } + } + + if (savePatterns.isEmpty()) { + Timber.w("No patterns found in Ludusavi for ${appInfo.name} (${appInfo.id})") + } + } if (savePatterns.isNotEmpty()) { val result = mutableMapOf>() diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 1016c4cb0..63b610af8 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -200,6 +200,9 @@ class SteamService : Service(), IChallengeUrlChanged { @Inject lateinit var downloadingAppInfoDao: DownloadingAppInfoDao + @Inject + lateinit var ludusaviService: LudusaviService + private lateinit var notificationHelper: NotificationHelper internal var callbackManager: CallbackManager? = null @@ -1930,6 +1933,7 @@ class SteamService : Service(), IChallengeUrlChanged { parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO), ignorePendingOperations: Boolean = false, preferredSave: SaveLocation = SaveLocation.None, + preferLudusavi: Boolean = false, prefixToPath: (String) -> String, isOffline: Boolean = false, onProgress: ((message: String, progress: Float) -> Unit)? = null, @@ -1955,8 +1959,10 @@ class SteamService : Service(), IChallengeUrlChanged { steamInstance = steamInstance, steamCloud = steamCloud, preferredSave = preferredSave, + preferLudusavi = preferLudusavi, parentScope = parentScope, prefixToPath = prefixToPath, + ludusaviService = instance?.ludusaviService, onProgress = onProgress, ).await() @@ -2006,6 +2012,7 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun forceSyncUserFiles( appId: Int, + preferLudusavi: Boolean = false, prefixToPath: (String) -> String, preferredSave: SaveLocation = SaveLocation.None, parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO), @@ -2029,8 +2036,10 @@ class SteamService : Service(), IChallengeUrlChanged { steamInstance = steamInstance, steamCloud = steamCloud, preferredSave = preferredSave, + preferLudusavi = preferLudusavi, parentScope = parentScope, prefixToPath = prefixToPath, + ludusaviService = instance?.ludusaviService, overrideLocalChangeNumber = overrideLocalChangeNumber, ).await() @@ -2049,7 +2058,7 @@ class SteamService : Service(), IChallengeUrlChanged { } } - suspend fun closeApp(appId: Int, isOffline: Boolean, prefixToPath: (String) -> String) = withContext(Dispatchers.IO) { + suspend fun closeApp(appId: Int, isOffline: Boolean, preferLudusavi: Boolean = false, prefixToPath: (String) -> String) = withContext(Dispatchers.IO) { async { if (isOffline || !isConnected) { return@async @@ -2070,8 +2079,10 @@ class SteamService : Service(), IChallengeUrlChanged { clientId = clientId, steamInstance = steamInstance, steamCloud = steamCloud, + preferLudusavi = preferLudusavi, parentScope = this, prefixToPath = prefixToPath, + ludusaviService = instance?.ludusaviService, ).await() steamCloud.signalAppExitSyncDone( diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index d80cd04f2..101d7b48e 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1274,6 +1274,7 @@ fun preLaunchApp( prefixToPath = prefixToPath, ignorePendingOperations = ignorePendingOperations, preferredSave = preferredSave, + preferLudusavi = container.isPreferLudusavi(), parentScope = this, isOffline = isOffline, onProgress = { message, progress -> 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 14c32a6d7..4f80d88f0 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 @@ -1143,7 +1143,7 @@ fun ContainerConfigDialog( .verticalScroll(scrollState) .weight(1f), ) { - if (selectedTab == 0) GeneralTabContent(state, nonzeroResolutionError, aspectResolutionError) + if (selectedTab == 0) GeneralTabContent(state, nonzeroResolutionError, aspectResolutionError, default) if (selectedTab == 1) GraphicsTabContent(state) if (selectedTab == 2) EmulationTabContent(state) if (selectedTab == 3) ControllerTabContent(state, default) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt index 3dead19f3..53c56a51c 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt @@ -43,6 +43,7 @@ fun GeneralTabContent( state: ContainerConfigState, nonzeroResolutionError: String, aspectResolutionError: String, + default: Boolean = false, ) { val config = state.config.value val graphicsDrivers = state.graphicsDrivers.value @@ -351,5 +352,14 @@ fun GeneralTabContent( state.config.value = config.copy(steamType = type) }, ) + if (!default) { + SettingsSwitch( + colors = settingsTileColorsAlt(), + title = { Text(text = "Prefer Ludusavi Manifest") }, + subtitle = { Text(text = "Use community save data over Steam UFS for cloud saves") }, + state = config.preferLudusavi, + onCheckedChange = { state.config.value = config.copy(preferLudusavi = it) }, + ) + } } } 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 3106bcf40..50c975899 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -334,7 +334,8 @@ class MainViewModel @Inject constructor( } } else { // For Steam games, sync cloud saves - SteamService.closeApp(gameId, isOffline.value) { prefix -> + val container = ContainerUtils.getContainer(context, appId) + SteamService.closeApp(gameId, isOffline.value, container?.isPreferLudusavi() ?: false) { prefix -> PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) }.await() } 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 9893d5ec0..612db0ef3 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 @@ -901,6 +901,7 @@ class SteamAppScreen : BaseAppScreen() { } val syncResult = SteamService.forceSyncUserFiles( appId = gameId, + preferLudusavi = container.isPreferLudusavi(), prefixToPath = prefixToPath ).await() diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 1781c6f91..01f48c989 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -135,6 +135,7 @@ object ContainerUtils { disableMouseInput = PrefManager.disableMouseInput, externalDisplayMode = PrefManager.externalDisplayInputMode, externalDisplaySwap = PrefManager.externalDisplaySwap, + preferLudusavi = PrefManager.preferLudusavi, sharpnessEffect = PrefManager.sharpnessEffect, sharpnessLevel = PrefManager.sharpnessLevel, sharpnessDenoise = PrefManager.sharpnessDenoise, @@ -174,6 +175,7 @@ object ContainerUtils { PrefManager.disableMouseInput = containerData.disableMouseInput PrefManager.externalDisplayInputMode = containerData.externalDisplayMode PrefManager.externalDisplaySwap = containerData.externalDisplaySwap + PrefManager.preferLudusavi = containerData.preferLudusavi PrefManager.containerLanguage = containerData.language PrefManager.containerVariant = containerData.containerVariant PrefManager.wineVersion = containerData.wineVersion @@ -299,6 +301,7 @@ object ContainerUtils { sharpnessEffect = container.getExtra("sharpnessEffect", "None"), sharpnessLevel = container.getExtra("sharpnessLevel", "100").toIntOrNull() ?: 100, sharpnessDenoise = container.getExtra("sharpnessDenoise", "100").toIntOrNull() ?: 100, + preferLudusavi = container.isPreferLudusavi(), ) } @@ -497,6 +500,7 @@ object ContainerUtils { container.setInputType(api.ordinal) container.setDinputMapperType(containerData.dinputMapperType) container.setUseDRI3(containerData.useDRI3) + container.setPreferLudusavi(containerData.preferLudusavi) Timber.d("Container set: preferredInputApi=%s, dinputMapperType=0x%02x", api, containerData.dinputMapperType) if (saveToDisk) { @@ -798,6 +802,7 @@ object ContainerUtils { unpackFiles = PrefManager.unpackFiles, externalDisplayMode = PrefManager.externalDisplayInputMode, externalDisplaySwap = PrefManager.externalDisplaySwap, + preferLudusavi = PrefManager.preferLudusavi, ) } diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index 421e9cebc..16c2eebe6 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -127,6 +127,8 @@ public enum XrControllerMapping { private boolean useDRI3 = true; // Steam client type for selecting appropriate Box64 RC config: normal, light, ultralight private String steamType = DefaultVersion.STEAM_TYPE; + // Prefer Ludusavi manifest over Steam UFS for save detection + private boolean preferLudusavi = false; private boolean gstreamerWorkaround = false; @@ -678,6 +680,9 @@ public void saveData() { // Unpack Files setting data.put("unpackFiles", unpackFiles); + // Prefer Ludusavi setting + data.put("preferLudusavi", preferLudusavi); + if (!WineInfo.isMainWineVersion(wineVersion)) data.put("wineVersion", wineVersion); FileUtils.writeString(getConfigFile(), data.toString()); } @@ -843,6 +848,9 @@ public void loadData(JSONObject data) throws JSONException { case "useDRI3" : setUseDRI3(data.getBoolean(key)); break; + case "preferLudusavi" : + setPreferLudusavi(data.getBoolean(key)); + break; case "fexcoreVersion" : setFEXCoreVersion(data.getString(key)); break; @@ -995,6 +1003,15 @@ public void setExternalDisplaySwap(boolean externalDisplaySwap) { this.externalDisplaySwap = externalDisplaySwap; } + // Prefer Ludusavi manifest over Steam UFS for save detection + public boolean isPreferLudusavi() { + return preferLudusavi; + } + + public void setPreferLudusavi(boolean preferLudusavi) { + this.preferLudusavi = preferLudusavi; + } + // Use DRI3 WSI public boolean isUseDRI3() { return useDRI3; diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index ac4ca7eac..68f4acee9 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -78,6 +78,8 @@ data class ContainerData( val externalDisplayMode: String = Container.DEFAULT_EXTERNAL_DISPLAY_MODE, /** Swap game/input between internal and external displays **/ val externalDisplaySwap: Boolean = false, + /** Prefer Ludusavi manifest over Steam UFS for save detection **/ + val preferLudusavi: Boolean = false, /** Preferred game language (Goldberg) **/ val language: String = "english", val forceDlc: Boolean = false, @@ -135,6 +137,7 @@ data class ContainerData( "touchscreenMode" to state.touchscreenMode, "externalDisplayMode" to state.externalDisplayMode, "externalDisplaySwap" to state.externalDisplaySwap, + "preferLudusavi" to state.preferLudusavi, "useDRI3" to state.useDRI3, "language" to state.language, "forceDlc" to state.forceDlc, @@ -191,6 +194,7 @@ data class ContainerData( touchscreenMode = savedMap["touchscreenMode"] as Boolean, externalDisplayMode = (savedMap["externalDisplayMode"] as? String) ?: Container.DEFAULT_EXTERNAL_DISPLAY_MODE, externalDisplaySwap = (savedMap["externalDisplaySwap"] as? Boolean) ?: false, + preferLudusavi = (savedMap["preferLudusavi"] as? Boolean) ?: false, useDRI3 = (savedMap["useDRI3"] as? Boolean) ?: true, language = (savedMap["language"] as? String) ?: "english", forceDlc = (savedMap["forceDlc"] as? Boolean) ?: false, diff --git a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt index efe2664c4..c8f4baa14 100644 --- a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt +++ b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt @@ -296,6 +296,7 @@ class SteamAutoCloudTest { steamCloud = mockSteamCloud, preferredSave = SaveLocation.None, prefixToPath = prefixToPath, + ludusaviService = null, ).await() // Verify result @@ -479,6 +480,7 @@ class SteamAutoCloudTest { steamCloud = mockSteamCloud, preferredSave = SaveLocation.None, prefixToPath = prefixToPath, + ludusaviService = null, ).await() // Verify result @@ -607,6 +609,7 @@ class SteamAutoCloudTest { steamCloud = mockSteamCloud, preferredSave = SaveLocation.None, prefixToPath = prefixToPath, + ludusaviService = null, ).await() // Verify result @@ -732,6 +735,7 @@ class SteamAutoCloudTest { steamCloud = mockSteamCloud, preferredSave = SaveLocation.None, prefixToPath = prefixToPath, + ludusaviService = null, ).await() // Verify result @@ -851,6 +855,7 @@ class SteamAutoCloudTest { steamCloud = mockSteamCloud, preferredSave = SaveLocation.None, prefixToPath = prefixToPath, + ludusaviService = null, ).await() // Verify result - should find files at depths 0-5 (6 files), but not depths 6-7 @@ -991,6 +996,7 @@ class SteamAutoCloudTest { steamCloud = mockSteamCloud, preferredSave = SaveLocation.None, prefixToPath = prefixToPath, + ludusaviService = null, ).await() // Verify result @@ -1134,6 +1140,7 @@ class SteamAutoCloudTest { steamCloud = mockSteamCloud, preferredSave = SaveLocation.None, prefixToPath = prefixToPath, + ludusaviService = null, ).await() // Verify result diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 22498cfd2..8a047726e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ javasteam = "1.8.0-11-SNAPSHOT" # https://mvnrepository.com/artifact/in.dragonbr 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 +kaml = "0.61.0" # https://mvnrepository.com/artifact/com.charleskorn.kaml/kaml kotlin = "2.1.21" # https://mvnrepository.com/artifact/org.jetbrains.kotlin.android/org.jetbrains.kotlin.android.gradle.plugin 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 @@ -74,6 +75,7 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagge 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" } +kaml = { group = "com.charleskorn.kaml", name = "kaml", version.ref = "kaml" } 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" }