From 16ece2210b8dc68fdf172df15f0688bb305ee93c Mon Sep 17 00:00:00 2001 From: phobos665 Date: Tue, 2 Dec 2025 16:41:34 +0000 Subject: [PATCH 001/122] add the gogdl files and python support. --- app/build.gradle.kts | 20 + app/src/main/python/gogdl/__init__.py | 6 + app/src/main/python/gogdl/api.py | 118 +++ app/src/main/python/gogdl/args.py | 85 ++ app/src/main/python/gogdl/auth.py | 133 +++ app/src/main/python/gogdl/cli.py | 177 ++++ app/src/main/python/gogdl/constants.py | 29 + app/src/main/python/gogdl/dl/__init__.py | 3 + app/src/main/python/gogdl/dl/dl_utils.py | 184 +++++ .../main/python/gogdl/dl/managers/__init__.py | 4 + .../python/gogdl/dl/managers/dependencies.py | 166 ++++ .../main/python/gogdl/dl/managers/linux.py | 19 + .../main/python/gogdl/dl/managers/manager.py | 207 +++++ .../python/gogdl/dl/managers/task_executor.py | 759 ++++++++++++++++++ app/src/main/python/gogdl/dl/managers/v1.py | 313 ++++++++ app/src/main/python/gogdl/dl/managers/v2.py | 310 +++++++ .../main/python/gogdl/dl/objects/__init__.py | 2 + .../main/python/gogdl/dl/objects/generic.py | 127 +++ app/src/main/python/gogdl/dl/objects/linux.py | 388 +++++++++ app/src/main/python/gogdl/dl/objects/v1.py | 168 ++++ app/src/main/python/gogdl/dl/objects/v2.py | 295 +++++++ app/src/main/python/gogdl/dl/progressbar.py | 125 +++ .../python/gogdl/dl/workers/task_executor.py | 366 +++++++++ app/src/main/python/gogdl/imports.py | 130 +++ app/src/main/python/gogdl/languages.py | 123 +++ app/src/main/python/gogdl/launch.py | 284 +++++++ app/src/main/python/gogdl/process.py | 138 ++++ app/src/main/python/gogdl/saves.py | 365 +++++++++ app/src/main/python/gogdl/xdelta/__init__.py | 1 + app/src/main/python/gogdl/xdelta/objects.py | 139 ++++ app/src/main/python/gogdl/xdelta/patcher.py | 204 +++++ 31 files changed, 5388 insertions(+) create mode 100644 app/src/main/python/gogdl/__init__.py create mode 100644 app/src/main/python/gogdl/api.py create mode 100644 app/src/main/python/gogdl/args.py create mode 100644 app/src/main/python/gogdl/auth.py create mode 100644 app/src/main/python/gogdl/cli.py create mode 100644 app/src/main/python/gogdl/constants.py create mode 100644 app/src/main/python/gogdl/dl/__init__.py create mode 100644 app/src/main/python/gogdl/dl/dl_utils.py create mode 100644 app/src/main/python/gogdl/dl/managers/__init__.py create mode 100644 app/src/main/python/gogdl/dl/managers/dependencies.py create mode 100644 app/src/main/python/gogdl/dl/managers/linux.py create mode 100644 app/src/main/python/gogdl/dl/managers/manager.py create mode 100644 app/src/main/python/gogdl/dl/managers/task_executor.py create mode 100644 app/src/main/python/gogdl/dl/managers/v1.py create mode 100644 app/src/main/python/gogdl/dl/managers/v2.py create mode 100644 app/src/main/python/gogdl/dl/objects/__init__.py create mode 100644 app/src/main/python/gogdl/dl/objects/generic.py create mode 100644 app/src/main/python/gogdl/dl/objects/linux.py create mode 100644 app/src/main/python/gogdl/dl/objects/v1.py create mode 100644 app/src/main/python/gogdl/dl/objects/v2.py create mode 100644 app/src/main/python/gogdl/dl/progressbar.py create mode 100644 app/src/main/python/gogdl/dl/workers/task_executor.py create mode 100644 app/src/main/python/gogdl/imports.py create mode 100644 app/src/main/python/gogdl/languages.py create mode 100644 app/src/main/python/gogdl/launch.py create mode 100644 app/src/main/python/gogdl/process.py create mode 100644 app/src/main/python/gogdl/saves.py create mode 100644 app/src/main/python/gogdl/xdelta/__init__.py create mode 100644 app/src/main/python/gogdl/xdelta/objects.py create mode 100644 app/src/main/python/gogdl/xdelta/patcher.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4c74c6c5c..c8a4c6488 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.kotlinter) alias(libs.plugins.ksp) alias(libs.plugins.secrets.gradle) + id("com.chaquo.python") version "16.0.0" } val keystorePropertiesFile = rootProject.file("app/keystores/keystore.properties") @@ -197,8 +198,27 @@ android { // } } +chaquopy { + defaultConfig { + version = "3.11" // Last Python version supporting armeabi-v7a (32-bit ARM) + pip { + // Install GOGDL dependencies + install("requests") + } + } + sourceSets { + getByName("main") { + srcDir("src/main/python") + } + } +} + dependencies { implementation(libs.material) + + // Chrome Custom Tabs for GOG OAuth + implementation("androidx.browser:browser:1.8.0") + // JavaSteam val localBuild = false // Change to 'true' needed when building JavaSteam manually if (localBuild) { diff --git a/app/src/main/python/gogdl/__init__.py b/app/src/main/python/gogdl/__init__.py new file mode 100644 index 000000000..89b905c65 --- /dev/null +++ b/app/src/main/python/gogdl/__init__.py @@ -0,0 +1,6 @@ +""" +Android-compatible GOGDL implementation +Modified from heroic-gogdl for Android/Chaquopy compatibility +""" + +version = "1.1.2-post1" diff --git a/app/src/main/python/gogdl/api.py b/app/src/main/python/gogdl/api.py new file mode 100644 index 000000000..d45413b9f --- /dev/null +++ b/app/src/main/python/gogdl/api.py @@ -0,0 +1,118 @@ +import logging +import time +import requests +import json +from multiprocessing import cpu_count +from gogdl.dl import dl_utils +from gogdl import constants +import gogdl.constants as constants + + +class ApiHandler: + def __init__(self, auth_manager): + self.auth_manager = auth_manager + self.logger = logging.getLogger("API") + self.session = requests.Session() + adapter = requests.adapters.HTTPAdapter(pool_maxsize=cpu_count()) + self.session.mount("https://", adapter) + self.session.headers = { + 'User-Agent': f'gogdl/1.0.0 (Android GameNative)' + } + credentials = self.auth_manager.get_credentials() + if credentials: + token = credentials["access_token"] + self.session.headers["Authorization"] = f"Bearer {token}" + self.owned = [] + + self.endpoints = dict() # Map of secure link endpoints + self.working_on_ids = list() # List of products we are waiting for to complete getting the secure link + + def get_item_data(self, id, expanded=None): + if expanded is None: + expanded = [] + self.logger.info(f"Getting info from products endpoint for id: {id}") + url = f'{constants.GOG_API}/products/{id}' + expanded_arg = '?expand=' + if len(expanded) > 0: + expanded_arg += ','.join(expanded) + url += expanded_arg + response = self.session.get(url) + self.logger.debug(url) + if response.ok: + return response.json() + else: + self.logger.error(f"Request failed {response}") + + def get_game_details(self, id): + url = f'{constants.GOG_EMBED}/account/gameDetails/{id}.json' + response = self.session.get(url) + if response.ok: + return response.json() + else: + self.logger.error(f"Request failed {response}") + + def get_user_data(self): + url = f'{constants.GOG_API}/user/data/games' + response = self.session.get(url) + if response.ok: + return response.json() + else: + self.logger.error(f"Request failed {response}") + + def get_builds(self, product_id, platform): + url = f'{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/os/{platform}/builds?generation=2' + response = self.session.get(url) + if response.ok: + return response.json() + else: + self.logger.error(f"Request failed {response}") + + def get_manifest(self, manifest_id, product_id): + url = f'{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/os/windows/builds/{manifest_id}' + response = self.session.get(url) + if response.ok: + return response.json() + else: + self.logger.error(f"Request failed {response}") + + def get_authenticated_request(self, url): + """Make an authenticated request with proper headers""" + return self.session.get(url) + + + def get_dependencies_repo(self, depot_version=2): + self.logger.info("Getting Dependencies repository") + url = constants.DEPENDENCIES_URL if depot_version == 2 else constants.DEPENDENCIES_V1_URL + response = self.session.get(url) + if not response.ok: + return None + + json_data = json.loads(response.content) + return json_data + + def get_secure_link(self, product_id, path="", generation=2, root=None): + """Get secure download links from GOG API""" + url = "" + if generation == 2: + url = f"{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/secure_link?_version=2&generation=2&path={path}" + elif generation == 1: + url = f"{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/secure_link?_version=2&type=depot&path={path}" + + if root: + url += f"&root={root}" + + try: + response = self.get_authenticated_request(url) + + if response.status_code != 200: + self.logger.warning(f"Invalid secure link response: {response.status_code}") + time.sleep(0.2) + return self.get_secure_link(product_id, path, generation, root) + + js = response.json() + return js.get('urls', []) + + except Exception as e: + self.logger.error(f"Failed to get secure link: {e}") + time.sleep(0.2) + return self.get_secure_link(product_id, path, generation, root) \ No newline at end of file diff --git a/app/src/main/python/gogdl/args.py b/app/src/main/python/gogdl/args.py new file mode 100644 index 000000000..dca4cf519 --- /dev/null +++ b/app/src/main/python/gogdl/args.py @@ -0,0 +1,85 @@ +""" +Android-compatible argument parser for GOGDL +""" + +import argparse +from gogdl import constants + +def init_parser(): + """Initialize argument parser with Android-compatible defaults""" + + parser = argparse.ArgumentParser( + description='Android-compatible GOG downloader', + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument( + '--auth-config-path', + type=str, + default=f"{constants.ANDROID_DATA_DIR}/gog_auth.json", + help='Path to authentication config file' + ) + + parser.add_argument( + '--display-version', + action='store_true', + help='Display version information' + ) + + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Auth command + auth_parser = subparsers.add_parser('auth', help='Authenticate with GOG or get existing credentials') + auth_parser.add_argument('--code', type=str, help='Authorization code from GOG (optional - if not provided, returns existing credentials)') + + # Download command + download_parser = subparsers.add_parser('download', help='Download a game') + download_parser.add_argument('id', type=str, help='Game ID to download') + download_parser.add_argument('--path', type=str, default=constants.ANDROID_GAMES_DIR, help='Download path') + download_parser.add_argument('--platform', type=str, default='windows', choices=['windows', 'linux'], help='Platform') + download_parser.add_argument('--branch', type=str, help='Game branch to download') + download_parser.add_argument('--skip-dlcs', dest='dlcs', action='store_false', help='Skip DLC downloads') + download_parser.add_argument('--with-dlcs', dest='dlcs', action='store_true', help='Download DLCs') + download_parser.add_argument('--dlcs', dest='dlcs_list', default=[], help='List of dlc ids to download (separated by comma)') + download_parser.add_argument('--dlc-only', dest='dlc_only', action='store_true', help='Download only DLC') + + download_parser.add_argument('--lang', type=str, default='en-US', help='Language for the download') + download_parser.add_argument('--max-workers', dest='workers_count', type=int, default=2, help='Number of download workers') + download_parser.add_argument('--support', dest='support_path', type=str, help='Support files path') + download_parser.add_argument('--password', dest='password', help='Password to access other branches') + download_parser.add_argument('--force-gen', choices=['1', '2'], dest='force_generation', help='Force specific manifest generation (FOR DEBUGGING)') + download_parser.add_argument('--build', '-b', dest='build', help='Specify buildId') + + # Info command (same as heroic-gogdl calculate_size_parser) + info_parser = subparsers.add_parser('info', help='Calculates estimated download size and list of DLCs') + info_parser.add_argument('--with-dlcs', dest='dlcs', action='store_true', help='Should download all dlcs') + info_parser.add_argument('--skip-dlcs', dest='dlcs', action='store_false', help='Should skip all dlcs') + info_parser.add_argument('--dlcs', dest='dlcs_list', help='Comma separated list of dlc ids to download') + info_parser.add_argument('--dlc-only', dest='dlc_only', action='store_true', help='Download only DLC') + info_parser.add_argument('id', help='Game ID') + info_parser.add_argument('--platform', '--os', dest='platform', help='Target operating system', choices=['windows', 'linux'], default='windows') + info_parser.add_argument('--build', '-b', dest='build', help='Specify buildId') + info_parser.add_argument('--branch', dest='branch', help='Choose build branch to use') + info_parser.add_argument('--password', dest='password', help='Password to access other branches') + info_parser.add_argument('--force-gen', choices=['1', '2'], dest='force_generation', help='Force specific manifest generation (FOR DEBUGGING)') + info_parser.add_argument('--lang', '-l', dest='lang', help='Specify game language', default='en-US') + info_parser.add_argument('--max-workers', dest='workers_count', type=int, default=2, help='Number of download workers') + + # Repair command + repair_parser = subparsers.add_parser('repair', help='Repair/verify game files') + repair_parser.add_argument('id', type=str, help='Game ID to repair') + repair_parser.add_argument('--path', type=str, default=constants.ANDROID_GAMES_DIR, help='Game path') + repair_parser.add_argument('--platform', type=str, default='windows', choices=['windows', 'linux'], help='Platform') + repair_parser.add_argument('--password', dest='password', help='Password to access other branches') + repair_parser.add_argument('--force-gen', choices=['1', '2'], dest='force_generation', help='Force specific manifest generation (FOR DEBUGGING)') + repair_parser.add_argument('--build', '-b', dest='build', help='Specify buildId') + repair_parser.add_argument('--branch', dest='branch', help='Choose build branch to use') + + # Save sync command + save_parser = subparsers.add_parser('save-sync', help='Sync game saves') + save_parser.add_argument('path', help='Path to sync files') + save_parser.add_argument('--dirname', help='Cloud save directory name') + save_parser.add_argument('--timestamp', type=float, default=0.0, help='Last sync timestamp') + save_parser.add_argument('--prefered-action', choices=['upload', 'download', 'none'], help='Preferred sync action') + + return parser.parse_known_args() diff --git a/app/src/main/python/gogdl/auth.py b/app/src/main/python/gogdl/auth.py new file mode 100644 index 000000000..9eda306fd --- /dev/null +++ b/app/src/main/python/gogdl/auth.py @@ -0,0 +1,133 @@ +""" +Android-compatible authentication module +Based on original auth.py with Android compatibility +""" + +import json +import os +import logging +import requests +import time +from typing import Optional, Dict, Any + +CLIENT_ID = "46899977096215655" +CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" + +class AuthorizationManager: + """Android-compatible authorization manager with token refresh""" + + def __init__(self, config_path: str): + self.config_path = config_path + self.logger = logging.getLogger("AUTH") + self.credentials_data = {} + self._read_config() + + def _read_config(self): + """Read credentials from config file""" + if os.path.exists(self.config_path): + try: + with open(self.config_path, "r") as f: + self.credentials_data = json.load(f) + except Exception as e: + self.logger.error(f"Failed to read config: {e}") + self.credentials_data = {} + + def _write_config(self): + """Write credentials to config file""" + try: + os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + with open(self.config_path, "w") as f: + json.dump(self.credentials_data, f, indent=2) + except Exception as e: + self.logger.error(f"Failed to write config: {e}") + + def get_credentials(self, client_id=None, client_secret=None): + """ + Reads data from config and returns it with automatic refresh if expired + :param client_id: GOG client ID + :param client_secret: GOG client secret + :return: dict with credentials or None if not present + """ + if not client_id: + client_id = CLIENT_ID + if not client_secret: + client_secret = CLIENT_SECRET + + credentials = self.credentials_data.get(client_id) + if not credentials: + return None + + # Check if credentials are expired and refresh if needed + if self.is_credential_expired(client_id): + if self.refresh_credentials(client_id, client_secret): + credentials = self.credentials_data.get(client_id) + else: + return None + + return credentials + + def is_credential_expired(self, client_id=None) -> bool: + """ + Checks if provided client_id credential is expired + :param client_id: GOG client ID + :return: whether credentials are expired + """ + if not client_id: + client_id = CLIENT_ID + credentials = self.credentials_data.get(client_id) + + if not credentials: + return True + + # If no loginTime or expires_in, assume expired + if "loginTime" not in credentials or "expires_in" not in credentials: + return True + + return time.time() >= credentials["loginTime"] + credentials["expires_in"] + + def refresh_credentials(self, client_id=None, client_secret=None) -> bool: + """ + Refreshes credentials and saves them to config + :param client_id: GOG client ID + :param client_secret: GOG client secret + :return: bool if operation was success + """ + if not client_id: + client_id = CLIENT_ID + if not client_secret: + client_secret = CLIENT_SECRET + + credentials = self.credentials_data.get(CLIENT_ID) + if not credentials or "refresh_token" not in credentials: + self.logger.error("No refresh token available") + return False + + refresh_token = credentials["refresh_token"] + url = f"https://auth.gog.com/token?client_id={client_id}&client_secret={client_secret}&grant_type=refresh_token&refresh_token={refresh_token}" + + try: + response = requests.get(url, timeout=10) + except (requests.ConnectionError, requests.Timeout): + self.logger.error("Failed to refresh credentials") + return False + + if not response.ok: + self.logger.error(f"Failed to refresh credentials: HTTP {response.status_code}") + return False + + data = response.json() + data["loginTime"] = time.time() + self.credentials_data.update({client_id: data}) + self._write_config() + return True + + def get_access_token(self) -> Optional[str]: + """Get access token from auth config""" + credentials = self.get_credentials() + if credentials and 'access_token' in credentials: + return credentials['access_token'] + return None + + def is_authenticated(self) -> bool: + """Check if user is authenticated""" + return self.get_access_token() is not None diff --git a/app/src/main/python/gogdl/cli.py b/app/src/main/python/gogdl/cli.py new file mode 100644 index 000000000..63cfc4d55 --- /dev/null +++ b/app/src/main/python/gogdl/cli.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Android-compatible GOGDL CLI module +Removes multiprocessing and other Android-incompatible features +""" + +import gogdl.args as args +from gogdl.dl.managers import manager +import gogdl.api as api +import gogdl.auth as auth +from gogdl import version as gogdl_version +import json +import logging + + +def display_version(): + print(f"{gogdl_version}") + + +def handle_auth(arguments, api_handler): + """Handle GOG authentication - exchange authorization code for access token or get existing credentials""" + logger = logging.getLogger("GOGDL-AUTH") + + try: + import requests + import os + import time + + # GOG OAuth constants + GOG_CLIENT_ID = "46899977096215655" + GOG_CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" + GOG_TOKEN_URL = "https://auth.gog.com/token" + GOG_USER_URL = "https://embed.gog.com/userData.json" + + # Initialize authorization manager + auth_manager = api_handler.auth_manager + + if arguments.code: + # Exchange authorization code for access token + logger.info("Exchanging authorization code for access token...") + + token_data = { + "client_id": GOG_CLIENT_ID, + "client_secret": GOG_CLIENT_SECRET, + "grant_type": "authorization_code", + "code": arguments.code, + "redirect_uri": "https://embed.gog.com/on_login_success?origin=client" + } + + response = requests.post(GOG_TOKEN_URL, data=token_data) + + if response.status_code != 200: + error_msg = f"Token exchange failed: HTTP {response.status_code} - {response.text}" + logger.error(error_msg) + print(json.dumps({"error": True, "message": error_msg})) + return + + token_response = response.json() + access_token = token_response.get("access_token") + refresh_token = token_response.get("refresh_token") + + if not access_token: + error_msg = "No access token in response" + logger.error(error_msg) + print(json.dumps({"error": True, "message": error_msg})) + return + + # Get user information + logger.info("Getting user information...") + user_response = requests.get( + GOG_USER_URL, + headers={"Authorization": f"Bearer {access_token}"} + ) + + username = "GOG User" + user_id = "unknown" + + if user_response.status_code == 200: + user_data = user_response.json() + username = user_data.get("username", "GOG User") + user_id = str(user_data.get("userId", "unknown")) + else: + logger.warning(f"Failed to get user info: HTTP {user_response.status_code}") + + # Save credentials with loginTime and expires_in (like original auth.py) + auth_data = { + GOG_CLIENT_ID: { + "access_token": access_token, + "refresh_token": refresh_token, + "user_id": user_id, + "username": username, + "loginTime": time.time(), + "expires_in": token_response.get("expires_in", 3600) + } + } + + os.makedirs(os.path.dirname(arguments.auth_config_path), exist_ok=True) + + with open(arguments.auth_config_path, 'w') as f: + json.dump(auth_data, f, indent=2) + + logger.info(f"Authentication successful for user: {username}") + print(json.dumps(auth_data[GOG_CLIENT_ID])) + + else: + # Get existing credentials (like original auth.py get_credentials) + logger.info("Getting existing credentials...") + credentials = auth_manager.get_credentials() + + if credentials: + logger.info(f"Retrieved credentials for user: {credentials.get('username', 'GOG User')}") + print(json.dumps(credentials)) + else: + logger.warning("No valid credentials found") + print(json.dumps({"error": True, "message": "No valid credentials found"})) + + except Exception as e: + logger.error(f"Authentication failed: {e}") + print(json.dumps({"error": True, "message": str(e)})) + raise + + +def main(): + arguments, unknown_args = args.init_parser() + level = logging.INFO + if '-d' in unknown_args or '--debug' in unknown_args: + level = logging.DEBUG + logging.basicConfig(format="[%(name)s] %(levelname)s: %(message)s", level=level) + logger = logging.getLogger("GOGDL-ANDROID") + logger.debug(arguments) + + if arguments.display_version: + display_version() + return + + if not arguments.command: + print("No command provided!") + return + + # Initialize Android-compatible managers + authorization_manager = auth.AuthorizationManager(arguments.auth_config_path) + api_handler = api.ApiHandler(authorization_manager) + + switcher = {} + + # Handle authentication command + if arguments.command == "auth": + switcher["auth"] = lambda: handle_auth(arguments, api_handler) + + # Handle download/info commands + if arguments.command in ["download", "repair", "update", "info"]: + download_manager = manager.AndroidManager(arguments, unknown_args, api_handler) + switcher.update({ + "download": download_manager.download, + "repair": download_manager.download, + "update": download_manager.download, + "info": lambda: download_manager.calculate_download_size(arguments, unknown_args), + }) + + # Handle save sync command + if arguments.command == "save-sync": + import gogdl.saves as saves + clouds_storage_manager = saves.CloudStorageManager(api_handler, authorization_manager) + switcher["save-sync"] = lambda: clouds_storage_manager.sync(arguments, unknown_args) + + if arguments.command in switcher: + try: + switcher[arguments.command]() + except Exception as e: + logger.error(f"Command failed: {e}") + raise + else: + logger.error(f"Unknown command: {arguments.command}") + + +if __name__ == "__main__": + main() diff --git a/app/src/main/python/gogdl/constants.py b/app/src/main/python/gogdl/constants.py new file mode 100644 index 000000000..2e8a41c63 --- /dev/null +++ b/app/src/main/python/gogdl/constants.py @@ -0,0 +1,29 @@ +""" +Android-compatible constants for GOGDL +""" + +import os + +# GOG API endpoints (matching original heroic-gogdl) +GOG_CDN = "https://gog-cdn-fastly.gog.com" +GOG_CONTENT_SYSTEM = "https://content-system.gog.com" +GOG_EMBED = "https://embed.gog.com" +GOG_AUTH = "https://auth.gog.com" +GOG_API = "https://api.gog.com" +GOG_CLOUDSTORAGE = "https://cloudstorage.gog.com" +DEPENDENCIES_URL = "https://content-system.gog.com/dependencies/repository?generation=2" +DEPENDENCIES_V1_URL = "https://content-system.gog.com/redists/repository" + +NON_NATIVE_SEP = "\\" if os.sep == "/" else "/" + +# Android-specific paths +ANDROID_DATA_DIR = "/data/user/0/app.gamenative/files" +ANDROID_GAMES_DIR = "/data/data/app.gamenative/storage/gog_games" +CONFIG_DIR = ANDROID_DATA_DIR +MANIFESTS_DIR = os.path.join(CONFIG_DIR, "manifests") + +# Download settings optimized for Android +DEFAULT_CHUNK_SIZE = 1024 * 1024 # 1MB chunks for mobile +MAX_CONCURRENT_DOWNLOADS = 2 # Conservative for mobile +CONNECTION_TIMEOUT = 30 # 30 second timeout +READ_TIMEOUT = 60 # 1 minute read timeout diff --git a/app/src/main/python/gogdl/dl/__init__.py b/app/src/main/python/gogdl/dl/__init__.py new file mode 100644 index 000000000..0c3e11496 --- /dev/null +++ b/app/src/main/python/gogdl/dl/__init__.py @@ -0,0 +1,3 @@ +""" +Android-compatible download module +""" \ No newline at end of file diff --git a/app/src/main/python/gogdl/dl/dl_utils.py b/app/src/main/python/gogdl/dl/dl_utils.py new file mode 100644 index 000000000..1f332a1dd --- /dev/null +++ b/app/src/main/python/gogdl/dl/dl_utils.py @@ -0,0 +1,184 @@ +import json +import zlib +import os +import gogdl.constants as constants +from gogdl.dl.objects import v1, v2 +import shutil +import time +import requests +from sys import exit, platform +import logging + +PATH_SEPARATOR = os.sep +TIMEOUT = 10 + + +def get_json(api_handler, url): + logger = logging.getLogger("DL_UTILS") + logger.info(f"Fetching JSON from: {url}") + x = api_handler.session.get(url, headers={"Accept": "application/json"}) + logger.info(f"Response status: {x.status_code}") + if not x.ok: + logger.error(f"Request failed: {x.status_code} - {x.text}") + return + logger.info("JSON fetch successful") + return x.json() + + +def get_zlib_encoded(api_handler, url): + retries = 5 + while retries > 0: + try: + x = api_handler.session.get(url, timeout=TIMEOUT) + if not x.ok: + return None, None + try: + decompressed = json.loads(zlib.decompress(x.content, 15)) + except zlib.error: + return x.json(), x.headers + return decompressed, x.headers + except Exception: + time.sleep(2) + retries-=1 + return None, None + + +def prepare_location(path, logger=None): + os.makedirs(path, exist_ok=True) + if logger: + logger.debug(f"Created directory {path}") + + +# V1 Compatible +def galaxy_path(manifest: str): + galaxy_path = manifest + if galaxy_path.find("/") == -1: + galaxy_path = manifest[0:2] + "/" + manifest[2:4] + "/" + galaxy_path + return galaxy_path + + +def get_secure_link(api_handler, path, gameId, generation=2, logger=None, root=None): + url = "" + if generation == 2: + url = f"{constants.GOG_CONTENT_SYSTEM}/products/{gameId}/secure_link?_version=2&generation=2&path={path}" + elif generation == 1: + url = f"{constants.GOG_CONTENT_SYSTEM}/products/{gameId}/secure_link?_version=2&type=depot&path={path}" + if root: + url += f"&root={root}" + + try: + r = requests.get(url, headers=api_handler.session.headers, timeout=TIMEOUT) + except BaseException as exception: + if logger: + logger.info(exception) + time.sleep(0.2) + return get_secure_link(api_handler, path, gameId, generation, logger) + + if r.status_code != 200: + if logger: + logger.info("invalid secure link response") + time.sleep(0.2) + return get_secure_link(api_handler, path, gameId, generation, logger) + + js = r.json() + + return js['urls'] + +def get_dependency_link(api_handler): + data = get_json( + api_handler, + f"{constants.GOG_CONTENT_SYSTEM}/open_link?generation=2&_version=2&path=/dependencies/store/", + ) + if not data: + return None + return data["urls"] + + +def merge_url_with_params(url, parameters): + for key in parameters.keys(): + url = url.replace("{" + key + "}", str(parameters[key])) + if not url: + print(f"Error ocurred getting a secure link: {url}") + return url + + +def parent_dir(path: str): + return os.path.split(path)[0] + + +def calculate_sum(path, function, read_speed_function=None): + with open(path, "rb") as f: + calculate = function() + while True: + chunk = f.read(16 * 1024) + if not chunk: + break + if read_speed_function: + read_speed_function(len(chunk)) + calculate.update(chunk) + + return calculate.hexdigest() + + +def get_readable_size(size): + power = 2 ** 10 + n = 0 + power_labels = {0: "", 1: "K", 2: "M", 3: "G"} + while size > power: + size /= power + n += 1 + return size, power_labels[n] + "B" + + +def check_free_space(size: int, path: str): + if not os.path.exists(path): + os.makedirs(path, exist_ok=True) + _, _, available_space = shutil.disk_usage(path) + + return size < available_space + + +def get_range_header(offset, size): + from_value = offset + to_value = (int(offset) + int(size)) - 1 + return f"bytes={from_value}-{to_value}" + +# Creates appropriate Manifest class based on provided meta from json +def create_manifest_class(meta: dict, api_handler): + version = meta.get("version") + if version == 1: + return v1.Manifest.from_json(meta, api_handler) + else: + return v2.Manifest.from_json(meta, api_handler) + +def get_case_insensitive_name(path): + if platform == "win32" or os.path.exists(path): + return path + root = path + # Find existing directory + while not os.path.exists(root): + root = os.path.split(root)[0] + + if not root[len(root) - 1] in ["/", "\\"]: + root = root + os.sep + # Separate unknown path from existing one + s_working_dir = path.replace(root, "").split(os.sep) + paths_to_find = len(s_working_dir) + paths_found = 0 + for directory in s_working_dir: + if not os.path.exists(root): + break + dir_list = os.listdir(root) + found = False + for existing_dir in dir_list: + if existing_dir.lower() == directory.lower(): + root = os.path.join(root, existing_dir) + paths_found += 1 + found = True + if not found: + root = os.path.join(root, directory) + paths_found += 1 + + if paths_to_find != paths_found: + root = os.path.join(root, os.sep.join(s_working_dir[paths_found:])) + return root \ No newline at end of file diff --git a/app/src/main/python/gogdl/dl/managers/__init__.py b/app/src/main/python/gogdl/dl/managers/__init__.py new file mode 100644 index 000000000..58e7b4716 --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/__init__.py @@ -0,0 +1,4 @@ +""" +Android-compatible download managers +""" + diff --git a/app/src/main/python/gogdl/dl/managers/dependencies.py b/app/src/main/python/gogdl/dl/managers/dependencies.py new file mode 100644 index 000000000..8727f7101 --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/dependencies.py @@ -0,0 +1,166 @@ +from sys import exit +import logging +import os +import json +from typing import Optional +from gogdl.dl import dl_utils +import gogdl.constants as constants +from gogdl.dl.managers.task_executor import ExecutingManager +from gogdl.dl.objects import v2 +from gogdl.dl.objects.generic import BaseDiff + + +def get_depot_list(manifest, product_id=None): + download_list = list() + for item in manifest["depot"]["items"]: + if item["type"] == "DepotFile": + download_list.append(v2.DepotFile(item, product_id)) + return download_list + + +# Looks like we can use V2 dependencies for V1 games too WOAH +# We are doing that obviously +class DependenciesManager: + def __init__( + self, ids, path, workers_count, api_handler, print_manifest=False, download_game_deps_only=False + ): + self.api = api_handler + + self.logger = logging.getLogger("REDIST") + + self.path = path + self.installed_manifest = os.path.join(self.path, '.gogdl-redist-manifest') + self.workers_count = int(workers_count) + self.build = self.api.get_dependencies_repo() + self.repository = dl_utils.get_zlib_encoded(self.api, self.build['repository_manifest'])[0] or {} + # Put version for easier serialization + self.repository['build_id'] = self.build['build_id'] + + self.ids = ids + self.download_game_deps_only = download_game_deps_only # Basically skip all redist with path starting with __redist + if self.repository and print_manifest: + print(json.dumps(self.repository)) + + def get_files_for_depot_manifest(self, manifest): + url = f'{constants.GOG_CDN}/content-system/v2/dependencies/meta/{dl_utils.galaxy_path(manifest)}' + manifest = dl_utils.get_zlib_encoded(self.api, url)[0] + + return get_depot_list(manifest, 'redist') + + + def get(self, return_files=False): + old_depots = [] + new_depots = [] + if not self.ids: + return [] + installed = set() + + # This will be always None for redist writen in game dir + existing_manifest = None + if os.path.exists(self.installed_manifest): + try: + with open(self.installed_manifest, 'r') as f: + existing_manifest = json.load(f) + except Exception: + existing_manifest = None + pass + else: + if 'depots' in existing_manifest and 'build_id' in existing_manifest: + already_installed = existing_manifest.get('HGLInstalled') or [] + for depot in existing_manifest["depots"]: + if depot["dependencyId"] in already_installed: + old_depots.append(depot) + + for depot in self.repository["depots"]: + if depot["dependencyId"] in self.ids: + # By default we want to download all redist beginning + # with redist (game installation runs installation of the game's ones) + should_download = depot["executable"]["path"].startswith("__redist") + + # If we want to download redist located in game dir we flip the boolean + if self.download_game_deps_only: + should_download = not should_download + + if should_download: + installed.add(depot['dependencyId']) + new_depots.append(depot) + + new_files = [] + old_files = [] + + # Collect files for each redistributable + for depot in new_depots: + new_files += self.get_files_for_depot_manifest(depot["manifest"]) + + for depot in old_depots: + old_files += self.get_files_for_depot_manifest(depot["manifest"]) + + if return_files: + return new_files + + + diff = DependenciesDiff.compare(new_files, old_files) + + if not len(diff.changed) and not len(diff.deleted) and not len(diff.new): + self.logger.info("Nothing to do") + self._write_manifest(installed) + return + + secure_link = dl_utils.get_dependency_link(self.api) # This should never expire + executor = ExecutingManager(self.api, self.workers_count, self.path, os.path.join(self.path, 'gog-support'), diff, {'redist': secure_link}, 'gog-redist') + success = executor.setup() + if not success: + print('Unable to proceed, Not enough disk space') + exit(2) + cancelled = executor.run() + + if cancelled: + return + + self._write_manifest(installed) + + def _write_manifest(self, installed: set): + repository = self.repository + repository['HGLInstalled'] = list(installed) + with open(self.installed_manifest, 'w') as f: + json.dump(repository, f) + + +class DependenciesDiff(BaseDiff): + def __init__(self): + super().__init__() + + @classmethod + def compare(cls, new_files: list, old_files: Optional[list]): + comparison = cls() + + if not old_files: + comparison.new = new_files + return comparison + + new_files_paths = dict() + for file in new_files: + new_files_paths.update({file.path.lower(): file}) + + old_files_paths = dict() + for file in old_files: + old_files_paths.update({file.path.lower(): file}) + + for old_file in old_files_paths.values(): + if not new_files_paths.get(old_file.path.lower()): + comparison.deleted.append(old_file) + + for new_file in new_files_paths.values(): + old_file = old_files_paths.get(new_file.path.lower()) + if not old_file: + comparison.new.append(new_file) + else: + if len(new_file.chunks) == 1 and len(old_file.chunks) == 1: + if new_file.chunks[0]["md5"] != old_file.chunks[0]["md5"]: + comparison.changed.append(new_file) + else: + if (new_file.md5 and old_file.md5 and new_file.md5 != old_file.md5) or (new_file.sha256 and old_file.sha256 != new_file.sha256): + comparison.changed.append(v2.FileDiff.compare(new_file, old_file)) + elif len(new_file.chunks) != len(old_file.chunks): + comparison.changed.append(v2.FileDiff.compare(new_file, old_file)) + return comparison diff --git a/app/src/main/python/gogdl/dl/managers/linux.py b/app/src/main/python/gogdl/dl/managers/linux.py new file mode 100644 index 000000000..26c97708e --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/linux.py @@ -0,0 +1,19 @@ +""" +Android-compatible Linux manager (simplified) +""" + +import logging +from gogdl.dl.managers.v2 import Manager + +class LinuxManager(Manager): + """Android-compatible Linux download manager""" + + def __init__(self, arguments, unknown_arguments, api_handler, max_workers=2): + super().__init__(arguments, unknown_arguments, api_handler, max_workers) + self.logger = logging.getLogger("LinuxManager") + + def download(self): + """Download Linux game (uses similar logic to Windows)""" + self.logger.info(f"Starting Linux download for game {self.game_id}") + # For now, use the same V2 logic but with Linux platform + super().download() diff --git a/app/src/main/python/gogdl/dl/managers/manager.py b/app/src/main/python/gogdl/dl/managers/manager.py new file mode 100644 index 000000000..f65849799 --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/manager.py @@ -0,0 +1,207 @@ +""" +Android-compatible download manager +Replaces multiprocessing with threading for Android compatibility +""" + +from dataclasses import dataclass +import os +import logging +import json +import threading +from concurrent.futures import ThreadPoolExecutor + +from gogdl import constants +from gogdl.dl.managers import linux, v1, v2 + +@dataclass +class UnsupportedPlatform(Exception): + pass + +class AndroidManager: + """Android-compatible version of GOGDL Manager that uses threading instead of multiprocessing""" + + def __init__(self, arguments, unknown_arguments, api_handler): + self.arguments = arguments + self.unknown_arguments = unknown_arguments + self.api_handler = api_handler + + self.platform = arguments.platform + self.should_append_folder_name = self.arguments.command == "download" + self.is_verifying = self.arguments.command == "repair" + self.game_id = arguments.id + self.branch = getattr(arguments, 'branch', None) + + # Use a reasonable number of threads for Android + if hasattr(arguments, "workers_count"): + self.allowed_threads = min(int(arguments.workers_count), 4) # Limit threads on mobile + else: + self.allowed_threads = 2 # Conservative default for Android + + self.logger = logging.getLogger("AndroidManager") + + def download(self): + """Download game using Android-compatible threading""" + try: + self.logger.info(f"Starting Android download for game {self.game_id}") + + if self.platform == "linux": + # Use Linux manager with threading + manager = linux.LinuxManager( + self.arguments, + self.unknown_arguments, + self.api_handler, + max_workers=self.allowed_threads + ) + manager.download() + return + + # Get builds to determine generation + builds = self.get_builds(self.platform) + if not builds or len(builds['items']) == 0: + raise Exception("No builds found") + + # Select target build (same logic as heroic-gogdl) + target_build = builds['items'][0] # Default to first build + + # Check for specific branch + for build in builds['items']: + if build.get("branch") == self.branch: + target_build = build + break + + # Check for specific build ID + if hasattr(self.arguments, 'build') and self.arguments.build: + for build in builds['items']: + if build.get("build_id") == self.arguments.build: + target_build = build + break + + # Store builds and target_build as instance attributes for V2 Manager + self.builds = builds + self.target_build = target_build + + generation = target_build.get("generation", 2) + self.logger.info(f"Using build {target_build.get('build_id', 'unknown')} for download (generation: {generation})") + + # Use the correct manager based on generation - same as heroic-gogdl + if generation == 1: + self.logger.info("Using V1Manager for generation 1 game") + manager = v1.Manager(self) # Pass self like V2 does + elif generation == 2: + self.logger.info("Using V2Manager for generation 2 game") + manager = v2.Manager(self) + else: + raise Exception(f"Unsupported generation: {generation}") + + manager.download() + + except Exception as e: + self.logger.error(f"Download failed: {e}") + raise + + def setup_download_manager(self): + # TODO: If content system for linux ever appears remove this if statement + # But keep the one below so we have some sort of fallback + # in case not all games were available in content system + if self.platform == "linux": + self.logger.info( + "Platform is Linux, redirecting download to Linux Native installer manager" + ) + + self.download_manager = linux.Manager(self) + + return + + try: + self.builds = self.get_builds(self.platform) + except UnsupportedPlatform: + if self.platform == "linux": + self.logger.info( + "Platform is Linux, redirecting download to Linux Native installer manager" + ) + + self.download_manager = linux.Manager(self) + + return + + self.logger.error(f"Game doesn't support content system api, unable to proceed using platform {self.platform}") + exit(1) + + # If Linux download ever progresses to this point, then it's time for some good party + + if len(self.builds["items"]) == 0: + self.logger.error("No builds found") + exit(1) + self.target_build = self.builds["items"][0] + + for build in self.builds["items"]: + if build["branch"] == None: + self.target_build = build + break + + for build in self.builds["items"]: + if build["branch"] == self.branch: + self.target_build = build + break + + if self.arguments.build: + # Find build + for build in self.builds["items"]: + if build["build_id"] == self.arguments.build: + self.target_build = build + break + self.logger.debug(f'Found build {self.target_build}') + + generation = self.target_build["generation"] + + if self.is_verifying: + manifest_path = os.path.join(constants.MANIFESTS_DIR, self.game_id) + if os.path.exists(manifest_path): + with open(manifest_path, 'r') as f: + manifest_data = json.load(f) + generation = int(manifest_data['version']) + + # This code shouldn't run at all but it's here just in case GOG decides they will return different generation than requested one + # Of course assuming they will ever change their content system generation (I highly doubt they will) + if generation not in [1, 2]: + raise Exception("Unsupported depot version please report this") + + self.logger.info(f"Depot version: {generation}") + + if generation == 1: + self.download_manager = v1.Manager(self) + elif generation == 2: + self.download_manager = v2.Manager(self) + + def calculate_download_size(self, arguments, unknown_arguments): + """Calculate download size - same as heroic-gogdl""" + try: + self.setup_download_manager() + + download_size_response = self.download_manager.get_download_size() + download_size_response['builds'] = self.builds + + # Print JSON output like heroic-gogdl does + import json + print(json.dumps(download_size_response)) + + except Exception as e: + self.logger.error(f"Calculate download size failed: {e}") + raise + + def get_builds(self, build_platform): + password_arg = getattr(self.arguments, 'password', None) + password = '' if not password_arg else '&password=' + password_arg + generation = getattr(self.arguments, 'force_generation', None) or "2" + response = self.api_handler.session.get( + f"{constants.GOG_CONTENT_SYSTEM}/products/{self.game_id}/os/{build_platform}/builds?&generation={generation}{password}" + ) + + if not response.ok: + raise UnsupportedPlatform() + data = response.json() + + if data['total_count'] == 0: + raise UnsupportedPlatform() + + return data diff --git a/app/src/main/python/gogdl/dl/managers/task_executor.py b/app/src/main/python/gogdl/dl/managers/task_executor.py new file mode 100644 index 000000000..3814a7cf6 --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/task_executor.py @@ -0,0 +1,759 @@ +import logging +import os +import signal +import time +from sys import exit +from threading import Thread +from collections import deque, Counter +from queue import Queue # Use threading.Queue instead of multiprocessing.Queue +from threading import Condition +import tempfile +from typing import Union +from gogdl.dl import dl_utils + +from gogdl.dl.dl_utils import get_readable_size +from gogdl.dl.progressbar import ProgressBar +from gogdl.dl.workers import task_executor +from gogdl.dl.objects import generic, v2, v1, linux + +class ExecutingManager: + def __init__(self, api_handler, allowed_threads, path, support, diff, secure_links, game_id=None) -> None: + self.api_handler = api_handler + self.allowed_threads = allowed_threads + self.path = path + self.resume_file = os.path.join(path, '.gogdl-resume') + self.game_id = game_id # Store game_id for cancellation checking + self.support = support or os.path.join(path, 'gog-support') + self.cache = os.path.join(path, '.gogdl-download-cache') + self.diff: generic.BaseDiff = diff + self.secure_links = secure_links + self.logger = logging.getLogger("TASK_EXEC") + self.logger.info(f"ExecutingManager initialized with game_id: {self.game_id}") + + self.download_size = 0 + self.disk_size = 0 + + # Use temporary directory instead of shared memory on Android + self.temp_dir = tempfile.mkdtemp(prefix='gogdl_') + self.temp_files = deque() + self.hash_map = dict() + self.v2_chunks_to_download = deque() + self.v1_chunks_to_download = deque() + self.linux_chunks_to_download = deque() + self.tasks = deque() + self.active_tasks = 0 + + self.processed_items = 0 + self.items_to_complete = 0 + + self.download_workers = list() + self.writer_worker = None + self.threads = list() + + self.temp_cond = Condition() + self.task_cond = Condition() + + self.running = True + + def setup(self): + self.logger.debug("Beginning executor manager setup") + self.logger.debug("Initializing queues") + # Use threading queues instead of multiprocessing + self.download_queue = Queue() + self.download_res_queue = Queue() + self.writer_queue = Queue() + self.writer_res_queue = Queue() + + self.download_speed_updates = Queue() + self.writer_speed_updates = Queue() + + # Required space for download to succeed + required_disk_size_delta = 0 + + # This can be either v1 File or v2 DepotFile + for f in self.diff.deleted + self.diff.removed_redist: + support_flag = generic.TaskFlag.SUPPORT if 'support' in f.flags else generic.TaskFlag.NONE + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.DELETE_FILE | support_flag)) + if isinstance(f, v1.File): + required_disk_size_delta -= f.size + elif isinstance(f, v2.DepotFile): + required_disk_size_delta -= sum([ch['size'] for ch in f.chunks]) + + current_tmp_size = required_disk_size_delta + + shared_chunks_counter = Counter() + completed_files = set() + + missing_files = set() + mismatched_files = set() + + downloaded_v1 = dict() + downloaded_linux = dict() + cached = set() + + # Re-use caches + if os.path.exists(self.cache): + for cache_file in os.listdir(self.cache): + cached.add(cache_file) + + self.biggest_chunk = 0 + # Find biggest chunk to optimize how much memory is 'wasted' per chunk + # Also create hashmap for those files + for f in self.diff.new + self.diff.changed + self.diff.redist: + if isinstance(f, v1.File): + self.hash_map.update({f.path.lower(): f.hash}) + + elif isinstance(f, linux.LinuxFile): + self.hash_map.update({f.path.lower(): f.hash}) + + elif isinstance(f, v2.DepotFile): + first_chunk_checksum = f.chunks[0]['md5'] if len(f.chunks) else None + checksum = f.md5 or f.sha256 or first_chunk_checksum + self.hash_map.update({f.path.lower(): checksum}) + for i, chunk in enumerate(f.chunks): + shared_chunks_counter[chunk["compressedMd5"]] += 1 + if self.biggest_chunk < chunk["size"]: + self.biggest_chunk = chunk["size"] + + elif isinstance(f, v2.FileDiff): + first_chunk_checksum = f.file.chunks[0]['md5'] if len(f.file.chunks) else None + checksum = f.file.md5 or f.file.sha256 or first_chunk_checksum + self.hash_map.update({f.file.path.lower(): checksum}) + for i, chunk in enumerate(f.file.chunks): + if chunk.get("old_offset") is None: + shared_chunks_counter[chunk["compressedMd5"]] += 1 + if self.biggest_chunk < chunk["size"]: + self.biggest_chunk = chunk["size"] + + elif isinstance(f, v2.FilePatchDiff): + first_chunk_checksum = f.new_file.chunks[0]['md5'] if len(f.new_file.chunks) else None + checksum = f.new_file.md5 or f.new_file.sha256 or first_chunk_checksum + self.hash_map.update({f.new_file.path.lower(): checksum}) + for chunk in f.chunks: + shared_chunks_counter[chunk["compressedMd5"]] += 1 + if self.biggest_chunk < chunk["size"]: + self.biggest_chunk = chunk["size"] + + + if not self.biggest_chunk: + self.biggest_chunk = 20 * 1024 * 1024 + else: + # Have at least 10 MiB chunk size for V1 downloads + self.biggest_chunk = max(self.biggest_chunk, 10 * 1024 * 1024) + + if os.path.exists(self.resume_file): + self.logger.info("Attempting to continue the download") + try: + missing = 0 + mismatch = 0 + + with open(self.resume_file, 'r') as f: + for line in f.readlines(): + hash, support, file_path = line.strip().split(':') + + if support == 'support': + abs_path = os.path.join(self.support, file_path) + else: + abs_path = os.path.join(self.path, file_path) + + if not os.path.exists(dl_utils.get_case_insensitive_name(abs_path)): + missing_files.add(file_path.lower()) + missing += 1 + continue + + current_hash = self.hash_map.get(file_path.lower()) + if current_hash != hash: + mismatched_files.add(file_path.lower()) + mismatch += 1 + continue + + completed_files.add(file_path.lower()) + if missing: + self.logger.warning(f'There are {missing} missing files, and will be re-downloaded') + if mismatch: + self.logger.warning(f'There are {mismatch} changed files since last download, and will be re-downloaded') + + except Exception as e: + self.logger.error(f"Unable to resume download, continuing as normal {e}") + + # Create temp files for chunks instead of using shared memory + for i in range(self.allowed_threads * 4): # More temp files than threads + temp_file = os.path.join(self.temp_dir, f'chunk_{i}.tmp') + self.temp_files.append(temp_file) + + # Create tasks for each chunk + for f in self.diff.new + self.diff.changed + self.diff.redist: + if isinstance(f, v1.File): + support_flag = generic.TaskFlag.SUPPORT if 'support' in f.flags else generic.TaskFlag.NONE + if f.size == 0: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CREATE_FILE | support_flag)) + continue + + if f.path.lower() in completed_files: + downloaded_v1[f.hash] = f + continue + + required_disk_size_delta += f.size + # In case of same file we can copy it over + if f.hash in downloaded_v1: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.COPY_FILE | support_flag, old_flags=generic.TaskFlag.SUPPORT if 'support' in downloaded_v1[f.hash].flags else generic.TaskFlag.NONE, old_file=downloaded_v1[f.hash].path)) + if 'executable' in f.flags: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.MAKE_EXE | support_flag)) + continue + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.OPEN_FILE | support_flag)) + self.download_size += f.size + self.disk_size += f.size + size_left = f.size + chunk_offset = 0 + i = 0 + # Split V1 file by chunks, so we can store it in temp files + while size_left: + chunk_size = min(self.biggest_chunk, size_left) + offset = f.offset + chunk_offset + + task = generic.V1Task(f.product_id, i, offset, chunk_size, f.hash) + self.tasks.append(task) + self.v1_chunks_to_download.append((f.product_id, task.compressed_md5, offset, chunk_size)) + + chunk_offset += chunk_size + size_left -= chunk_size + i += 1 + + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CLOSE_FILE | support_flag)) + if 'executable' in f.flags: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.MAKE_EXE | support_flag)) + downloaded_v1[f.hash] = f + + elif isinstance(f, linux.LinuxFile): + if f.size == 0: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CREATE_FILE)) + continue + + if f.path.lower() in completed_files: + downloaded_linux[f.hash] = f + continue + + required_disk_size_delta += f.size + if f.hash in downloaded_linux: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.COPY_FILE, old_flags=generic.TaskFlag.NONE, old_file=downloaded_linux[f.hash].path)) + if 'executable' in f.flags: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.MAKE_EXE)) + continue + + self.tasks.append(generic.FileTask(f.path+'.tmp', flags=generic.TaskFlag.OPEN_FILE)) + self.download_size += f.compressed_size + self.disk_size += f.size + size_left = f.compressed_size + chunk_offset = 0 + i = 0 + # Split V1 file by chunks, so we can store it in temp files + while size_left: + chunk_size = min(self.biggest_chunk, size_left) + offset = f.offset + chunk_offset + + task = generic.V1Task(f.product, i, offset, chunk_size, f.hash) + self.tasks.append(task) + self.linux_chunks_to_download.append((f.product, task.compressed_md5, offset, chunk_size)) + + chunk_offset += chunk_size + size_left -= chunk_size + i += 1 + + self.tasks.append(generic.FileTask(f.path + '.tmp', flags=generic.TaskFlag.CLOSE_FILE)) + if f.compression: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.OPEN_FILE)) + self.tasks.append(generic.ChunkTask(f.product, 0, f.hash+"_dec", f.hash+"_dec", f.compressed_size, f.compressed_size, True, False, 0, old_flags=generic.TaskFlag.ZIP_DEC, old_file=f.path+'.tmp')) + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CLOSE_FILE)) + self.tasks.append(generic.FileTask(f.path + '.tmp', flags=generic.TaskFlag.DELETE_FILE)) + else: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.DELETE_FILE | generic.TaskFlag.RENAME_FILE, old_file=f.path+'.tmp')) + + if 'executable' in f.flags: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.MAKE_EXE)) + downloaded_linux[f.hash] = f + + elif isinstance(f, v2.DepotFile): + support_flag = generic.TaskFlag.SUPPORT if 'support' in f.flags else generic.TaskFlag.NONE + if not len(f.chunks): + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CREATE_FILE | support_flag)) + continue + if f.path.lower() in completed_files: + continue + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.OPEN_FILE | support_flag)) + for i, chunk in enumerate(f.chunks): + new_task = generic.ChunkTask(f.product_id, i, chunk["compressedMd5"], chunk["md5"], chunk["size"], chunk["compressedSize"]) + is_cached = chunk["md5"] in cached + if shared_chunks_counter[chunk["compressedMd5"]] > 1 and not is_cached: + self.v2_chunks_to_download.append((f.product_id, chunk["compressedMd5"])) + self.download_size += chunk['compressedSize'] + new_task.offload_to_cache = True + new_task.cleanup = True + cached.add(chunk["md5"]) + current_tmp_size += chunk['size'] + elif is_cached: + new_task.old_offset = 0 + # This can safely be absolute path, due to + # how os.path.join works in Writer + new_task.old_file = os.path.join(self.cache, chunk["md5"]) + else: + self.v2_chunks_to_download.append((f.product_id, chunk["compressedMd5"])) + self.download_size += chunk['compressedSize'] + self.disk_size += chunk['size'] + current_tmp_size += chunk['size'] + shared_chunks_counter[chunk["compressedMd5"]] -= 1 + new_task.cleanup = True + self.tasks.append(new_task) + if is_cached and shared_chunks_counter[chunk["compressedMd5"]] == 0: + cached.remove(chunk["md5"]) + self.tasks.append(generic.FileTask(os.path.join(self.cache, chunk["md5"]), flags=generic.TaskFlag.DELETE_FILE)) + current_tmp_size -= chunk['size'] + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CLOSE_FILE | support_flag)) + if 'executable' in f.flags: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.MAKE_EXE | support_flag)) + + elif isinstance(f, v2.FileDiff): + chunk_tasks = [] + reused = 0 + file_size = 0 + support_flag = generic.TaskFlag.SUPPORT if 'support' in f.file.flags else generic.TaskFlag.NONE + old_support_flag = generic.TaskFlag.SUPPORT if 'support' in f.old_file_flags else generic.TaskFlag.NONE + if f.file.path.lower() in completed_files: + continue + for i, chunk in enumerate(f.file.chunks): + chunk_task = generic.ChunkTask(f.file.product_id, i, chunk["compressedMd5"], chunk["md5"], chunk["size"], chunk["compressedSize"]) + file_size += chunk['size'] + if chunk.get("old_offset") is not None and f.file.path.lower() not in mismatched_files and f.file.path.lower() not in missing_files: + chunk_task.old_offset = chunk["old_offset"] + chunk_task.old_flags = old_support_flag + chunk_task.old_file = f.file.path + reused += 1 + + chunk_tasks.append(chunk_task) + else: + is_cached = chunk["md5"] in cached + if shared_chunks_counter[chunk["compressedMd5"]] > 1 and not is_cached: + self.v2_chunks_to_download.append((f.file.product_id, chunk["compressedMd5"])) + self.download_size += chunk['compressedSize'] + chunk_task.offload_to_cache = True + cached.add(chunk["md5"]) + current_tmp_size += chunk['size'] + elif is_cached: + chunk_task.old_offset = 0 + chunk_task.old_file = os.path.join(self.cache, chunk["md5"]) + else: + self.v2_chunks_to_download.append((f.file.product_id, chunk["compressedMd5"])) + self.download_size += chunk['compressedSize'] + + shared_chunks_counter[chunk["compressedMd5"]] -= 1 + chunk_task.cleanup = True + chunk_tasks.append(chunk_task) + if is_cached and shared_chunks_counter[chunk["compressedMd5"]] == 0: + cached.remove(chunk["md5"]) + self.tasks.append(generic.FileTask(os.path.join(self.cache, chunk["md5"]), flags=generic.TaskFlag.DELETE_FILE)) + current_tmp_size -= chunk['size'] + current_tmp_size += file_size + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + if reused: + self.tasks.append(generic.FileTask(f.file.path + ".tmp", flags=generic.TaskFlag.OPEN_FILE | support_flag)) + self.tasks.extend(chunk_tasks) + self.tasks.append(generic.FileTask(f.file.path + ".tmp", flags=generic.TaskFlag.CLOSE_FILE | support_flag)) + self.tasks.append(generic.FileTask(f.file.path, flags=generic.TaskFlag.RENAME_FILE | generic.TaskFlag.DELETE_FILE | support_flag, old_file=f.file.path + ".tmp")) + current_tmp_size -= file_size + else: + self.tasks.append(generic.FileTask(f.file.path, flags=generic.TaskFlag.OPEN_FILE | support_flag)) + self.tasks.extend(chunk_tasks) + self.tasks.append(generic.FileTask(f.file.path, flags=generic.TaskFlag.CLOSE_FILE | support_flag)) + if 'executable' in f.file.flags: + self.tasks.append(generic.FileTask(f.file.path, flags=generic.TaskFlag.MAKE_EXE | support_flag)) + self.disk_size += file_size + + elif isinstance(f, v2.FilePatchDiff): + chunk_tasks = [] + patch_size = 0 + old_file_size = 0 + out_file_size = 0 + if f.target.lower() in completed_files: + continue + + # Calculate output size + for chunk in f.new_file.chunks: + out_file_size += chunk['size'] + + # Calculate old size + for chunk in f.old_file.chunks: + old_file_size += chunk['size'] + + # Make chunk tasks + for i, chunk in enumerate(f.chunks): + chunk_task = generic.ChunkTask(f'{f.new_file.product_id}_patch', i, chunk['compressedMd5'], chunk['md5'], chunk['size'], chunk['compressedSize']) + chunk_task.cleanup = True + patch_size += chunk['size'] + is_cached = chunk["md5"] in cached + if shared_chunks_counter[chunk["compressedMd5"]] > 1 and not is_cached: + self.v2_chunks_to_download.append((f'{f.new_file.product_id}_patch', chunk["compressedMd5"])) + chunk_task.offload_to_cache = True + cached.add(chunk["md5"]) + self.download_size += chunk['compressedSize'] + current_tmp_size += chunk['size'] + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + elif is_cached: + chunk_task.old_offset = 0 + chunk_task.old_file = os.path.join(self.cache, chunk["md5"]) + else: + self.v2_chunks_to_download.append((f'{f.new_file.product_id}_patch', chunk["compressedMd5"])) + self.download_size += chunk['compressedSize'] + shared_chunks_counter[chunk['compressedMd5']] -= 1 + chunk_tasks.append(chunk_task) + if is_cached and shared_chunks_counter[chunk["compressedMd5"]] == 0: + cached.remove(chunk["md5"]) + self.tasks.append(generic.FileTask(os.path.join(self.cache, chunk["md5"]), flags=generic.TaskFlag.DELETE_FILE)) + current_tmp_size -= chunk['size'] + + self.disk_size += patch_size + current_tmp_size += patch_size + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + + # Download patch + self.tasks.append(generic.FileTask(f.target + ".delta", flags=generic.TaskFlag.OPEN_FILE)) + self.tasks.extend(chunk_tasks) + self.tasks.append(generic.FileTask(f.target + ".delta", flags=generic.TaskFlag.CLOSE_FILE)) + + current_tmp_size += out_file_size + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + + # Apply patch to .tmp file + self.tasks.append(generic.FileTask(f.target + ".tmp", flags=generic.TaskFlag.PATCH, patch_file=(f.target + '.delta'), old_file=f.source)) + current_tmp_size -= patch_size + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + # Remove patch file + self.tasks.append(generic.FileTask(f.target + ".delta", flags=generic.TaskFlag.DELETE_FILE)) + current_tmp_size -= old_file_size + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + # Move new file to old one's location + self.tasks.append(generic.FileTask(f.target, flags=generic.TaskFlag.RENAME_FILE | generic.TaskFlag.DELETE_FILE, old_file=f.target + ".tmp")) + self.disk_size += out_file_size + + required_disk_size_delta = max(current_tmp_size, required_disk_size_delta) + + + for f in self.diff.links: + self.tasks.append(generic.FileTask(f.path, flags=generic.TaskFlag.CREATE_SYMLINK, old_file=f.target)) + + self.items_to_complete = len(self.tasks) + + print(get_readable_size(self.download_size), self.download_size) + print(get_readable_size(required_disk_size_delta), required_disk_size_delta) + + return dl_utils.check_free_space(required_disk_size_delta, self.path) + + + def run(self): + self.logger.debug(f"Using temp directory: {self.temp_dir}") + interrupted = False + self.fatal_error = False + + def handle_sig(num, frame): + nonlocal interrupted + self.interrupt_shutdown() + interrupted = True + exit(-num) + + try: + self.threads.append(Thread(target=self.download_manager, args=(self.task_cond, self.temp_cond))) + self.threads.append(Thread(target=self.process_task_results, args=(self.task_cond,))) + self.threads.append(Thread(target=self.process_writer_task_results, args=(self.temp_cond,))) + self.progress = ProgressBar(self.disk_size, self.download_speed_updates, self.writer_speed_updates, self.game_id) + + # Spawn workers using threads instead of processes + self.logger.info(f"Starting {self.allowed_threads} download workers for game {self.game_id}") + for i in range(self.allowed_threads): + worker = Thread(target=task_executor.download_worker, args=( + self.download_queue, self.download_res_queue, + self.download_speed_updates, self.secure_links, self.temp_dir, self.game_id + )) + worker.start() + self.download_workers.append(worker) + + self.writer_worker = Thread(target=task_executor.writer_worker, args=( + self.writer_queue, self.writer_res_queue, + self.writer_speed_updates, self.cache, self.temp_dir + )) + self.writer_worker.start() + + [th.start() for th in self.threads] + + # Signal handling - Android compatibility + try: + signal.signal(signal.SIGTERM, handle_sig) + signal.signal(signal.SIGINT, handle_sig) + except ValueError as e: + # Android: signal only works in main thread + self.logger.debug(f"Signal handling not available: {e}") + + if self.disk_size: + self.progress.start() + + while self.processed_items < self.items_to_complete and not interrupted and not self.fatal_error: + # Check for Android cancellation signal + try: + import builtins + flag_name = f'GOGDL_CANCEL_{self.game_id}' + if hasattr(builtins, flag_name): + flag_value = getattr(builtins, flag_name, False) + if flag_value: + self.logger.info(f"Download cancelled by user for game {self.game_id}") + self.fatal_error = True # Mark as error to prevent completion + interrupted = True + break + except Exception as e: + self.logger.debug(f"Error checking cancellation flag: {e}") + + time.sleep(1) + if interrupted: + return True + except KeyboardInterrupt: + return True + + self.shutdown() + return self.fatal_error + + def interrupt_shutdown(self): + self.progress.completed = True + self.running = False + + with self.task_cond: + self.task_cond.notify() + + with self.temp_cond: + self.temp_cond.notify() + + for t in self.threads: + t.join(timeout=5.0) + if t.is_alive(): + self.logger.warning(f'Thread did not terminate! {repr(t)}') + + for worker in self.download_workers: + worker.join(timeout=5.0) + + def shutdown(self): + self.logger.debug("Stopping progressbar") + self.progress.completed = True + + self.logger.debug("Sending terminate instruction to workers") + for _ in range(self.allowed_threads): + self.download_queue.put(generic.TerminateWorker()) + + self.writer_queue.put(generic.TerminateWorker()) + + for worker in self.download_workers: + worker.join(timeout=2) + + if self.writer_worker: + self.writer_worker.join(timeout=10) + + self.running = False + with self.task_cond: + self.task_cond.notify() + + with self.temp_cond: + self.temp_cond.notify() + + # Clean up temp directory + import shutil + try: + shutil.rmtree(self.temp_dir) + except: + self.logger.warning("Failed to clean up temp directory") + + try: + if os.path.exists(self.resume_file): + os.remove(self.resume_file) + except: + self.logger.error("Failed to remove resume file") + + def download_manager(self, task_cond: Condition, temp_cond: Condition): + self.logger.debug("Starting download scheduler") + no_temp = False + while self.running: + while self.active_tasks <= self.allowed_threads * 2 and (self.v2_chunks_to_download or self.v1_chunks_to_download): + + try: + temp_file = self.temp_files.popleft() + no_temp = False + except IndexError: + no_temp = True + break + + if self.v1_chunks_to_download: + product_id, chunk_id, offset, chunk_size = self.v1_chunks_to_download.popleft() + + try: + self.download_queue.put(task_executor.DownloadTask1(product_id, offset, chunk_size, chunk_id, temp_file)) + self.logger.debug(f"Pushed v1 download to queue {chunk_id} {product_id} {offset} {chunk_size}") + self.active_tasks += 1 + continue + except Exception as e: + self.logger.warning(f"Failed to push v1 task to download {e}") + self.v1_chunks_to_download.appendleft((product_id, chunk_id, offset, chunk_size)) + self.temp_files.appendleft(temp_file) + break + + elif self.v2_chunks_to_download: + product_id, chunk_hash = self.v2_chunks_to_download.popleft() + try: + self.download_queue.put(task_executor.DownloadTask2(product_id, chunk_hash, temp_file)) + self.logger.debug(f"Pushed DownloadTask2 for {chunk_hash}") + self.active_tasks += 1 + except Exception as e: + self.logger.warning(f"Failed to push task to download {e}") + self.v2_chunks_to_download.appendleft((product_id, chunk_hash)) + self.temp_files.appendleft(temp_file) + break + + else: + with task_cond: + self.logger.debug("Waiting for more tasks") + task_cond.wait(timeout=1.0) + continue + + if no_temp: + with temp_cond: + self.logger.debug(f"Waiting for more temp files") + temp_cond.wait(timeout=1.0) + + self.logger.debug("Download scheduler out..") + + def process_task_results(self, task_cond: Condition): + self.logger.debug("Download results collector starting") + ready_chunks = dict() + + try: + task = self.tasks.popleft() + except IndexError: + task = None + + current_dest = self.path + current_file = '' + + while task and self.running: + if isinstance(task, generic.FileTask): + try: + task_dest = self.path + old_destination = self.path + if task.flags & generic.TaskFlag.SUPPORT: + task_dest = self.support + if task.old_flags & generic.TaskFlag.SUPPORT: + old_destination = self.support + + writer_task = task_executor.WriterTask(task_dest, task.path, task.flags, old_destination=old_destination, old_file=task.old_file, patch_file=task.patch_file) + self.writer_queue.put(writer_task) + if task.flags & generic.TaskFlag.OPEN_FILE: + current_file = task.path + current_dest = task_dest + except Exception as e: + self.tasks.appendleft(task) + self.logger.warning(f"Failed to add queue element {e}") + continue + + try: + task: Union[generic.ChunkTask, generic.V1Task] = self.tasks.popleft() + except IndexError: + break + continue + + while ((task.compressed_md5 in ready_chunks) or task.old_file): + temp_file = None + if not task.old_file: + temp_file = ready_chunks[task.compressed_md5].temp_file + + try: + self.logger.debug(f"Adding {task.compressed_md5} to writer") + flags = generic.TaskFlag.NONE + old_destination = None + if task.cleanup: + flags |= generic.TaskFlag.RELEASE_TEMP + if task.offload_to_cache: + flags |= generic.TaskFlag.OFFLOAD_TO_CACHE + if task.old_flags & generic.TaskFlag.SUPPORT: + old_destination = self.support + self.writer_queue.put(task_executor.WriterTask(current_dest, current_file, flags=flags, temp_file=temp_file, old_destination=old_destination, old_file=task.old_file, old_offset=task.old_offset, size=task.size, hash=task.md5)) + except Exception as e: + self.logger.error(f"Adding to writer queue failed {e}") + break + + if task.cleanup and not task.old_file: + del ready_chunks[task.compressed_md5] + + try: + task = self.tasks.popleft() + if isinstance(task, generic.FileTask): + break + except IndexError: + task = None + break + + else: + try: + res: task_executor.DownloadTaskResult = self.download_res_queue.get(timeout=1) + if res.success: + self.logger.debug(f"Chunk {res.task.compressed_sum} ready") + ready_chunks[res.task.compressed_sum] = res + self.progress.update_downloaded_size(res.download_size) + self.progress.update_decompressed_size(res.decompressed_size) + self.active_tasks -= 1 + else: + self.logger.warning(f"Chunk download failed, reason {res.fail_reason}") + try: + self.download_queue.put(res.task) + except Exception as e: + self.logger.warning("Failed to resubmit download task") + + with task_cond: + task_cond.notify() + except: + pass + + self.logger.debug("Download results collector exiting...") + + def process_writer_task_results(self, temp_cond: Condition): + self.logger.debug("Starting writer results collector") + while self.running: + try: + res: task_executor.WriterTaskResult = self.writer_res_queue.get(timeout=1) + + if isinstance(res.task, generic.TerminateWorker): + break + + if res.success and res.task.flags & generic.TaskFlag.CLOSE_FILE and not res.task.file_path.endswith('.delta'): + if res.task.file_path.endswith('.tmp'): + res.task.file_path = res.task.file_path[:-4] + + checksum = self.hash_map.get(res.task.file_path.lower()) + if not checksum: + self.logger.warning(f"No checksum for closed file, unable to push to resume file {res.task.file_path}") + else: + if res.task.flags & generic.TaskFlag.SUPPORT: + support = "support" + else: + support = "" + + with open(self.resume_file, 'a') as f: + f.write(f"{checksum}:{support}:{res.task.file_path}\n") + + if not res.success: + self.logger.fatal("Task writer failed") + self.fatal_error = True + return + + self.progress.update_bytes_written(res.written) + if res.task.flags & generic.TaskFlag.RELEASE_TEMP and res.task.temp_file: + self.logger.debug(f"Releasing temp file {res.task.temp_file}") + self.temp_files.appendleft(res.task.temp_file) + with temp_cond: + temp_cond.notify() + self.processed_items += 1 + + except: + continue + + self.logger.debug("Writer results collector exiting...") diff --git a/app/src/main/python/gogdl/dl/managers/v1.py b/app/src/main/python/gogdl/dl/managers/v1.py new file mode 100644 index 000000000..eef5f902e --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/v1.py @@ -0,0 +1,313 @@ +""" +Android-compatible V1 manager for generation 1 games +Based on heroic-gogdl v1.py but with Android compatibility +""" + +# Handle old games downloading via V1 depot system +# V1 is there since GOG 1.0 days, it has no compression and relies on downloading chunks from big main.bin file +import hashlib +from sys import exit +import os +import logging +import json +from typing import Union +from gogdl import constants +from gogdl.dl import dl_utils +from gogdl.dl.managers.dependencies import DependenciesManager +from gogdl.dl.managers.task_executor import ExecutingManager +from gogdl.dl.workers.task_executor import DownloadTask1, DownloadTask2, WriterTask +from gogdl.dl.objects import v1 +from gogdl.languages import Language + + +class Manager: + def __init__(self, generic_manager): + self.game_id = generic_manager.game_id + self.arguments = generic_manager.arguments + self.unknown_arguments = generic_manager.unknown_arguments + if "path" in self.arguments: + self.path = self.arguments.path + else: + self.path = "" + + if "support_path" in self.arguments: + self.support = self.arguments.support_path + else: + self.support = "" + + self.api_handler = generic_manager.api_handler + self.should_append_folder_name = generic_manager.should_append_folder_name + self.is_verifying = generic_manager.is_verifying + self.allowed_threads = generic_manager.allowed_threads + + self.platform = generic_manager.platform + + self.builds = generic_manager.builds + self.build = generic_manager.target_build + self.version_name = self.build["version_name"] + + self.lang = Language.parse(self.arguments.lang or "English") + self.dlcs_should_be_downloaded = self.arguments.dlcs + if self.arguments.dlcs_list: + self.dlcs_list = self.arguments.dlcs_list.split(",") + + else: + self.dlcs_list = list() + + self.dlc_only = self.arguments.dlc_only + + self.manifest = None + self.meta = None + + self.logger = logging.getLogger("V1") + self.logger.info("Initialized V1 Download Manager") + + # Get manifest of selected build + def get_meta(self): + meta_url = self.build["link"] + self.meta, headers = dl_utils.get_zlib_encoded(self.api_handler, meta_url) + if not self.meta: + raise Exception("There was an error obtaining meta") + if headers: + self.version_etag = headers.get("Etag") + + # Append folder name when downloading + if self.should_append_folder_name: + self.path = os.path.join(self.path, self.meta["product"]["installDirectory"]) + + def get_download_size(self): + self.get_meta() + dlcs = self.get_dlcs_user_owns(True) + self.manifest = v1.Manifest(self.platform, self.meta, self.lang, dlcs, self.api_handler, False) + + build = self.api_handler.get_dependencies_repo() + repository = dl_utils.get_zlib_encoded(self.api_handler, build['repository_manifest'])[0] or {} + + size_data = self.manifest.calculate_download_size() + + for depot in repository["depots"]: + if depot["dependencyId"] in self.manifest.dependencies_ids: + if not depot["executable"]["path"].startswith("__redist"): + size_data[self.game_id]['*']["download_size"] += depot["compressedSize"] + size_data[self.game_id]['*']["disk_size"] += depot["size"] + + available_branches = set([build["branch"] for build in self.builds["items"] if build["branch"]]) + available_branches_list = [None] + list(available_branches) + + for dlc in dlcs: + dlc.update({"size": size_data[dlc["id"]]}) + + response = { + "size": size_data[self.game_id], + "dlcs": dlcs, + "buildId": self.build["legacy_build_id"], + "languages": self.manifest.list_languages(), + "folder_name": self.meta["product"]["installDirectory"], + "dependencies": [dep.id for dep in self.manifest.dependencies], + "versionEtag": self.version_etag, + "versionName": self.version_name, + "available_branches": available_branches_list + } + return response + + + def get_dlcs_user_owns(self, info_command=False, requested_dlcs=None): + if requested_dlcs is None: + requested_dlcs = list() + if not self.dlcs_should_be_downloaded and not info_command: + return [] + self.logger.debug("Getting dlcs user owns") + dlcs = [] + if len(requested_dlcs) > 0: + for product in self.meta["product"]["gameIDs"]: + if ( + product["gameID"] != self.game_id # Check if not base game + and product["gameID"] in requested_dlcs # Check if requested by user + and self.api_handler.does_user_own(product["gameID"]) # Check if owned + ): + dlcs.append({"title": product["name"]["en"], "id": product["gameID"]}) + return dlcs + for product in self.meta["product"]["gameIDs"]: + # Check if not base game and if owned + if product["gameID"] != self.game_id and self.api_handler.does_user_own( + product["gameID"] + ): + dlcs.append({"title": product["name"]["en"], "id": product["gameID"]}) + return dlcs + + + def download(self): + manifest_path = os.path.join(constants.MANIFESTS_DIR, self.game_id) + old_manifest = None + + # Load old manifest + if os.path.exists(manifest_path): + with open(manifest_path, "r") as f_handle: + try: + json_data = json.load(f_handle) + old_manifest = dl_utils.create_manifest_class(json_data, self.api_handler) + except json.JSONDecodeError: + old_manifest = None + pass + + if self.is_verifying: + if old_manifest: + self.manifest = old_manifest + old_manifest = None + dlcs_user_owns = self.manifest.dlcs or [] + else: + raise Exception("No manifest stored locally, unable to verify") + else: + self.get_meta() + dlcs_user_owns = self.get_dlcs_user_owns(requested_dlcs=self.dlcs_list) + + if self.arguments.dlcs_list: + self.logger.info(f"Requested dlcs {self.arguments.dlcs_list}") + self.logger.info(f"Owned dlcs {dlcs_user_owns}") + self.logger.debug("Parsing manifest") + self.manifest = v1.Manifest(self.platform, self.meta, self.lang, dlcs_user_owns, self.api_handler, self.dlc_only) + + if self.manifest: + self.manifest.get_files() + + if old_manifest: + old_manifest.get_files() + + diff = v1.ManifestDiff.compare(self.manifest, old_manifest) + + self.logger.info(f"{diff}") + self.logger.info(f"Old manifest files count: {len(old_manifest.files) if old_manifest else 0}") + self.logger.info(f"New manifest files count: {len(self.manifest.files)}") + + # Calculate total expected size + total_size = sum(file.size for file in self.manifest.files) + self.logger.info(f"Total expected game size: {total_size} bytes ({total_size / (1024*1024):.2f} MB)") + + # Show some example files + if self.manifest.files: + self.logger.info(f"Example files in manifest:") + for i, file in enumerate(self.manifest.files[:5]): # Show first 5 files + self.logger.info(f" {file.path}: {file.size} bytes") + if len(self.manifest.files) > 5: + self.logger.info(f" ... and {len(self.manifest.files) - 5} more files") + + + has_dependencies = len(self.manifest.dependencies) > 0 + + secure_link_endpoints_ids = [product["id"] for product in dlcs_user_owns] + if not self.dlc_only: + secure_link_endpoints_ids.append(self.game_id) + secure_links = dict() + for product_id in secure_link_endpoints_ids: + secure_links.update( + { + product_id: dl_utils.get_secure_link( + self.api_handler, f"/{self.platform}/{self.manifest.data['product']['timestamp']}/", product_id, generation=1 + ) + } + ) + + dependency_manager = DependenciesManager([dep.id for dep in self.manifest.dependencies], self.path, self.allowed_threads, self.api_handler, download_game_deps_only=True) + + # Find dependencies that are no longer used + if old_manifest: + removed_dependencies = [id for id in old_manifest.dependencies_ids if id not in self.manifest.dependencies_ids] + + for depot in dependency_manager.repository["depots"]: + if depot["dependencyId"] in removed_dependencies and not depot["executable"]["path"].startswith("__redist"): + diff.removed_redist += dependency_manager.get_files_for_depot_manifest(depot['manifest']) + + if has_dependencies: + secure_links.update({'redist': dl_utils.get_dependency_link(self.api_handler)}) + + diff.redist = dependency_manager.get(return_files=True) or [] + + + if not len(diff.changed) and not len(diff.deleted) and not len(diff.new) and not len(diff.redist) and not len(diff.removed_redist): + self.logger.info("Nothing to do") + return + + if self.is_verifying: + new_diff = v1.ManifestDiff() + invalid = 0 + for file in diff.new: + # V1 only files + if not file.size: + continue + + if 'support' in file.flags: + file_path = os.path.join(self.support, file.path) + else: + file_path = os.path.join(self.path, file.path) + file_path = dl_utils.get_case_insensitive_name(file_path) + + if not os.path.exists(file_path): + invalid += 1 + new_diff.new.append(file) + continue + + with open(file_path, 'rb') as fh: + file_sum = hashlib.md5() + + while chunk := fh.read(8 * 1024 * 1024): + file_sum.update(chunk) + + if file_sum.hexdigest() != file.hash: + invalid += 1 + new_diff.new.append(file) + continue + + for file in diff.redist: + if len(file.chunks) == 0: + continue + file_path = dl_utils.get_case_insensitive_name(os.path.join(self.path, file.path)) + if not os.path.exists(file_path): + invalid += 1 + new_diff.redist.append(file) + continue + valid = True + with open(file_path, 'rb') as fh: + for chunk in file.chunks: + chunk_sum = hashlib.md5() + chunk_data = fh.read(chunk['size']) + chunk_sum.update(chunk_data) + + if chunk_sum.hexdigest() != chunk['md5']: + valid = False + break + if not valid: + invalid += 1 + new_diff.redist.append(file) + continue + if not invalid: + self.logger.info("All files look good") + return + + self.logger.info(f"Found {invalid} broken files, repairing...") + diff = new_diff + + executor = ExecutingManager(self.api_handler, self.allowed_threads, self.path, self.support, diff, secure_links, self.game_id) + success = executor.setup() + if not success: + print('Unable to proceed, Not enough disk space') + exit(2) + dl_utils.prepare_location(self.path) + + for dir in self.manifest.dirs: + manifest_dir_path = os.path.join(self.path, dir.path) + dl_utils.prepare_location(dl_utils.get_case_insensitive_name(manifest_dir_path)) + + cancelled = executor.run() + + if cancelled: + return + + dl_utils.prepare_location(constants.MANIFESTS_DIR) + if self.manifest: + with open(manifest_path, 'w') as f_handle: + data = self.manifest.serialize_to_json() + f_handle.write(data) + + self.logger.info(f"Old manifest files count: {len(old_manifest.files) if old_manifest else 0}") + self.logger.info(f"New manifest files count: {len(self.manifest.files)}") + self.logger.info(f"Target directory: {self.path}") \ No newline at end of file diff --git a/app/src/main/python/gogdl/dl/managers/v2.py b/app/src/main/python/gogdl/dl/managers/v2.py new file mode 100644 index 000000000..9b51033bd --- /dev/null +++ b/app/src/main/python/gogdl/dl/managers/v2.py @@ -0,0 +1,310 @@ +""" +Android-compatible V2 manager for Windows game downloads +""" + +# Handle newer depots download +# This was introduced in GOG Galaxy 2.0, it features compression and files split by chunks +import json +from sys import exit +from gogdl.dl import dl_utils +import gogdl.dl.objects.v2 as v2 +import hashlib +from gogdl.dl.managers import dependencies +from gogdl.dl.managers.task_executor import ExecutingManager +from gogdl.dl.workers import task_executor +from gogdl.languages import Language +from gogdl import constants +import os +import logging + + +class Manager: + def __init__(self, generic_manager): + self.game_id = generic_manager.game_id + self.arguments = generic_manager.arguments + self.unknown_arguments = generic_manager.unknown_arguments + if "path" in self.arguments: + self.path = self.arguments.path + else: + self.path = "" + if "support_path" in self.arguments: + self.support = self.arguments.support_path + else: + self.support = "" + + self.allowed_threads = generic_manager.allowed_threads + + self.api_handler = generic_manager.api_handler + self.should_append_folder_name = generic_manager.should_append_folder_name + self.is_verifying = generic_manager.is_verifying + + self.builds = generic_manager.builds + self.build = generic_manager.target_build + self.version_name = self.build["version_name"] + + self.lang = Language.parse(self.arguments.lang or "en-US") + self.dlcs_should_be_downloaded = self.arguments.dlcs + if self.arguments.dlcs_list: + self.dlcs_list = self.arguments.dlcs_list.split(",") + else: + self.dlcs_list = list() + self.dlc_only = self.arguments.dlc_only + + self.manifest = None + self.stop_all_threads = False + + self.logger = logging.getLogger("V2") + self.logger.info("Initialized V2 Download Manager") + + def get_download_size(self): + self.get_meta() + dlcs = self.get_dlcs_user_owns(info_command=True) + self.manifest = v2.Manifest(self.meta, self.lang, dlcs, self.api_handler, False) + + build = self.api_handler.get_dependencies_repo() + repository = dl_utils.get_zlib_encoded(self.api_handler, build['repository_manifest'])[0] or {} + + size_data = self.manifest.calculate_download_size() + + for depot in repository["depots"]: + if depot["dependencyId"] in self.manifest.dependencies_ids: + if not depot["executable"]["path"].startswith("__redist"): + size_data[self.game_id]['*']["download_size"] += depot.get("compressedSize") or 0 + size_data[self.game_id]['*']["disk_size"] += depot.get("size") or 0 + + available_branches = set([build["branch"] for build in self.builds["items"] if build["branch"]]) + available_branches_list = [None] + list(available_branches) + + + for dlc in dlcs: + dlc.update({"size": size_data[dlc["id"]]}) + + response = { + "size": size_data[self.game_id], + "dlcs": dlcs, + "buildId": self.build["build_id"], + "languages": self.manifest.list_languages(), + "folder_name": self.meta["installDirectory"], + "dependencies": self.manifest.dependencies_ids, + "versionEtag": self.version_etag, + "versionName": self.version_name, + "available_branches": available_branches_list + } + return response + + def download(self): + manifest_path = os.path.join(constants.MANIFESTS_DIR, self.game_id) + old_manifest = None + + # Load old manifest + if os.path.exists(manifest_path): + self.logger.debug(f"Loading existing manifest for game {self.game_id}") + with open(manifest_path, 'r') as f_handle: + try: + json_data = json.load(f_handle) + self.logger.info("Creating Manifest instance from existing manifest") + old_manifest = dl_utils.create_manifest_class(json_data, self.api_handler) + except json.JSONDecodeError: + old_manifest = None + pass + + if self.is_verifying: + if old_manifest: + self.logger.warning("Verifying - ignoring obtained manifest in favor of existing one") + self.manifest = old_manifest + dlcs_user_owns = self.manifest.dlcs or [] + old_manifest = None + else: + raise Exception("No manifest stored locally, unable to verify") + else: + self.get_meta() + dlcs_user_owns = self.get_dlcs_user_owns( + requested_dlcs=self.dlcs_list + ) + + if self.arguments.dlcs_list: + self.logger.info(f"Requested dlcs {self.arguments.dlcs_list}") + self.logger.info(f"Owned dlcs {dlcs_user_owns}") + + self.logger.debug("Parsing manifest") + self.manifest = v2.Manifest( + self.meta, self.lang, dlcs_user_owns, self.api_handler, self.dlc_only + ) + patch = None + if self.manifest: + self.logger.debug("Requesting files of primary manifest") + self.manifest.get_files() + if old_manifest: + self.logger.debug("Requesting files of previous manifest") + old_manifest.get_files() + patch = v2.Patch.get(self.manifest, old_manifest, self.lang, dlcs_user_owns, self.api_handler) + if not patch: + self.logger.info("No patch found, falling back to chunk based updates") + + diff = v2.ManifestDiff.compare(self.manifest, old_manifest, patch) + self.logger.info(diff) + + + dependencies_manager = dependencies.DependenciesManager(self.manifest.dependencies_ids, self.path, + self.arguments.workers_count, self.api_handler, download_game_deps_only=True) + + # Find dependencies that are no longer used + if old_manifest: + removed_dependencies = [id for id in old_manifest.dependencies_ids if id not in self.manifest.dependencies_ids] + + for depot in dependencies_manager.repository["depots"]: + if depot["dependencyId"] in removed_dependencies and not depot["executable"]["path"].startswith("__redist"): + diff.removed_redist += dependencies_manager.get_files_for_depot_manifest(depot['manifest']) + + + diff.redist = dependencies_manager.get(True) or [] + + if not len(diff.changed) and not len(diff.deleted) and not len(diff.new) and not len(diff.redist) and not len(diff.removed_redist): + self.logger.info("Nothing to do") + return + secure_link_endpoints_ids = [product["id"] for product in dlcs_user_owns] + if not self.dlc_only: + secure_link_endpoints_ids.append(self.game_id) + secure_links = dict() + for product_id in secure_link_endpoints_ids: + secure_links.update( + { + product_id: dl_utils.get_secure_link( + self.api_handler, "/", product_id + ) + } + ) + if patch: + secure_links.update( + { + f"{product_id}_patch": dl_utils.get_secure_link( + self.api_handler, "/", product_id, root="/patches/store" + ) + } + ) + + if len(diff.redist) > 0: + secure_links.update( + { + 'redist': dl_utils.get_dependency_link(self.api_handler) + } + ) + + if self.is_verifying: + new_diff = v2.ManifestDiff() + invalid = 0 + + for file in diff.new: + if len(file.chunks) == 0: + continue + if 'support' in file.flags: + file_path = os.path.join(self.support, file.path) + else: + file_path = os.path.join(self.path, file.path) + file_path = dl_utils.get_case_insensitive_name(file_path) + if not os.path.exists(file_path): + invalid += 1 + new_diff.new.append(file) + continue + valid = True + with open(file_path, 'rb') as fh: + for chunk in file.chunks: + chunk_sum = hashlib.md5() + chunk_data = fh.read(chunk['size']) + chunk_sum.update(chunk_data) + + if chunk_sum.hexdigest() != chunk['md5']: + valid = False + break + if not valid: + invalid += 1 + new_diff.new.append(file) + continue + + for file in diff.redist: + if len(file.chunks) == 0: + continue + file_path = dl_utils.get_case_insensitive_name(os.path.join(self.path, file.path)) + if not os.path.exists(file_path): + invalid += 1 + new_diff.redist.append(file) + continue + valid = True + with open(file_path, 'rb') as fh: + for chunk in file.chunks: + chunk_sum = hashlib.md5() + chunk_data = fh.read(chunk['size']) + chunk_sum.update(chunk_data) + + if chunk_sum.hexdigest() != chunk['md5']: + valid = False + break + if not valid: + invalid += 1 + new_diff.redist.append(file) + continue + for file in diff.links: + file_path = os.path.join(self.path, file.path) + file_path = dl_utils.get_case_insensitive_name(file_path) + if not os.path.exists(file_path): + new_diff.links.append(file) + + if not invalid: + self.logger.info("All files look good") + return + + self.logger.info(f"Found {invalid} broken files, repairing...") + diff = new_diff + + executor = ExecutingManager(self.api_handler, self.allowed_threads, self.path, self.support, diff, secure_links, self.game_id) + success = executor.setup() + if not success: + print('Unable to proceed, Not enough disk space') + exit(2) + dl_utils.prepare_location(self.path) + + for dir in self.manifest.dirs: + manifest_dir_path = os.path.join(self.path, dir.path) + dl_utils.prepare_location(dl_utils.get_case_insensitive_name(manifest_dir_path)) + cancelled = executor.run() + + if cancelled: + return + + dl_utils.prepare_location(constants.MANIFESTS_DIR) + if self.manifest: + with open(manifest_path, 'w') as f_handle: + data = self.manifest.serialize_to_json() + f_handle.write(data) + + def get_meta(self): + meta_url = self.build["link"] + self.meta, headers = dl_utils.get_zlib_encoded(self.api_handler, meta_url) + self.version_etag = headers.get("Etag") + + # Append folder name when downloading + if self.should_append_folder_name: + self.path = os.path.join(self.path, self.meta["installDirectory"]) + + def get_dlcs_user_owns(self, info_command=False, requested_dlcs=None): + if requested_dlcs is None: + requested_dlcs = list() + if not self.dlcs_should_be_downloaded and not info_command: + return [] + self.logger.debug("Getting dlcs user owns") + dlcs = [] + if len(requested_dlcs) > 0: + for product in self.meta["products"]: + if ( + product["productId"] != self.game_id + and product["productId"] in requested_dlcs + and self.api_handler.does_user_own(product["productId"]) + ): + dlcs.append({"title": product["name"], "id": product["productId"]}) + return dlcs + for product in self.meta["products"]: + if product["productId"] != self.game_id and self.api_handler.does_user_own( + product["productId"] + ): + dlcs.append({"title": product["name"], "id": product["productId"]}) + return dlcs diff --git a/app/src/main/python/gogdl/dl/objects/__init__.py b/app/src/main/python/gogdl/dl/objects/__init__.py new file mode 100644 index 000000000..587f18fe5 --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/__init__.py @@ -0,0 +1,2 @@ +# Data objects for GOG content system +from . import v1, v2, generic diff --git a/app/src/main/python/gogdl/dl/objects/generic.py b/app/src/main/python/gogdl/dl/objects/generic.py new file mode 100644 index 000000000..a5ecd3344 --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/generic.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from enum import Flag, auto +from typing import Optional + + +class BaseDiff: + def __init__(self): + self.deleted = [] + self.new = [] + self.changed = [] + self.redist = [] + self.removed_redist = [] + + self.links = [] # Unix only + + def __str__(self): + return f"Deleted: {len(self.deleted)} New: {len(self.new)} Changed: {len(self.changed)}" + +class TaskFlag(Flag): + NONE = 0 + SUPPORT = auto() + OPEN_FILE = auto() + CLOSE_FILE = auto() + CREATE_FILE = auto() + CREATE_SYMLINK = auto() + RENAME_FILE = auto() + COPY_FILE = auto() + DELETE_FILE = auto() + OFFLOAD_TO_CACHE = auto() + MAKE_EXE = auto() + PATCH = auto() + RELEASE_MEM = auto() + RELEASE_TEMP = auto() + ZIP_DEC = auto() + +@dataclass +class MemorySegment: + offset: int + end: int + + @property + def size(self): + return self.end - self.offset + +@dataclass +class ChunkTask: + product: str + index: int + + compressed_md5: str + md5: str + size: int + download_size: int + + cleanup: bool = False + offload_to_cache: bool = False + old_offset: Optional[int] = None + old_flags: TaskFlag = TaskFlag.NONE + old_file: Optional[str] = None + +@dataclass +class V1Task: + product: str + index: int + offset: int + size: int + md5: str + cleanup: Optional[bool] = True + + old_offset: Optional[int] = None + offload_to_cache: Optional[bool] = False + old_flags: TaskFlag = TaskFlag.NONE + old_file: Optional[str] = None + + # This isn't actual sum, but unique id of chunk we use to decide + # if we should push it to writer + @property + def compressed_md5(self): + return self.md5 + "_" + str(self.index) + +@dataclass +class Task: + flag: TaskFlag + file_path: Optional[str] = None + file_index: Optional[int] = None + + chunks: Optional[list[ChunkTask]] = None + + target_path: Optional[str] = None + source_path: Optional[str] = None + + old_file_index: Optional[int] = None + + data: Optional[bytes] = None + +@dataclass +class FileTask: + path: str + flags: TaskFlag + + old_flags: TaskFlag = TaskFlag.NONE + old_file: Optional[str] = None + + patch_file: Optional[str] = None + +@dataclass +class FileInfo: + index: int + path: str + md5: str + size: int + + def __eq__(self, other): + if not isinstance(other, FileInfo): + return False + return (self.path, self.md5, self.size) == (other.path, other.md5, other.size) + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash((self.path, self.md5, self.size)) + + +@dataclass +class TerminateWorker: + pass diff --git a/app/src/main/python/gogdl/dl/objects/linux.py b/app/src/main/python/gogdl/dl/objects/linux.py new file mode 100644 index 000000000..9cd9df2e9 --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/linux.py @@ -0,0 +1,388 @@ +from io import BytesIO +import stat + + +END_OF_CENTRAL_DIRECTORY = b"\x50\x4b\x05\x06" +CENTRAL_DIRECTORY = b"\x50\x4b\x01\x02" +LOCAL_FILE_HEADER = b"\x50\x4b\x03\x04" + +# ZIP64 +ZIP_64_END_OF_CD_LOCATOR = b"\x50\x4b\x06\x07" +ZIP_64_END_OF_CD = b"\x50\x4b\x06\x06" + +class LocalFile: + def __init__(self) -> None: + self.relative_local_file_offset: int + self.version_needed: bytes + self.general_purpose_bit_flag: bytes + self.compression_method: int + self.last_modification_time: bytes + self.last_modification_date: bytes + self.crc32: bytes + self.compressed_size: int + self.uncompressed_size: int + self.file_name_length: int + self.extra_field_length: int + self.file_name: str + self.extra_field: bytes + self.last_byte: int + + def load_data(self, handler): + return handler.get_bytes_from_file( + from_b=self.last_byte + self.relative_local_file_offset, + size=self.compressed_size, + raw_response=True + ) + + @classmethod + def from_bytes(cls, data, offset, handler): + local_file = cls() + local_file.relative_local_file_offset = 0 + local_file.version_needed = data[4:6] + local_file.general_purpose_bit_flag = data[6:8] + local_file.compression_method = int.from_bytes(data[8:10], "little") + local_file.last_modification_time = data[10:12] + local_file.last_modification_date = data[12:14] + local_file.crc32 = data[14:18] + local_file.compressed_size = int.from_bytes(data[18:22], "little") + local_file.uncompressed_size = int.from_bytes(data[22:26], "little") + local_file.file_name_length = int.from_bytes(data[26:28], "little") + local_file.extra_field_length = int.from_bytes(data[28:30], "little") + + extra_data = handler.get_bytes_from_file( + from_b=30 + offset, + size=local_file.file_name_length + local_file.extra_field_length, + ) + + local_file.file_name = bytes( + extra_data[0: local_file.file_name_length] + ).decode() + + local_file.extra_field = data[ + local_file.file_name_length: local_file.file_name_length + + local_file.extra_field_length + ] + local_file.last_byte = ( + local_file.file_name_length + local_file.extra_field_length + 30 + ) + return local_file + + def __str__(self): + return f"\nCompressionMethod: {self.compression_method} \nFileNameLen: {self.file_name_length} \nFileName: {self.file_name} \nCompressedSize: {self.compressed_size} \nUncompressedSize: {self.uncompressed_size}" + + +class CentralDirectoryFile: + def __init__(self, product): + self.product = product + self.version_made_by: bytes + self.version_needed_to_extract: bytes + self.general_purpose_bit_flag: bytes + self.compression_method: int + self.last_modification_time: bytes + self.last_modification_date: bytes + self.crc32: int + self.compressed_size: int + self.uncompressed_size: int + self.file_name_length: int + self.extra_field_length: int + self.file_comment_length: int + self.disk_number_start: bytes + self.int_file_attrs: bytes + self.ext_file_attrs: bytes + self.relative_local_file_offset: int + self.file_name: str + self.extra_field: BytesIO + self.comment: bytes + self.last_byte: int + self.file_data_offset: int + + @classmethod + def from_bytes(cls, data, product): + cd_file = cls(product) + + cd_file.version_made_by = data[4:6] + cd_file.version_needed_to_extract = data[6:8] + cd_file.general_purpose_bit_flag = data[8:10] + cd_file.compression_method = int.from_bytes(data[10:12], "little") + cd_file.last_modification_time = data[12:14] + cd_file.last_modification_date = data[14:16] + cd_file.crc32 = int.from_bytes(data[16:20], "little") + cd_file.compressed_size = int.from_bytes(data[20:24], "little") + cd_file.uncompressed_size = int.from_bytes(data[24:28], "little") + cd_file.file_name_length = int.from_bytes(data[28:30], "little") + cd_file.extra_field_length = int.from_bytes(data[30:32], "little") + cd_file.file_comment_length = int.from_bytes(data[32:34], "little") + cd_file.disk_number_start = data[34:36] + cd_file.int_file_attrs = data[36:38] + cd_file.ext_file_attrs = data[38:42] + cd_file.relative_local_file_offset = int.from_bytes(data[42:46], "little") + cd_file.file_data_offset = 0 + + extra_field_start = 46 + cd_file.file_name_length + cd_file.file_name = bytes(data[46:extra_field_start]).decode() + + cd_file.extra_field = BytesIO(data[ + extra_field_start: extra_field_start + cd_file.extra_field_length + ]) + + field = None + while True: + id = int.from_bytes(cd_file.extra_field.read(2), "little") + size = int.from_bytes(cd_file.extra_field.read(2), "little") + + if id == 0x01: + if cd_file.extra_field_length - cd_file.extra_field.tell() >= size: + field = BytesIO(cd_file.extra_field.read(size)) + break + + cd_file.extra_field.seek(size, 1) + + if cd_file.extra_field_length - cd_file.extra_field.tell() == 0: + break + + + if field: + if cd_file.uncompressed_size == 0xFFFFFFFF: + cd_file.uncompressed_size = int.from_bytes(field.read(8), "little") + + if cd_file.compressed_size == 0xFFFFFFFF: + cd_file.compressed_size = int.from_bytes(field.read(8), "little") + + if cd_file.relative_local_file_offset == 0xFFFFFFFF: + cd_file.relative_local_file_offset = int.from_bytes(field.read(8), "little") + + comment_start = extra_field_start + cd_file.extra_field_length + cd_file.comment = data[ + comment_start: comment_start + cd_file.file_comment_length + ] + + cd_file.last_byte = comment_start + cd_file.file_comment_length + + return cd_file, comment_start + cd_file.file_comment_length + + def is_symlink(self): + return stat.S_ISLNK(int.from_bytes(self.ext_file_attrs, "little") >> 16) + + def as_dict(self): + return {'file_name': self.file_name, 'crc32': self.crc32, 'compressed_size': self.compressed_size, 'size': self.uncompressed_size, 'is_symlink': self.is_symlink()} + + def __str__(self): + return f"\nCompressionMethod: {self.compression_method} \nFileNameLen: {self.file_name_length} \nFileName: {self.file_name} \nStartDisk: {self.disk_number_start} \nCompressedSize: {self.compressed_size} \nUncompressedSize: {self.uncompressed_size}" + + def __repr__(self): + return self.file_name + + +class CentralDirectory: + def __init__(self, product): + self.files = [] + self.product = product + + @staticmethod + def create_central_dir_file(data, product): + return CentralDirectoryFile.from_bytes(data, product) + + @classmethod + def from_bytes(cls, data, n, product): + central_dir = cls(product) + for record in range(n): + cd_file, next_offset = central_dir.create_central_dir_file(data, product) + central_dir.files.append(cd_file) + data = data[next_offset:] + if record == 0: + continue + + prev_i = record - 1 + if not (prev_i >= 0 and prev_i < len(central_dir.files)): + continue + prev = central_dir.files[prev_i] + prev.file_data_offset = cd_file.relative_local_file_offset - prev.compressed_size + + return central_dir + +class Zip64EndOfCentralDirLocator: + def __init__(self): + self.number_of_disk: int + self.zip64_end_of_cd_offset: int + self.total_number_of_disks: int + + @classmethod + def from_bytes(cls, data): + zip64_end_of_cd = cls() + zip64_end_of_cd.number_of_disk = int.from_bytes(data[4:8], "little") + zip64_end_of_cd.zip64_end_of_cd_offset = int.from_bytes(data[8:16], "little") + zip64_end_of_cd.total_number_of_disks = int.from_bytes(data[16:20], "little") + return zip64_end_of_cd + + def __str__(self): + return f"\nZIP64EOCDLocator\nDisk Number: {self.number_of_disk}\nZ64_EOCD Offset: {self.zip64_end_of_cd_offset}\nNumber of disks: {self.total_number_of_disks}" + +class Zip64EndOfCentralDir: + def __init__(self): + self.size: int + self.version_made_by: bytes + self.version_needed: bytes + self.number_of_disk: bytes + self.central_directory_start_disk: bytes + self.number_of_entries_on_this_disk: int + self.number_of_entries_total: int + self.size_of_central_directory: int + self.central_directory_offset: int + self.extensible_data = None + + @classmethod + def from_bytes(cls, data): + end_of_cd = cls() + + end_of_cd.size = int.from_bytes(data[4:12], "little") + end_of_cd.version_made_by = data[12:14] + end_of_cd.version_needed = data[14:16] + end_of_cd.number_of_disk = data[16:20] + end_of_cd.central_directory_start_disk = data[20:24] + end_of_cd.number_of_entries_on_this_disk = int.from_bytes(data[24:32], "little") + end_of_cd.number_of_entries_total = int.from_bytes(data[32:40], "little") + end_of_cd.size_of_central_directory = int.from_bytes(data[40:48], "little") + end_of_cd.central_directory_offset = int.from_bytes(data[48:56], "little") + + return end_of_cd + + def __str__(self) -> str: + return f"\nZ64 EndOfCD\nSize: {self.size}\nNumber of disk: {self.number_of_disk}\nEntries on this disk: {self.number_of_entries_on_this_disk}\nEntries total: {self.number_of_entries_total}\nCD offset: {self.central_directory_offset}" + + +class EndOfCentralDir: + def __init__(self): + self.number_of_disk: bytes + self.central_directory_disk: bytes + self.central_directory_records: int + self.size_of_central_directory: int + self.central_directory_offset: int + self.comment_length: bytes + self.comment: bytes + + @classmethod + def from_bytes(cls, data): + central_dir = cls() + central_dir.number_of_disk = data[4:6] + central_dir.central_directory_disk = data[6:8] + central_dir.central_directory_records = int.from_bytes(data[8:10], "little") + central_dir.size_of_central_directory = int.from_bytes(data[12:16], "little") + central_dir.central_directory_offset = int.from_bytes(data[16:20], "little") + central_dir.comment_length = data[20:22] + central_dir.comment = data[ + 22: 22 + int.from_bytes(central_dir.comment_length, "little") + ] + + return central_dir + + def __str__(self): + return f"\nDiskNumber: {self.number_of_disk} \nCentralDirRecords: {self.central_directory_records} \nCentralDirSize: {self.size_of_central_directory} \nCentralDirOffset: {self.central_directory_offset}" + + +class InstallerHandler: + def __init__(self, url, product_id, session): + self.url = url + self.product = product_id + self.session = session + self.file_size = 0 + + SEARCH_OFFSET = 0 + SEARCH_RANGE = 2 * 1024 * 1024 # 2 MiB + + beginning_of_file = self.get_bytes_from_file( + from_b=SEARCH_OFFSET, size=SEARCH_RANGE, add_archive_index=False + ) + + self.start_of_archive_index = beginning_of_file.find(LOCAL_FILE_HEADER) + SEARCH_OFFSET + + # ZIP contents + self.central_directory_offset: int + self.central_directory_records: int + self.size_of_central_directory: int + self.central_directory: CentralDirectory + + def get_bytes_from_file(self, from_b=-1, size=None, add_archive_index=True, raw_response=False): + if add_archive_index: + from_b += self.start_of_archive_index + + from_b_repr = str(from_b) if from_b > -1 else "" + if size: + end_b = from_b + size - 1 + else: + end_b = "" + range_header = self.get_range_header(from_b_repr, end_b) + + response = self.session.get(self.url, headers={'Range': range_header}, + allow_redirects=False, stream=raw_response) + if response.status_code == 302: + # Skip content-system API + self.url = response.headers.get('Location') or self.url + return self.get_bytes_from_file(from_b, size, add_archive_index, raw_response) + if not self.file_size: + self.file_size = int(response.headers.get("Content-Range").split("/")[-1]) + if raw_response: + return response + else: + data = response.content + return data + + @staticmethod + def get_range_header(from_b="", to_b=""): + return f"bytes={from_b}-{to_b}" + + def setup(self): + self.__find_end_of_cd() + self.__find_central_directory() + + def __find_end_of_cd(self): + end_of_cd_data = self.get_bytes_from_file( + from_b=self.file_size - 100, add_archive_index=False + ) + + end_of_cd_header_data_index = end_of_cd_data.find(END_OF_CENTRAL_DIRECTORY) + zip64_end_of_cd_locator_index = end_of_cd_data.find(ZIP_64_END_OF_CD_LOCATOR) + assert end_of_cd_header_data_index != -1 + end_of_cd = EndOfCentralDir.from_bytes(end_of_cd_data[end_of_cd_header_data_index:]) + if end_of_cd.central_directory_offset == 0xFFFFFFFF: + assert zip64_end_of_cd_locator_index != -1 + # We need to find zip64 headers + + zip64_end_of_cd_locator = Zip64EndOfCentralDirLocator.from_bytes(end_of_cd_data[zip64_end_of_cd_locator_index:]) + zip64_end_of_cd_data = self.get_bytes_from_file(from_b=zip64_end_of_cd_locator.zip64_end_of_cd_offset, size=200) + zip64_end_of_cd = Zip64EndOfCentralDir.from_bytes(zip64_end_of_cd_data) + + self.central_directory_offset = zip64_end_of_cd.central_directory_offset + self.size_of_central_directory = zip64_end_of_cd.size_of_central_directory + self.central_directory_records = zip64_end_of_cd.number_of_entries_total + else: + self.central_directory_offset = end_of_cd.central_directory_offset + self.size_of_central_directory = end_of_cd.size_of_central_directory + self.central_directory_records = end_of_cd.central_directory_records + + def __find_central_directory(self): + central_directory_data = self.get_bytes_from_file( + from_b=self.central_directory_offset, + size=self.size_of_central_directory, + ) + + assert central_directory_data[:4] == CENTRAL_DIRECTORY + + self.central_directory = CentralDirectory.from_bytes( + central_directory_data, self.central_directory_records, self.product + ) + last_entry = self.central_directory.files[-1] + last_entry.file_data_offset = self.central_directory_offset - last_entry.compressed_size + + +class LinuxFile: + def __init__(self, product, path, compression, start, compressed_size, size, checksum, executable): + self.product = product + self.path = path + self.compression = compression == 8 + self.offset = start + self.compressed_size = compressed_size + self.size = size + self.hash = str(checksum) + self.flags = [] + if executable: + self.flags.append("executable") diff --git a/app/src/main/python/gogdl/dl/objects/v1.py b/app/src/main/python/gogdl/dl/objects/v1.py new file mode 100644 index 000000000..41f279b9f --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/v1.py @@ -0,0 +1,168 @@ +import json +import os +from gogdl.dl import dl_utils +from gogdl.dl.objects import generic, v2 +from gogdl import constants +from gogdl.languages import Language + + +class Depot: + def __init__(self, target_lang, depot_data): + self.target_lang = target_lang + self.languages = depot_data["languages"] + self.game_ids = depot_data["gameIDs"] + self.size = int(depot_data["size"]) + self.manifest = depot_data["manifest"] + + def check_language(self): + status = True + for lang in self.languages: + status = lang == "Neutral" or lang == self.target_lang + if status: + break + return status + +class Directory: + def __init__(self, item_data): + self.path = item_data["path"].replace(constants.NON_NATIVE_SEP, os.sep).lstrip(os.sep) + +class Dependency: + def __init__(self, data): + self.id = data["redist"] + self.size = data.get("size") + self.target_dir = data.get("targetDir") + + +class File: + def __init__(self, data, product_id): + self.offset = data.get("offset") + self.hash = data.get("hash") + self.url = data.get("url") + self.path = data["path"].lstrip("/") + self.size = data["size"] + self.flags = [] + if data.get("support"): + self.flags.append("support") + if data.get("executable"): + self.flags.append("executble") + + self.product_id = product_id + +class Manifest: + def __init__(self, platform, meta, language, dlcs, api_handler, dlc_only): + self.platform = platform + self.data = meta + self.data['HGLPlatform'] = platform + self.data["HGLInstallLanguage"] = language.code + self.data["HGLdlcs"] = dlcs + self.product_id = meta["product"]["rootGameID"] + self.dlcs = dlcs + self.dlc_only = dlc_only + self.all_depots = [] + self.depots = self.parse_depots(language, meta["product"]["depots"]) + self.dependencies = [Dependency(depot) for depot in meta["product"]["depots"] if depot.get('redist')] + self.dependencies_ids = [depot['redist'] for depot in meta["product"]["depots"] if depot.get('redist')] + + self.api_handler = api_handler + + self.files = [] + self.dirs = [] + + @classmethod + def from_json(cls, meta, api_handler): + manifest = cls(meta['HGLPlatform'], meta, Language.parse(meta['HGLInstallLanguage']), meta["HGLdlcs"], api_handler, False) + return manifest + + def serialize_to_json(self): + return json.dumps(self.data) + + def parse_depots(self, language, depots): + parsed = [] + dlc_ids = [dlc["id"] for dlc in self.dlcs] + for depot in depots: + if depot.get("redist"): + continue + + for g_id in depot["gameIDs"]: + if g_id in dlc_ids or (not self.dlc_only and self.product_id == g_id): + new_depot = Depot(language, depot) + parsed.append(new_depot) + self.all_depots.append(new_depot) + break + return list(filter(lambda x: x.check_language(), parsed)) + + def list_languages(self): + languages_dict = set() + for depot in self.all_depots: + for language in depot.languages: + if language != "Neutral": + languages_dict.add(Language.parse(language).code) + + return list(languages_dict) + + def calculate_download_size(self): + data = dict() + + for depot in self.all_depots: + for product_id in depot.game_ids: + if not product_id in data: + data[product_id] = dict() + product_data = data[product_id] + for lang in depot.languages: + if lang == "Neutral": + lang = "*" + if not lang in product_data: + product_data[lang] = {"download_size": 0, "disk_size": 0} + + product_data[lang]["download_size"] += depot.size + product_data[lang]["disk_size"] += depot.size + + return data + + + def get_files(self): + for depot in self.depots: + manifest = dl_utils.get_json(self.api_handler, f"{constants.GOG_CDN}/content-system/v1/manifests/{depot.game_ids[0]}/{self.platform}/{self.data['product']['timestamp']}/{depot.manifest}") + for record in manifest["depot"]["files"]: + if "directory" in record: + self.dirs.append(Directory(record)) + else: + self.files.append(File(record, depot.game_ids[0])) + +class ManifestDiff(generic.BaseDiff): + def __init__(self): + super().__init__() + + @classmethod + def compare(cls, new_manifest, old_manifest=None): + comparison = cls() + + if not old_manifest: + comparison.new = new_manifest.files + return comparison + + new_files = dict() + for file in new_manifest.files: + new_files.update({file.path.lower(): file}) + + old_files = dict() + for file in old_manifest.files: + old_files.update({file.path.lower(): file}) + + for old_file in old_files.values(): + if not new_files.get(old_file.path.lower()): + comparison.deleted.append(old_file) + + if type(old_manifest) == v2.Manifest: + comparison.new = new_manifest.files + return comparison + + for new_file in new_files.values(): + old_file = old_files.get(new_file.path.lower()) + if not old_file: + comparison.new.append(new_file) + else: + if new_file.hash != old_file.hash: + comparison.changed.append(new_file) + + return comparison \ No newline at end of file diff --git a/app/src/main/python/gogdl/dl/objects/v2.py b/app/src/main/python/gogdl/dl/objects/v2.py new file mode 100644 index 000000000..102a71a1c --- /dev/null +++ b/app/src/main/python/gogdl/dl/objects/v2.py @@ -0,0 +1,295 @@ +import json +import os + +from gogdl.dl import dl_utils +from gogdl.dl.objects import generic, v1 +from gogdl import constants +from gogdl.languages import Language + + +class DepotFile: + def __init__(self, item_data, product_id): + self.flags = item_data.get("flags") or list() + self.path = item_data["path"].replace(constants.NON_NATIVE_SEP, os.sep).lstrip(os.sep) + if "support" in self.flags: + self.path = os.path.join(product_id, self.path) + self.chunks = item_data["chunks"] + self.md5 = item_data.get("md5") + self.sha256 = item_data.get("sha256") + self.product_id = product_id + + +# That exists in some depots, indicates directory to be created, it has only path in it +# Yes that's the thing +class DepotDirectory: + def __init__(self, item_data): + self.path = item_data["path"].replace(constants.NON_NATIVE_SEP, os.sep).rstrip(os.sep) + +class DepotLink: + def __init__(self, item_data): + self.path = item_data["path"] + self.target = item_data["target"] + + +class Depot: + def __init__(self, target_lang, depot_data): + self.target_lang = target_lang + self.languages = depot_data["languages"] + self.bitness = depot_data.get("osBitness") + self.product_id = depot_data["productId"] + self.compressed_size = depot_data.get("compressedSize") or 0 + self.size = depot_data.get("size") or 0 + self.manifest = depot_data["manifest"] + + def check_language(self): + status = False + for lang in self.languages: + status = ( + lang == "*" + or self.target_lang == lang + ) + if status: + break + return status + +class Manifest: + def __init__(self, meta, language, dlcs, api_handler, dlc_only): + self.data = meta + self.data["HGLInstallLanguage"] = language.code + self.data["HGLdlcs"] = dlcs + self.product_id = meta["baseProductId"] + self.dlcs = dlcs + self.dlc_only = dlc_only + self.all_depots = [] + self.depots = self.parse_depots(language, meta["depots"]) + self.dependencies_ids = meta.get("dependencies") + if not self.dependencies_ids: + self.dependencies_ids = list() + self.install_directory = meta["installDirectory"] + + self.api_handler = api_handler + + self.files = [] + self.dirs = [] + + @classmethod + def from_json(cls, meta, api_handler): + manifest = cls(meta, Language.parse(meta["HGLInstallLanguage"]), meta["HGLdlcs"], api_handler, False) + return manifest + + def serialize_to_json(self): + return json.dumps(self.data) + + def parse_depots(self, language, depots): + parsed = [] + dlc_ids = [dlc["id"] for dlc in self.dlcs] + for depot in depots: + if depot["productId"] in dlc_ids or ( + not self.dlc_only and self.product_id == depot["productId"] + ): + new_depot = Depot(language, depot) + parsed.append(new_depot) + self.all_depots.append(new_depot) + + + return list(filter(lambda x: x.check_language(), parsed)) + + def list_languages(self): + languages_dict = set() + for depot in self.all_depots: + for language in depot.languages: + if language != "*": + languages_dict.add(Language.parse(language).code) + + return list(languages_dict) + + def calculate_download_size(self): + data = dict() + + for depot in self.all_depots: + if not depot.product_id in data: + data[depot.product_id] = dict() + data[depot.product_id]['*'] = {"download_size": 0, "disk_size": 0} + product_data = data[depot.product_id] + for lang in depot.languages: + if not lang in product_data: + product_data[lang] = {"download_size":0, "disk_size":0} + + product_data[lang]["download_size"] += depot.compressed_size + product_data[lang]["disk_size"] += depot.size + + return data + + def get_files(self): + for depot in self.depots: + manifest = dl_utils.get_zlib_encoded( + self.api_handler, + f"{constants.GOG_CDN}/content-system/v2/meta/{dl_utils.galaxy_path(depot.manifest)}", + )[0] + for item in manifest["depot"]["items"]: + if item["type"] == "DepotFile": + self.files.append(DepotFile(item, depot.product_id)) + elif item["type"] == "DepotLink": + self.files.append(DepotLink(item)) + else: + self.dirs.append(DepotDirectory(item)) + +class FileDiff: + def __init__(self): + self.file: DepotFile + self.old_file_flags: list[str] + self.disk_size_diff: int = 0 + + @classmethod + def compare(cls, new: DepotFile, old: DepotFile): + diff = cls() + diff.disk_size_diff = sum([ch['size'] for ch in new.chunks]) + diff.disk_size_diff -= sum([ch['size'] for ch in old.chunks]) + diff.old_file_flags = old.flags + for new_chunk in new.chunks: + old_offset = 0 + for old_chunk in old.chunks: + if old_chunk["md5"] == new_chunk["md5"]: + new_chunk["old_offset"] = old_offset + old_offset += old_chunk["size"] + diff.file = new + return diff + +# Using xdelta patching +class FilePatchDiff: + def __init__(self, data): + self.md5_source = data['md5_source'] + self.md5_target = data['md5_target'] + self.source = data['path_source'].replace('\\', '/') + self.target = data['path_target'].replace('\\', '/') + self.md5 = data['md5'] + self.chunks = data['chunks'] + + self.old_file: DepotFile + self.new_file: DepotFile + +class ManifestDiff(generic.BaseDiff): + def __init__(self): + super().__init__() + + @classmethod + def compare(cls, manifest, old_manifest=None, patch=None): + comparison = cls() + is_manifest_upgrade = isinstance(old_manifest, v1.Manifest) + + if not old_manifest: + comparison.new = manifest.files + return comparison + + new_files = dict() + for file in manifest.files: + new_files.update({file.path.lower(): file}) + + old_files = dict() + for file in old_manifest.files: + old_files.update({file.path.lower(): file}) + + for old_file in old_files.values(): + if not new_files.get(old_file.path.lower()): + comparison.deleted.append(old_file) + + for new_file in new_files.values(): + old_file = old_files.get(new_file.path.lower()) + if isinstance(new_file, DepotLink): + comparison.links.append(new_file) + continue + if not old_file: + comparison.new.append(new_file) + else: + if is_manifest_upgrade: + if len(new_file.chunks) == 0: + continue + new_final_sum = new_file.md5 or new_file.chunks[0]["md5"] + if new_final_sum: + if old_file.hash != new_final_sum: + comparison.changed.append(new_file) + continue + + patch_file = None + if patch and len(old_file.chunks): + for p_file in patch.files: + old_final_sum = old_file.md5 or old_file.chunks[0]["md5"] + if p_file.md5_source == old_final_sum: + patch_file = p_file + patch_file.old_file = old_file + patch_file.new_file = new_file + + if patch_file: + comparison.changed.append(patch_file) + continue + + if len(new_file.chunks) == 1 and len(old_file.chunks) == 1: + if new_file.chunks[0]["md5"] != old_file.chunks[0]["md5"]: + comparison.changed.append(new_file) + else: + if (new_file.md5 and old_file.md5 and new_file.md5 != old_file.md5) or (new_file.sha256 and old_file.sha256 and old_file.sha256 != new_file.sha256): + comparison.changed.append(FileDiff.compare(new_file, old_file)) + elif len(new_file.chunks) != len(old_file.chunks): + comparison.changed.append(FileDiff.compare(new_file, old_file)) + return comparison + +class Patch: + def __init__(self): + self.patch_data = {} + self.files = [] + + @classmethod + def get(cls, manifest, old_manifest, lang: str, dlcs: list, api_handler): + if isinstance(manifest, v1.Manifest) or isinstance(old_manifest, v1.Manifest): + return None + from_build = old_manifest.data.get('buildId') + to_build = manifest.data.get('buildId') + if not from_build or not to_build: + return None + dlc_ids = [dlc["id"] for dlc in dlcs] + patch_meta = dl_utils.get_zlib_encoded(api_handler, f'{constants.GOG_CONTENT_SYSTEM}/products/{manifest.product_id}/patches?_version=4&from_build_id={from_build}&to_build_id={to_build}')[0] + if not patch_meta or patch_meta.get('error'): + return None + patch_data = dl_utils.get_zlib_encoded(api_handler, patch_meta['link'])[0] + if not patch_data: + return None + + if patch_data['algorithm'] != 'xdelta3': + print("Unsupported patch algorithm") + return None + + depots = [] + # Get depots we need + for depot in patch_data['depots']: + if depot['productId'] == patch_data['baseProductId'] or depot['productId'] in dlc_ids: + if lang in depot['languages']: + depots.append(depot) + + if not depots: + return None + + files = [] + fail = False + for depot in depots: + depotdiffs = dl_utils.get_zlib_encoded(api_handler, f'{constants.GOG_CDN}/content-system/v2/patches/meta/{dl_utils.galaxy_path(depot["manifest"])}')[0] + if not depotdiffs: + fail = True + break + for diff in depotdiffs['depot']['items']: + if diff['type'] == 'DepotDiff': + files.append(FilePatchDiff(diff)) + else: + print('Unknown type in patcher', diff['type']) + return None + + if fail: + # TODO: Handle this beter + # Maybe exception? + print("Failed to get patch manifests") + return None + + patch = cls() + patch.patch_data = patch_data + patch.files = files + + return patch diff --git a/app/src/main/python/gogdl/dl/progressbar.py b/app/src/main/python/gogdl/dl/progressbar.py new file mode 100644 index 000000000..6cd0470e7 --- /dev/null +++ b/app/src/main/python/gogdl/dl/progressbar.py @@ -0,0 +1,125 @@ +import queue +from multiprocessing import Queue +import threading +import logging +from time import sleep, time + + +class ProgressBar(threading.Thread): + def __init__(self, max_val: int, speed_queue: Queue, write_queue: Queue, game_id=None): + self.logger = logging.getLogger("PROGRESS") + self.downloaded = 0 + self.total = max_val + self.speed_queue = speed_queue + self.write_queue = write_queue + self.started_at = time() + self.last_update = time() + self.completed = False + self.game_id = game_id + + self.decompressed = 0 + + self.downloaded_since_last_update = 0 + self.decompressed_since_last_update = 0 + self.written_since_last_update = 0 + self.read_since_last_update = 0 + + self.written_total = 0 + + super().__init__(target=self.loop) + + def loop(self): + while not self.completed: + # Check for cancellation signal + if self.game_id: + try: + import builtins + flag_name = f'GOGDL_CANCEL_{self.game_id}' + if hasattr(builtins, flag_name) and getattr(builtins, flag_name, False): + self.logger.info(f"Progress reporting cancelled for game {self.game_id}") + self.completed = True + break + except: + pass + + self.print_progressbar() + self.downloaded_since_last_update = self.decompressed_since_last_update = 0 + self.written_since_last_update = self.read_since_last_update = 0 + timestamp = time() + while not self.completed and (time() - timestamp) < 1: + try: + dl, dec = self.speed_queue.get(timeout=1) + self.downloaded_since_last_update += dl + self.decompressed_since_last_update += dec + except queue.Empty: + pass + try: + wr, r = self.write_queue.get(timeout=1) + self.written_since_last_update += wr + self.read_since_last_update += r + except queue.Empty: + pass + + self.print_progressbar() + def print_progressbar(self): + percentage = (self.written_total / self.total) * 100 + running_time = time() - self.started_at + runtime_h = int(running_time // 3600) + runtime_m = int((running_time % 3600) // 60) + runtime_s = int((running_time % 3600) % 60) + + print_time_delta = time() - self.last_update + + current_dl_speed = 0 + current_decompress = 0 + if print_time_delta: + current_dl_speed = self.downloaded_since_last_update / print_time_delta + current_decompress = self.decompressed_since_last_update / print_time_delta + current_w_speed = self.written_since_last_update / print_time_delta + current_r_speed = self.read_since_last_update / print_time_delta + else: + current_w_speed = 0 + current_r_speed = 0 + + if percentage > 0: + estimated_time = (100 * running_time) / percentage - running_time + else: + estimated_time = 0 + estimated_time = max(estimated_time, 0) # Cap to 0 + + estimated_h = int(estimated_time // 3600) + estimated_time = estimated_time % 3600 + estimated_m = int(estimated_time // 60) + estimated_s = int(estimated_time % 60) + + self.logger.info( + f"= Progress: {percentage:.02f} {self.written_total}/{self.total}, " + + f"Running for: {runtime_h:02d}:{runtime_m:02d}:{runtime_s:02d}, " + + f"ETA: {estimated_h:02d}:{estimated_m:02d}:{estimated_s:02d}" + ) + + self.logger.info( + f"= Downloaded: {self.downloaded / 1024 / 1024:.02f} MiB, " + f"Written: {self.written_total / 1024 / 1024:.02f} MiB" + ) + + self.logger.info( + f" + Download\t- {current_dl_speed / 1024 / 1024:.02f} MiB/s (raw) " + f"/ {current_decompress / 1024 / 1024:.02f} MiB/s (decompressed)" + ) + + self.logger.info( + f" + Disk\t- {current_w_speed / 1024 / 1024:.02f} MiB/s (write) / " + f"{current_r_speed / 1024 / 1024:.02f} MiB/s (read)" + ) + + self.last_update = time() + + def update_downloaded_size(self, addition): + self.downloaded += addition + + def update_decompressed_size(self, addition): + self.decompressed += addition + + def update_bytes_written(self, addition): + self.written_total += addition diff --git a/app/src/main/python/gogdl/dl/workers/task_executor.py b/app/src/main/python/gogdl/dl/workers/task_executor.py new file mode 100644 index 000000000..f105c482e --- /dev/null +++ b/app/src/main/python/gogdl/dl/workers/task_executor.py @@ -0,0 +1,366 @@ +import os +import shutil +import sys +import stat +import traceback +import time +import requests +import zlib +import hashlib +from io import BytesIO +from typing import Optional, Union +from copy import copy, deepcopy +from gogdl.dl import dl_utils +from dataclasses import dataclass +from enum import Enum, auto +from gogdl.dl.objects.generic import TaskFlag, TerminateWorker +from gogdl.xdelta import patcher + + +class FailReason(Enum): + UNKNOWN = 0 + CHECKSUM = auto() + CONNECTION = auto() + UNAUTHORIZED = auto() + MISSING_CHUNK = auto() + + +@dataclass +class DownloadTask: + product_id: str + +@dataclass +class DownloadTask1(DownloadTask): + offset: int + size: int + compressed_sum: str + temp_file: str # Use temp file instead of memory segment + +@dataclass +class DownloadTask2(DownloadTask): + compressed_sum: str + temp_file: str # Use temp file instead of memory segment + + +@dataclass +class WriterTask: + destination: str + file_path: str + flags: TaskFlag + + hash: Optional[str] = None + size: Optional[int] = None + temp_file: Optional[str] = None # Use temp file instead of shared memory + old_destination: Optional[str] = None + old_file: Optional[str] = None + old_offset: Optional[int] = None + patch_file: Optional[str] = None + +@dataclass +class DownloadTaskResult: + success: bool + fail_reason: Optional[FailReason] + task: Union[DownloadTask2, DownloadTask1] + temp_file: Optional[str] = None + download_size: Optional[int] = None + decompressed_size: Optional[int] = None + +@dataclass +class WriterTaskResult: + success: bool + task: Union[WriterTask, TerminateWorker] + written: int = 0 + + +def download_worker(download_queue, results_queue, speed_queue, secure_links, temp_dir, game_id): + """Download worker function that runs in a thread""" + session = requests.session() + + while True: + # Check for cancellation signal before processing next task + try: + import builtins + flag_name = f'GOGDL_CANCEL_{game_id}' + if hasattr(builtins, flag_name) and getattr(builtins, flag_name, False): + session.close() + return # Exit worker thread if cancelled + except: + pass # Continue if cancellation check fails + + try: + task: Union[DownloadTask1, DownloadTask2, TerminateWorker] = download_queue.get(timeout=1) + except: + continue + + if isinstance(task, TerminateWorker): + break + + if type(task) == DownloadTask2: + download_v2_chunk(task, session, secure_links, results_queue, speed_queue, game_id) + elif type(task) == DownloadTask1: + download_v1_chunk(task, session, secure_links, results_queue, speed_queue, game_id) + + session.close() + + +def download_v2_chunk(task: DownloadTask2, session, secure_links, results_queue, speed_queue, game_id): + retries = 5 + urls = secure_links[task.product_id] + compressed_md5 = task.compressed_sum + + endpoint = deepcopy(urls[0]) # Use deepcopy for thread safety + if task.product_id != 'redist': + endpoint["parameters"]["path"] += f"/{dl_utils.galaxy_path(compressed_md5)}" + url = dl_utils.merge_url_with_params( + endpoint["url_format"], endpoint["parameters"] + ) + else: + endpoint["url"] += "/" + dl_utils.galaxy_path(compressed_md5) + url = endpoint["url"] + + buffer = bytes() + compressed_sum = hashlib.md5() + download_size = 0 + response = None + + while retries > 0: + buffer = bytes() + compressed_sum = hashlib.md5() + download_size = 0 + decompressor = zlib.decompressobj() + + try: + response = session.get(url, stream=True, timeout=10) + response.raise_for_status() + for chunk in response.iter_content(1024 * 512): + # Check for cancellation during download + try: + import builtins + flag_name = f'GOGDL_CANCEL_{game_id}' + if hasattr(builtins, flag_name) and getattr(builtins, flag_name, False): + return # Exit immediately if cancelled + except: + pass + + download_size += len(chunk) + compressed_sum.update(chunk) + decompressed = decompressor.decompress(chunk) + buffer += decompressed + speed_queue.put((len(chunk), len(decompressed))) + + except Exception as e: + print("Connection failed", e) + if response and response.status_code == 401: + results_queue.put(DownloadTaskResult(False, FailReason.UNAUTHORIZED, task)) + return + retries -= 1 + time.sleep(2) + continue + break + else: + results_queue.put(DownloadTaskResult(False, FailReason.CHECKSUM, task)) + return + + decompressed_size = len(buffer) + + # Write to temp file instead of shared memory + try: + with open(task.temp_file, 'wb') as f: + f.write(buffer) + except Exception as e: + print("ERROR writing temp file", e) + results_queue.put(DownloadTaskResult(False, FailReason.UNKNOWN, task)) + return + + if compressed_sum.hexdigest() != compressed_md5: + results_queue.put(DownloadTaskResult(False, FailReason.CHECKSUM, task)) + return + + results_queue.put(DownloadTaskResult(True, None, task, temp_file=task.temp_file, download_size=download_size, decompressed_size=decompressed_size)) + + +def download_v1_chunk(task: DownloadTask1, session, secure_links, results_queue, speed_queue, game_id): + retries = 5 + urls = secure_links[task.product_id] + + response = None + if type(urls) == str: + url = urls + else: + endpoint = deepcopy(urls[0]) + endpoint["parameters"]["path"] += "/main.bin" + url = dl_utils.merge_url_with_params( + endpoint["url_format"], endpoint["parameters"] + ) + range_header = dl_utils.get_range_header(task.offset, task.size) + + # Stream directly to temp file for V1 to avoid memory issues with large files + download_size = 0 + while retries > 0: + download_size = 0 + try: + response = session.get(url, stream=True, timeout=10, headers={'Range': range_header}) + response.raise_for_status() + + # Stream directly to temp file instead of loading into memory + with open(task.temp_file, 'wb') as temp_f: + for chunk in response.iter_content(1024 * 512): # 512KB chunks + # Check for cancellation during download + try: + import builtins + flag_name = f'GOGDL_CANCEL_{game_id}' + if hasattr(builtins, flag_name) and getattr(builtins, flag_name, False): + return # Exit immediately if cancelled + except: + pass + + temp_f.write(chunk) + download_size += len(chunk) + speed_queue.put((len(chunk), len(chunk))) + + except Exception as e: + print("Connection failed", e) + if response and response.status_code == 401: + results_queue.put(DownloadTaskResult(False, FailReason.UNAUTHORIZED, task)) + return + retries -= 1 + time.sleep(2) + continue + break + else: + results_queue.put(DownloadTaskResult(False, FailReason.CHECKSUM, task)) + return + + # Verify file size + if download_size != task.size: + results_queue.put(DownloadTaskResult(False, FailReason.CHECKSUM, task)) + return + + results_queue.put(DownloadTaskResult(True, None, task, temp_file=task.temp_file, download_size=download_size, decompressed_size=download_size)) + + +def writer_worker(writer_queue, results_queue, speed_queue, cache, temp_dir): + """Writer worker function that runs in a thread""" + file_handle = None + current_file = '' + + while True: + try: + task: Union[WriterTask, TerminateWorker] = writer_queue.get(timeout=2) + except: + continue + + if isinstance(task, TerminateWorker): + results_queue.put(WriterTaskResult(True, task)) + break + + written = 0 + + task_path = dl_utils.get_case_insensitive_name(os.path.join(task.destination, task.file_path)) + split_path = os.path.split(task_path) + if split_path[0] and not os.path.exists(split_path[0]): + dl_utils.prepare_location(split_path[0]) + + if task.flags & TaskFlag.CREATE_FILE: + open(task_path, 'a').close() + results_queue.put(WriterTaskResult(True, task)) + continue + + elif task.flags & TaskFlag.OPEN_FILE: + if file_handle: + print("Opening on unclosed file") + file_handle.close() + file_handle = open(task_path, 'wb') + current_file = task_path + results_queue.put(WriterTaskResult(True, task)) + continue + + elif task.flags & TaskFlag.CLOSE_FILE: + if file_handle: + file_handle.close() + file_handle = None + results_queue.put(WriterTaskResult(True, task)) + continue + + elif task.flags & TaskFlag.COPY_FILE: + if file_handle and task.file_path == current_file: + print("Copy on unclosed file") + file_handle.close() + file_handle = None + + if not task.old_file: + results_queue.put(WriterTaskResult(False, task)) + continue + + dest = task.old_destination or task.destination + try: + shutil.copy(dl_utils.get_case_insensitive_name(os.path.join(dest, task.old_file)), task_path) + except shutil.SameFileError: + pass + except Exception: + results_queue.put(WriterTaskResult(False, task)) + continue + results_queue.put(WriterTaskResult(True, task)) + continue + + elif task.flags & TaskFlag.MAKE_EXE: + if file_handle and task.file_path == current_file: + print("Making exe on unclosed file") + file_handle.close() + file_handle = None + if sys.platform != 'win32': + try: + st = os.stat(task_path) + os.chmod(task_path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + except Exception as e: + results_queue.put(WriterTaskResult(False, task)) + continue + results_queue.put(WriterTaskResult(True, task)) + continue + + try: + if task.temp_file: + if not task.size: + print("No size") + results_queue.put(WriterTaskResult(False, task)) + continue + + # Read from temp file instead of shared memory + with open(task.temp_file, 'rb') as temp_f: + left = task.size + while left > 0: + chunk = temp_f.read(min(1024 * 1024, left)) + written += file_handle.write(chunk) + speed_queue.put((len(chunk), 0)) + left -= len(chunk) + + if task.flags & TaskFlag.OFFLOAD_TO_CACHE and task.hash: + cache_file_path = os.path.join(cache, task.hash) + dl_utils.prepare_location(cache) + shutil.copy(task.temp_file, cache_file_path) + speed_queue.put((task.size, 0)) + + elif task.old_file: + if not task.size: + print("No size") + results_queue.put(WriterTaskResult(False, task)) + continue + dest = task.old_destination or task.destination + old_file_path = dl_utils.get_case_insensitive_name(os.path.join(dest, task.old_file)) + old_file_handle = open(old_file_path, "rb") + if task.old_offset: + old_file_handle.seek(task.old_offset) + left = task.size + while left > 0: + chunk = old_file_handle.read(min(1024*1024, left)) + data = chunk + written += file_handle.write(data) + speed_queue.put((len(data), len(chunk))) + left -= len(chunk) + old_file_handle.close() + + except Exception as e: + print("Writer exception", e) + results_queue.put(WriterTaskResult(False, task)) + else: + results_queue.put(WriterTaskResult(True, task, written=written)) \ No newline at end of file diff --git a/app/src/main/python/gogdl/imports.py b/app/src/main/python/gogdl/imports.py new file mode 100644 index 000000000..b633c0864 --- /dev/null +++ b/app/src/main/python/gogdl/imports.py @@ -0,0 +1,130 @@ +import os +import glob +import json +import logging +from sys import exit +from gogdl import constants +import requests + + +def get_info(args, unknown_args): + logger = logging.getLogger("IMPORT") + path = args.path + if not os.path.exists(path): + logger.error("Provided path is invalid!") + exit(1) + game_details = load_game_details(path) + + info_file = game_details[0] + build_id_file = game_details[1] + platform = game_details[2] + with_dlcs = game_details[3] + build_id = "" + installed_language = None + info = {} + if platform != "linux": + if not info_file: + print("Error importing, no info file") + return + f = open(info_file, "r") + info = json.loads(f.read()) + f.close() + + title = info["name"] + game_id = info["rootGameId"] + build_id = info.get("buildId") + if "languages" in info: + installed_language = info["languages"][0] + elif "language" in info: + installed_language = info["language"] + else: + installed_language = "en-US" + if build_id_file: + f = open(build_id_file, "r") + build = json.loads(f.read()) + f.close() + build_id = build.get("buildId") + + version_name = build_id + if build_id and platform != "linux": + # Get version name + builds_res = requests.get( + f"{constants.GOG_CONTENT_SYSTEM}/products/{game_id}/os/{platform}/builds?generation=2", + headers={ + "User-Agent": "GOGGalaxyCommunicationService/2.0.4.164 (Windows_32bit)" + }, + ) + builds = builds_res.json() + target_build = builds["items"][0] + for build in builds["items"]: + if build["build_id"] == build_id: + target_build = build + break + version_name = target_build["version_name"] + if platform == "linux" and os.path.exists(os.path.join(path, "gameinfo")): + # Linux version installed using installer + gameinfo_file = open(os.path.join(path, "gameinfo"), "r") + data = gameinfo_file.read() + lines = data.split("\n") + title = lines[0] + version_name = lines[1] + + if not installed_language: + installed_language = lines[3] + if len(lines) > 4: + game_id = lines[4] + build_id = lines[6] + else: + game_id = None + build_id = None + print( + json.dumps( + { + "appName": game_id, + "buildId": build_id, + "title": title, + "tasks": info["playTasks"] if info and info.get("playTasks") else None, + "installedLanguage": installed_language, + "dlcs": with_dlcs, + "platform": platform, + "versionName": version_name, + } + ) + ) + + +def load_game_details(path): + base_path = path + found = glob.glob(os.path.join(path, "goggame-*.info")) + build_id = glob.glob(os.path.join(path, "goggame-*.id")) + platform = "windows" + if not found: + base_path = os.path.join(path, "Contents", "Resources") + found = glob.glob(os.path.join(path, "Contents", "Resources", "goggame-*.info")) + build_id = glob.glob( + os.path.join(path, "Contents", "Resources", "goggame-*.id") + ) + platform = "osx" + if not found: + base_path = os.path.join(path, "game") + found = glob.glob(os.path.join(path, "game", "goggame-*.info")) + build_id = glob.glob(os.path.join(path, "game", "goggame-*.id")) + platform = "linux" + if not found: + if os.path.exists(os.path.join(path, "gameinfo")): + return (None, None, "linux", []) + + root_id = None + # Array of DLC game ids + dlcs = [] + for info in found: + with open(info) as info_file: + data = json.load(info_file) + if not root_id: + root_id = data.get("rootGameId") + if data["gameId"] == root_id: + continue + + dlcs.append(data["gameId"]) + + return (os.path.join(base_path, f"goggame-{root_id}.info"), os.path.join(base_path, f"goggame-{root_id}.id") if build_id else None, platform, dlcs) diff --git a/app/src/main/python/gogdl/languages.py b/app/src/main/python/gogdl/languages.py new file mode 100644 index 000000000..ca37cebee --- /dev/null +++ b/app/src/main/python/gogdl/languages.py @@ -0,0 +1,123 @@ +from dataclasses import dataclass + + +@dataclass +class Language: + code: str + name: str + native_name: str + deprecated_codes: list[str] + + def __eq__(self, value: object) -> bool: + # Compare the class by language code + if isinstance(value, Language): + return self.code == value.code + # If comparing to string, look for the code, name and deprecated code + if type(value) is str: + return ( + value == self.code + or value.lower() == self.name.lower() + or value in self.deprecated_codes + ) + return NotImplemented + + def __hash__(self): + return hash(self.code) + + def __repr__(self): + return self.code + + @staticmethod + def parse(val: str): + for lang in LANGUAGES: + if lang == val: + return lang + + +# Auto-generated list of languages +LANGUAGES = [ + Language("af-ZA", "Afrikaans", "Afrikaans", []), + Language("ar", "Arabic", "العربية", []), + Language("az-AZ", "Azeri", "Azərbaycan­ılı", []), + Language("be-BY", "Belarusian", "Беларускі", ["be"]), + Language("bn-BD", "Bengali", "বাংলা", ["bn_BD"]), + Language("bg-BG", "Bulgarian", "български", ["bg", "bl"]), + Language("bs-BA", "Bosnian", "босански", []), + Language("ca-ES", "Catalan", "Català", ["ca"]), + Language("cs-CZ", "Czech", "Čeština", ["cz"]), + Language("cy-GB", "Welsh", "Cymraeg", []), + Language("da-DK", "Danish", "Dansk", ["da"]), + Language("de-DE", "German", "Deutsch", ["de"]), + Language("dv-MV", "Divehi", "ދިވެހިބަސް", []), + Language("el-GR", "Greek", "ελληνικά", ["gk", "el-GK"]), + Language("en-GB", "British English", "British English", ["en_GB"]), + Language("en-US", "English", "English", ["en"]), + Language("es-ES", "Spanish", "Español", ["es"]), + Language("es-MX", "Latin American Spanish", "Español (AL)", ["es_mx"]), + Language("et-EE", "Estonian", "Eesti", ["et"]), + Language("eu-ES", "Basque", "Euskara", []), + Language("fa-IR", "Persian", "فارسى", ["fa"]), + Language("fi-FI", "Finnish", "Suomi", ["fi"]), + Language("fo-FO", "Faroese", "Føroyskt", []), + Language("fr-FR", "French", "Français", ["fr"]), + Language("gl-ES", "Galician", "Galego", []), + Language("gu-IN", "Gujarati", "ગુજરાતી", ["gu"]), + Language("he-IL", "Hebrew", "עברית", ["he"]), + Language("hi-IN", "Hindi", "हिंदी", ["hi"]), + Language("hr-HR", "Croatian", "Hrvatski", []), + Language("hu-HU", "Hungarian", "Magyar", ["hu"]), + Language("hy-AM", "Armenian", "Հայերեն", []), + Language("id-ID", "Indonesian", "Bahasa Indonesia", []), + Language("is-IS", "Icelandic", "Íslenska", ["is"]), + Language("it-IT", "Italian", "Italiano", ["it"]), + Language("ja-JP", "Japanese", "日本語", ["jp"]), + Language("jv-ID", "Javanese", "ꦧꦱꦗꦮ", ["jv"]), + Language("ka-GE", "Georgian", "ქართული", []), + Language("kk-KZ", "Kazakh", "Қазақ", []), + Language("kn-IN", "Kannada", "ಕನ್ನಡ", []), + Language("ko-KR", "Korean", "한국어", ["ko"]), + Language("kok-IN", "Konkani", "कोंकणी", []), + Language("ky-KG", "Kyrgyz", "Кыргыз", []), + Language("la", "Latin", "latine", []), + Language("lt-LT", "Lithuanian", "Lietuvių", []), + Language("lv-LV", "Latvian", "Latviešu", []), + Language("ml-IN", "Malayalam", "മലയാളം", ["ml"]), + Language("mi-NZ", "Maori", "Reo Māori", []), + Language("mk-MK", "Macedonian", "Mакедонски јазик", []), + Language("mn-MN", "Mongolian", "Монгол хэл", []), + Language("mr-IN", "Marathi", "मराठी", ["mr"]), + Language("ms-MY", "Malay", "Bahasa Malaysia", []), + Language("mt-MT", "Maltese", "Malti", []), + Language("nb-NO", "Norwegian", "Norsk", ["no"]), + Language("nl-NL", "Dutch", "Nederlands", ["nl"]), + Language("ns-ZA", "Northern Sotho", "Sesotho sa Leboa", []), + Language("pa-IN", "Punjabi", "ਪੰਜਾਬੀ", []), + Language("pl-PL", "Polish", "Polski", ["pl"]), + Language("ps-AR", "Pashto", "پښتو", []), + Language("pt-BR", "Portuguese (Brazilian)", "Português do Brasil", ["br"]), + Language("pt-PT", "Portuguese", "Português", ["pt"]), + Language("ro-RO", "Romanian", "Română", ["ro"]), + Language("ru-RU", "Russian", "Pусский", ["ru"]), + Language("sa-IN", "Sanskrit", "संस्कृत", []), + Language("sk-SK", "Slovak", "Slovenčina", ["sk"]), + Language("sl-SI", "Slovenian", "Slovenski", []), + Language("sq-AL", "Albanian", "Shqipe", []), + Language("sr-SP", "Serbian", "Srpski", ["sb"]), + Language("sv-SE", "Swedish", "Svenska", ["sv"]), + Language("sw-KE", "Kiswahili", "Kiswahili", []), + Language("ta-IN", "Tamil", "தமிழ்", ["ta_IN"]), + Language("te-IN", "Telugu", "తెలుగు", ["te"]), + Language("th-TH", "Thai", "ไทย", ["th"]), + Language("tl-PH", "Tagalog", "Filipino", []), + Language("tn-ZA", "Setswana", "Setswana", []), + Language("tr-TR", "Turkish", "Türkçe", ["tr"]), + Language("tt-RU", "Tatar", "Татар", []), + Language("uk-UA", "Ukrainian", "Українська", ["uk"]), + Language("ur-PK", "Urdu", "اُردو", ["ur_PK"]), + Language("uz-UZ", "Uzbek", "U'zbek", []), + Language("vi-VN", "Vietnamese", "Tiếng Việt", ["vi"]), + Language("xh-ZA", "isiXhosa", "isiXhosa", []), + Language("zh-Hans", "Chinese (Simplified)", "中文(简体)", ["zh_Hans", "zh", "cn"]), + Language("zh-Hant", "Chinese (Traditional)", "中文(繁體)", ["zh_Hant"]), + Language("zu-ZA", "isiZulu", "isiZulu", []), +] diff --git a/app/src/main/python/gogdl/launch.py b/app/src/main/python/gogdl/launch.py new file mode 100644 index 000000000..ab3a96253 --- /dev/null +++ b/app/src/main/python/gogdl/launch.py @@ -0,0 +1,284 @@ +import os +import json +import sys +import subprocess +import time +from gogdl.dl.dl_utils import get_case_insensitive_name +from ctypes import * +from gogdl.process import Process +import signal +import shutil +import shlex + +class NoMoreChildren(Exception): + pass + +def get_flatpak_command(id: str) -> list[str]: + if sys.platform != "linux": + return [] + new_process_command = [] + process_command = ["flatpak", "info", id] + if os.path.exists("/.flatpak-info"): + try: + spawn_test = subprocess.run(["flatpak-spawn", "--host", "ls"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except FileNotFoundError: + return [] + if spawn_test.returncode != 0: + return [] + + new_process_command = ["flatpak-spawn", "--host"] + process_command = new_process_command + process_command + + try: + output = subprocess.run(process_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + if output.returncode == 0: + return new_process_command + ["flatpak", "run", id] + + except FileNotFoundError: + pass + return [] + + +# Supports launching linux builds +def launch(arguments, unknown_args): + # print(arguments) + info = load_game_info(arguments.path, arguments.id, arguments.platform) + + wrapper = [] + if arguments.wrapper: + wrapper = shlex.split(arguments.wrapper) + envvars = {} + + unified_platform = {"win32": "windows", "darwin": "osx", "linux": "linux"} + command = list() + working_dir = arguments.path + heroic_exe_wrapper = os.environ.get("HEROIC_GOGDL_WRAPPER_EXE") + # If type is a string we know it's a path to start.sh on linux + if type(info) != str: + if sys.platform != "win32": + if not arguments.dont_use_wine and arguments.platform != unified_platform[sys.platform]: + if arguments.wine_prefix: + envvars["WINEPREFIX"] = arguments.wine_prefix + wrapper.append(arguments.wine) + + primary_task = get_preferred_task(info, arguments.preferred_task) + launch_arguments = primary_task.get("arguments") + compatibility_flags = primary_task.get("compatibilityFlags") + executable = os.path.join(arguments.path, primary_task["path"]) + if arguments.platform == "linux": + executable = os.path.join(arguments.path, "game", primary_task["path"]) + if launch_arguments is None: + launch_arguments = [] + if type(launch_arguments) == str: + launch_arguments = launch_arguments.replace('\\', '/') + launch_arguments = shlex.split(launch_arguments) + if compatibility_flags is None: + compatibility_flags = [] + + relative_working_dir = ( + primary_task["workingDir"] if primary_task.get("workingDir") else "" + ) + if sys.platform != "win32": + relative_working_dir = relative_working_dir.replace("\\", os.sep) + executable = executable.replace("\\", os.sep) + working_dir = os.path.join(arguments.path, relative_working_dir) + + if not os.path.exists(executable): + executable = get_case_insensitive_name(executable) + # Handle case sensitive file systems + if not os.path.exists(working_dir): + working_dir = get_case_insensitive_name(working_dir) + + os.chdir(working_dir) + + if sys.platform != "win32" and arguments.platform == 'windows' and not arguments.override_exe: + if "scummvm.exe" in executable.lower(): + flatpak_scummvm = get_flatpak_command("org.scummvm.ScummVM") + native_scummvm = shutil.which("scummvm") + if native_scummvm: + native_scummvm = [native_scummvm] + + native_runner = flatpak_scummvm or native_scummvm + if native_runner: + wrapper = native_runner + executable = None + elif "dosbox.exe" in executable.lower(): + flatpak_dosbox = get_flatpak_command("io.github.dosbox-staging") + native_dosbox= shutil.which("dosbox") + if native_dosbox: + native_dosbox = [native_dosbox] + + native_runner = flatpak_dosbox or native_dosbox + if native_runner: + wrapper = native_runner + executable = None + + if len(wrapper) > 0 and wrapper[0] is not None: + command.extend(wrapper) + + if heroic_exe_wrapper: + command.append(heroic_exe_wrapper.strip()) + + if arguments.override_exe: + command.append(arguments.override_exe) + working_dir = os.path.split(arguments.override_exe)[0] + if not os.path.exists(working_dir): + working_dir = get_case_insensitive_name(working_dir) + elif executable: + command.append(executable) + command.extend(launch_arguments) + else: + if len(wrapper) > 0 and wrapper[0] is not None: + command.extend(wrapper) + + if heroic_exe_wrapper: + command.append(heroic_exe_wrapper.strip()) + + if arguments.override_exe: + command.append(arguments.override_exe) + working_dir = os.path.split(arguments.override_exe)[0] + # Handle case sensitive file systems + if not os.path.exists(working_dir): + working_dir = get_case_insensitive_name(working_dir) + else: + command.append(info) + + os.chdir(working_dir) + command.extend(unknown_args) + environment = os.environ.copy() + environment.update(envvars) + + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + bundle_dir = sys._MEIPASS + ld_library = environment.get("LD_LIBRARY_PATH") + if ld_library: + splitted = ld_library.split(":") + try: + splitted.remove(bundle_dir) + except ValueError: + pass + environment.update({"LD_LIBRARY_PATH": ":".join(splitted)}) + + print("Launch command:", command) + + status = None + if sys.platform == 'linux': + libc = cdll.LoadLibrary("libc.so.6") + prctl = libc.prctl + result = prctl(36 ,1, 0, 0, 0, 0) # PR_SET_CHILD_SUBREAPER = 36 + + if result == -1: + print("PR_SET_CHILD_SUBREAPER is not supported by your kernel (Linux 3.4 and above)") + + process = subprocess.Popen(command, env=environment) + process_pid = process.pid + + def iterate_processes(): + for child in Process(os.getpid()).iter_children(): + if child.state == 'Z': + continue + + if child.name: + yield child + + def hard_sig_handler(signum, _frame): + for _ in range(3): # just in case we race a new process. + for child in Process(os.getpid()).iter_children(): + try: + os.kill(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + + def sig_handler(signum, _frame): + signal.signal(signal.SIGTERM, hard_sig_handler) + signal.signal(signal.SIGINT, hard_sig_handler) + for _ in range(3): # just in case we race a new process. + for child in Process(os.getpid()).iter_children(): + try: + os.kill(child.pid, signal.SIGTERM) + except ProcessLookupError: + pass + + def is_alive(): + return next(iterate_processes(), None) is not None + + signal.signal(signal.SIGTERM, sig_handler) + signal.signal(signal.SIGINT, sig_handler) + + def reap_children(): + nonlocal status + while True: + try: + child_pid, child_returncode, _resource_usage = os.wait3(os.WNOHANG) + except ChildProcessError: + raise NoMoreChildren from None # No processes remain. + if child_pid == process_pid: + status = child_returncode + + if child_pid == 0: + break + + try: + # The initial wait loop: + # the initial process may have been excluded. Wait for the game + # to be considered "started". + if not is_alive(): + while not is_alive(): + reap_children() + time.sleep(0.1) + while is_alive(): + reap_children() + time.sleep(0.1) + reap_children() + except NoMoreChildren: + print("All processes exited") + + + else: + process = subprocess.Popen(command, env=environment, + shell=sys.platform=="win32") + status = process.wait() + + sys.exit(status) + + +def get_preferred_task(info, index): + primaryTask = None + for task in info["playTasks"]: + if task.get("isPrimary") == True: + primaryTask = task + break + if index is None: + return primaryTask + indexI = int(index) + if len(info["playTasks"]) > indexI: + return info["playTasks"][indexI] + + return primaryTask + + + + +def load_game_info(path, id, platform): + filename = f"goggame-{id}.info" + abs_path = ( + ( + os.path.join(path, filename) + if platform == "windows" + else os.path.join(path, "start.sh") + ) + if platform != "osx" + else os.path.join(path, "Contents", "Resources", filename) + ) + if not os.path.isfile(abs_path): + sys.exit(1) + if platform == "linux": + return abs_path + with open(abs_path) as f: + data = f.read() + f.close() + return json.loads(data) + + diff --git a/app/src/main/python/gogdl/process.py b/app/src/main/python/gogdl/process.py new file mode 100644 index 000000000..c54cac082 --- /dev/null +++ b/app/src/main/python/gogdl/process.py @@ -0,0 +1,138 @@ +import os + + +class InvalidPid(Exception): + + """Exception raised when an operation on a non-existent PID is called""" + + +class Process: + + """Python abstraction a Linux process""" + + def __init__(self, pid): + try: + self.pid = int(pid) + self.error_cache = [] + except ValueError as err: + raise InvalidPid("'%s' is not a valid pid" % pid) from err + + def __repr__(self): + return "Process {}".format(self.pid) + + def __str__(self): + return "{} ({}:{})".format(self.name, self.pid, self.state) + + def _read_content(self, file_path): + """Return the contents from a file in /proc""" + try: + with open(file_path, encoding='utf-8') as proc_file: + content = proc_file.read() + except (ProcessLookupError, FileNotFoundError, PermissionError): + return "" + return content + + def get_stat(self, parsed=True): + stat_filename = "/proc/{}/stat".format(self.pid) + try: + with open(stat_filename, encoding='utf-8') as stat_file: + _stat = stat_file.readline() + except (ProcessLookupError, FileNotFoundError): + return None + if parsed: + return _stat[_stat.rfind(")") + 1:].split() + return _stat + + def get_thread_ids(self): + """Return a list of thread ids opened by process.""" + basedir = "/proc/{}/task/".format(self.pid) + if os.path.isdir(basedir): + try: + return os.listdir(basedir) + except FileNotFoundError: + return [] + else: + return [] + + def get_children_pids_of_thread(self, tid): + """Return pids of child processes opened by thread `tid` of process.""" + children_path = "/proc/{}/task/{}/children".format(self.pid, tid) + try: + with open(children_path, encoding='utf-8') as children_file: + children_content = children_file.read() + except (FileNotFoundError, ProcessLookupError): + children_content = "" + return children_content.strip().split() + + @property + def name(self): + """Filename of the executable.""" + _stat = self.get_stat(parsed=False) + if _stat: + return _stat[_stat.find("(") + 1:_stat.rfind(")")] + return None + + @property + def state(self): + """One character from the string "RSDZTW" where R is running, S is + sleeping in an interruptible wait, D is waiting in uninterruptible disk + sleep, Z is zombie, T is traced or stopped (on a signal), and W is + paging. + """ + _stat = self.get_stat() + if _stat: + return _stat[0] + return None + + @property + def cmdline(self): + """Return command line used to run the process `pid`.""" + cmdline_path = "/proc/{}/cmdline".format(self.pid) + _cmdline_content = self._read_content(cmdline_path) + if _cmdline_content: + return _cmdline_content.replace("\x00", " ").replace("\\", "/") + + @property + def cwd(self): + """Return current working dir of process""" + cwd_path = "/proc/%d/cwd" % int(self.pid) + return os.readlink(cwd_path) + + @property + def environ(self): + """Return the process' environment variables""" + environ_path = "/proc/{}/environ".format(self.pid) + _environ_text = self._read_content(environ_path) + if not _environ_text: + return {} + try: + return dict([line.split("=", 1) for line in _environ_text.split("\x00") if line]) + except ValueError: + if environ_path not in self.error_cache: + self.error_cache.append(environ_path) + return {} + + @property + def children(self): + """Return the child processes of this process""" + _children = [] + for tid in self.get_thread_ids(): + for child_pid in self.get_children_pids_of_thread(tid): + _children.append(Process(child_pid)) + return _children + + def iter_children(self): + """Iterator that yields all the children of a process""" + for child in self.children: + yield child + yield from child.iter_children() + + def wait_for_finish(self): + """Waits until the process finishes + This only works if self.pid is a child process of Lutris + """ + try: + pid, ret_status = os.waitpid(int(self.pid) * -1, 0) + except OSError as ex: + return -1 + return ret_status diff --git a/app/src/main/python/gogdl/saves.py b/app/src/main/python/gogdl/saves.py new file mode 100644 index 000000000..9f2994247 --- /dev/null +++ b/app/src/main/python/gogdl/saves.py @@ -0,0 +1,365 @@ +""" +Android-compatible GOG cloud save synchronization +Adapted from heroic-gogdl saves.py +""" + +import os +import sys +import logging +import requests +import hashlib +import datetime +import gzip +from enum import Enum + +import gogdl.dl.dl_utils as dl_utils +import gogdl.constants as constants + +LOCAL_TIMEZONE = datetime.datetime.utcnow().astimezone().tzinfo + + +class SyncAction(Enum): + DOWNLOAD = 0 + UPLOAD = 1 + CONFLICT = 2 + NONE = 3 + + +class SyncFile: + def __init__(self, path, abs_path, md5=None, update_time=None): + self.relative_path = path.replace('\\', '/') # cloud file identifier + self.absolute_path = abs_path + self.md5 = md5 + self.update_time = update_time + self.update_ts = ( + datetime.datetime.fromisoformat(update_time).astimezone().timestamp() + if update_time + else None + ) + + def get_file_metadata(self): + ts = os.stat(self.absolute_path).st_mtime + date_time_obj = datetime.datetime.fromtimestamp( + ts, tz=LOCAL_TIMEZONE + ).astimezone(datetime.timezone.utc) + self.md5 = hashlib.md5( + gzip.compress(open(self.absolute_path, "rb").read(), 6, mtime=0) + ).hexdigest() + + self.update_time = date_time_obj.isoformat(timespec="seconds") + self.update_ts = date_time_obj.timestamp() + + def __repr__(self): + return f"{self.md5} {self.relative_path}" + + +class CloudStorageManager: + def __init__(self, api_handler, authorization_manager): + self.api = api_handler + self.auth_manager = authorization_manager + self.session = requests.Session() + self.logger = logging.getLogger("SAVES") + + self.session.headers.update( + {"User-Agent": "GOGGalaxyCommunicationService/2.0.13.27 (Windows_32bit) dont_sync_marker/true installation_source/gog", + "X-Object-Meta-User-Agent": "GOGGalaxyCommunicationService/2.0.13.27 (Windows_32bit) dont_sync_marker/true installation_source/gog"} + ) + + self.credentials = dict() + self.client_id = str() + self.client_secret = str() + + def create_directory_map(self, path: str) -> list: + """ + Creates list of every file in directory to be synced + """ + files = list() + try: + directory_contents = os.listdir(path) + except (OSError, FileNotFoundError): + self.logger.warning(f"Cannot access directory: {path}") + return files + + for content in directory_contents: + abs_path = os.path.join(path, content) + if os.path.isdir(abs_path): + files.extend(self.create_directory_map(abs_path)) + else: + files.append(abs_path) + return files + + @staticmethod + def get_relative_path(root: str, path: str) -> str: + if not root.endswith("/") and not root.endswith("\\"): + root = root + os.sep + return path.replace(root, "") + + def sync(self, arguments, unknown_args): + try: + prefered_action = getattr(arguments, 'prefered_action', None) + self.sync_path = os.path.normpath(arguments.path.strip('"')) + self.sync_path = self.sync_path.replace("\\", os.sep) + self.cloud_save_dir_name = getattr(arguments, 'dirname', 'saves') + self.arguments = arguments + self.unknown_args = unknown_args + + if not os.path.exists(self.sync_path): + self.logger.warning("Provided path doesn't exist, creating") + os.makedirs(self.sync_path, exist_ok=True) + + dir_list = self.create_directory_map(self.sync_path) + if len(dir_list) == 0: + self.logger.info("No files in directory") + + local_files = [ + SyncFile(self.get_relative_path(self.sync_path, f), f) for f in dir_list + ] + + for f in local_files: + try: + f.get_file_metadata() + except Exception as e: + self.logger.warning(f"Failed to get metadata for {f.absolute_path}: {e}") + + self.logger.info(f"Local files: {len(dir_list)}") + + # Get authentication credentials + try: + self.client_id, self.client_secret = self.get_auth_ids() + self.get_auth_token() + except Exception as e: + self.logger.error(f"Authentication failed: {e}") + return + + # Get cloud files + try: + cloud_files = self.get_cloud_files_list() + downloadable_cloud = [f for f in cloud_files if f.md5 != "aadd86936a80ee8a369579c3926f1b3c"] + except Exception as e: + self.logger.error(f"Failed to get cloud files: {e}") + return + + # Handle sync logic + if len(local_files) > 0 and len(cloud_files) == 0: + self.logger.info("No files in cloud, uploading") + for f in local_files: + try: + self.upload_file(f) + except Exception as e: + self.logger.error(f"Failed to upload {f.relative_path}: {e}") + self.logger.info("Done") + sys.stdout.write(str(datetime.datetime.now().timestamp())) + sys.stdout.flush() + return + + elif len(local_files) == 0 and len(cloud_files) > 0: + self.logger.info("No files locally, downloading") + for f in downloadable_cloud: + try: + self.download_file(f) + except Exception as e: + self.logger.error(f"Failed to download {f.relative_path}: {e}") + self.logger.info("Done") + sys.stdout.write(str(datetime.datetime.now().timestamp())) + sys.stdout.flush() + return + + # Handle more complex sync scenarios + timestamp = float(getattr(arguments, 'timestamp', 0.0)) + classifier = SyncClassifier.classify(local_files, cloud_files, timestamp) + + action = classifier.get_action() + if action == SyncAction.DOWNLOAD: + self.logger.info("Downloading newer cloud files") + for f in classifier.updated_cloud: + try: + self.download_file(f) + except Exception as e: + self.logger.error(f"Failed to download {f.relative_path}: {e}") + + elif action == SyncAction.UPLOAD: + self.logger.info("Uploading newer local files") + for f in classifier.updated_local: + try: + self.upload_file(f) + except Exception as e: + self.logger.error(f"Failed to upload {f.relative_path}: {e}") + + elif action == SyncAction.CONFLICT: + self.logger.warning("Sync conflict detected - manual intervention required") + + self.logger.info("Sync completed") + sys.stdout.write(str(datetime.datetime.now().timestamp())) + sys.stdout.flush() + + except Exception as e: + self.logger.error(f"Sync failed: {e}") + raise + + def get_auth_ids(self): + """Get client credentials from auth manager""" + try: + # Use the same client ID as the main app + client_id = "46899977096215655" + client_secret = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" + return client_id, client_secret + except Exception as e: + self.logger.error(f"Failed to get auth IDs: {e}") + raise + + def get_auth_token(self): + """Get authentication token""" + try: + # Load credentials from auth file + import json + with open(self.auth_manager.config_path, 'r') as f: + auth_data = json.load(f) + + # Extract credentials for our client ID + client_creds = auth_data.get(self.client_id, {}) + self.credentials = { + 'access_token': client_creds.get('access_token', ''), + 'user_id': client_creds.get('user_id', '') + } + + if not self.credentials['access_token']: + raise Exception("No valid access token found") + + # Update session headers + self.session.headers.update({ + 'Authorization': f"Bearer {self.credentials['access_token']}" + }) + + except Exception as e: + self.logger.error(f"Failed to get auth token: {e}") + raise + + def get_cloud_files_list(self): + """Get list of files from GOG cloud storage""" + try: + url = f"{constants.GOG_CLOUDSTORAGE}/v1/{self.credentials['user_id']}/{self.client_id}" + response = self.session.get(url) + + if not response.ok: + self.logger.error(f"Failed to get cloud files: {response.status_code}") + return [] + + cloud_data = response.json() + cloud_files = [] + + for item in cloud_data.get('items', []): + if self.is_save_file(item): + cloud_file = SyncFile( + self.get_relative_path(f"{self.cloud_save_dir_name}/", item['name']), + "", # No local path for cloud files + item.get('hash'), + item.get('last_modified') + ) + cloud_files.append(cloud_file) + + return cloud_files + + except Exception as e: + self.logger.error(f"Failed to get cloud files list: {e}") + return [] + + def is_save_file(self, item): + """Check if cloud item is a save file""" + return item.get("name", "").startswith(self.cloud_save_dir_name) + + def upload_file(self, file: SyncFile): + """Upload file to GOG cloud storage""" + try: + url = f"{constants.GOG_CLOUDSTORAGE}/v1/{self.credentials['user_id']}/{self.client_id}/{self.cloud_save_dir_name}/{file.relative_path}" + + with open(file.absolute_path, 'rb') as f: + headers = { + 'X-Object-Meta-LocalLastModified': file.update_time, + 'Content-Type': 'application/octet-stream' + } + response = self.session.put(url, data=f, headers=headers) + + if not response.ok: + self.logger.error(f"Upload failed for {file.relative_path}: {response.status_code}") + + except Exception as e: + self.logger.error(f"Failed to upload {file.relative_path}: {e}") + + def download_file(self, file: SyncFile, retries=3): + """Download file from GOG cloud storage""" + try: + url = f"{constants.GOG_CLOUDSTORAGE}/v1/{self.credentials['user_id']}/{self.client_id}/{self.cloud_save_dir_name}/{file.relative_path}" + response = self.session.get(url, stream=True) + + if not response.ok: + self.logger.error(f"Download failed for {file.relative_path}: {response.status_code}") + return + + # Create local directory structure + local_path = os.path.join(self.sync_path, file.relative_path) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + + # Download file + with open(local_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + # Set file timestamp if available + if 'X-Object-Meta-LocalLastModified' in response.headers: + try: + timestamp = datetime.datetime.fromisoformat( + response.headers['X-Object-Meta-LocalLastModified'] + ).timestamp() + os.utime(local_path, (timestamp, timestamp)) + except Exception as e: + self.logger.warning(f"Failed to set timestamp for {file.relative_path}: {e}") + + except Exception as e: + if retries > 1: + self.logger.debug(f"Failed sync of {file.relative_path}, retrying (retries left {retries - 1})") + self.download_file(file, retries - 1) + else: + self.logger.error(f"Failed to download {file.relative_path}: {e}") + + +class SyncClassifier: + def __init__(self): + self.action = None + self.updated_local = list() + self.updated_cloud = list() + self.not_existing_locally = list() + self.not_existing_remotely = list() + + def get_action(self): + if len(self.updated_local) == 0 and len(self.updated_cloud) > 0: + self.action = SyncAction.DOWNLOAD + elif len(self.updated_local) > 0 and len(self.updated_cloud) == 0: + self.action = SyncAction.UPLOAD + elif len(self.updated_local) == 0 and len(self.updated_cloud) == 0: + self.action = SyncAction.NONE + else: + self.action = SyncAction.CONFLICT + return self.action + + @classmethod + def classify(cls, local, cloud, timestamp): + classifier = cls() + + local_paths = [f.relative_path for f in local] + cloud_paths = [f.relative_path for f in cloud] + + for f in local: + if f.relative_path not in cloud_paths: + classifier.not_existing_remotely.append(f) + if f.update_ts and f.update_ts > timestamp: + classifier.updated_local.append(f) + + for f in cloud: + if f.md5 == "aadd86936a80ee8a369579c3926f1b3c": + continue + if f.relative_path not in local_paths: + classifier.not_existing_locally.append(f) + if f.update_ts and f.update_ts > timestamp: + classifier.updated_cloud.append(f) + + return classifier diff --git a/app/src/main/python/gogdl/xdelta/__init__.py b/app/src/main/python/gogdl/xdelta/__init__.py new file mode 100644 index 000000000..6ccc12390 --- /dev/null +++ b/app/src/main/python/gogdl/xdelta/__init__.py @@ -0,0 +1 @@ +# Python implementation of xdelta3 decoding only diff --git a/app/src/main/python/gogdl/xdelta/objects.py b/app/src/main/python/gogdl/xdelta/objects.py new file mode 100644 index 000000000..f2bb9b691 --- /dev/null +++ b/app/src/main/python/gogdl/xdelta/objects.py @@ -0,0 +1,139 @@ +from dataclasses import dataclass +from io import IOBase, BytesIO +from typing import Optional + +@dataclass +class CodeTable: + add_sizes = 17 + near_modes = 4 + same_modes = 3 + + cpy_sizes = 15 + + addcopy_add_max = 4 + addcopy_near_cpy_max = 6 + addcopy_same_cpy_max = 4 + + copyadd_add_max = 1 + copyadd_near_cpy_max = 4 + copyadd_same_cpy_max = 4 + + addcopy_max_sizes = [ [6,163,3],[6,175,3],[6,187,3],[6,199,3],[6,211,3],[6,223,3], + [4,235,1],[4,239,1],[4,243,1]] + copyadd_max_sizes = [[4,247,1],[4,248,1],[4,249,1],[4,250,1],[4,251,1],[4,252,1], + [4,253,1],[4,254,1],[4,255,1]] + +XD3_NOOP = 0 +XD3_ADD = 1 +XD3_RUN = 2 +XD3_CPY = 3 + +@dataclass +class Instruction: + type1:int = 0 + size1:int = 0 + type2:int = 0 + size2:int = 0 + +@dataclass +class HalfInstruction: + type: int = 0 + size: int = 0 + addr: int = 0 + + +@dataclass +class AddressCache: + s_near = CodeTable.near_modes + s_same = CodeTable.same_modes + next_slot = 0 + near_array = [0 for _ in range(s_near)] + same_array = [0 for _ in range(s_same * 256)] + + def update(self, addr): + self.near_array[self.next_slot] = addr + self.next_slot = (self.next_slot + 1) % self.s_near + + self.same_array[addr % (self.s_same*256)] = addr + +@dataclass +class Context: + source: IOBase + target: IOBase + + data_sec: BytesIO + inst_sec: BytesIO + addr_sec: BytesIO + + acache: AddressCache + dec_pos: int = 0 + cpy_len: int = 0 + cpy_off: int = 0 + dec_winoff: int = 0 + + target_buffer: Optional[bytearray] = None + +def build_code_table(): + table: list[Instruction] = [] + for _ in range(256): + table.append(Instruction()) + + cpy_modes = 2 + CodeTable.near_modes + CodeTable.same_modes + i = 0 + + table[i].type1 = XD3_RUN + i+=1 + table[i].type1 = XD3_ADD + i+=1 + + size1 = 1 + + for size1 in range(1, CodeTable.add_sizes + 1): + table[i].type1 = XD3_ADD + table[i].size1 = size1 + i+=1 + + for mode in range(0, cpy_modes): + table[i].type1 = XD3_CPY + mode + i += 1 + for size1 in range(4, 4 + CodeTable.cpy_sizes): + table[i].type1 = XD3_CPY + mode + table[i].size1 = size1 + i+=1 + + + for mode in range(cpy_modes): + for size1 in range(1, CodeTable.addcopy_add_max + 1): + is_near = mode < (2 + CodeTable.near_modes) + if is_near: + max = CodeTable.addcopy_near_cpy_max + else: + max = CodeTable.addcopy_same_cpy_max + for size2 in range(4, max + 1): + table[i].type1 = XD3_ADD + table[i].size1 = size1 + table[i].type2 = XD3_CPY + mode + table[i].size2 = size2 + i+=1 + + + for mode in range(cpy_modes): + is_near = mode < (2 + CodeTable.near_modes) + if is_near: + max = CodeTable.copyadd_near_cpy_max + else: + max = CodeTable.copyadd_same_cpy_max + for size1 in range(4, max + 1): + for size2 in range(1, CodeTable.copyadd_add_max + 1): + table[i].type1 = XD3_CPY + mode + table[i].size1 = size1 + table[i].type2 = XD3_ADD + table[i].size2 = size2 + i+=1 + + return table + +CODE_TABLE = build_code_table() + +class ChecksumMissmatch(AssertionError): + pass diff --git a/app/src/main/python/gogdl/xdelta/patcher.py b/app/src/main/python/gogdl/xdelta/patcher.py new file mode 100644 index 000000000..19f3a9f1b --- /dev/null +++ b/app/src/main/python/gogdl/xdelta/patcher.py @@ -0,0 +1,204 @@ +from io import BytesIO +import math +from multiprocessing import Queue +from zlib import adler32 +from gogdl.xdelta import objects + +# Convert stfio integer +def read_integer_stream(stream): + res = 0 + while True: + res <<= 7 + integer = stream.read(1)[0] + res |= (integer & 0b1111111) + if not (integer & 0b10000000): + break + + return res + +def parse_halfinst(context: objects.Context, halfinst: objects.HalfInstruction): + if halfinst.size == 0: + halfinst.size = read_integer_stream(context.inst_sec) + + if halfinst.type >= objects.XD3_CPY: + # Decode address + mode = halfinst.type - objects.XD3_CPY + same_start = 2 + context.acache.s_near + + if mode < same_start: + halfinst.addr = read_integer_stream(context.addr_sec) + + if mode == 0: + pass + elif mode == 1: + halfinst.addr = context.dec_pos - halfinst.addr + if halfinst.addr < 0: + halfinst.addr = context.cpy_len + halfinst.addr + else: + halfinst.addr += context.acache.near_array[mode - 2] + else: + mode -= same_start + addr = context.addr_sec.read(1)[0] + halfinst.addr = context.acache.same_array[(mode * 256) + addr] + context.acache.update(halfinst.addr) + + context.dec_pos += halfinst.size + + +def decode_halfinst(context:objects.Context, halfinst: objects.HalfInstruction, speed_queue: Queue): + take = halfinst.size + + if halfinst.type == objects.XD3_RUN: + byte = context.data_sec.read(1) + + for _ in range(take): + context.target_buffer.extend(byte) + + halfinst.type = objects.XD3_NOOP + elif halfinst.type == objects.XD3_ADD: + buffer = context.data_sec.read(take) + assert len(buffer) == take + context.target_buffer.extend(buffer) + halfinst.type = objects.XD3_NOOP + else: # XD3_CPY and higher + if halfinst.addr < (context.cpy_len or 0): + context.source.seek(context.cpy_off + halfinst.addr) + left = take + while left > 0: + buffer = context.source.read(min(1024 * 1024, left)) + size = len(buffer) + speed_queue.put((0, size)) + context.target_buffer.extend(buffer) + left -= size + + else: + print("OVERLAP NOT IMPLEMENTED") + raise Exception("OVERLAP") + halfinst.type = objects.XD3_NOOP + + +def patch(source: str, patch: str, out: str, speed_queue: Queue): + src_handle = open(source, 'rb') + patch_handle = open(patch, 'rb') + dst_handle = open(out, 'wb') + + + # Verify if patch is actually xdelta patch + headers = patch_handle.read(5) + try: + assert headers[0] == 0xD6 + assert headers[1] == 0xC3 + assert headers[2] == 0xC4 + except AssertionError: + print("Specified patch file is unlikely to be xdelta patch") + return + + HDR_INDICATOR = headers[4] + COMPRESSOR_ID = HDR_INDICATOR & (1 << 0) != 0 + CODE_TABLE = HDR_INDICATOR & (1 << 1) != 0 + APP_HEADER = HDR_INDICATOR & (1 << 2) != 0 + app_header_data = bytes() + + if COMPRESSOR_ID or CODE_TABLE: + print("Compressor ID and codetable are yet not supported") + return + + if APP_HEADER: + app_header_size = read_integer_stream(patch_handle) + app_header_data = patch_handle.read(app_header_size) + + context = objects.Context(src_handle, dst_handle, BytesIO(), BytesIO(), BytesIO(), objects.AddressCache()) + + win_number = 0 + win_indicator = patch_handle.read(1)[0] + while win_indicator is not None: + context.acache = objects.AddressCache() + source_used = win_indicator & (1 << 0) != 0 + target_used = win_indicator & (1 << 1) != 0 + adler32_sum = win_indicator & (1 << 2) != 0 + + if source_used: + source_segment_length = read_integer_stream(patch_handle) + source_segment_position = read_integer_stream(patch_handle) + else: + source_segment_length = 0 + source_segment_position = 0 + + context.cpy_len = source_segment_length + context.cpy_off = source_segment_position + context.source.seek(context.cpy_off or 0) + context.dec_pos = 0 + + # Parse delta + delta_encoding_length = read_integer_stream(patch_handle) + + window_length = read_integer_stream(patch_handle) + context.target_buffer = bytearray() + + delta_indicator = patch_handle.read(1)[0] + + add_run_data_length = read_integer_stream(patch_handle) + instructions_length = read_integer_stream(patch_handle) + addresses_length = read_integer_stream(patch_handle) + + parsed_sum = 0 + if adler32_sum: + checksum = patch_handle.read(4) + parsed_sum = int.from_bytes(checksum, 'big') + + + context.data_sec = BytesIO(patch_handle.read(add_run_data_length)) + context.inst_sec = BytesIO(patch_handle.read(instructions_length)) + context.addr_sec = BytesIO(patch_handle.read(addresses_length)) + + + current1 = objects.HalfInstruction() + current2 = objects.HalfInstruction() + + while context.inst_sec.tell() < instructions_length or current1.type != objects.XD3_NOOP or current2.type != objects.XD3_NOOP: + if current1.type == objects.XD3_NOOP and current2.type == objects.XD3_NOOP: + ins = objects.CODE_TABLE[context.inst_sec.read(1)[0]] + current1.type = ins.type1 + current2.type = ins.type2 + current1.size = ins.size1 + current2.size = ins.size2 + + if current1.type != objects.XD3_NOOP: + parse_halfinst(context, current1) + if current2.type != objects.XD3_NOOP: + parse_halfinst(context, current2) + + while current1.type != objects.XD3_NOOP: + decode_halfinst(context, current1, speed_queue) + + while current2.type != objects.XD3_NOOP: + decode_halfinst(context, current2, speed_queue) + + if adler32_sum: + calculated_sum = adler32(context.target_buffer) + if parsed_sum != calculated_sum: + raise objects.ChecksumMissmatch + + total_size = len(context.target_buffer) + chunk_size = 1024 * 1024 + for i in range(math.ceil(total_size / chunk_size)): + chunk = context.target_buffer[i * chunk_size : min((i + 1) * chunk_size, total_size)] + context.target.write(chunk) + speed_queue.put((len(chunk), 0)) + + context.target.flush() + + indicator = patch_handle.read(1) + if not len(indicator): + win_indicator = None + continue + win_indicator = indicator[0] + win_number += 1 + + + dst_handle.flush() + src_handle.close() + patch_handle.close() + dst_handle.close() + + From edee90eff563c60c243d2f23537f5bb4afe2a220 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Tue, 2 Dec 2025 16:50:53 +0000 Subject: [PATCH 002/122] Initial data models and adjustments to overall managers to allow for GoG installation. --- .../main/java/app/gamenative/data/GOGGame.kt | 114 ++++++++++++++++++ .../java/app/gamenative/data/LibraryItem.kt | 13 ++ .../java/app/gamenative/db/PluviaDatabase.kt | 9 +- .../gamenative/db/converters/GOGConverter.kt | 25 ++++ .../java/app/gamenative/db/dao/GOGGameDao.kt | 84 +++++++++++++ .../java/app/gamenative/di/DatabaseModule.kt | 4 + .../ui/screen/library/LibraryAppScreen.kt | 2 + .../app/gamenative/utils/ContainerUtils.kt | 44 ++++--- 8 files changed, 277 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/app/gamenative/data/GOGGame.kt create mode 100644 app/src/main/java/app/gamenative/db/converters/GOGConverter.kt create mode 100644 app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt diff --git a/app/src/main/java/app/gamenative/data/GOGGame.kt b/app/src/main/java/app/gamenative/data/GOGGame.kt new file mode 100644 index 000000000..a78006e66 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/GOGGame.kt @@ -0,0 +1,114 @@ +package app.gamenative.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import app.gamenative.enums.AppType + +/** + * GOG Game entity for Room database + * Represents a game from the GOG platform + */ +@Entity(tableName = "gog_games") +data class GOGGame( + @PrimaryKey + @ColumnInfo("id") + val id: String, + + @ColumnInfo("title") + val title: String = "", + + @ColumnInfo("slug") + val slug: String = "", + + @ColumnInfo("download_size") + val downloadSize: Long = 0, + + @ColumnInfo("install_size") + val installSize: Long = 0, + + @ColumnInfo("is_installed") + val isInstalled: Boolean = false, + + @ColumnInfo("install_path") + val installPath: String = "", + + @ColumnInfo("image_url") + val imageUrl: String = "", + + @ColumnInfo("icon_url") + val iconUrl: String = "", + + @ColumnInfo("description") + val description: String = "", + + @ColumnInfo("release_date") + val releaseDate: String = "", + + @ColumnInfo("developer") + val developer: String = "", + + @ColumnInfo("publisher") + val publisher: String = "", + + @ColumnInfo("genres") + val genres: List = emptyList(), + + @ColumnInfo("languages") + val languages: List = emptyList(), + + @ColumnInfo("last_played") + val lastPlayed: Long = 0, + + @ColumnInfo("play_time") + val playTime: Long = 0, + + @ColumnInfo("type") + val type: AppType = AppType.game, +) { + companion object { + const val GOG_IMAGE_BASE_URL = "https://images.gog.com/images" + } + + /** + * Get the GOG CDN image URL for this game + * GOG uses a specific URL pattern for game images + */ + val gogImageUrl: String + get() = if (imageUrl.isNotEmpty()) { + imageUrl + } else if (slug.isNotEmpty()) { + "$GOG_IMAGE_BASE_URL/$slug.jpg" + } else { + "" + } + + /** + * Get the icon URL for this game + */ + val gogIconUrl: String + get() = iconUrl.ifEmpty { gogImageUrl } +} + +/** + * GOG user credentials for authentication + */ +data class GOGCredentials( + val accessToken: String, + val refreshToken: String, + val userId: String, + val username: String, +) + +/** + * GOG download progress information + */ +data class GOGDownloadInfo( + val gameId: String, + val totalSize: Long, + val downloadedSize: Long = 0, + val progress: Float = 0f, + val isActive: Boolean = false, + val isPaused: Boolean = false, + val error: String? = null, +) diff --git a/app/src/main/java/app/gamenative/data/LibraryItem.kt b/app/src/main/java/app/gamenative/data/LibraryItem.kt index ca55fde9d..8226a816d 100644 --- a/app/src/main/java/app/gamenative/data/LibraryItem.kt +++ b/app/src/main/java/app/gamenative/data/LibraryItem.kt @@ -6,6 +6,7 @@ import app.gamenative.utils.CustomGameScanner enum class GameSource { STEAM, CUSTOM_GAME, + GOG, // Add other platforms here.. } @@ -44,6 +45,18 @@ data class LibraryItem( "" } } + GameSource.GOG -> { + // GoG Images are typically the full URL, but have fallback just in case. + if (iconHash.isNotEmpty()) { + if (iconHash.startsWith("http")) { + iconHash + } else { + "${GOGGame.GOG_IMAGE_BASE_URL}/$iconHash" + } + } else { + "" + } + } } /** diff --git a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt index 6a03d3c1e..e34dc19dd 100644 --- a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt +++ b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt @@ -13,12 +13,14 @@ import app.gamenative.data.SteamFriend import app.gamenative.data.SteamLicense import app.gamenative.data.CachedLicense import app.gamenative.data.EncryptedAppTicket +import app.gamenative.data.GOGGame import app.gamenative.db.converters.AppConverter import app.gamenative.db.converters.ByteArrayConverter import app.gamenative.db.converters.FriendConverter import app.gamenative.db.converters.LicenseConverter import app.gamenative.db.converters.PathTypeConverter import app.gamenative.db.converters.UserFileInfoListConverter +import app.gamenative.db.converters.GOGConverter import app.gamenative.db.dao.ChangeNumbersDao import app.gamenative.db.dao.EmoticonDao import app.gamenative.db.dao.FileChangeListsDao @@ -29,6 +31,7 @@ import app.gamenative.db.dao.SteamLicenseDao import app.gamenative.db.dao.AppInfoDao import app.gamenative.db.dao.CachedLicenseDao import app.gamenative.db.dao.EncryptedAppTicketDao +import app.gamenative.db.dao.GOGGameDao const val DATABASE_NAME = "pluvia.db" @@ -44,8 +47,9 @@ const val DATABASE_NAME = "pluvia.db" AppInfo::class, CachedLicense::class, EncryptedAppTicket::class, + GOGGame::class, ], - version = 7, + version = 8, exportSchema = false, // Should export once stable. ) @TypeConverters( @@ -55,6 +59,7 @@ const val DATABASE_NAME = "pluvia.db" LicenseConverter::class, PathTypeConverter::class, UserFileInfoListConverter::class, + GOGConverter::class, ) abstract class PluviaDatabase : RoomDatabase() { @@ -77,4 +82,6 @@ abstract class PluviaDatabase : RoomDatabase() { abstract fun cachedLicenseDao(): CachedLicenseDao abstract fun encryptedAppTicketDao(): EncryptedAppTicketDao + + abstract fun gogGameDao(): GOGGameDao } diff --git a/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt b/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt new file mode 100644 index 000000000..12ff6bf98 --- /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 { + return if (value.isEmpty()) { + emptyList() + } else { + Json.decodeFromString>(value) + } + } +} diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt new file mode 100644 index 000000000..4e685b5f8 --- /dev/null +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -0,0 +1,84 @@ +package app.gamenative.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import app.gamenative.data.GOGGame +import kotlinx.coroutines.flow.Flow + +/** + * DAO for GOG games in the Room database + */ +@Dao +interface GOGGameDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(game: GOGGame) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(games: List) + + @Update + suspend fun update(game: GOGGame) + + @Delete + suspend fun delete(game: GOGGame) + + @Query("DELETE FROM gog_games WHERE id = :gameId") + suspend fun deleteById(gameId: String) + + @Query("SELECT * FROM gog_games WHERE id = :gameId") + suspend fun getById(gameId: String): GOGGame? + + @Query("SELECT * FROM gog_games ORDER BY title ASC") + fun getAll(): Flow> + + @Query("SELECT * FROM gog_games ORDER BY title ASC") + suspend fun getAllAsList(): List + + @Query("SELECT * FROM gog_games WHERE is_installed = :isInstalled ORDER BY title ASC") + fun getByInstallStatus(isInstalled: Boolean): Flow> + + @Query("SELECT * FROM gog_games WHERE title LIKE '%' || :searchQuery || '%' ORDER BY title ASC") + fun searchByTitle(searchQuery: String): Flow> + + @Query("DELETE FROM gog_games") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM gog_games") + fun getCount(): Flow + + @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 and path from existing game + val gameToInsert = newGame.copy( + isInstalled = existingGame.isInstalled, + installPath = existingGame.installPath, + lastPlayed = existingGame.lastPlayed, + playTime = existingGame.playTime, + ) + insert(gameToInsert) + } else { + // New game, insert as-is + insert(newGame) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/di/DatabaseModule.kt b/app/src/main/java/app/gamenative/di/DatabaseModule.kt index 32bc64ae0..975385fc9 100644 --- a/app/src/main/java/app/gamenative/di/DatabaseModule.kt +++ b/app/src/main/java/app/gamenative/di/DatabaseModule.kt @@ -67,4 +67,8 @@ class DatabaseModule { @Provides @Singleton fun provideEncryptedAppTicketDao(db: PluviaDatabase): EncryptedAppTicketDao = db.encryptedAppTicketDao() + + @Provides + @Singleton + fun provideGOGGameDao(db: PluviaDatabase) = db.gogGameDao() } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 2201e3372..e0418cb5d 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 @@ -97,6 +97,7 @@ import com.winlator.xenvironment.ImageFsInstaller import com.winlator.fexcore.FEXCoreManager import app.gamenative.ui.screen.library.appscreen.SteamAppScreen import app.gamenative.ui.screen.library.appscreen.CustomGameAppScreen +import app.gamenative.ui.screen.library.appscreen.GOGAppScreen import app.gamenative.ui.data.GameDisplayInfo import java.text.SimpleDateFormat import java.util.Date @@ -184,6 +185,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() } } diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index cd88a3b37..7c0dfffed 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -492,25 +492,33 @@ 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 } - "$defaultDrives$drive:$gameFolderPath" - } else { - Timber.w("Could not find folder path for Custom Game: $appId") + } + GameSource.GOG -> { + // Just use DefaultDrives. We can create a specific one later. + Timber.d("Sending to Default Drive: $defaultDrives$drive") defaultDrives } } @@ -637,6 +645,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 { @@ -972,6 +981,7 @@ object ContainerUtils { return when { containerId.startsWith("STEAM_") -> GameSource.STEAM containerId.startsWith("CUSTOM_GAME_") -> GameSource.CUSTOM_GAME + containerId.startsWith("GOG_") -> GameSource.GOG // Add other platforms here.. else -> GameSource.STEAM // default fallback } From 9b535d7583a2aefc97479174be42bfab62c35d9d Mon Sep 17 00:00:00 2001 From: phobos665 Date: Tue, 2 Dec 2025 16:52:22 +0000 Subject: [PATCH 003/122] WIP understanding how the manager and app screen work --- .../gamenative/service/gog/GOGGameManager.kt | 133 ++++++++++++++++ .../screen/library/appscreen/GOGAppScreen.kt | 150 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt create mode 100644 app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt diff --git a/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt new file mode 100644 index 000000000..16c9fecf7 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt @@ -0,0 +1,133 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.data.GOGGame +import app.gamenative.db.dao.GOGGameDao +import kotlinx.coroutines.flow.Flow +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manager for GOG game operations + * + * This class handles GOG game library management, authentication, + * downloads, and installation via the Python gogdl backend. + * + * TODO: Implement the following features: + * - GOG OAuth authentication flow + * - Library sync with GOG API + * - Game downloads via Python gogdl + * - Installation and verification + * - Cloud saves sync + * - Update checking + */ +@Singleton +class GOGGameManager @Inject constructor( + private val context: Context, + private val gogGameDao: GOGGameDao, +) { + + /** + * Check if user is authenticated with GOG + */ + fun isAuthenticated(): Boolean { + // TODO: Check for valid GOG credentials in secure storage + return false + } + + /** + * Get all GOG games from the database + */ + fun getAllGames(): Flow> { + return gogGameDao.getAll() + } + + /** + * Get installed GOG games + */ + fun getInstalledGames(): Flow> { + return gogGameDao.getByInstallStatus(true) + } + + /** + * Refresh the GOG library from the API + * This will fetch owned games and update the database + */ + suspend fun refreshLibrary() { + if (!isAuthenticated()) { + Timber.w("Cannot refresh library - not authenticated with GOG") + return + } + + // TODO: Implement library refresh via Python gogdl + // 1. Call Python gogdl to fetch owned games + // 2. Parse the response + // 3. Update database using gogGameDao.upsertPreservingInstallStatus() + Timber.d("GOG library refresh not yet implemented") + } + + /** + * Download and install a GOG game + */ + suspend fun downloadGame(gameId: String, installPath: String) { + // TODO: Implement game download via Python gogdl + // 1. Validate authentication + // 2. Call Python gogdl download command + // 3. Monitor download progress + // 4. Update database when complete + Timber.d("GOG game download not yet implemented for game: $gameId") + } + + /** + * Uninstall a GOG game + */ + suspend fun uninstallGame(gameId: String) { + // TODO: Implement game uninstallation + // 1. Remove game files + // 2. Update database + // 3. Remove container if exists + Timber.d("GOG game uninstall not yet implemented for game: $gameId") + } + + /** + * Launch a GOG game + * Returns the executable path to launch + */ + suspend fun getLaunchInfo(gameId: String): String? { + // TODO: Implement launch info retrieval via Python gogdl + // This should return the correct executable path within the install directory + Timber.d("GOG game launch info not yet implemented for game: $gameId") + return null + } + + /** + * Verify game files + */ + suspend fun verifyGame(gameId: String): Boolean { + // TODO: Implement file verification via Python gogdl + Timber.d("GOG game verification not yet implemented for game: $gameId") + return false + } + + /** + * Check for game updates + */ + suspend fun checkForUpdates(gameId: String): Boolean { + // TODO: Implement update checking via Python gogdl + Timber.d("GOG game update check not yet implemented for game: $gameId") + return false + } + + companion object { + private const val TAG = "GOGGameManager" + + // GOG Python module paths + const val PYTHON_GOGDL_MODULE = "gogdl.cli" + + // Default GOG install directory + fun getDefaultInstallDir(context: Context): String { + return "${context.getExternalFilesDir(null)}/GOGGames" + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt new file mode 100644 index 000000000..d14fbf3ed --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt @@ -0,0 +1,150 @@ +package app.gamenative.ui.screen.library.appscreen + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import app.gamenative.R +import app.gamenative.data.GOGGame +import app.gamenative.data.LibraryItem +import app.gamenative.ui.data.AppMenuOption +import app.gamenative.ui.data.GameDisplayInfo +import app.gamenative.ui.enums.AppOptionMenuType +import com.winlator.container.ContainerManager +import timber.log.Timber + +/** + * GOG-specific implementation of BaseAppScreen + * Handles GOG games with integration to the Python gogdl backend + */ +class GOGAppScreen : BaseAppScreen() { + + @Composable + override fun getGameDisplayInfo( + context: Context, + libraryItem: LibraryItem + ): GameDisplayInfo { + // TODO: Fetch GOG game details from database + // For now, use basic info from libraryItem + return GameDisplayInfo( + heroImageUrl = libraryItem.iconHash, // GOG stores image URLs in iconHash + capsuleImageUrl = libraryItem.iconHash, + logoImageUrl = null, + iconImageUrl = libraryItem.iconHash, + description = "GOG Game", // TODO: Fetch from GOGGame entity + releaseDate = null, // TODO: Fetch from GOGGame entity + developer = null, // TODO: Fetch from GOGGame entity + publisher = null // TODO: Fetch from GOGGame entity + ) + } + + override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean { + // TODO: Check GOGGame.isInstalled from database + // For now, check if container exists + val containerManager = ContainerManager(context) + return containerManager.hasContainer(libraryItem.appId) + } + + override fun getInstallPath(context: Context, libraryItem: LibraryItem): String? { + // TODO: Get install path from GOGGame entity in database + // For now, return null as GOG games aren't installed yet + return null + } + + override fun canUninstall(context: Context, libraryItem: LibraryItem): Boolean { + // GOG games can be uninstalled + return isInstalled(context, libraryItem) + } + + override fun onUninstall(context: Context, libraryItem: LibraryItem) { + // TODO: Implement GOG game uninstallation + // This should: + // 1. Remove game files from install directory + // 2. Update GOGGame.isInstalled in database + // 3. Remove container + Timber.d("Uninstall requested for GOG game: ${libraryItem.appId}") + } + + override fun canDownload(context: Context, libraryItem: LibraryItem): Boolean { + // GOG games can be downloaded if not installed + return !isInstalled(context, libraryItem) + } + + override fun onDownload(context: Context, libraryItem: LibraryItem) { + // TODO: Implement GOG game download via Python gogdl + // This should: + // 1. Check GOG authentication + // 2. Start download via Python gogdl CLI + // 3. Update download progress in UI + // 4. Update GOGGame.isInstalled when complete + Timber.d("Download requested for GOG game: ${libraryItem.appId}") + } + + /** + * GOG games can use the standard Play button + */ + @Composable + override fun getPlayButtonOverride( + context: Context, + libraryItem: LibraryItem, + onClickPlay: (Boolean) -> Unit + ): AppMenuOption? { + return null // Use default Play button + } + + /** + * GOG-specific menu options + */ + @Composable + override fun getSourceSpecificMenuOptions( + context: Context, + libraryItem: LibraryItem, + onEditContainer: () -> Unit, + onBack: () -> Unit, + ): List { + val options = mutableListOf() + + // TODO: Add GOG-specific options like: + // - Verify game files + // - Check for updates + // - View game on GOG.com + // - Manage DLC + + return options + } + + /** + * 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 to add GOG-specific analytics + */ + override fun onRunContainerClick( + context: Context, + libraryItem: LibraryItem, + onClickPlay: (Boolean) -> Unit + ) { + // TODO: Add PostHog analytics for GOG game launches + super.onRunContainerClick(context, libraryItem, onClickPlay) + } + + /** + * GOG games don't need special image fetching logic like Custom Games + * Images come from GOG CDN + */ + override fun getGameFolderPathForImageFetch(context: Context, libraryItem: LibraryItem): String? { + return null // GOG uses CDN images, not local files + } +} From a6f1137dd75d9bacb72aca35d5315c88b9c7a94f Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 4 Dec 2025 21:32:10 +0000 Subject: [PATCH 004/122] WIP initial work and working on the automatic authentication via oAuth. --- app/src/main/AndroidManifest.xml | 10 + .../main/java/app/gamenative/MainActivity.kt | 17 +- app/src/main/java/app/gamenative/PluviaApp.kt | 13 + .../main/java/app/gamenative/enums/Marker.kt | 1 + .../app/gamenative/events/AndroidEvent.kt | 1 + .../gamenative/service/gog/GOGConstants.kt | 47 + .../gamenative/service/gog/GOGGameManager.kt | 795 ++++++++++++-- .../service/gog/GOGLibraryManager.kt | 64 ++ .../app/gamenative/service/gog/GOGService.kt | 969 ++++++++++++++++++ .../ui/component/dialog/GOGLoginDialog.kt | 182 ++++ .../gamenative/ui/model/LibraryViewModel.kt | 4 + .../screen/library/appscreen/GOGAppScreen.kt | 111 +- .../screen/settings/SettingsGroupInterface.kt | 95 ++ .../app/gamenative/utils/ContainerUtils.kt | 2 +- 14 files changed, 2197 insertions(+), 114 deletions(-) create mode 100644 app/src/main/java/app/gamenative/service/gog/GOGConstants.kt create mode 100644 app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt create mode 100644 app/src/main/java/app/gamenative/service/gog/GOGService.kt create mode 100644 app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78fa409ac..51693fbd2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,6 +50,16 @@ android:host="pluvia" android:scheme="home" /> + + + + + + + + diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 8254ed3a9..f1dcebfd1 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -196,8 +196,23 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) handleLaunchIntent(intent) } + private fun handleLaunchIntent(intent: Intent) { - Timber.d("[IntentLaunch]: handleLaunchIntent called with action=${intent.action}") + Timber.d("[IntentLaunch]: handleLaunchIntent called with action=${intent.action}, data=${intent.data}") + + // Handle GOG OAuth callback + if (intent.data?.scheme == "gamenative" && intent.data?.host == "gog-callback") { + val code = intent.data?.getQueryParameter("code") + Timber.d("[GOG OAuth]: Received callback with code=${code?.take(20)}...") + if (code != null) { + // Emit event with authorization code + lifecycleScope.launch { + PluviaApp.events.emit(app.gamenative.events.AndroidEvent.GOGAuthCodeReceived(code)) + } + } + return + } + try { val launchRequest = IntentLaunchManager.parseLaunchIntent(intent) if (launchRequest != null) { diff --git a/app/src/main/java/app/gamenative/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt index afc59ad3a..23a563bff 100644 --- a/app/src/main/java/app/gamenative/PluviaApp.kt +++ b/app/src/main/java/app/gamenative/PluviaApp.kt @@ -100,6 +100,19 @@ class PluviaApp : SplitCompatApplication() { Timber.e(e, "Failed to initialize Supabase client: ${e.message}") e.printStackTrace() } + + // Initialize GOG service + appScope.launch { + try { + if (app.gamenative.service.gog.GOGService.initialize(applicationContext)) { + Timber.d("GOGService initialized successfully") + } else { + Timber.w("GOGService initialization returned false") + } + } catch (e: Exception) { + Timber.e(e, "Failed to initialize GOGService: ${e.message}") + } + } } companion object { diff --git a/app/src/main/java/app/gamenative/enums/Marker.kt b/app/src/main/java/app/gamenative/enums/Marker.kt index bdf6b39f7..ba1bbbf18 100644 --- a/app/src/main/java/app/gamenative/enums/Marker.kt +++ b/app/src/main/java/app/gamenative/enums/Marker.kt @@ -2,6 +2,7 @@ package app.gamenative.enums enum class Marker(val fileName: String ) { DOWNLOAD_COMPLETE_MARKER(".download_complete"), + DOWNLOAD_IN_PROGRESS_MARKER(".download_in_progress"), STEAM_DLL_REPLACED(".steam_dll_replaced"), STEAM_DLL_RESTORED(".steam_dll_restored"), STEAM_COLDCLIENT_USED(".steam_coldclient_used"), diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt index 25b64e5e5..62f6e239d 100644 --- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt +++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt @@ -23,5 +23,6 @@ interface AndroidEvent : Event { data class DownloadStatusChanged(val appId: Int, val isDownloading: Boolean) : AndroidEvent data class LibraryInstallStatusChanged(val appId: Int) : AndroidEvent data class CustomGameImagesFetched(val appId: String) : AndroidEvent + data class GOGAuthCodeReceived(val authCode: String) : AndroidEvent // data class SetAppBarVisibility(val visible: Boolean) : AndroidEvent } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt new file mode 100644 index 000000000..85f2a9fe4 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt @@ -0,0 +1,47 @@ +package app.gamenative.service.gog + +/** + * Constants for GOG integration + */ +object GOGConstants { + // GOG API URLs + const val GOG_BASE_API_URL = "https://api.gog.com" + const val GOG_AUTH_URL = "https://auth.gog.com" + const val GOG_EMBED_URL = "https://embed.gog.com" + const val GOG_GAMESDB_URL = "https://gamesdb.gog.com" + + // GOG Client ID for authentication + const val GOG_CLIENT_ID = "46899977096215655" + + // Redirect URI for OAuth callback + const val GOG_REDIRECT_URI = "gamenative://gog-callback" + + // GOG OAuth authorization URL with redirect + const val GOG_AUTH_LOGIN_URL = "https://auth.gog.com/auth?client_id=$GOG_CLIENT_ID&redirect_uri=$GOG_REDIRECT_URI&response_type=code&layout=client2" + + // GOG paths + const val GOG_GAMES_BASE_PATH = "/data/data/app.gamenative/files/gog_games" + + /** + * Get the install path for a specific GOG game + */ + fun getGameInstallPath(gameTitle: String): String { + // Sanitize game title for filesystem + val sanitizedTitle = gameTitle.replace(Regex("[^a-zA-Z0-9 ]"), "").trim() + return "$GOG_GAMES_BASE_PATH/$sanitizedTitle" + } + + /** + * Get the auth config path + */ + fun getAuthConfigPath(): String { + return "/data/data/app.gamenative/files/gog_auth.json" + } + + /** + * Get the support directory path (for redistributables) + */ + fun getSupportPath(): String { + return "/data/data/app.gamenative/files/gog-support" + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt index 16c9fecf7..3df846cbb 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt @@ -1,133 +1,786 @@ package app.gamenative.service.gog import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import app.gamenative.R +import app.gamenative.data.DownloadInfo import app.gamenative.data.GOGGame +import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem +import app.gamenative.data.PostSyncInfo +import app.gamenative.data.SteamApp +import app.gamenative.data.GameSource import app.gamenative.db.dao.GOGGameDao -import kotlinx.coroutines.flow.Flow -import timber.log.Timber +import app.gamenative.enums.AppType +import app.gamenative.enums.ControllerSupport +import app.gamenative.enums.Marker +import app.gamenative.enums.OS +import app.gamenative.enums.ReleaseState +import app.gamenative.enums.SyncResult +import app.gamenative.ui.component.dialog.state.MessageDialogState +import app.gamenative.ui.enums.DialogType +import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.MarkerUtils +import app.gamenative.utils.StorageUtils +import com.winlator.container.Container +import com.winlator.core.envvars.EnvVars +import com.winlator.xenvironment.components.GuestProgramLauncherComponent +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.EnumSet +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import timber.log.Timber /** * Manager for GOG game operations * * This class handles GOG game library management, authentication, * downloads, and installation via the Python gogdl backend. - * - * TODO: Implement the following features: - * - GOG OAuth authentication flow - * - Library sync with GOG API - * - Game downloads via Python gogdl - * - Installation and verification - * - Cloud saves sync - * - Update checking */ @Singleton class GOGGameManager @Inject constructor( - private val context: Context, private val gogGameDao: GOGGameDao, ) { /** - * Check if user is authenticated with GOG + * Download a GOG game */ - fun isAuthenticated(): Boolean { - // TODO: Check for valid GOG credentials in secure storage - return false + fun downloadGame(context: Context, libraryItem: LibraryItem): Result { + try { + // Check if another download is already in progress + if (GOGService.hasActiveDownload()) { + return Result.failure(Exception("Another GOG game is already downloading. Please wait for it to finish before starting a new download.")) + } + + // Check authentication first + if (!GOGService.hasStoredCredentials(context)) { + return Result.failure(Exception("GOG authentication required. Please log in to your GOG account first.")) + } + + // Validate credentials and refresh if needed + val validationResult = runBlocking { GOGService.validateCredentials(context) } + if (!validationResult.isSuccess || !validationResult.getOrDefault(false)) { + return Result.failure(Exception("GOG authentication is invalid. Please re-authenticate.")) + } + + val installPath = getGameInstallPath(context, libraryItem.appId, libraryItem.name) + val authConfigPath = "${context.filesDir}/gog_auth.json" + + Timber.i("Starting GOG game installation: ${libraryItem.name} to $installPath") + + // Use the new download method that returns DownloadInfo + val result = runBlocking { GOGService.downloadGame(libraryItem.appId, installPath, authConfigPath) } + + if (result.isSuccess) { + val downloadInfo = result.getOrNull() + if (downloadInfo != null) { + // Add download in progress marker and remove completion marker + val appDirPath = getAppDirPath(libraryItem.appId) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Add a progress listener to update markers when download completes + downloadInfo.addProgressListener { progress -> + when { + progress >= 1.0f -> { + // Download completed successfully + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + Timber.i("GOG game installation completed: ${libraryItem.name}") + } + progress < 0.0f -> { + // Download failed or cancelled + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + Timber.i("GOG game installation failed/cancelled: ${libraryItem.name}") + } + } + } + + Timber.i("GOG game installation started successfully: ${libraryItem.name}") + } + return Result.success(downloadInfo) + } else { + val error = result.exceptionOrNull() ?: Exception("Unknown download error") + Timber.e(error, "Failed to install GOG game: ${libraryItem.name}") + return Result.failure(error) + } + } catch (e: Exception) { + Timber.e(e, "Failed to install GOG game: ${libraryItem.name}") + return Result.failure(e) + } } /** - * Get all GOG games from the database + * Delete a GOG game */ - fun getAllGames(): Flow> { - return gogGameDao.getAll() + fun deleteGame(context: Context, libraryItem: LibraryItem): Result { + try { + val gameId = libraryItem.gameId.toString() + val installPath = getGameInstallPath(context, gameId, libraryItem.name) + val installDir = File(installPath) + + // Delete the manifest file to ensure fresh downloads on reinstall + val manifestPath = File(context.filesDir, "manifests/$gameId") + if (manifestPath.exists()) { + val manifestDeleted = manifestPath.delete() + if (manifestDeleted) { + Timber.i("Deleted manifest file for game $gameId") + } else { + Timber.w("Failed to delete manifest file for game $gameId") + } + } + + if (installDir.exists()) { + val success = installDir.deleteRecursively() + if (success) { + // Remove all markers + val appDirPath = getAppDirPath(libraryItem.appId) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Cancel and clean up any active download + GOGService.cancelDownload(libraryItem.appId) + GOGService.cleanupDownload(libraryItem.appId) + + // Update database to mark as not installed + val game = runBlocking { getGameById(gameId) } + if (game != null) { + val updatedGame = game.copy( + isInstalled = false, + installPath = "", + ) + runBlocking { gogGameDao.update(updatedGame) } + } + + Timber.i("GOG game ${libraryItem.name} deleted successfully") + return Result.success(Unit) + } else { + return Result.failure(Exception("Failed to delete GOG game directory")) + } + } else { + Timber.w("GOG game directory doesn't exist: $installPath") + // Remove all markers even if directory doesn't exist + val appDirPath = getAppDirPath(libraryItem.appId) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Cancel and clean up any active download + GOGService.cancelDownload(libraryItem.appId) + GOGService.cleanupDownload(libraryItem.appId) + + // Update database anyway to ensure consistency + val game = runBlocking { getGameById(gameId) } + if (game != null) { + val updatedGame = game.copy( + isInstalled = false, + installPath = "", + ) + runBlocking { gogGameDao.update(updatedGame) } + } + + return Result.success(Unit) // Consider it already deleted + } + } catch (e: Exception) { + Timber.e(e, "Failed to delete GOG game ${libraryItem.gameId}") + return Result.failure(e) + } + } + + /** + * Check if a GOG game is installed + */ + fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean { + try { + val appDirPath = getAppDirPath(libraryItem.appId) + + // Use marker-based approach for reliable state tracking + val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Game is installed only if download is complete and not in progress + val isInstalled = isDownloadComplete && !isDownloadInProgress + + // Update database if the install status has changed + val gameId = libraryItem.gameId.toString() + val game = runBlocking { getGameById(gameId) } + if (game != null && isInstalled != game.isInstalled) { + val installPath = if (isInstalled) getGameInstallPath(context, gameId, libraryItem.name) else "" + val updatedGame = game.copy( + isInstalled = isInstalled, + installPath = installPath, + ) + runBlocking { gogGameDao.update(updatedGame) } + } + + return isInstalled + } catch (e: Exception) { + Timber.e(e, "Error checking if GOG game is installed") + return false + } + } + + /** + * Check if update is pending for a game + */ + suspend fun isUpdatePending(libraryItem: LibraryItem): Boolean { + return false // Not implemented yet + } + + /** + * Get download info for a game + */ + fun getDownloadInfo(libraryItem: LibraryItem): DownloadInfo? { + return GOGService.getDownloadInfo(libraryItem.appId) + } + + /** + * Check if game has a partial download + */ + fun hasPartialDownload(libraryItem: LibraryItem): Boolean { + try { + val appDirPath = getAppDirPath(libraryItem.appId) + + // Use marker-based approach for reliable state tracking + val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + + // Has partial download if download is in progress or if there are files but no completion marker + if (isDownloadInProgress) { + return true + } + + // Also check if there are files in the directory but no completion marker (interrupted download) + if (!isDownloadComplete) { + val gameId = libraryItem.gameId.toString() + val gameName = libraryItem.name + // Use GOGConstants directly since we don't have context here and it's not needed + val installPath = GOGConstants.getGameInstallPath(gameName) + val installDir = File(installPath) + + // If directory has files but no completion marker, it's a partial download + return installDir.exists() && installDir.listFiles()?.isNotEmpty() == true + } + + return false + } catch (e: Exception) { + Timber.w(e, "Error checking partial download status for ${libraryItem.name}") + return false + } + } + + /** + * Get disk size of installed game + */ + suspend fun getGameDiskSize(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { + // Calculate size from install directory + val installPath = getGameInstallPath(context, libraryItem.appId, libraryItem.name) + val folderSize = StorageUtils.getFolderSize(installPath) + StorageUtils.formatBinarySize(folderSize) + } + + /** + * Get app directory path for a game + */ + fun getAppDirPath(appId: String): String { + // Extract the numeric game ID from the appId + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + + // Get the game details to find the correct title + val game = runBlocking { getGameById(gameId.toString()) } + if (game != null) { + // Return the specific game installation path + val gamePath = GOGConstants.getGameInstallPath(game.title) + Timber.d("GOG getAppDirPath for appId $appId (game: ${game.title}) -> $gamePath") + return gamePath + } + + // Fallback to base path if game not found (shouldn't happen normally) + Timber.w("Could not find game for appId $appId, using base path") + return GOGConstants.GOG_GAMES_BASE_PATH + } + + /** + * Launch game with save sync + */ + suspend fun launchGameWithSaveSync( + context: Context, + libraryItem: LibraryItem, + parentScope: CoroutineScope, + ignorePendingOperations: Boolean, + preferredSave: Int?, + ): PostSyncInfo = withContext(Dispatchers.IO) { + try { + Timber.i("Starting GOG game launch with save sync for ${libraryItem.name}") + + // Check if GOG credentials exist + if (!GOGService.hasStoredCredentials(context)) { + Timber.w("No GOG credentials found, skipping cloud save sync") + return@withContext PostSyncInfo(SyncResult.Success) // Continue without sync + } + + // Determine save path for GOG game + val savePath = "${getGameInstallPath(context, libraryItem.appId, libraryItem.name)}/saves" + val authConfigPath = "${context.filesDir}/gog_auth.json" + + Timber.i("Starting GOG cloud save sync for game ${libraryItem.gameId}") + + // Perform GOG cloud save sync + val syncResult = GOGService.syncCloudSaves( + gameId = libraryItem.gameId.toString(), + savePath = savePath, + authConfigPath = authConfigPath, + timestamp = 0.0f, + ) + + if (syncResult.isSuccess) { + Timber.i("GOG cloud save sync completed successfully") + PostSyncInfo(SyncResult.Success) + } else { + val error = syncResult.exceptionOrNull() + Timber.e(error, "GOG cloud save sync failed") + PostSyncInfo(SyncResult.UnknownFail) + } + } catch (e: Exception) { + Timber.e(e, "GOG cloud save sync exception for game ${libraryItem.gameId}") + PostSyncInfo(SyncResult.UnknownFail) + } + } + + /** + * Get store URL for game + */ + fun getStoreUrl(libraryItem: LibraryItem): Uri { + val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } + val slug = gogGame?.slug ?: "" + return "https://www.gog.com/en/game/$slug".toUri() + } + + /** + * Get Wine start command for launching a game + */ + fun getWineStartCommand( + context: Context, + libraryItem: LibraryItem, + container: Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: EnvVars, + guestProgramLauncherComponent: GuestProgramLauncherComponent, + ): String { + // For GOG games, we always want to launch the actual game + // because GOG doesn't have appLaunchInfo like Steam does + + // Extract the numeric game ID from appId using the existing utility function + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId) + + // Get the game details to find the correct title + val game = runBlocking { getGameById(gameId.toString()) } + if (game == null) { + Timber.e("Game not found for ID: $gameId") + return "\"explorer.exe\"" + } + + Timber.i("Looking for GOG game '${game.title}' with ID: $gameId") + + // Get the specific game installation directory using the existing function + val gameInstallPath = getGameInstallPath(context, gameId.toString(), game.title) + val gameDir = File(gameInstallPath) + + if (!gameDir.exists()) { + Timber.e("Game installation directory does not exist: $gameInstallPath") + return "\"explorer.exe\"" + } + + Timber.i("Found game directory: ${gameDir.absolutePath}") + + // Use GOGGameManager to get the correct executable + val executablePath = runBlocking { getInstalledExe(context, libraryItem) } + + if (executablePath.isEmpty()) { + Timber.w("No executable found for GOG game ${libraryItem.name}, opening file manager") + return "\"explorer.exe\"" + } + + // Calculate the Windows path for the game subdirectory + val gameSubDirRelativePath = gameDir.relativeTo(File(GOGConstants.GOG_GAMES_BASE_PATH)).path.replace('\\', '/') + val windowsGamePath = "E:/gog_games/$gameSubDirRelativePath" + + // Set WINEPATH to the game subdirectory on E: drive + envVars.put("WINEPATH", windowsGamePath) + + // Set the working directory to the game directory + val gameWorkingDir = File(GOGConstants.GOG_GAMES_BASE_PATH, gameSubDirRelativePath) + guestProgramLauncherComponent.workingDir = gameWorkingDir + Timber.i("Setting working directory to: ${gameWorkingDir.absolutePath}") + + val executableName = File(executablePath).name + Timber.i("GOG game executable name: $executableName") + Timber.i("GOG game Windows path: $windowsGamePath") + Timber.i("GOG game subdirectory relative path: $gameSubDirRelativePath") + + // Determine structure type by checking if game_* subdirectory exists + val isV2Structure = gameDir.listFiles()?.any { + it.isDirectory && it.name.startsWith("game_$gameId") + } ?: false + Timber.i("Game structure type: ${if (isV2Structure) "V2" else "V1"}") + + val fullCommand = "\"$windowsGamePath/$executablePath\"" + + Timber.i("Full Wine command will be: $fullCommand") + return fullCommand } /** - * Get installed GOG games + * Create a LibraryItem from GOG game data */ - fun getInstalledGames(): Flow> { - return gogGameDao.getByInstallStatus(true) + fun createLibraryItem(appId: String, gameId: String, context: Context): LibraryItem { + val gogGame = runBlocking { getGameById(gameId) } + + return LibraryItem( + appId = appId, + name = gogGame?.title ?: "Unknown GOG Game", + iconHash = "", // GOG games don't have icon hashes like Steam + gameSource = GameSource.GOG, + ) } + // Simple cache for download sizes + private val downloadSizeCache = mutableMapOf() + /** - * Refresh the GOG library from the API - * This will fetch owned games and update the database + * Get download size for a game */ - suspend fun refreshLibrary() { - if (!isAuthenticated()) { - Timber.w("Cannot refresh library - not authenticated with GOG") - return + suspend fun getDownloadSize(libraryItem: LibraryItem): String { + val gameId = libraryItem.gameId.toString() + + // Return cached result if available + downloadSizeCache[gameId]?.let { return it } + + // Get size info directly (now properly async) + return try { + Timber.d("Getting download size for game $gameId") + val sizeInfo = GOGService.getGameSizeInfo(gameId) + val formattedSize = sizeInfo?.let { StorageUtils.formatBinarySize(it.downloadSize) } ?: "Unknown" + + // Cache the result + downloadSizeCache[gameId] = formattedSize + Timber.d("Got download size for game $gameId: $formattedSize") + + formattedSize + } catch (e: Exception) { + Timber.w(e, "Failed to get download size for game $gameId") + val errorResult = "Unknown" + downloadSizeCache[gameId] = errorResult + errorResult } + } + + /** + * Get cached download size if available + */ + fun getCachedDownloadSize(gameId: String): String? { + return downloadSizeCache[gameId] + } - // TODO: Implement library refresh via Python gogdl - // 1. Call Python gogdl to fetch owned games - // 2. Parse the response - // 3. Update database using gogGameDao.upsertPreservingInstallStatus() - Timber.d("GOG library refresh not yet implemented") + /** + * Check if game is valid to download + */ + fun isValidToDownload(library: LibraryItem): Boolean { + return true // GOG games are always downloadable if owned } /** - * Download and install a GOG game + * Get app info (convert GOG game to SteamApp format for UI compatibility) */ - suspend fun downloadGame(gameId: String, installPath: String) { - // TODO: Implement game download via Python gogdl - // 1. Validate authentication - // 2. Call Python gogdl download command - // 3. Monitor download progress - // 4. Update database when complete - Timber.d("GOG game download not yet implemented for game: $gameId") + fun getAppInfo(libraryItem: LibraryItem): SteamApp? { + val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } + return if (gogGame != null) { + convertGOGGameToSteamApp(gogGame) + } else { + null + } } /** - * Uninstall a GOG game + * Get release date for a game */ - suspend fun uninstallGame(gameId: String) { - // TODO: Implement game uninstallation - // 1. Remove game files - // 2. Update database - // 3. Remove container if exists - Timber.d("GOG game uninstall not yet implemented for game: $gameId") + fun getReleaseDate(libraryItem: LibraryItem): String { + val appInfo = getAppInfo(libraryItem) + if (appInfo?.releaseDate == null || appInfo.releaseDate == 0L) { + return "Unknown" + } + val date = Date(appInfo.releaseDate) + return SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(date) + } + + /** + * Get hero image for a game + */ + fun getHeroImage(libraryItem: LibraryItem): String { + val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } + val imageUrl = gogGame?.imageUrl ?: "" + + // Fix GOG URLs that are missing the protocol + return if (imageUrl.startsWith("//")) { + "https:$imageUrl" + } else { + imageUrl + } } /** - * Launch a GOG game - * Returns the executable path to launch + * Get icon image for a game */ - suspend fun getLaunchInfo(gameId: String): String? { - // TODO: Implement launch info retrieval via Python gogdl - // This should return the correct executable path within the install directory - Timber.d("GOG game launch info not yet implemented for game: $gameId") - return null + fun getIconImage(libraryItem: LibraryItem): String { + return libraryItem.iconHash } /** - * Verify game files + * Get install info dialog state */ - suspend fun verifyGame(gameId: String): Boolean { - // TODO: Implement file verification via Python gogdl - Timber.d("GOG game verification not yet implemented for game: $gameId") - return false + fun getInstallInfoDialog(context: Context, libraryItem: LibraryItem): MessageDialogState { + // GOG install logic + val gogInstallPath = "${context.dataDir.path}/gog_games" + val availableBytes = StorageUtils.getAvailableSpace(context.dataDir.path) + val availableSpace = StorageUtils.formatBinarySize(availableBytes) + + // Get cached download size if available, otherwise show "Calculating..." + val gameId = libraryItem.gameId.toString() + val downloadSize = getCachedDownloadSize(gameId) ?: "Calculating..." + + return MessageDialogState( + visible = true, + type = DialogType.INSTALL_APP, + title = context.getString(R.string.download_prompt_title), + message = "Install ${libraryItem.name} from GOG?" + + "\n\nDownload Size: $downloadSize" + + "\nInstall Path: $gogInstallPath/${libraryItem.name}" + + "\nAvailable Space: $availableSpace", + confirmBtnText = context.getString(R.string.proceed), + dismissBtnText = context.getString(R.string.cancel), + ) } /** - * Check for game updates + * Run before launch (no-op for GOG games) */ - suspend fun checkForUpdates(gameId: String): Boolean { - // TODO: Implement update checking via Python gogdl - Timber.d("GOG game update check not yet implemented for game: $gameId") - return false + fun runBeforeLaunch(context: Context, libraryItem: LibraryItem) { + // Don't run anything before launch for GOG games } - companion object { - private const val TAG = "GOGGameManager" + /** + * Get all GOG games as a Flow + */ + fun getAllGames(): Flow> { + return gogGameDao.getAll() + } - // GOG Python module paths - const val PYTHON_GOGDL_MODULE = "gogdl.cli" + /** + * Get install path for a specific GOG game + */ + fun getGameInstallPath(context: Context, gameId: String, gameTitle: String): String { + return GOGConstants.getGameInstallPath(gameTitle) + } - // Default GOG install directory - fun getDefaultInstallDir(context: Context): String { - return "${context.getExternalFilesDir(null)}/GOGGames" + /** + * Get GOG game by ID from database + */ + suspend fun getGameById(gameId: String): GOGGame? = withContext(Dispatchers.IO) { + try { + gogGameDao.getById(gameId) + } catch (e: Exception) { + Timber.e(e, "Failed to get GOG game by ID: $gameId") + null + } + } + + /** + * Get the executable path for an installed GOG game. + * Handles both V1 and V2 game directory structures. + */ + suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { + val gameId = libraryItem.gameId + try { + val game = runBlocking { getGameById(gameId.toString()) } ?: return@withContext "" + val installPath = getGameInstallPath(context, game.id, game.title) + + // Try V2 structure first (game_$gameId subdirectory) + val v2GameDir = File(installPath, "game_$gameId") + if (v2GameDir.exists()) { + Timber.i("Found V2 game structure: ${v2GameDir.absolutePath}") + return@withContext getGameExecutable(installPath, v2GameDir) + } else { + // Try V1 structure (look for any subdirectory in the install path) + val installDirFile = File(installPath) + val subdirs = installDirFile.listFiles()?.filter { + it.isDirectory && it.name != "saves" + } ?: emptyList() + + if (subdirs.isNotEmpty()) { + // For V1 games, find the subdirectory with .exe files + val v1GameDir = subdirs.find { subdir -> + val exeFiles = subdir.listFiles()?.filter { + it.isFile && + it.name.endsWith(".exe", ignoreCase = true) && + !isGOGUtilityExecutable(it.name) + } ?: emptyList() + exeFiles.isNotEmpty() + } + + if (v1GameDir != null) { + Timber.i("Found V1 game structure: ${v1GameDir.absolutePath}") + return@withContext getGameExecutable(installPath, v1GameDir) + } else { + Timber.w("No V1 game subdirectories with executables found in: $installPath") + return@withContext "" + } + } else { + Timber.w("No game directories found in: $installPath") + return@withContext "" + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to get executable for GOG game $gameId") + "" + } + } + + /** + * Check if an executable is a GOG utility (should be skipped) + */ + private fun isGOGUtilityExecutable(filename: String): Boolean { + return filename.equals("unins000.exe", ignoreCase = true) || + filename.equals("CheckApplication.exe", ignoreCase = true) || + filename.equals("SettingsApplication.exe", ignoreCase = true) + } + + private fun getGameExecutable(installPath: String, gameDir: File): String { + // Get the main executable from GOG game info file + val mainExe = getMainExecutableFromGOGInfo(gameDir, installPath) + + if (mainExe.isNotEmpty()) { + Timber.i("Found GOG game executable from info file: $mainExe") + return mainExe } + + Timber.e("Failed to find executable from GOG info file in: ${gameDir.absolutePath}") + return "" + } + + private fun getMainExecutableFromGOGInfo(gameDir: File, installPath: String): String { + // Look for goggame-*.info file + val infoFile = gameDir.listFiles()?.find { + it.isFile && it.name.startsWith("goggame-") && it.name.endsWith(".info") + } + + if (infoFile == null) { + throw Exception("GOG info file not found in: ${gameDir.absolutePath}") + } + + val content = infoFile.readText() + Timber.d("GOG info file content: $content") + + // Parse JSON to find the primary task + val jsonObject = org.json.JSONObject(content) + + // Look for playTasks array + if (!jsonObject.has("playTasks")) { + throw Exception("GOG info file does not contain playTasks array") + } + + val playTasks = jsonObject.getJSONArray("playTasks") + + // Find the primary task + for (i in 0 until playTasks.length()) { + val task = playTasks.getJSONObject(i) + if (task.has("isPrimary") && task.getBoolean("isPrimary")) { + val executablePath = task.getString("path") + + Timber.i("Found primary task executable path: $executablePath") + + // Check if the executable actually exists (case-insensitive) + val actualExeFile = gameDir.listFiles()?.find { + it.name.equals(executablePath, ignoreCase = true) + } + if (actualExeFile != null && actualExeFile.exists()) { + return "${gameDir.name}/${actualExeFile.name}" + } else { + Timber.w("Primary task executable '$executablePath' not found in game directory") + } + break + } + } + + return "" + } + + /** + * Convert GOGGame to SteamApp format for compatibility with existing UI components. + * This allows GOG games to be displayed using the same UI components as Steam games. + */ + private fun convertGOGGameToSteamApp(gogGame: GOGGame): SteamApp { + // Convert release date string (ISO format like "2021-06-17T15:55:+0300") to timestamp + val releaseTimestamp = try { + if (gogGame.releaseDate.isNotEmpty()) { + // Try different date formats that GOG might use + val formats = arrayOf( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ZZZZZ", Locale.US), // 2021-06-17T15:55:+0300 + SimpleDateFormat("yyyy-MM-dd'T'HH:mmZ", Locale.US), // 2021-06-17T15:55+0300 + SimpleDateFormat("yyyy-MM-dd", Locale.US), // 2021-06-17 + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US), // 2021-06-17T15:55:30 + ) + + var parsedDate: Date? = null + for (format in formats) { + try { + parsedDate = format.parse(gogGame.releaseDate) + break + } catch (e: Exception) { + // Try next format + } + } + + parsedDate?.time ?: 0L + } else { + 0L + } + } catch (e: Exception) { + Timber.w(e, "Failed to parse release date: ${gogGame.releaseDate}") + 0L + } + + // Convert GOG game ID (string) to integer for SteamApp compatibility + val appId = try { + gogGame.id.toIntOrNull() ?: gogGame.id.hashCode() + } catch (e: Exception) { + gogGame.id.hashCode() + } + + return SteamApp( + id = appId, + name = gogGame.title, + type = AppType.game, + osList = EnumSet.of(OS.windows), + releaseState = ReleaseState.released, + releaseDate = releaseTimestamp, + developer = gogGame.developer.takeIf { it.isNotEmpty() } ?: "Unknown Developer", + publisher = gogGame.publisher.takeIf { it.isNotEmpty() } ?: "Unknown Publisher", + controllerSupport = ControllerSupport.none, + logoHash = "", + iconHash = "", + clientIconHash = "", + installDir = gogGame.title.replace(Regex("[^a-zA-Z0-9 ]"), "").trim(), + ) } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt new file mode 100644 index 000000000..54141f94e --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt @@ -0,0 +1,64 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.data.GOGGame +import app.gamenative.db.dao.GOGGameDao +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manager for GOG library operations - fetching, caching, and syncing the user's GOG library + */ +@Singleton +class GOGLibraryManager @Inject constructor( + private val gogGameDao: GOGGameDao, +) { + + /** + * Start background library sync + * TODO: Implement full progressive library fetching from GOG API + */ + suspend fun startBackgroundSync(context: Context): Result = withContext(Dispatchers.IO) { + try { + if (!GOGService.hasStoredCredentials(context)) { + return@withContext Result.failure(Exception("No stored credentials found")) + } + + Timber.i("Starting GOG library background sync...") + + // TODO: Implement progressive library fetching like in the branch + // This should: + // 1. Call getUserLibraryProgressively + // 2. Fetch games one by one from GOG API + // 3. Enrich with GamesDB metadata + // 4. Update database using gogGameDao.upsertPreservingInstallStatus() + + Timber.w("GOG library sync not yet fully implemented") + Result.success(Unit) + } catch (e: Exception) { + Timber.e(e, "Failed to sync GOG library") + Result.failure(e) + } + } + + /** + * Refresh the entire library (called manually by user) + */ + suspend fun refreshLibrary(context: Context): Result = withContext(Dispatchers.IO) { + try { + if (!GOGService.hasStoredCredentials(context)) { + return@withContext Result.failure(Exception("Not authenticated with GOG")) + } + + // TODO: Implement full library refresh + Timber.i("Refreshing GOG library...") + Result.success(0) + } catch (e: Exception) { + Timber.e(e, "Failed to refresh GOG library") + Result.failure(e) + } + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt new file mode 100644 index 000000000..26bb6b707 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -0,0 +1,969 @@ +package app.gamenative.service.gog + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import app.gamenative.data.DownloadInfo +import app.gamenative.data.GOGCredentials +import app.gamenative.data.GOGGame +import app.gamenative.service.NotificationHelper +import app.gamenative.utils.ContainerUtils +import com.chaquo.python.Kwarg +import com.chaquo.python.PyObject +import com.chaquo.python.Python +import com.chaquo.python.android.AndroidPlatform +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.* +import okhttp3.OkHttpClient +import org.json.JSONObject +import timber.log.Timber + +/** + * Data class to hold metadata extracted from GOG GamesDB + */ +private data class GameMetadata( + val developer: String = "Unknown Developer", + val publisher: String = "Unknown Publisher", + val title: String? = null, + val description: String? = null +) + +/** + * Data class to hold size information from gogdl info command + */ +data class GameSizeInfo( + val downloadSize: Long, + val diskSize: Long +) + +@Singleton +class GOGService @Inject constructor() : Service() { + + companion object { + private var instance: GOGService? = null + private var appContext: Context? = null + private var isInitialized = false + private var httpClient: OkHttpClient? = null + private var python: Python? = null + + // Constants + private const val GOG_CLIENT_ID = "46899977096215655" + + // Add sync tracking variables + private var syncInProgress: Boolean = false + private var backgroundSyncJob: Job? = null + + val isRunning: Boolean + get() = instance != null + + fun start(context: Context) { + if (!isRunning) { + val intent = Intent(context, GOGService::class.java) + context.startForegroundService(intent) + } + } + + fun stop() { + instance?.let { service -> + service.stopSelf() + } + } + + fun setHttpClient(client: OkHttpClient) { + httpClient = client + } + + /** + * Initialize the GOG service with Chaquopy Python + */ + fun initialize(context: Context): Boolean { + if (isInitialized) return true + + try { + // Store the application context + appContext = context.applicationContext + + Timber.i("Initializing GOG service with Chaquopy...") + + // Initialize Python if not already started + if (!Python.isStarted()) { + Python.start(AndroidPlatform(context)) + } + python = Python.getInstance() + + isInitialized = true + Timber.i("GOG service initialized successfully with Chaquopy") + + return isInitialized + } catch (e: Exception) { + Timber.e(e, "Exception during GOG service initialization") + return false + } + } + + /** + * Execute GOGDL command using Chaquopy + */ + suspend fun executeCommand(vararg args: String): Result { + return withContext(Dispatchers.IO) { + try { + Timber.d("executeCommand called with args: ${args.joinToString(" ")}") + + if (!Python.isStarted()) { + Timber.e("Python is not started! Cannot execute GOGDL command") + return@withContext Result.failure(Exception("Python environment not initialized")) + } + + val python = Python.getInstance() + Timber.d("Python instance obtained successfully") + + val sys = python.getModule("sys") + val io = python.getModule("io") + val originalArgv = sys.get("argv") + + try { + // Now import our Android-compatible GOGDL CLI module + Timber.d("Importing gogdl.cli module...") + val gogdlCli = python.getModule("gogdl.cli") + Timber.d("gogdl.cli module imported successfully") + + // Set up arguments for argparse + val argsList = listOf("gogdl") + args.toList() + Timber.d("Setting GOGDL arguments for argparse: ${args.joinToString(" ")}") + // Convert to Python list to avoid jarray issues + val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) + sys.put("argv", pythonList) + Timber.d("sys.argv set to: $argsList") + + // Capture stdout + val stdoutCapture = io.callAttr("StringIO") + val originalStdout = sys.get("stdout") + sys.put("stdout", stdoutCapture) + Timber.d("stdout capture configured") + + // Execute the main function + Timber.d("Calling gogdl.cli.main()...") + gogdlCli.callAttr("main") + Timber.d("gogdl.cli.main() completed") + + // Get the captured output + val output = stdoutCapture.callAttr("getvalue").toString() + Timber.d("GOGDL raw output (length: ${output.length}): $output") + + // Restore original stdout + sys.put("stdout", originalStdout) + + if (output.isNotEmpty()) { + Timber.d("Returning success with output") + Result.success(output) + } else { + Timber.w("GOGDL execution completed but output is empty") + Result.success("GOGDL execution completed") + } + + } catch (e: Exception) { + Timber.e(e, "GOGDL execution exception: ${e.javaClass.simpleName} - ${e.message}") + Timber.e("Exception stack trace: ${e.stackTraceToString()}") + Result.failure(Exception("GOGDL execution failed: ${e.message}", e)) + } finally { + // Restore original sys.argv + sys.put("argv", originalArgv) + Timber.d("sys.argv restored") + } + } catch (e: Exception) { + Timber.e(e, "Failed to execute GOGDL command: ${args.joinToString(" ")}") + Timber.e("Outer exception stack trace: ${e.stackTraceToString()}") + Result.failure(Exception("GOGDL execution failed: ${e.message}", e)) + } + } + } + + /** + * Read and parse auth credentials from file + */ + private fun readAuthCredentials(authConfigPath: String): Result> { + return try { + val authFile = File(authConfigPath) + Timber.d("Checking auth file at: ${authFile.absolutePath}") + Timber.d("Auth file exists: ${authFile.exists()}") + + if (!authFile.exists()) { + return Result.failure(Exception("No authentication found. Please log in first.")) + } + + val authContent = authFile.readText() + Timber.d("Auth file content: $authContent") + + val authJson = JSONObject(authContent) + + // GOGDL stores credentials nested under client ID + val credentialsJson = if (authJson.has(GOG_CLIENT_ID)) { + authJson.getJSONObject(GOG_CLIENT_ID) + } else { + // Fallback: try to read from root level + authJson + } + + val accessToken = credentialsJson.optString("access_token", "") + val userId = credentialsJson.optString("user_id", "") + + Timber.d("Parsed access_token: ${if (accessToken.isNotEmpty()) "${accessToken.take(20)}..." else "EMPTY"}") + Timber.d("Parsed user_id: $userId") + + if (accessToken.isEmpty() || userId.isEmpty()) { + Timber.e("Auth data validation failed - accessToken empty: ${accessToken.isEmpty()}, userId empty: ${userId.isEmpty()}") + return Result.failure(Exception("Invalid authentication data. Please log in again.")) + } + + Result.success(Pair(accessToken, userId)) + } catch (e: Exception) { + Timber.e(e, "Failed to read auth credentials") + Result.failure(e) + } + } + + /** + * Parse full GOGCredentials from auth file + */ + private fun parseFullCredentials(authConfigPath: String): GOGCredentials { + return try { + val authFile = File(authConfigPath) + if (authFile.exists()) { + val authContent = authFile.readText() + val authJson = JSONObject(authContent) + + // GOGDL stores credentials nested under client ID + val credentialsJson = if (authJson.has(GOG_CLIENT_ID)) { + authJson.getJSONObject(GOG_CLIENT_ID) + } else { + // Fallback: try to read from root level + authJson + } + + GOGCredentials( + accessToken = credentialsJson.optString("access_token", ""), + refreshToken = credentialsJson.optString("refresh_token", ""), + userId = credentialsJson.optString("user_id", ""), + username = credentialsJson.optString("username", "GOG User"), + ) + } else { + // Return dummy credentials for successful auth + GOGCredentials( + accessToken = "authenticated_${System.currentTimeMillis()}", + refreshToken = "refresh_${System.currentTimeMillis()}", + userId = "user_123", + username = "GOG User", + ) + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse auth result") + // Return dummy credentials as fallback + GOGCredentials( + accessToken = "fallback_token", + refreshToken = "fallback_refresh", + userId = "fallback_user", + username = "GOG User", + ) + } + } + + /** + * Create GOGCredentials from JSON output + */ + private fun createCredentialsFromJson(outputJson: JSONObject): GOGCredentials { + return GOGCredentials( + accessToken = outputJson.optString("access_token", ""), + refreshToken = outputJson.optString("refresh_token", ""), + userId = outputJson.optString("user_id", ""), + username = "GOG User", // We don't have username in the token response + ) + } + + /** + * Authenticate with GOG using authorization code from OAuth2 flow + * Users must visit GOG login page, authenticate, and copy the authorization code + */ + suspend fun authenticateWithCode(authConfigPath: String, authorizationCode: String): Result { + return try { + Timber.i("Starting GOG authentication with authorization code...") + + // Extract the actual authorization code from URL if needed + val actualCode = if (authorizationCode.startsWith("http")) { + // Extract code parameter from URL + val codeParam = authorizationCode.substringAfter("code=", "") + if (codeParam.isEmpty()) { + return Result.failure(Exception("Invalid authorization URL: no code parameter found")) + } + // Remove any additional parameters after the code + val cleanCode = codeParam.substringBefore("&") + Timber.d("Extracted authorization code from URL: ${cleanCode.take(20)}...") + cleanCode + } else { + authorizationCode + } + + // Create auth config directory + val authFile = File(authConfigPath) + val authDir = authFile.parentFile + if (authDir != null && !authDir.exists()) { + authDir.mkdirs() + Timber.d("Created auth config directory: ${authDir.absolutePath}") + } + + // Execute GOGDL auth command with the authorization code + Timber.d("Authenticating with auth config path: $authConfigPath, code: ${actualCode.take(10)}...") + Timber.d("Full auth command: --auth-config-path $authConfigPath auth --code ${actualCode.take(20)}...") + + val result = executeCommand("--auth-config-path", authConfigPath, "auth", "--code", actualCode) + + Timber.d("GOGDL executeCommand result: isSuccess=${result.isSuccess}, exception=${result.exceptionOrNull()?.message}") + + if (result.isSuccess) { + val gogdlOutput = result.getOrNull() ?: "" + Timber.i("GOGDL command completed, checking authentication result...") + Timber.d("GOGDL output for auth: $gogdlOutput") + + // First, check if GOGDL output indicates success + try { + Timber.d("Attempting to parse GOGDL output as JSON (length: ${gogdlOutput.length})") + val outputJson = JSONObject(gogdlOutput.trim()) + Timber.d("Successfully parsed JSON, keys: ${outputJson.keys().asSequence().toList()}") + + // Check if the response indicates an error + if (outputJson.has("error") && outputJson.getBoolean("error")) { + val errorMsg = outputJson.optString("error_description", "Authentication failed") + val errorDetails = outputJson.optString("message", "No details available") + Timber.e("GOG authentication failed: $errorMsg - Details: $errorDetails") + Timber.e("Full error JSON: $outputJson") + return Result.failure(Exception("GOG authentication failed: $errorMsg")) + } + + // Check if we have the required fields for successful auth + val accessToken = outputJson.optString("access_token", "") + val userId = outputJson.optString("user_id", "") + + if (accessToken.isEmpty() || userId.isEmpty()) { + Timber.e("GOG authentication incomplete: missing access_token or user_id in output") + return Result.failure(Exception("Authentication incomplete: missing required data")) + } + + // GOGDL output looks good, now check if auth file was created + val authFile = File(authConfigPath) + if (authFile.exists()) { + // Parse authentication result from file + val authData = parseFullCredentials(authConfigPath) + Timber.i("GOG authentication successful for user: ${authData.username}") + Result.success(authData) + } else { + Timber.w("GOGDL returned success but no auth file created, using output data") + // Create credentials from GOGDL output + val credentials = createCredentialsFromJson(outputJson) + Result.success(credentials) + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse GOGDL output") + // Fallback: check if auth file exists + val authFile = File(authConfigPath) + if (authFile.exists()) { + try { + val authData = parseFullCredentials(authConfigPath) + Timber.i("GOG authentication successful (fallback) for user: ${authData.username}") + Result.success(authData) + } catch (ex: Exception) { + Timber.e(ex, "Failed to parse auth file") + Result.failure(Exception("Failed to parse authentication result: ${ex.message}")) + } + } else { + Timber.e("GOG authentication failed: no auth file created and failed to parse output") + Result.failure(Exception("Authentication failed: no credentials available")) + } + } + } else { + val error = result.exceptionOrNull() + val errorMsg = error?.message ?: "Unknown authentication error" + Timber.e(error, "GOG authentication command failed: $errorMsg") + Timber.e("Full error details: ${error?.stackTraceToString()}") + Result.failure(Exception("Authentication failed: $errorMsg", error)) + } + } catch (e: Exception) { + Timber.e(e, "GOG authentication exception: ${e.message}") + Timber.e("Exception stack trace: ${e.stackTraceToString()}") + Result.failure(Exception("Authentication exception: ${e.message}", e)) + } + } + + // Enhanced hasActiveOperations to track background sync + fun hasActiveOperations(): Boolean { + return syncInProgress || backgroundSyncJob?.isActive == true + } + + // Add methods to control sync state + private fun setSyncInProgress(inProgress: Boolean) { + syncInProgress = inProgress + } + + fun isSyncInProgress(): Boolean = syncInProgress + + fun getInstance(): GOGService? = instance + + /** + * Check if any download is currently active + */ + fun hasActiveDownload(): Boolean { + return getInstance()?.activeDownloads?.isNotEmpty() ?: false + } + + /** + * Get the currently downloading game ID (for error messages) + */ + fun getCurrentlyDownloadingGame(): String? { + return getInstance()?.activeDownloads?.keys?.firstOrNull() + } + + /** + * Get download info for a specific game + */ + fun getDownloadInfo(gameId: String): DownloadInfo? { + return getInstance()?.activeDownloads?.get(gameId) + } + + /** + * Clean up active download when game is deleted + */ + fun cleanupDownload(gameId: String) { + getInstance()?.activeDownloads?.remove(gameId) + } + + /** + * Check if user is authenticated by testing GOGDL command + */ + fun hasStoredCredentials(context: Context): Boolean { + val authFile = File(context.filesDir, "gog_auth.json") + return authFile.exists() + } + + /** + * Get user credentials by calling GOGDL auth command (without --code) + * This will automatically handle token refresh if needed + */ + suspend fun getStoredCredentials(context: Context): Result { + return try { + val authConfigPath = "${context.filesDir}/gog_auth.json" + + if (!hasStoredCredentials(context)) { + return Result.failure(Exception("No stored credentials found")) + } + + // Use GOGDL to get credentials - this will handle token refresh automatically + val result = executeCommand("--auth-config-path", authConfigPath, "auth") + + if (result.isSuccess) { + val output = result.getOrNull() ?: "" + Timber.d("GOGDL credentials output: $output") + + try { + val credentialsJson = JSONObject(output.trim()) + + // Check if there's an error + if (credentialsJson.has("error") && credentialsJson.getBoolean("error")) { + val errorMsg = credentialsJson.optString("message", "Authentication failed") + Timber.e("GOGDL credentials failed: $errorMsg") + return Result.failure(Exception("Authentication failed: $errorMsg")) + } + + // Extract credentials from GOGDL response + val accessToken = credentialsJson.optString("access_token", "") + val refreshToken = credentialsJson.optString("refresh_token", "") + val username = credentialsJson.optString("username", "GOG User") + val userId = credentialsJson.optString("user_id", "") + + val credentials = GOGCredentials( + accessToken = accessToken, + refreshToken = refreshToken, + username = username, + userId = userId, + ) + + Timber.d("Got credentials for user: $username") + Result.success(credentials) + } catch (e: Exception) { + Timber.e(e, "Failed to parse GOGDL credentials response") + Result.failure(e) + } + } else { + Timber.e("GOGDL credentials command failed") + Result.failure(Exception("Failed to get credentials from GOG")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to get stored credentials via GOGDL") + Result.failure(e) + } + } + + /** + * Validate credentials by calling GOGDL auth command (without --code) + * This will automatically refresh tokens if they're expired + */ + suspend fun validateCredentials(context: Context): Result { + return try { + val authConfigPath = "${context.filesDir}/gog_auth.json" + + if (!hasStoredCredentials(context)) { + Timber.d("No stored credentials found for validation") + return Result.success(false) + } + + Timber.d("Starting credentials validation with GOGDL") + + // Use GOGDL to get credentials - this will handle token refresh automatically + val result = executeCommand("--auth-config-path", authConfigPath, "auth") + + if (!result.isSuccess) { + val error = result.exceptionOrNull() + Timber.e("Credentials validation failed - command failed: ${error?.message}") + return Result.success(false) + } + + val output = result.getOrNull() ?: "" + Timber.d("GOGDL validation output: $output") + + try { + val credentialsJson = JSONObject(output.trim()) + + // Check if there's an error + if (credentialsJson.has("error") && credentialsJson.getBoolean("error")) { + val errorDesc = credentialsJson.optString("message", "Unknown error") + Timber.e("Credentials validation failed: $errorDesc") + return Result.success(false) + } + + Timber.d("Credentials validation successful") + return Result.success(true) + } catch (e: Exception) { + Timber.e(e, "Failed to parse validation response: $output") + return Result.success(false) + } + } catch (e: Exception) { + Timber.e(e, "Failed to validate credentials") + return Result.failure(e) + } + } + + fun clearStoredCredentials(context: Context): Boolean { + return try { + val authFile = File(context.filesDir, "gog_auth.json") + if (authFile.exists()) { + authFile.delete() + } else { + true + } + } catch (e: Exception) { + Timber.e(e, "Failed to clear GOG credentials") + false + } + } + + /** + * Download a GOG game with full progress tracking via GOGDL log parsing + */ + suspend fun downloadGame(gameId: String, installPath: String, authConfigPath: String): Result { + return try { + Timber.i("Starting GOGDL download with progress parsing for game $gameId") + + val installDir = File(installPath) + if (!installDir.exists()) { + installDir.mkdirs() + } + + // Create DownloadInfo for progress tracking + val downloadInfo = DownloadInfo(jobCount = 1) + + // Track this download in the active downloads map + getInstance()?.activeDownloads?.put(gameId, downloadInfo) + + // Start GOGDL download with progress parsing + val downloadJob = CoroutineScope(Dispatchers.IO).launch { + try { + // Create support directory for redistributables (like Heroic does) + val supportDir = File(installDir.parentFile, "gog-support") + supportDir.mkdirs() + + val result = executeCommandWithProgressParsing( + downloadInfo, + "--auth-config-path", authConfigPath, + "download", ContainerUtils.extractGameIdFromContainerId(gameId).toString(), + "--platform", "windows", + "--path", installPath, + "--support", supportDir.absolutePath, + "--skip-dlcs", + "--lang", "en-US", + "--max-workers", "1", + ) + + if (result.isSuccess) { + // Check if the download was actually cancelled + if (downloadInfo.getProgress() < 0.0f) { + Timber.i("GOGDL download was cancelled by user") + } else { + downloadInfo.setProgress(1.0f) // Mark as complete + Timber.i("GOGDL download completed successfully") + } + } else { + downloadInfo.setProgress(-1.0f) // Mark as failed + Timber.e("GOGDL download failed: ${result.exceptionOrNull()?.message}") + } + } catch (e: CancellationException) { + Timber.i("GOGDL download cancelled by user") + downloadInfo.setProgress(-1.0f) // Mark as cancelled + } catch (e: Exception) { + Timber.e(e, "GOGDL download failed") + downloadInfo.setProgress(-1.0f) // Mark as failed + } finally { + // Clean up the download from active downloads + getInstance()?.activeDownloads?.remove(gameId) + Timber.d("Cleaned up download for game: $gameId") + } + } + + // Store the job in DownloadInfo so it can be cancelled + downloadInfo.setDownloadJob(downloadJob) + + Result.success(downloadInfo) + } catch (e: Exception) { + Timber.e(e, "Failed to start GOG game download") + Result.failure(e) + } + } + + /** + * Execute GOGDL command with progress parsing from logcat output + */ + private suspend fun executeCommandWithProgressParsing(downloadInfo: DownloadInfo, vararg args: String): Result { + return withContext(Dispatchers.IO) { + var logMonitorJob: Job? = null + try { + // Start log monitoring for GOGDL progress + logMonitorJob = CoroutineScope(Dispatchers.IO).launch { + monitorGOGDLProgress(downloadInfo) + } + + val python = Python.getInstance() + val sys = python.getModule("sys") + val originalArgv = sys.get("argv") + + try { + val gogdlCli = python.getModule("gogdl.cli") + + // Set up arguments for argparse + val argsList = listOf("gogdl") + args.toList() + Timber.d("Setting GOGDL arguments for argparse: ${args.joinToString(" ")}") + val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) + sys.put("argv", pythonList) + + // Check for cancellation before starting + ensureActive() + + // Execute the main function + gogdlCli.callAttr("main") + Timber.d("GOGDL execution completed successfully") + Result.success("Download completed") + } catch (e: Exception) { + Timber.e(e, "GOGDL execution failed: ${e.message}") + Result.failure(e) + } finally { + sys.put("argv", originalArgv) + } + } catch (e: CancellationException) { + Timber.i("GOGDL command cancelled") + throw e // Re-throw to propagate cancellation + } catch (e: Exception) { + Timber.e(e, "Failed to execute GOGDL command: ${args.joinToString(" ")}") + Result.failure(e) + } finally { + logMonitorJob?.cancel() + } + } + } + + /** + * Monitor GOGDL progress by parsing logcat output (python.stderr) + * Parses progress like Heroic Games Launcher does + */ + private suspend fun monitorGOGDLProgress(downloadInfo: DownloadInfo) { + var process: Process? = null + try { + // Clear any existing logcat buffer to ensure fresh start + try { + val clearProcess = ProcessBuilder("logcat", "-c").start() + clearProcess.waitFor() + Timber.d("Cleared logcat buffer for fresh progress monitoring") + } catch (e: Exception) { + Timber.w(e, "Failed to clear logcat buffer, continuing anyway") + } + + // Add delay to ensure Python process has started and old logs are cleared + delay(1000) + + // Use logcat to read python.stderr logs in real-time with timestamp filtering + process = ProcessBuilder("logcat", "-s", "python.stderr:W", "-T", "1") + .redirectErrorStream(true) + .start() + + val reader = process.inputStream.bufferedReader() + Timber.d("Progress monitoring logcat process started successfully") + + // Track progress state exactly like Heroic does + var currentPercent: Float? = null + var currentEta: String = "" + var currentBytes: String = "" + var currentDownSpeed: Float? = null + var currentDiskSpeed: Float? = null + + while (downloadInfo.getProgress() < 1.0f && downloadInfo.getProgress() >= 0.0f) { + val line = reader.readLine() + if (line != null) { + // Parse like Heroic: only update if field is empty/undefined + + // Parse log for percent (only if not already set) + if (currentPercent == null) { + val percentMatch = Regex("""Progress: (\d+\.\d+) """).find(line) + if (percentMatch != null) { + val percent = percentMatch.groupValues[1].toFloatOrNull() + if (percent != null && !percent.isNaN()) { + currentPercent = percent + } + } + } + + // Parse log for eta (only if empty) + if (currentEta.isEmpty()) { + val etaMatch = Regex("""ETA: (\d\d:\d\d:\d\d)""").find(line) + if (etaMatch != null) { + currentEta = etaMatch.groupValues[1] + } + } + + // Parse log for game download progress (only if empty) + if (currentBytes.isEmpty()) { + val bytesMatch = Regex("""Downloaded: (\S+) MiB""").find(line) + if (bytesMatch != null) { + currentBytes = "${bytesMatch.groupValues[1]}MB" + } + } + + // Parse log for download speed (only if not set) + if (currentDownSpeed == null) { + val downSpeedMatch = Regex("""Download\t- (\S+) MiB""").find(line) + if (downSpeedMatch != null) { + val speed = downSpeedMatch.groupValues[1].toFloatOrNull() + if (speed != null && !speed.isNaN()) { + currentDownSpeed = speed + } + } + } + + // Parse disk write speed (only if not set) + if (currentDiskSpeed == null) { + val diskSpeedMatch = Regex("""Disk\t- (\S+) MiB""").find(line) + if (diskSpeedMatch != null) { + val speed = diskSpeedMatch.groupValues[1].toFloatOrNull() + if (speed != null && !speed.isNaN()) { + currentDiskSpeed = speed + } + } + } + + // Only send update if all values are present (exactly like Heroic) + if (currentPercent != null && currentEta.isNotEmpty() && + currentBytes.isNotEmpty() && currentDownSpeed != null && currentDiskSpeed != null) { + + // Update progress with the percentage + val progress = (currentPercent!! / 100.0f).coerceIn(0.0f, 1.0f) + downloadInfo.setProgress(progress) + + // Log exactly like Heroic does + Timber.i("Progress for game: ${currentPercent}%/${currentBytes}/${currentEta} Down: ${currentDownSpeed}MB/s / Disk: ${currentDiskSpeed}MB/s") + + // Reset (exactly like Heroic does) + currentPercent = null + currentEta = "" + currentBytes = "" + currentDownSpeed = null + currentDiskSpeed = null + } + } else { + delay(100L) // Brief delay if no new log lines + } + } + + Timber.d("Progress monitoring loop ended - progress: ${downloadInfo.getProgress()}") + process?.destroyForcibly() + Timber.d("Logcat process destroyed forcibly") + } catch (e: CancellationException) { + Timber.d("GOGDL progress monitoring cancelled") + process?.destroyForcibly() + throw e + } catch (e: Exception) { + Timber.w(e, "Error monitoring GOGDL progress, falling back to estimation") + // Simple fallback - estimate progress over time + var lastProgress = 0.0f + val startTime = System.currentTimeMillis() + + while (downloadInfo.getProgress() < 1.0f && downloadInfo.getProgress() >= 0.0f) { + delay(2000L) + val elapsed = System.currentTimeMillis() - startTime + val estimatedProgress = when { + elapsed < 5000 -> 0.05f + elapsed < 15000 -> 0.20f + elapsed < 30000 -> 0.50f + elapsed < 60000 -> 0.80f + else -> 0.90f + }.coerceAtLeast(lastProgress) + + if (estimatedProgress > lastProgress) { + downloadInfo.setProgress(estimatedProgress) + lastProgress = estimatedProgress + } + } + } finally { + process?.destroyForcibly() + } + } + + /** + * Sync GOG cloud saves for a game (stub) + * TODO: Implement cloud save sync + */ + suspend fun syncCloudSaves(gameId: String, savePath: String, authConfigPath: String, timestamp: Float = 0.0f): Result { + return try { + Timber.i("Starting GOG cloud save sync for game $gameId") + + val result = executeCommand( + "--auth-config-path", authConfigPath, + "save-sync", savePath, + "--dirname", gameId, + "--timestamp", timestamp.toString(), + ) + + if (result.isSuccess) { + Timber.i("GOG cloud save sync completed successfully for game $gameId") + Result.success(Unit) + } else { + val error = result.exceptionOrNull() ?: Exception("Save sync failed") + Timber.e(error, "GOG cloud save sync failed for game $gameId") + Result.failure(error) + } + } catch (e: Exception) { + Timber.e(e, "GOG cloud save sync exception for game $gameId") + Result.failure(e) + } + } + + /** + * Get download and install size information using gogdl info command (stub) + * TODO: Implement size info fetching + */ + suspend fun getGameSizeInfo(gameId: String): GameSizeInfo? = withContext(Dispatchers.IO) { + try { + val authConfigPath = "/data/data/app.gamenative/files/gog_config.json" + + Timber.d("Getting size info for GOG game: $gameId") + + // TODO: Use executeCommand to get game size info + // For now, return null + Timber.w("GOG size info not fully implemented yet") + null + } catch (e: Exception) { + Timber.w(e, "Failed to get size info for game $gameId") + null + } + } + + /** + * Cancel an active download for a specific game + */ + fun cancelDownload(gameId: String): Boolean { + val instance = getInstance() + val downloadInfo = instance?.activeDownloads?.get(gameId) + + return if (downloadInfo != null) { + Timber.i("Cancelling download for game: $gameId") + downloadInfo.cancel() + Timber.d("Cancelled download job for game: $gameId") + + // Clean up immediately + instance.activeDownloads.remove(gameId) + Timber.d("Removed game from active downloads: $gameId") + true + } else { + Timber.w("No active download found for game: $gameId") + false + } + } + } + + // Add these for foreground service support + private lateinit var notificationHelper: NotificationHelper + + @Inject + lateinit var gogLibraryManager: GOGLibraryManager + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Track active downloads by game ID + private val activeDownloads = ConcurrentHashMap() + + override fun onCreate() { + super.onCreate() + instance = this + + // Initialize notification helper for foreground service + notificationHelper = NotificationHelper(applicationContext) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Start as foreground service + val notification = notificationHelper.createForegroundNotification("GOG Service running...") + startForeground(2, notification) // Use different ID than SteamService (which uses 1) + + // Start background library sync automatically when service starts with tracking + backgroundSyncJob = scope.launch { + try { + setSyncInProgress(true) + Timber.d("[GOGService]: Starting background library sync") + + val syncResult = gogLibraryManager.startBackgroundSync(applicationContext) + if (syncResult.isFailure) { + Timber.w("[GOGService]: Failed to start background sync: ${syncResult.exceptionOrNull()?.message}") + } else { + Timber.i("[GOGService]: Background library sync started successfully") + } + } catch (e: Exception) { + Timber.e(e, "[GOGService]: Exception starting background sync") + } finally { + setSyncInProgress(false) + } + } + + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + + // Cancel sync operations + backgroundSyncJob?.cancel() + setSyncInProgress(false) + + scope.cancel() // Cancel any ongoing operations + stopForeground(STOP_FOREGROUND_REMOVE) + notificationHelper.cancel() + instance = null + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt new file mode 100644 index 000000000..304bd12c6 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -0,0 +1,182 @@ +package app.gamenative.ui.component.dialog + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +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.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.gamenative.service.gog.GOGConstants +import app.gamenative.ui.theme.PluviaTheme +import android.content.Intent +import android.net.Uri + +/** + * 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 + */ +@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("") } + + if (visible) { + AlertDialog( + onDismissRequest = onDismissRequest, + icon = { Icon(imageVector = Icons.Default.Login, contentDescription = null) }, + title = { Text("Sign in to GOG") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Instructions + Text( + text = "Sign in with your GOG account:", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "Tap 'Open GOG Login' and sign in. The app will automatically receive your authorization.", + 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) { + // Browser not available + } + }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Open GOG Login") + } + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + // Manual code entry fallback + Text( + text = "Or manually paste authorization code:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Authorization code input + OutlinedTextField( + value = authCode, + onValueChange = { authCode = it.trim() }, + label = { Text("Authorization Code (optional)") }, + placeholder = { Text("Paste code here if needed...") }, + 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()) { + onAuthCodeClick(authCode) + } + }, + enabled = !isLoading && authCode.isNotBlank() + ) { + Text("Login") + } + }, + dismissButton = { + TextButton( + onClick = onDismissRequest, + enabled = !isLoading + ) { + Text("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/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index 9c66b6c70..d252b8200 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -139,6 +139,10 @@ class LibraryViewModel @Inject constructor( PrefManager.showCustomGamesInLibrary = newValue _state.update { it.copy(showCustomGamesInLibrary = newValue) } } + GameSource.GOG -> { + // TODO: Add GOG library toggle preference + // For now, do nothing - GOG games are always shown + } } onFilterApps(paginationCurrentPage) } 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 index d14fbf3ed..36f62cb2a 100644 --- 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 @@ -9,6 +9,7 @@ import app.gamenative.data.LibraryItem import app.gamenative.ui.data.AppMenuOption import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.ui.enums.AppOptionMenuType +import com.winlator.container.ContainerData import com.winlator.container.ContainerManager import timber.log.Timber @@ -26,14 +27,13 @@ class GOGAppScreen : BaseAppScreen() { // TODO: Fetch GOG game details from database // For now, use basic info from libraryItem return GameDisplayInfo( - heroImageUrl = libraryItem.iconHash, // GOG stores image URLs in iconHash - capsuleImageUrl = libraryItem.iconHash, - logoImageUrl = null, - iconImageUrl = libraryItem.iconHash, - description = "GOG Game", // TODO: Fetch from GOGGame entity - releaseDate = null, // TODO: Fetch from GOGGame entity - developer = null, // TODO: Fetch from GOGGame entity - publisher = null // TODO: Fetch from GOGGame entity + name = libraryItem.name, + iconUrl = libraryItem.iconHash ?: "", + heroImageUrl = libraryItem.iconHash ?: "", + gameId = libraryItem.appId.toIntOrNull() ?: 0, + appId = libraryItem.appId, + releaseDate = 0L, + developer = "Unknown" ) } @@ -44,51 +44,78 @@ class GOGAppScreen : BaseAppScreen() { return containerManager.hasContainer(libraryItem.appId) } - override fun getInstallPath(context: Context, libraryItem: LibraryItem): String? { - // TODO: Get install path from GOGGame entity in database - // For now, return null as GOG games aren't installed yet - return null + override fun isValidToDownload(context: Context, libraryItem: LibraryItem): Boolean { + // GOG games can be downloaded if not already installed or downloading + return !isInstalled(context, libraryItem) && !isDownloading(context, libraryItem) } - override fun canUninstall(context: Context, libraryItem: LibraryItem): Boolean { - // GOG games can be uninstalled - return isInstalled(context, libraryItem) + override fun isDownloading(context: Context, libraryItem: LibraryItem): Boolean { + // TODO: Check GOGGame download status from database or marker files + // For now, return false + return false } - override fun onUninstall(context: Context, libraryItem: LibraryItem) { - // TODO: Implement GOG game uninstallation - // This should: - // 1. Remove game files from install directory - // 2. Update GOGGame.isInstalled in database - // 3. Remove container - Timber.d("Uninstall requested for GOG game: ${libraryItem.appId}") - } - - override fun canDownload(context: Context, libraryItem: LibraryItem): Boolean { - // GOG games can be downloaded if not installed - return !isInstalled(context, libraryItem) + override fun getDownloadProgress(context: Context, libraryItem: LibraryItem): Float { + // TODO: Get actual download progress from GOGGame or download manager + // Return 0.0 for now + return 0f } - override fun onDownload(context: Context, libraryItem: LibraryItem) { + override fun onDownloadInstallClick(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { // TODO: Implement GOG game download via Python gogdl // This should: // 1. Check GOG authentication - // 2. Start download via Python gogdl CLI + // 2. Start download via GOGService // 3. Update download progress in UI - // 4. Update GOGGame.isInstalled when complete - Timber.d("Download requested for GOG game: ${libraryItem.appId}") + // 4. When complete, call onClickPlay(true) to launch + Timber.d("Download/Install clicked for GOG game: ${libraryItem.appId}") } - /** - * GOG games can use the standard Play button - */ - @Composable - override fun getPlayButtonOverride( - context: Context, - libraryItem: LibraryItem, - onClickPlay: (Boolean) -> Unit - ): AppMenuOption? { - return null // Use default Play button + override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) { + // TODO: Implement pause/resume for GOG downloads + Timber.d("Pause/Resume clicked for GOG game: ${libraryItem.appId}") + } + + override fun onDeleteDownloadClick(context: Context, libraryItem: LibraryItem) { + // TODO: Implement delete download for GOG games + // This should: + // 1. Cancel ongoing download if any + // 2. Remove partial download files + // 3. Update database + Timber.d("Delete download clicked for GOG game: ${libraryItem.appId}") + } + + override fun onUpdateClick(context: Context, libraryItem: LibraryItem) { + // TODO: Implement update for GOG games + // Check GOG for newer version and download if available + Timber.d("Update clicked for GOG game: ${libraryItem.appId}") + } + + override fun getExportFileExtension(): String { + // GOG containers use the same export format as other Wine containers + return "tzst" + } + + override fun getInstallPath(context: Context, libraryItem: LibraryItem): String? { + // TODO: Get install path from GOGGame entity in database + // For now, return null as GOG games aren't installed yet + return null + } + + override fun loadContainerData(context: Context, libraryItem: LibraryItem): ContainerData { + // Load GOG-specific container data using ContainerUtils + val container = app.gamenative.utils.ContainerUtils.getOrCreateContainer(context, libraryItem.appId) + return app.gamenative.utils.ContainerUtils.toContainerData(container) + } + + override fun saveContainerConfig(context: Context, libraryItem: LibraryItem, config: ContainerData) { + // Save GOG-specific container configuration using ContainerUtils + app.gamenative.utils.ContainerUtils.applyToContainer(context, libraryItem.appId, config) + } + + override fun supportsContainerConfig(): Boolean { + // GOG games support container configuration like other Wine games + return true } /** @@ -100,6 +127,8 @@ class GOGAppScreen : BaseAppScreen() { libraryItem: LibraryItem, onEditContainer: () -> Unit, onBack: () -> Unit, + onClickPlay: (Boolean) -> Unit, + isInstalled: Boolean ): List { val options = mutableListOf() diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt index 0bfe64687..cf655a78c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Login import androidx.compose.material.icons.filled.Map import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -57,10 +58,14 @@ 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.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 @Composable fun SettingsGroupInterface( @@ -114,6 +119,37 @@ fun SettingsGroupInterface( steamRegionsList.indexOfFirst { it.first == PrefManager.cellId }.takeIf { it >= 0 } ?: 0 ) } + // GOG login dialog state + var openGOGLoginDialog by rememberSaveable { mutableStateOf(false) } + var gogLoginLoading by rememberSaveable { mutableStateOf(false) } + var gogLoginError by rememberSaveable { mutableStateOf(null) } + var gogLoginSuccess by rememberSaveable { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + // Listen for GOG OAuth callback + LaunchedEffect(Unit) { + app.gamenative.PluviaApp.events.on { event -> + timber.log.Timber.d("Received GOG auth code from deep link: ${event.authCode.take(20)}...") + gogLoginLoading = true + gogLoginError = null + + try { + val authConfigPath = "${context.filesDir}/gog_auth.json" + val result = app.gamenative.service.gog.GOGService.authenticateWithCode(authConfigPath, event.authCode) + gogLoginLoading = false + if (result.isSuccess) { + gogLoginSuccess = true + openGOGLoginDialog = false + } else { + gogLoginError = result.exceptionOrNull()?.message ?: "Authentication failed" + } + } catch (e: Exception) { + gogLoginLoading = false + gogLoginError = e.message ?: "Authentication failed" + } + } + } + SettingsGroup(title = { Text(text = stringResource(R.string.settings_interface_title)) }) { SettingsSwitch( colors = settingsTileColorsAlt(), @@ -182,6 +218,20 @@ fun SettingsGroupInterface( } } + // GOG Integration + SettingsGroup(title = { Text(text = "GOG Integration") }) { + SettingsMenuLink( + colors = settingsTileColorsAlt(), + title = { Text(text = "GOG Login") }, + subtitle = { Text(text = "Sign in to your GOG account") }, + onClick = { + openGOGLoginDialog = true + gogLoginError = null + gogLoginSuccess = false + } + ) + } + // Downloads settings SettingsGroup(title = { Text(text = stringResource(R.string.settings_downloads_title)) }) { var wifiOnlyDownload by rememberSaveable { mutableStateOf(PrefManager.downloadOnWifiOnly) } @@ -452,6 +502,51 @@ 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 -> + gogLoginLoading = true + gogLoginError = null + coroutineScope.launch { + try { + val authConfigPath = "${context.filesDir}/gog_auth.json" + val result = GOGService.authenticateWithCode(authConfigPath, authCode) + gogLoginLoading = false + if (result.isSuccess) { + gogLoginSuccess = true + openGOGLoginDialog = false + } else { + gogLoginError = result.exceptionOrNull()?.message ?: "Authentication failed" + } + } catch (e: Exception) { + gogLoginLoading = false + gogLoginError = e.message ?: "Authentication failed" + } + } + }, + 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 = "Login Successful", + message = "You are now signed in to GOG." + ) + } } diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 7c0dfffed..903f12cfa 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -518,7 +518,7 @@ object ContainerUtils { } GameSource.GOG -> { // Just use DefaultDrives. We can create a specific one later. - Timber.d("Sending to Default Drive: $defaultDrives$drive") + Timber.d("Sending to Default Drives for GOG: $defaultDrives") defaultDrives } } From 5069b7d941b76605b67007ed8b0f02e7fee42387 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 4 Dec 2025 21:34:26 +0000 Subject: [PATCH 005/122] coroutine async fix --- .../screen/settings/SettingsGroupInterface.kt | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) 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 cf655a78c..9990dc872 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 @@ -125,27 +125,29 @@ fun SettingsGroupInterface( var gogLoginError by rememberSaveable { mutableStateOf(null) } var gogLoginSuccess by rememberSaveable { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() - + // Listen for GOG OAuth callback LaunchedEffect(Unit) { app.gamenative.PluviaApp.events.on { event -> timber.log.Timber.d("Received GOG auth code from deep link: ${event.authCode.take(20)}...") gogLoginLoading = true gogLoginError = null - - try { - val authConfigPath = "${context.filesDir}/gog_auth.json" - val result = app.gamenative.service.gog.GOGService.authenticateWithCode(authConfigPath, event.authCode) - gogLoginLoading = false - if (result.isSuccess) { - gogLoginSuccess = true - openGOGLoginDialog = false - } else { - gogLoginError = result.exceptionOrNull()?.message ?: "Authentication failed" + + coroutineScope.launch { + try { + val authConfigPath = "${context.filesDir}/gog_auth.json" + val result = app.gamenative.service.gog.GOGService.authenticateWithCode(authConfigPath, event.authCode) + gogLoginLoading = false + if (result.isSuccess) { + gogLoginSuccess = true + openGOGLoginDialog = false + } else { + gogLoginError = result.exceptionOrNull()?.message ?: "Authentication failed" + } + } catch (e: Exception) { + gogLoginLoading = false + gogLoginError = e.message ?: "Authentication failed" } - } catch (e: Exception) { - gogLoginLoading = false - gogLoginError = e.message ?: "Authentication failed" } } } From 26a4c05c37ad3bf7971614bd3f2c8c5a0e52afc6 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 4 Dec 2025 23:14:04 +0000 Subject: [PATCH 006/122] WIP gog integration. --- app/src/main/AndroidManifest.xml | 30 +++- .../main/java/app/gamenative/MainActivity.kt | 65 +++++-- .../main/java/app/gamenative/PrefManager.kt | 14 ++ .../gamenative/service/gog/GOGConstants.kt | 8 +- .../app/gamenative/service/gog/GOGService.kt | 105 ++++++++++- .../app/gamenative/ui/data/LibraryState.kt | 3 +- .../gamenative/ui/model/LibraryViewModel.kt | 62 ++++++- .../library/components/LibraryBottomSheet.kt | 8 + .../library/components/LibraryListPane.kt | 19 +- .../screen/settings/SettingsGroupInterface.kt | 168 +++++++++++++++++- app/src/main/python/gogdl/api.py | 35 +++- app/src/main/python/gogdl/args.py | 24 +-- app/src/main/python/gogdl/cli.py | 165 ++++++++++++++--- 13 files changed, 624 insertions(+), 82 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 51693fbd2..5f0ac06e6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -57,19 +57,20 @@ + android:scheme="https" + android:host="embed.gog.com" + android:pathPrefix="/on_login_success" /> - + - + + + + + + + + + + + + + + - + > { + return try { + Timber.i("Fetching GOG library via GOGDL...") + val authConfigPath = "${context.filesDir}/gog_auth.json" + + if (!hasStoredCredentials(context)) { + Timber.e("Cannot list games: not authenticated") + return Result.failure(Exception("Not authenticated. Please log in first.")) + } + + // Execute gogdl list command - auth-config-path must come BEFORE the command + val result = executeCommand("--auth-config-path", authConfigPath, "list", "--pretty") + + if (result.isFailure) { + val error = result.exceptionOrNull() + Timber.e(error, "Failed to fetch GOG library: ${error?.message}") + return Result.failure(error ?: Exception("Failed to fetch GOG library")) + } + + val output = result.getOrNull() ?: "" + Timber.d("GOGDL list output length: ${output.length}") + Timber.d("GOGDL list output preview: ${output.take(500)}") + + // Parse the JSON output + try { + // GOGDL list returns a JSON array of games + val gamesArray = org.json.JSONArray(output.trim()) + val games = mutableListOf() + + Timber.d("Found ${gamesArray.length()} games in GOG library") + + for (i in 0 until gamesArray.length()) { + try { + val gameObj = gamesArray.getJSONObject(i) + + // Parse genres array if present + val genresList = mutableListOf() + if (gameObj.has("genres")) { + val genresArray = gameObj.optJSONArray("genres") + if (genresArray != null) { + for (j in 0 until genresArray.length()) { + genresList.add(genresArray.getString(j)) + } + } + } + + // Parse languages array if present + val languagesList = mutableListOf() + if (gameObj.has("languages")) { + val languagesArray = gameObj.optJSONArray("languages") + if (languagesArray != null) { + for (j in 0 until languagesArray.length()) { + languagesList.add(languagesArray.getString(j)) + } + } + } + + val game = GOGGame( + id = gameObj.optString("id", ""), + title = gameObj.optString("title", "Unknown Game"), + slug = gameObj.optString("slug", ""), + imageUrl = gameObj.optString("image", ""), + iconUrl = gameObj.optString("icon", ""), + description = gameObj.optString("description", ""), + releaseDate = gameObj.optString("releaseDate", ""), + developer = gameObj.optString("developer", ""), + publisher = gameObj.optString("publisher", ""), + genres = genresList, + languages = languagesList, + downloadSize = 0L, // Will be fetched separately when needed + installSize = 0L, + isInstalled = false, + installPath = "", + lastPlayed = 0L, + playTime = 0L, + ) + + games.add(game) + } catch (e: Exception) { + Timber.w(e, "Failed to parse game at index $i, skipping") + } + } + + Timber.i("Successfully parsed ${games.size} games from GOG library") + Result.success(games) + } catch (e: Exception) { + Timber.e(e, "Failed to parse GOG library JSON: $output") + Result.failure(Exception("Failed to parse GOG library: ${e.message}", e)) + } + } catch (e: Exception) { + Timber.e(e, "Unexpected error while fetching GOG library") + Result.failure(e) + } + } + /** * Download a GOG game with full progress tracking via GOGDL log parsing */ diff --git a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt index c6cac8364..c58322776 100644 --- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt +++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt @@ -21,9 +21,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/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index d252b8200..dad94cb6b 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -10,8 +10,10 @@ import app.gamenative.PrefManager import app.gamenative.PluviaApp import app.gamenative.data.LibraryItem import app.gamenative.data.SteamApp +import app.gamenative.data.GOGGame import app.gamenative.data.GameSource import app.gamenative.db.dao.SteamAppDao +import app.gamenative.db.dao.GOGGameDao import app.gamenative.service.DownloadService import app.gamenative.service.SteamService import app.gamenative.ui.data.LibraryState @@ -43,6 +45,7 @@ import kotlin.math.min @HiltViewModel class LibraryViewModel @Inject constructor( private val steamAppDao: SteamAppDao, + private val gogGameDao: GOGGameDao, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -68,6 +71,7 @@ class LibraryViewModel @Inject constructor( // Complete and unfiltered app list private var appList: List = emptyList() + private var gogGameList: List = emptyList() // Track if this is the first load to apply minimum load time private var isFirstLoad = true @@ -105,6 +109,18 @@ class LibraryViewModel @Inject constructor( } } + // Collect GOG games + viewModelScope.launch(Dispatchers.IO) { + gogGameDao.getAll().collect { games -> + Timber.tag("LibraryViewModel").d("Collecting ${games.size} GOG games") + + if (gogGameList.size != games.size) { + gogGameList = games + onFilterApps(paginationCurrentPage) + } + } + } + PluviaApp.events.on(onInstallStatusChanged) PluviaApp.events.on(onCustomGameImagesFetched) } @@ -140,8 +156,9 @@ class LibraryViewModel @Inject constructor( _state.update { it.copy(showCustomGamesInLibrary = newValue) } } GameSource.GOG -> { - // TODO: Add GOG library toggle preference - // For now, do nothing - GOG games are always shown + val newValue = !current.showGOGInLibrary + PrefManager.showGOGInLibrary = newValue + _state.update { it.copy(showGOGInLibrary = newValue) } } } onFilterApps(paginationCurrentPage) @@ -304,22 +321,59 @@ 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 + } + } + .sortedBy { it.title.lowercase() } + .toList() + + val gogEntries = filteredGOGGames.map { game -> + LibraryEntry( + item = LibraryItem( + index = 0, + appId = "${GameSource.GOG.name}_${game.id}", + name = game.title, + iconHash = game.iconUrl, + isShared = false, + gameSource = GameSource.GOG, + ), + isInstalled = game.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 + Timber.tag("LibraryViewModel").d("Saved counts - Custom: ${customGameItems.size}, Steam: ${filteredSteamApps.size}, GOG: ${filteredGOGGames.size}") } // Apply App Source filters val includeSteam = _state.value.showSteamInLibrary val includeOpen = _state.value.showCustomGamesInLibrary + val includeGOG = _state.value.showGOGInLibrary - // Combine both lists + // Combine all lists val combined = buildList { if (includeSteam) addAll(steamEntries) if (includeOpen) addAll(customEntries) + if (includeGOG) addAll(gogEntries) }.sortedWith( // Always sort by installed status first (installed games at top), then alphabetically within each group compareBy { entry -> diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt index 0003ccb89..b957e70dd 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt @@ -41,6 +41,7 @@ fun LibraryBottomSheet( onViewChanged: (PaneType) -> Unit, showSteam: Boolean, showCustomGames: Boolean, + showGOG: Boolean, onSourceToggle: (app.gamenative.data.GameSource) -> Unit, ) { Column( @@ -102,6 +103,12 @@ fun LibraryBottomSheet( selected = showCustomGames, leadingIcon = { Icon(imageVector = Icons.Filled.CustomGame, contentDescription = null) }, ) + FlowFilterChip( + onClick = { onSourceToggle(GameSource.GOG) }, + label = { Text(text = "GOG") }, + selected = showGOG, + leadingIcon = { Icon(imageVector = Icons.Filled.CustomGame, contentDescription = null) }, // TODO: Add GOG icon + ) } Spacer(modifier = Modifier.height(16.dp)) @@ -152,6 +159,7 @@ private fun Preview_LibraryBottomSheet() { onViewChanged = { }, showSteam = true, showCustomGames = true, + showGOG = true, onSourceToggle = { }, ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt index d9531e1bd..9f9280eb1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt @@ -103,7 +103,15 @@ private fun calculateInstalledCount(state: LibraryState): Int { 0 } - return steamCount + customGameCount + // Count GOG games that are installed + val gogCount = if (state.showGOGInLibrary) { + // For now, count all GOG games since we don't track their install status separately yet + PrefManager.gogGamesCount + } else { + 0 + } + + return steamCount + customGameCount + gogCount } @OptIn(ExperimentalMaterial3Api::class) @@ -133,6 +141,7 @@ internal fun LibraryListPane( state.appInfoSortType, state.showSteamInLibrary, state.showCustomGamesInLibrary, + state.showGOGInLibrary, state.totalAppsInFilter ) { calculateInstalledCount(state) @@ -318,11 +327,12 @@ internal fun LibraryListPane( } } - val totalSkeletonCount = remember(state.showSteamInLibrary, state.showCustomGamesInLibrary) { + val totalSkeletonCount = remember(state.showSteamInLibrary, state.showCustomGamesInLibrary, state.showGOGInLibrary) { val customCount = if (state.showCustomGamesInLibrary) PrefManager.customGamesCount else 0 val steamCount = if (state.showSteamInLibrary) PrefManager.steamGamesCount else 0 - val total = customCount + steamCount - Timber.tag("LibraryListPane").d("Skeleton calculation - Custom: $customCount, Steam: $steamCount, Total: $total") + val gogCount = if (state.showGOGInLibrary) PrefManager.gogGamesCount else 0 + val total = customCount + steamCount + gogCount + Timber.tag("LibraryListPane").d("Skeleton calculation - Custom: $customCount, Steam: $steamCount, GOG: $gogCount, Total: $total") // Show at least a few skeletons, but not more than a reasonable amount if (total == 0) 6 else minOf(total, 20) } @@ -437,6 +447,7 @@ internal fun LibraryListPane( }, showSteam = state.showSteamInLibrary, showCustomGames = state.showCustomGamesInLibrary, + showGOG = state.showGOGInLibrary, onSourceToggle = onSourceToggle, ) }, diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt index 9990dc872..fdb5a74d4 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 @@ -66,6 +66,8 @@ import kotlinx.coroutines.launch import app.gamenative.utils.LocaleHelper import app.gamenative.ui.component.dialog.GOGLoginDialog import app.gamenative.service.gog.GOGService +import dagger.hilt.android.EntryPointAccessors +import app.gamenative.di.DatabaseModule @Composable fun SettingsGroupInterface( @@ -75,6 +77,16 @@ fun SettingsGroupInterface( onPaletteStyle: (PaletteStyle) -> Unit, ) { val context = LocalContext.current + + // Get GOGGameDao from Hilt + val gogGameDao = remember { + val appContext = context.applicationContext + val entryPoint = EntryPointAccessors.fromApplication( + appContext, + DatabaseEntryPoint::class.java + ) + entryPoint.gogGameDao() + } var openWebLinks by rememberSaveable { mutableStateOf(PrefManager.openWebLinksExternally) } @@ -124,32 +136,80 @@ fun SettingsGroupInterface( 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 LaunchedEffect(Unit) { + timber.log.Timber.d("[SettingsGOG]: Setting up GOG auth code event listener") app.gamenative.PluviaApp.events.on { event -> - timber.log.Timber.d("Received GOG auth code from deep link: ${event.authCode.take(20)}...") + timber.log.Timber.i("[SettingsGOG]: ✓ Received GOG auth code event! Code: ${event.authCode.take(20)}...") gogLoginLoading = true gogLoginError = null coroutineScope.launch { try { + timber.log.Timber.d("[SettingsGOG]: Starting authentication...") val authConfigPath = "${context.filesDir}/gog_auth.json" val result = app.gamenative.service.gog.GOGService.authenticateWithCode(authConfigPath, event.authCode) - gogLoginLoading = false + if (result.isSuccess) { + timber.log.Timber.i("[SettingsGOG]: ✓ Authentication successful!") + + // Fetch the user's GOG library + timber.log.Timber.i("[SettingsGOG]: Fetching GOG library...") + val libraryResult = app.gamenative.service.gog.GOGService.listGames(context) + + if (libraryResult.isSuccess) { + val games = libraryResult.getOrNull() ?: emptyList() + timber.log.Timber.i("[SettingsGOG]: ✓ Fetched ${games.size} games from GOG library") + + // Save games to database + try { + withContext(Dispatchers.IO) { + gogGameDao.upsertPreservingInstallStatus(games) + } + timber.log.Timber.i("[SettingsGOG]: ✓ Saved ${games.size} games to database") + } catch (e: Exception) { + timber.log.Timber.e(e, "[SettingsGOG]: Failed to save games to database") + } + + // Log first few games + games.take(5).forEach { game -> + timber.log.Timber.d("[SettingsGOG]: - ${game.title} (${game.id})") + } + if (games.size > 5) { + timber.log.Timber.d("[SettingsGOG]: ... and ${games.size - 5} more") + } + } else { + val error = libraryResult.exceptionOrNull()?.message ?: "Failed to fetch library" + timber.log.Timber.w("[SettingsGOG]: Failed to fetch library: $error") + // Don't fail authentication if library fetch fails + } + + gogLoginLoading = false gogLoginSuccess = true openGOGLoginDialog = false } else { - gogLoginError = result.exceptionOrNull()?.message ?: "Authentication failed" + val error = result.exceptionOrNull()?.message ?: "Authentication failed" + timber.log.Timber.e("[SettingsGOG]: Authentication failed: $error") + gogLoginLoading = false + gogLoginError = error } } catch (e: Exception) { + timber.log.Timber.e(e, "[SettingsGOG]: Authentication exception: ${e.message}") gogLoginLoading = false gogLoginError = e.message ?: "Authentication failed" } } } + timber.log.Timber.d("[SettingsGOG]: GOG auth code event listener registered") } SettingsGroup(title = { Text(text = stringResource(R.string.settings_interface_title)) }) { @@ -232,6 +292,70 @@ fun SettingsGroupInterface( gogLoginSuccess = false } ) + + SettingsMenuLink( + colors = settingsTileColorsAlt(), + title = { Text(text = "Sync GOG Library") }, + subtitle = { + Text( + text = when { + gogLibrarySyncing -> "Syncing..." + gogLibrarySyncError != null -> "Error: ${gogLibrarySyncError}" + gogLibrarySyncSuccess -> "✓ Synced ${gogLibraryGameCount} games" + else -> "Fetch your GOG games library" + } + ) + }, + enabled = !gogLibrarySyncing, + onClick = { + gogLibrarySyncing = true + gogLibrarySyncError = null + gogLibrarySyncSuccess = false + + coroutineScope.launch { + try { + timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") + val libraryResult = app.gamenative.service.gog.GOGService.listGames(context) + + if (libraryResult.isSuccess) { + val games = libraryResult.getOrNull() ?: emptyList() + gogLibraryGameCount = games.size + timber.log.Timber.i("[SettingsGOG]: ✓ Synced ${games.size} games from GOG library") + + // Save games to database + try { + withContext(Dispatchers.IO) { + gogGameDao.upsertPreservingInstallStatus(games) + } + timber.log.Timber.i("[SettingsGOG]: ✓ Saved ${games.size} games to database") + } catch (e: Exception) { + timber.log.Timber.e(e, "[SettingsGOG]: Failed to save games to database") + } + + // Log first few games + games.take(5).forEach { game -> + timber.log.Timber.d("[SettingsGOG]: - ${game.title} (${game.id})") + } + if (games.size > 5) { + timber.log.Timber.d("[SettingsGOG]: ... and ${games.size - 5} more") + } + + gogLibrarySyncing = false + gogLibrarySyncSuccess = true + } else { + val error = libraryResult.exceptionOrNull()?.message ?: "Failed to sync library" + timber.log.Timber.e("[SettingsGOG]: Library sync failed: $error") + gogLibrarySyncing = false + gogLibrarySyncError = error + } + } catch (e: Exception) { + timber.log.Timber.e(e, "[SettingsGOG]: Library sync exception: ${e.message}") + gogLibrarySyncing = false + gogLibrarySyncError = e.message ?: "Sync failed" + } + } + } + ) } // Downloads settings @@ -518,16 +642,43 @@ fun SettingsGroupInterface( gogLoginError = null coroutineScope.launch { try { + timber.log.Timber.d("[SettingsGOG]: Starting manual authentication...") val authConfigPath = "${context.filesDir}/gog_auth.json" val result = GOGService.authenticateWithCode(authConfigPath, authCode) - gogLoginLoading = false + if (result.isSuccess) { + timber.log.Timber.i("[SettingsGOG]: ✓ Manual authentication successful!") + + // Fetch the user's GOG library + timber.log.Timber.i("[SettingsGOG]: Fetching GOG library...") + val libraryResult = GOGService.listGames(context) + + if (libraryResult.isSuccess) { + val games = libraryResult.getOrNull() ?: emptyList() + timber.log.Timber.i("[SettingsGOG]: ✓ Fetched ${games.size} games from GOG library") + + // Log first 5 games + games.take(5).forEach { game -> + timber.log.Timber.d("[SettingsGOG]: - ${game.title} (${game.id})") + } + if (games.size > 5) { + timber.log.Timber.d("[SettingsGOG]: ... and ${games.size - 5} more") + } + } else { + val error = libraryResult.exceptionOrNull()?.message ?: "Failed to fetch library" + timber.log.Timber.w("[SettingsGOG]: Failed to fetch library: $error") + // Don't fail authentication if library fetch fails + } + + gogLoginLoading = false gogLoginSuccess = true openGOGLoginDialog = false } else { + gogLoginLoading = false gogLoginError = result.exceptionOrNull()?.message ?: "Authentication failed" } } catch (e: Exception) { + timber.log.Timber.e(e, "[SettingsGOG]: Manual authentication exception: ${e.message}") gogLoginLoading = false gogLoginError = e.message ?: "Authentication failed" } @@ -605,3 +756,12 @@ private fun Preview_SettingsScreen() { ) } } + +/** + * Hilt EntryPoint to access DAOs from Composables + */ +@dagger.hilt.EntryPoint +@dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) +interface DatabaseEntryPoint { + fun gogGameDao(): app.gamenative.db.dao.GOGGameDao +} diff --git a/app/src/main/python/gogdl/api.py b/app/src/main/python/gogdl/api.py index d45413b9f..9de95e973 100644 --- a/app/src/main/python/gogdl/api.py +++ b/app/src/main/python/gogdl/api.py @@ -18,15 +18,25 @@ def __init__(self, auth_manager): self.session.headers = { 'User-Agent': f'gogdl/1.0.0 (Android GameNative)' } - credentials = self.auth_manager.get_credentials() - if credentials: - token = credentials["access_token"] - self.session.headers["Authorization"] = f"Bearer {token}" + self._update_auth_header() self.owned = [] self.endpoints = dict() # Map of secure link endpoints self.working_on_ids = list() # List of products we are waiting for to complete getting the secure link + def _update_auth_header(self): + """Update authorization header with fresh token""" + credentials = self.auth_manager.get_credentials() + if credentials: + token = credentials.get("access_token") + if token: + self.session.headers["Authorization"] = f"Bearer {token}" + self.logger.debug(f"Authorization header updated with token: {token[:20]}...") + else: + self.logger.warning("No access_token found in credentials") + else: + self.logger.warning("No credentials available") + def get_item_data(self, id, expanded=None): if expanded is None: expanded = [] @@ -52,12 +62,21 @@ def get_game_details(self, id): self.logger.error(f"Request failed {response}") def get_user_data(self): - url = f'{constants.GOG_API}/user/data/games' + # Refresh auth header before making request + self._update_auth_header() + + # Try the embed endpoint which is more reliable for getting owned games + url = f'{constants.GOG_EMBED}/user/data/games' + self.logger.info(f"Fetching user data from: {url}") response = self.session.get(url) + self.logger.debug(f"Response status: {response.status_code}") if response.ok: - return response.json() + data = response.json() + self.logger.debug(f"User data keys: {list(data.keys())}") + return data else: - self.logger.error(f"Request failed {response}") + self.logger.error(f"Request failed with status {response.status_code}: {response.text[:200]}") + return None def get_builds(self, product_id, platform): url = f'{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/os/{platform}/builds?generation=2' @@ -115,4 +134,4 @@ def get_secure_link(self, product_id, path="", generation=2, root=None): except Exception as e: self.logger.error(f"Failed to get secure link: {e}") time.sleep(0.2) - return self.get_secure_link(product_id, path, generation, root) \ No newline at end of file + return self.get_secure_link(product_id, path, generation, root) diff --git a/app/src/main/python/gogdl/args.py b/app/src/main/python/gogdl/args.py index dca4cf519..b87932a41 100644 --- a/app/src/main/python/gogdl/args.py +++ b/app/src/main/python/gogdl/args.py @@ -7,31 +7,31 @@ def init_parser(): """Initialize argument parser with Android-compatible defaults""" - + parser = argparse.ArgumentParser( description='Android-compatible GOG downloader', formatter_class=argparse.RawDescriptionHelpFormatter ) - + parser.add_argument( '--auth-config-path', type=str, default=f"{constants.ANDROID_DATA_DIR}/gog_auth.json", help='Path to authentication config file' ) - + parser.add_argument( '--display-version', action='store_true', help='Display version information' ) - + subparsers = parser.add_subparsers(dest='command', help='Available commands') - + # Auth command auth_parser = subparsers.add_parser('auth', help='Authenticate with GOG or get existing credentials') auth_parser.add_argument('--code', type=str, help='Authorization code from GOG (optional - if not provided, returns existing credentials)') - + # Download command download_parser = subparsers.add_parser('download', help='Download a game') download_parser.add_argument('id', type=str, help='Game ID to download') @@ -42,7 +42,7 @@ def init_parser(): download_parser.add_argument('--with-dlcs', dest='dlcs', action='store_true', help='Download DLCs') download_parser.add_argument('--dlcs', dest='dlcs_list', default=[], help='List of dlc ids to download (separated by comma)') download_parser.add_argument('--dlc-only', dest='dlc_only', action='store_true', help='Download only DLC') - + download_parser.add_argument('--lang', type=str, default='en-US', help='Language for the download') download_parser.add_argument('--max-workers', dest='workers_count', type=int, default=2, help='Number of download workers') download_parser.add_argument('--support', dest='support_path', type=str, help='Support files path') @@ -64,7 +64,7 @@ def init_parser(): info_parser.add_argument('--force-gen', choices=['1', '2'], dest='force_generation', help='Force specific manifest generation (FOR DEBUGGING)') info_parser.add_argument('--lang', '-l', dest='lang', help='Specify game language', default='en-US') info_parser.add_argument('--max-workers', dest='workers_count', type=int, default=2, help='Number of download workers') - + # Repair command repair_parser = subparsers.add_parser('repair', help='Repair/verify game files') repair_parser.add_argument('id', type=str, help='Game ID to repair') @@ -74,12 +74,16 @@ def init_parser(): repair_parser.add_argument('--force-gen', choices=['1', '2'], dest='force_generation', help='Force specific manifest generation (FOR DEBUGGING)') repair_parser.add_argument('--build', '-b', dest='build', help='Specify buildId') repair_parser.add_argument('--branch', dest='branch', help='Choose build branch to use') - + # Save sync command save_parser = subparsers.add_parser('save-sync', help='Sync game saves') save_parser.add_argument('path', help='Path to sync files') save_parser.add_argument('--dirname', help='Cloud save directory name') save_parser.add_argument('--timestamp', type=float, default=0.0, help='Last sync timestamp') save_parser.add_argument('--prefered-action', choices=['upload', 'download', 'none'], help='Preferred sync action') - + + # List command + list_parser = subparsers.add_parser('list', help='List user\'s GOG games') + list_parser.add_argument('--pretty', action='store_true', help='Pretty print JSON output') + return parser.parse_known_args() diff --git a/app/src/main/python/gogdl/cli.py b/app/src/main/python/gogdl/cli.py index 63cfc4d55..fb95a3176 100644 --- a/app/src/main/python/gogdl/cli.py +++ b/app/src/main/python/gogdl/cli.py @@ -9,6 +9,7 @@ import gogdl.api as api import gogdl.auth as auth from gogdl import version as gogdl_version +import gogdl.constants as constants import json import logging @@ -17,28 +18,138 @@ def display_version(): print(f"{gogdl_version}") +def handle_list(arguments, api_handler): + """List user's GOG games with full details""" + logger = logging.getLogger("GOGDL-LIST") + + try: + # Check if we have valid credentials first + credentials = api_handler.auth_manager.get_credentials() + if not credentials: + logger.error("No valid credentials found. Please authenticate first.") + print(json.dumps([])) # Return empty array instead of error object + return + + logger.info("Fetching user's game library...") + logger.debug(f"Using access token: {credentials.get('access_token', '')[:20]}...") + + # Use the same endpoint as does_user_own - it just returns owned game IDs + response = api_handler.session.get(f'{constants.GOG_EMBED}/user/data/games') + + if not response.ok: + logger.error(f"Failed to fetch user data - HTTP {response.status_code}") + print(json.dumps([])) # Return empty array instead of error object + return + + user_data = response.json() + owned_games = user_data.get('owned', []) + logger.info(f"Found {len(owned_games)} games in library") + + # Fetch full details for each game + games_list = [] + for index, game_id in enumerate(owned_games, 1): + try: + logger.info(f"Fetching details for game {index}/{len(owned_games)}: {game_id}") + + # Get full game info with expanded data + game_info = api_handler.get_item_data(game_id, expanded=['downloads']) + + if game_info: + # Extract relevant fields + game_entry = { + "id": game_id, + "title": game_info.get('title', 'Unknown'), + "slug": game_info.get('slug', ''), + "imageUrl": game_info.get('images', {}).get('logo2x', '') or game_info.get('images', {}).get('logo', ''), + "iconUrl": game_info.get('images', {}).get('icon', ''), + "developer": game_info.get('developers', [{}])[0].get('name', '') if game_info.get('developers') else '', + "publisher": game_info.get('publisher', {}).get('name', ''), + "genres": [g.get('name', '') for g in game_info.get('genres', [])], + "languages": list(game_info.get('languages', {}).keys()), + "description": game_info.get('description', {}).get('lead', ''), + "releaseDate": game_info.get('release_date', '') + } + games_list.append(game_entry) + logger.debug(f" ✓ {game_entry['title']}") + else: + logger.warning(f"Failed to get details for game {game_id} - API returned None") + # Add minimal entry so we don't lose the game ID + games_list.append({ + "id": game_id, + "title": f"Game {game_id}", + "slug": "", + "imageUrl": "", + "iconUrl": "", + "developer": "", + "publisher": "", + "genres": [], + "languages": [], + "description": "", + "releaseDate": "" + }) + + # Small delay to avoid rate limiting (100ms between requests) + if index < len(owned_games): + import time + time.sleep(0.1) + + except Exception as e: + logger.error(f"Error fetching details for game {game_id}: {e}") + import traceback + logger.debug(traceback.format_exc()) + # Add minimal entry on error + games_list.append({ + "id": game_id, + "title": f"Game {game_id}", + "slug": "", + "imageUrl": "", + "iconUrl": "", + "developer": "", + "publisher": "", + "genres": [], + "languages": [], + "description": "", + "releaseDate": "" + }) + + logger.info(f"Successfully fetched details for {len(games_list)} games") + + # Output as JSON array (always return array, never error object) + if arguments.pretty: + print(json.dumps(games_list, indent=2)) + else: + print(json.dumps(games_list)) + + except Exception as e: + logger.error(f"List command failed: {e}") + import traceback + logger.error(traceback.format_exc()) + # Return empty array on error so Kotlin can parse it + print(json.dumps([])) + + def handle_auth(arguments, api_handler): """Handle GOG authentication - exchange authorization code for access token or get existing credentials""" logger = logging.getLogger("GOGDL-AUTH") - + try: import requests import os import time - + # GOG OAuth constants GOG_CLIENT_ID = "46899977096215655" GOG_CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" GOG_TOKEN_URL = "https://auth.gog.com/token" GOG_USER_URL = "https://embed.gog.com/userData.json" - + # Initialize authorization manager auth_manager = api_handler.auth_manager - + if arguments.code: # Exchange authorization code for access token logger.info("Exchanging authorization code for access token...") - + token_data = { "client_id": GOG_CLIENT_ID, "client_secret": GOG_CLIENT_SECRET, @@ -46,42 +157,42 @@ def handle_auth(arguments, api_handler): "code": arguments.code, "redirect_uri": "https://embed.gog.com/on_login_success?origin=client" } - + response = requests.post(GOG_TOKEN_URL, data=token_data) - + if response.status_code != 200: error_msg = f"Token exchange failed: HTTP {response.status_code} - {response.text}" logger.error(error_msg) print(json.dumps({"error": True, "message": error_msg})) return - + token_response = response.json() access_token = token_response.get("access_token") refresh_token = token_response.get("refresh_token") - + if not access_token: error_msg = "No access token in response" logger.error(error_msg) print(json.dumps({"error": True, "message": error_msg})) return - + # Get user information logger.info("Getting user information...") user_response = requests.get( GOG_USER_URL, headers={"Authorization": f"Bearer {access_token}"} ) - + username = "GOG User" user_id = "unknown" - + if user_response.status_code == 200: user_data = user_response.json() username = user_data.get("username", "GOG User") user_id = str(user_data.get("userId", "unknown")) else: logger.warning(f"Failed to get user info: HTTP {user_response.status_code}") - + # Save credentials with loginTime and expires_in (like original auth.py) auth_data = { GOG_CLIENT_ID: { @@ -93,27 +204,27 @@ def handle_auth(arguments, api_handler): "expires_in": token_response.get("expires_in", 3600) } } - + os.makedirs(os.path.dirname(arguments.auth_config_path), exist_ok=True) - + with open(arguments.auth_config_path, 'w') as f: json.dump(auth_data, f, indent=2) - + logger.info(f"Authentication successful for user: {username}") print(json.dumps(auth_data[GOG_CLIENT_ID])) - + else: # Get existing credentials (like original auth.py get_credentials) logger.info("Getting existing credentials...") credentials = auth_manager.get_credentials() - + if credentials: logger.info(f"Retrieved credentials for user: {credentials.get('username', 'GOG User')}") print(json.dumps(credentials)) else: logger.warning("No valid credentials found") print(json.dumps({"error": True, "message": "No valid credentials found"})) - + except Exception as e: logger.error(f"Authentication failed: {e}") print(json.dumps({"error": True, "message": str(e)})) @@ -128,25 +239,29 @@ def main(): logging.basicConfig(format="[%(name)s] %(levelname)s: %(message)s", level=level) logger = logging.getLogger("GOGDL-ANDROID") logger.debug(arguments) - + if arguments.display_version: display_version() return - + if not arguments.command: print("No command provided!") return - + # Initialize Android-compatible managers authorization_manager = auth.AuthorizationManager(arguments.auth_config_path) api_handler = api.ApiHandler(authorization_manager) switcher = {} - + # Handle authentication command if arguments.command == "auth": switcher["auth"] = lambda: handle_auth(arguments, api_handler) - + + # Handle list command + if arguments.command == "list": + switcher["list"] = lambda: handle_list(arguments, api_handler) + # Handle download/info commands if arguments.command in ["download", "repair", "update", "info"]: download_manager = manager.AndroidManager(arguments, unknown_args, api_handler) @@ -156,7 +271,7 @@ def main(): "update": download_manager.download, "info": lambda: download_manager.calculate_download_size(arguments, unknown_args), }) - + # Handle save sync command if arguments.command == "save-sync": import gogdl.saves as saves From c26e586797e05c587cd189605907f2ca262c5956 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 7 Dec 2025 22:13:13 +0000 Subject: [PATCH 007/122] updating the game screen for gog --- .../service/gog/GOGLibraryManager.kt | 8 + .../app/gamenative/service/gog/GOGService.kt | 272 ++++------ .../app/gamenative/ui/data/LibraryState.kt | 6 +- .../screen/library/appscreen/BaseAppScreen.kt | 8 +- .../screen/library/appscreen/GOGAppScreen.kt | 505 ++++++++++++++++-- .../screen/settings/SettingsGroupInterface.kt | 2 +- app/src/main/res/values/strings.xml | 4 + 7 files changed, 601 insertions(+), 204 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt index 54141f94e..8302a8221 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt @@ -16,6 +16,14 @@ import javax.inject.Singleton class GOGLibraryManager @Inject constructor( private val gogGameDao: GOGGameDao, ) { + /** + * Get a GOG game by ID from database + */ + suspend fun getGameById(gameId: String): GOGGame? { + return withContext(Dispatchers.IO) { + gogGameDao.getById(gameId) + } + } /** * Start background library sync diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index b779bcc4a..0fe186362 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.* import okhttp3.OkHttpClient import org.json.JSONObject import timber.log.Timber +import java.util.function.Function /** * Data class to hold metadata extracted from GOG GamesDB @@ -40,6 +41,26 @@ data class GameSizeInfo( val diskSize: Long ) +/** + * Progress callback that Python code can invoke to report download progress + */ +class ProgressCallback(private val downloadInfo: DownloadInfo) { + @JvmOverloads + fun update(percent: Float = 0f, downloadedMB: Float = 0f, totalMB: Float = 0f, downloadSpeedMBps: Float = 0f, eta: String = "") { + try { + val progress = (percent / 100.0f).coerceIn(0.0f, 1.0f) + downloadInfo.setProgress(progress) + + if (percent > 0f) { + Timber.d("Download progress: %.1f%% (%.1f/%.1f MB) Speed: %.2f MB/s ETA: %s", + percent, downloadedMB, totalMB, downloadSpeedMBps, eta) + } + } catch (e: Exception) { + Timber.w(e, "Error updating download progress") + } + } +} + @Singleton class GOGService @Inject constructor() : Service() { @@ -431,6 +452,16 @@ class GOGService @Inject constructor() : Service() { return getInstance()?.activeDownloads?.get(gameId) } + /** + * Get GOG game info by game ID (synchronously for UI) + * Similar to SteamService.getAppInfoOf() + */ + fun getGOGGameOf(gameId: String): GOGGame? { + return runBlocking(Dispatchers.IO) { + getInstance()?.gogLibraryManager?.getGameById(gameId) + } + } + /** * Clean up active download when game is deleted */ @@ -691,7 +722,7 @@ class GOGService @Inject constructor() : Service() { val supportDir = File(installDir.parentFile, "gog-support") supportDir.mkdirs() - val result = executeCommandWithProgressParsing( + val result = executeCommandWithCallback( downloadInfo, "--auth-config-path", authConfigPath, "download", ContainerUtils.extractGameIdFromContainerId(gameId).toString(), @@ -738,38 +769,55 @@ class GOGService @Inject constructor() : Service() { } } + /** - * Execute GOGDL command with progress parsing from logcat output + * Execute GOGDL command with progress callback */ - private suspend fun executeCommandWithProgressParsing(downloadInfo: DownloadInfo, vararg args: String): Result { + private suspend fun executeCommandWithCallback(downloadInfo: DownloadInfo, vararg args: String): Result { return withContext(Dispatchers.IO) { - var logMonitorJob: Job? = null try { - // Start log monitoring for GOGDL progress - logMonitorJob = CoroutineScope(Dispatchers.IO).launch { - monitorGOGDLProgress(downloadInfo) - } - val python = Python.getInstance() val sys = python.getModule("sys") val originalArgv = sys.get("argv") try { + // Create progress callback that Python can invoke + val progressCallback = ProgressCallback(downloadInfo) + // Get the gogdl module and set up callback + val gogdlModule = python.getModule("gogdl") + + // Try to set progress callback if gogdl supports it + try { + gogdlModule.put("_progress_callback", progressCallback) + Timber.d("Progress callback registered with GOGDL") + } catch (e: Exception) { + Timber.w(e, "Could not register progress callback, will use estimation") + } + val gogdlCli = python.getModule("gogdl.cli") // Set up arguments for argparse val argsList = listOf("gogdl") + args.toList() - Timber.d("Setting GOGDL arguments for argparse: ${args.joinToString(" ")}") + Timber.d("Setting GOGDL arguments: ${args.joinToString(" ")}") val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) sys.put("argv", pythonList) // Check for cancellation before starting ensureActive() - // Execute the main function - gogdlCli.callAttr("main") - Timber.d("GOGDL execution completed successfully") - Result.success("Download completed") + // Start a simple progress estimator in case callback doesn't work + val estimatorJob = CoroutineScope(Dispatchers.IO).launch { + estimateProgress(downloadInfo) + } + + try { + // Execute the main function + gogdlCli.callAttr("main") + Timber.i("GOGDL execution completed successfully") + Result.success("Download completed") + } finally { + estimatorJob.cancel() + } } catch (e: Exception) { Timber.e(e, "GOGDL execution failed: ${e.message}") Result.failure(e) @@ -782,154 +830,47 @@ class GOGService @Inject constructor() : Service() { } catch (e: Exception) { Timber.e(e, "Failed to execute GOGDL command: ${args.joinToString(" ")}") Result.failure(e) - } finally { - logMonitorJob?.cancel() } } } /** - * Monitor GOGDL progress by parsing logcat output (python.stderr) - * Parses progress like Heroic Games Launcher does + * Estimate progress when callback isn't available + * Shows gradual progress to indicate activity */ - private suspend fun monitorGOGDLProgress(downloadInfo: DownloadInfo) { - var process: Process? = null + private suspend fun estimateProgress(downloadInfo: DownloadInfo) { try { - // Clear any existing logcat buffer to ensure fresh start - try { - val clearProcess = ProcessBuilder("logcat", "-c").start() - clearProcess.waitFor() - Timber.d("Cleared logcat buffer for fresh progress monitoring") - } catch (e: Exception) { - Timber.w(e, "Failed to clear logcat buffer, continuing anyway") - } - - // Add delay to ensure Python process has started and old logs are cleared - delay(1000) - - // Use logcat to read python.stderr logs in real-time with timestamp filtering - process = ProcessBuilder("logcat", "-s", "python.stderr:W", "-T", "1") - .redirectErrorStream(true) - .start() - - val reader = process.inputStream.bufferedReader() - Timber.d("Progress monitoring logcat process started successfully") - - // Track progress state exactly like Heroic does - var currentPercent: Float? = null - var currentEta: String = "" - var currentBytes: String = "" - var currentDownSpeed: Float? = null - var currentDiskSpeed: Float? = null - - while (downloadInfo.getProgress() < 1.0f && downloadInfo.getProgress() >= 0.0f) { - val line = reader.readLine() - if (line != null) { - // Parse like Heroic: only update if field is empty/undefined - - // Parse log for percent (only if not already set) - if (currentPercent == null) { - val percentMatch = Regex("""Progress: (\d+\.\d+) """).find(line) - if (percentMatch != null) { - val percent = percentMatch.groupValues[1].toFloatOrNull() - if (percent != null && !percent.isNaN()) { - currentPercent = percent - } - } - } - - // Parse log for eta (only if empty) - if (currentEta.isEmpty()) { - val etaMatch = Regex("""ETA: (\d\d:\d\d:\d\d)""").find(line) - if (etaMatch != null) { - currentEta = etaMatch.groupValues[1] - } - } - - // Parse log for game download progress (only if empty) - if (currentBytes.isEmpty()) { - val bytesMatch = Regex("""Downloaded: (\S+) MiB""").find(line) - if (bytesMatch != null) { - currentBytes = "${bytesMatch.groupValues[1]}MB" - } - } - - // Parse log for download speed (only if not set) - if (currentDownSpeed == null) { - val downSpeedMatch = Regex("""Download\t- (\S+) MiB""").find(line) - if (downSpeedMatch != null) { - val speed = downSpeedMatch.groupValues[1].toFloatOrNull() - if (speed != null && !speed.isNaN()) { - currentDownSpeed = speed - } - } - } - - // Parse disk write speed (only if not set) - if (currentDiskSpeed == null) { - val diskSpeedMatch = Regex("""Disk\t- (\S+) MiB""").find(line) - if (diskSpeedMatch != null) { - val speed = diskSpeedMatch.groupValues[1].toFloatOrNull() - if (speed != null && !speed.isNaN()) { - currentDiskSpeed = speed - } - } - } - - // Only send update if all values are present (exactly like Heroic) - if (currentPercent != null && currentEta.isNotEmpty() && - currentBytes.isNotEmpty() && currentDownSpeed != null && currentDiskSpeed != null) { - - // Update progress with the percentage - val progress = (currentPercent!! / 100.0f).coerceIn(0.0f, 1.0f) - downloadInfo.setProgress(progress) - - // Log exactly like Heroic does - Timber.i("Progress for game: ${currentPercent}%/${currentBytes}/${currentEta} Down: ${currentDownSpeed}MB/s / Disk: ${currentDiskSpeed}MB/s") - - // Reset (exactly like Heroic does) - currentPercent = null - currentEta = "" - currentBytes = "" - currentDownSpeed = null - currentDiskSpeed = null - } - } else { - delay(100L) // Brief delay if no new log lines - } - } - - Timber.d("Progress monitoring loop ended - progress: ${downloadInfo.getProgress()}") - process?.destroyForcibly() - Timber.d("Logcat process destroyed forcibly") - } catch (e: CancellationException) { - Timber.d("GOGDL progress monitoring cancelled") - process?.destroyForcibly() - throw e - } catch (e: Exception) { - Timber.w(e, "Error monitoring GOGDL progress, falling back to estimation") - // Simple fallback - estimate progress over time var lastProgress = 0.0f val startTime = System.currentTimeMillis() while (downloadInfo.getProgress() < 1.0f && downloadInfo.getProgress() >= 0.0f) { - delay(2000L) + delay(3000L) // Update every 3 seconds + val elapsed = System.currentTimeMillis() - startTime val estimatedProgress = when { elapsed < 5000 -> 0.05f - elapsed < 15000 -> 0.20f - elapsed < 30000 -> 0.50f - elapsed < 60000 -> 0.80f - else -> 0.90f + elapsed < 15000 -> 0.15f + elapsed < 30000 -> 0.30f + elapsed < 60000 -> 0.50f + elapsed < 120000 -> 0.70f + elapsed < 180000 -> 0.85f + else -> 0.95f }.coerceAtLeast(lastProgress) - if (estimatedProgress > lastProgress) { + // Only update if progress hasn't been set by callback + if (downloadInfo.getProgress() <= lastProgress + 0.01f) { downloadInfo.setProgress(estimatedProgress) lastProgress = estimatedProgress + Timber.d("Estimated progress: %.1f%%", estimatedProgress * 100) + } else { + // Callback is working, update our tracking + lastProgress = downloadInfo.getProgress() } } - } finally { - process?.destroyForcibly() + } catch (e: CancellationException) { + Timber.d("Progress estimation cancelled") + } catch (e: Exception) { + Timber.w(e, "Error in progress estimation") } } @@ -938,28 +879,33 @@ class GOGService @Inject constructor() : Service() { * TODO: Implement cloud save sync */ suspend fun syncCloudSaves(gameId: String, savePath: String, authConfigPath: String, timestamp: Float = 0.0f): Result { - return try { - Timber.i("Starting GOG cloud save sync for game $gameId") - - val result = executeCommand( - "--auth-config-path", authConfigPath, - "save-sync", savePath, - "--dirname", gameId, - "--timestamp", timestamp.toString(), - ) - if (result.isSuccess) { - Timber.i("GOG cloud save sync completed successfully for game $gameId") - Result.success(Unit) - } else { - val error = result.exceptionOrNull() ?: Exception("Save sync failed") - Timber.e(error, "GOG cloud save sync failed for game $gameId") - Result.failure(error) - } - } catch (e: Exception) { - Timber.e(e, "GOG cloud save sync exception for game $gameId") - Result.failure(e) - } + // ! Keep out CloudSaves till we understand how they work. + // ! Return Result.success() + + return Result.success(Unit) + // return try { + // Timber.i("Starting GOG cloud save sync for game $gameId") + + // val result = executeCommand( + // "--auth-config-path", authConfigPath, + // "save-sync", savePath, + // "--dirname", gameId, + // "--timestamp", timestamp.toString(), + // ) + + // if (result.isSuccess) { + // Timber.i("GOG cloud save sync completed successfully for game $gameId") + // Result.success(Unit) + // } else { + // val error = result.exceptionOrNull() ?: Exception("Save sync failed") + // Timber.e(error, "GOG cloud save sync failed for game $gameId") + // Result.failure(error) + // } + // } catch (e: Exception) { + // Timber.e(e, "GOG cloud save sync exception for game $gameId") + // Result.failure(e) + // } } /** 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 c58322776..d6e751cfd 100644 --- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt +++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt @@ -25,14 +25,14 @@ data class LibraryState( val showSteamInLibrary: Boolean = PrefManager.showSteamInLibrary, val showCustomGamesInLibrary: Boolean = PrefManager.showCustomGamesInLibrary, val showGOGInLibrary: Boolean = PrefManager.showGOGInLibrary, - + // Loading state for skeleton loaders val isLoading: Boolean = false, - + // Refresh counter that increments when custom game images are fetched // Used to trigger UI recomposition to show newly downloaded images val imageRefreshCounter: Long = 0, - + // Compatibility status map: game name -> compatibility status val compatibilityMap: Map = emptyMap(), ) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 8e259f63f..879a213ec 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 @@ -627,7 +627,13 @@ abstract class BaseAppScreen { performStateRefresh(false) } }, - onDeleteDownloadClick = { onDeleteDownloadClick(context, libraryItem) }, + onDeleteDownloadClick = { + onDeleteDownloadClick(context, libraryItem) + uiScope.launch { + delay(100) + performStateRefresh(true) + } + }, 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 index 36f62cb2a..6e503ef48 100644 --- 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 @@ -1,17 +1,39 @@ 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.rememberCoroutineScope +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.db.PluviaDatabase +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 app.gamenative.utils.ContainerUtils import com.winlator.container.ContainerData import com.winlator.container.ContainerManager +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber +import java.io.File /** * GOG-specific implementation of BaseAppScreen @@ -19,29 +41,95 @@ import timber.log.Timber */ class GOGAppScreen : BaseAppScreen() { + companion object { + // Shared state for uninstall dialog - list of appIds that should show the dialog + private val uninstallDialogAppIds = mutableStateListOf() + + fun showUninstallDialog(appId: String) { + if (!uninstallDialogAppIds.contains(appId)) { + uninstallDialogAppIds.add(appId) + } + } + + fun hideUninstallDialog(appId: String) { + uninstallDialogAppIds.remove(appId) + } + + fun shouldShowUninstallDialog(appId: String): Boolean { + return uninstallDialogAppIds.contains(appId) + } + + // Shared state for install dialog - list of appIds that should show the dialog + private val installDialogAppIds = mutableStateListOf() + + fun showInstallDialog(appId: String) { + if (!installDialogAppIds.contains(appId)) { + installDialogAppIds.add(appId) + } + } + + fun hideInstallDialog(appId: String) { + installDialogAppIds.remove(appId) + } + + fun shouldShowInstallDialog(appId: String): Boolean { + return installDialogAppIds.contains(appId) + } + } + + /** + * Get PluviaDatabase instance using Hilt EntryPoint + */ + private fun getDatabase(context: Context): PluviaDatabase { + val appContext = context.applicationContext + val entryPoint = EntryPointAccessors.fromApplication( + appContext, + DatabaseEntryPoint::class.java + ) + return entryPoint.database() + } + @Composable override fun getGameDisplayInfo( context: Context, libraryItem: LibraryItem ): GameDisplayInfo { - // TODO: Fetch GOG game details from database - // For now, use basic info from libraryItem + var gogGame by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(libraryItem.appId) { + coroutineScope.launch(Dispatchers.IO) { + val db = getDatabase(context) + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + gogGame = db.gogGameDao().getById(gameId) + } + } + + val game = gogGame return GameDisplayInfo( - name = libraryItem.name, - iconUrl = libraryItem.iconHash ?: "", - heroImageUrl = libraryItem.iconHash ?: "", - gameId = libraryItem.appId.toIntOrNull() ?: 0, + name = game?.title ?: libraryItem.name, + iconUrl = game?.iconUrl ?: libraryItem.iconHash, + heroImageUrl = game?.imageUrl ?: game?.iconUrl ?: libraryItem.iconHash, + gameId = libraryItem.appId.removePrefix("GOG_").toIntOrNull() ?: 0, appId = libraryItem.appId, - releaseDate = 0L, - developer = "Unknown" + releaseDate = 0L, // GOG uses string release dates, would need parsing + developer = game?.developer ?: "Unknown" ) } override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean { - // TODO: Check GOGGame.isInstalled from database - // For now, check if container exists - val containerManager = ContainerManager(context) - return containerManager.hasContainer(libraryItem.appId) + // Check GOGGame.isInstalled from database (synchronous for UI) + return try { + val db = getDatabase(context) + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + val game = kotlinx.coroutines.runBlocking { + db.gogGameDao().getById(gameId) + } + game?.isInstalled == true + } catch (e: Exception) { + Timber.e(e, "Failed to check install status for ${libraryItem.appId}") + false + } } override fun isValidToDownload(context: Context, libraryItem: LibraryItem): Boolean { @@ -50,39 +138,239 @@ class GOGAppScreen : BaseAppScreen() { } override fun isDownloading(context: Context, libraryItem: LibraryItem): Boolean { - // TODO: Check GOGGame download status from database or marker files - // For now, return false - return false + // Check if there's an active download for this GOG game + val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + return downloadInfo != null && (downloadInfo.getProgress() ?: 0f) in 0f..0.99f } override fun getDownloadProgress(context: Context, libraryItem: LibraryItem): Float { - // TODO: Get actual download progress from GOGGame or download manager - // Return 0.0 for now - return 0f + // Get actual download progress from GOGService + val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + return downloadInfo?.getProgress() ?: 0f } override fun onDownloadInstallClick(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { - // TODO: Implement GOG game download via Python gogdl - // This should: - // 1. Check GOG authentication - // 2. Start download via GOGService - // 3. Update download progress in UI - // 4. When complete, call onClickPlay(true) to launch - Timber.d("Download/Install clicked for GOG game: ${libraryItem.appId}") + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + val installed = isInstalled(context, libraryItem) + + if (isDownloading) { + // Cancel ongoing download + Timber.d("Cancelling GOG download for: ${libraryItem.appId}") + downloadInfo.cancel() + } else if (installed) { + // Already installed: launch game + Timber.d("GOG game already installed, launching: ${libraryItem.appId}") + onClickPlay(false) + } else { + // Show install confirmation dialog + showInstallDialog(libraryItem.appId) + } + } + + /** + * Perform the actual download after confirmation + */ + private fun performDownload(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + Timber.i("Starting GOG game download: ${libraryItem.appId}") + CoroutineScope(Dispatchers.IO).launch { + try { + // Get auth config path + val authConfigPath = "${context.filesDir}/gog_auth.json" + val authFile = File(authConfigPath) + if (!authFile.exists()) { + Timber.e("GOG authentication file not found. User needs to login first.") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Please login to GOG first in Settings", + android.widget.Toast.LENGTH_LONG + ).show() + } + return@launch + } + + // Determine install path (use same pattern as Steam) + val downloadDir = File(android.os.Environment.getExternalStorageDirectory(), "Download") + val gogGamesDir = File(downloadDir, "GOGGames") + gogGamesDir.mkdirs() // Ensure directory exists + val installDir = File(gogGamesDir, gameId) + val installPath = installDir.absolutePath + + Timber.d("Downloading GOG game to: $installPath") + + // Start download + val result = GOGService.downloadGame(gameId, installPath, authConfigPath) + + if (result.isSuccess) { + val info = result.getOrNull() + Timber.i("GOG download started successfully for: $gameId") + + // Monitor download completion + info?.let { downloadInfo -> + // Wait for download to complete + while (downloadInfo.getProgress() in 0f..0.99f) { + kotlinx.coroutines.delay(1000) + } + + val finalProgress = downloadInfo.getProgress() + if (finalProgress >= 1.0f) { + // Download completed successfully + Timber.i("GOG download completed: $gameId") + + // Update database + val db = getDatabase(context) + val game = db.gogGameDao().getById(gameId) + if (game != null) { + db.gogGameDao().update( + game.copy( + isInstalled = true, + installPath = installPath + ) + ) + Timber.d("Updated GOG game install status in database") + } + + // Trigger library refresh + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(libraryItem.gameId) + ) + } else { + Timber.w("GOG download did not complete successfully: $finalProgress") + } + } + } else { + Timber.e(result.exceptionOrNull(), "Failed to start GOG download") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Failed to start download: ${result.exceptionOrNull()?.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) { - // TODO: Implement pause/resume for GOG downloads - Timber.d("Pause/Resume clicked for GOG game: ${libraryItem.appId}") + val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + + if (isDownloading) { + // Cancel/pause download + Timber.d("Pausing GOG download: ${libraryItem.appId}") + downloadInfo.cancel() + } else { + // Resume download (restart from beginning for now) + Timber.d("Resuming GOG download: ${libraryItem.appId}") + onDownloadInstallClick(context, libraryItem) {} + } } override fun onDeleteDownloadClick(context: Context, libraryItem: LibraryItem) { - // TODO: Implement delete download for GOG games - // This should: - // 1. Cancel ongoing download if any - // 2. Remove partial download files - // 3. Update database - Timber.d("Delete download clicked for GOG game: ${libraryItem.appId}") + val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + val isInstalled = isInstalled(context, libraryItem) + + if (isDownloading) { + // Cancel download immediately if currently downloading + Timber.d("Cancelling active download for GOG game: ${libraryItem.appId}") + downloadInfo.cancel() + android.widget.Toast.makeText( + context, + "Download cancelled", + android.widget.Toast.LENGTH_SHORT + ).show() + } else if (isInstalled) { + // Show uninstall confirmation dialog + showUninstallDialog(libraryItem.appId) + } + } + + /** + * Perform the actual uninstall of a GOG game + */ + private fun performUninstall(context: Context, libraryItem: LibraryItem) { + Timber.i("Uninstalling GOG game: ${libraryItem.appId}") + CoroutineScope(Dispatchers.IO).launch { + try { + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + + // Get install path from database + val db = getDatabase(context) + val game = db.gogGameDao().getById(gameId) + + if (game != null && game.installPath.isNotEmpty()) { + val installDir = File(game.installPath) + if (installDir.exists()) { + Timber.d("Deleting game files from: ${game.installPath}") + val deleted = installDir.deleteRecursively() + if (deleted) { + Timber.i("Successfully deleted game files") + } else { + Timber.w("Failed to delete some game files") + } + } + + // Update database - mark as not installed + db.gogGameDao().update( + game.copy( + isInstalled = false, + installPath = "" + ) + ) + Timber.d("Updated database: game marked as not installed") + + // Delete container + withContext(Dispatchers.Main) { + ContainerUtils.deleteContainer(context, libraryItem.appId) + } + + // Trigger library refresh + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(libraryItem.gameId) + ) + + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Game uninstalled successfully", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } else { + Timber.w("Game not found in database or no install path") + withContext(Dispatchers.Main) { + android.widget.Toast.makeText( + context, + "Game not found", + android.widget.Toast.LENGTH_SHORT + ).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) { @@ -97,9 +385,17 @@ class GOGAppScreen : BaseAppScreen() { } override fun getInstallPath(context: Context, libraryItem: LibraryItem): String? { - // TODO: Get install path from GOGGame entity in database - // For now, return null as GOG games aren't installed yet - return null + return try { + val db = getDatabase(context) + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + val game = kotlinx.coroutines.runBlocking { + db.gogGameDao().getById(gameId) + } + if (game?.isInstalled == true) game.installPath else null + } catch (e: Exception) { + Timber.e(e, "Failed to get install path for ${libraryItem.appId}") + null + } } override fun loadContainerData(context: Context, libraryItem: LibraryItem): ContainerData { @@ -176,4 +472,141 @@ class GOGAppScreen : BaseAppScreen() { override fun getGameFolderPathForImageFetch(context: Context, libraryItem: LibraryItem): String? { return null // GOG uses CDN images, not local files } + + /** + * GOG-specific dialogs (install confirmation, uninstall confirmation) + */ + @Composable + override fun AdditionalDialogs( + libraryItem: LibraryItem, + onDismiss: () -> Unit, + onEditContainer: () -> Unit, + onBack: () -> Unit + ) { + 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 + } + } + + // Monitor install dialog state + var showInstallDialog by remember { mutableStateOf(shouldShowInstallDialog(libraryItem.appId)) } + + LaunchedEffect(libraryItem.appId) { + snapshotFlow { shouldShowInstallDialog(libraryItem.appId) } + .collect { shouldShow -> + showInstallDialog = shouldShow + } + } + // Show install confirmation dialog + if (showInstallDialog) { + val gameId = remember(libraryItem.appId) { + ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + } + val gogGame = remember(gameId) { + GOGService.getGOGGameOf(gameId) + } + + val downloadSizeGB = (gogGame?.downloadSize ?: 0L) / 1_000_000_000.0 + val sizeText = if (downloadSizeGB > 0) { + String.format("%.2f GB", downloadSizeGB) + } else { + "Unknown size" + } + + AlertDialog( + onDismissRequest = { + hideInstallDialog(libraryItem.appId) + }, + title = { Text(stringResource(R.string.gog_install_game_title)) }, + text = { + Text( + text = stringResource( + R.string.gog_install_confirmation_message, + gogGame?.title ?: libraryItem.name, + sizeText + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + hideInstallDialog(libraryItem.appId) + performDownload(context, libraryItem) {} + } + ) { + Text(stringResource(R.string.download)) + } + }, + dismissButton = { + TextButton( + onClick = { + hideInstallDialog(libraryItem.appId) + } + ) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + // Show uninstall confirmation dialog + if (showUninstallDialog) { + val gameId = remember(libraryItem.appId) { + ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + } + val gogGame = remember(gameId) { + GOGService.getGOGGameOf(gameId) + } + + AlertDialog( + onDismissRequest = { + hideUninstallDialog(libraryItem.appId) + }, + title = { Text(stringResource(R.string.gog_uninstall_game_title)) }, + text = { + Text( + text = stringResource( + R.string.gog_uninstall_confirmation_message, + gogGame?.title ?: 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)) + } + } + ) + } + } +} + +/** + * Hilt EntryPoint to access PluviaDatabase from non-Hilt components + */ +@dagger.hilt.EntryPoint +@dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) +interface DatabaseEntryPoint { + fun database(): PluviaDatabase } 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 fdb5a74d4..5a1664af3 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 @@ -77,7 +77,7 @@ fun SettingsGroupInterface( onPaletteStyle: (PaletteStyle) -> Unit, ) { val context = LocalContext.current - + // Get GOGGameDao from Hilt val gogGameDao = remember { val appContext = context.applicationContext diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b0cf869b4..02b415bd8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,10 @@ Are you sure you want to uninstall %1$s? This action cannot be undone. %1$s has been uninstalled Failed to uninstall game + Uninstall Game + Are you sure you want to uninstall %1$s? This action cannot be undone. + Download Game + Download %1$s (%2$s)? Make sure you have enough storage space. Never Continue App header image From cdc34c528dbe68a20a0072414a5f78b4fb311c90 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 7 Dec 2025 23:42:18 +0000 Subject: [PATCH 008/122] Updating syncing. Still need to verify the fields as they're currently not giving everything back. --- .../service/gog/GOGLibraryManager.kt | 70 +++++-- .../app/gamenative/service/gog/GOGService.kt | 26 +++ .../screen/library/appscreen/GOGAppScreen.kt | 182 +++++++++++------- .../screen/settings/SettingsGroupInterface.kt | 55 +++--- 4 files changed, 215 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt index 8302a8221..90b49a04d 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt @@ -25,45 +25,87 @@ class GOGLibraryManager @Inject constructor( } } + /** + * Update a GOG game in database + */ + suspend fun updateGame(game: GOGGame) { + withContext(Dispatchers.IO) { + gogGameDao.update(game) + } + } + /** * Start background library sync - * TODO: Implement full progressive library fetching from GOG API + * Progressively fetches and updates the GOG library in the background */ suspend fun startBackgroundSync(context: Context): Result = withContext(Dispatchers.IO) { try { if (!GOGService.hasStoredCredentials(context)) { + Timber.w("Cannot start background sync: no stored credentials") return@withContext Result.failure(Exception("No stored credentials found")) } Timber.i("Starting GOG library background sync...") - // TODO: Implement progressive library fetching like in the branch - // This should: - // 1. Call getUserLibraryProgressively - // 2. Fetch games one by one from GOG API - // 3. Enrich with GamesDB metadata - // 4. Update database using gogGameDao.upsertPreservingInstallStatus() - - Timber.w("GOG library sync not yet fully implemented") - Result.success(Unit) + // Use the same refresh logic but don't block on completion + val result = refreshLibrary(context) + + if (result.isSuccess) { + val count = result.getOrNull() ?: 0 + Timber.i("Background sync completed: $count games synced") + Result.success(Unit) + } else { + val error = result.exceptionOrNull() + Timber.e(error, "Background sync failed: ${error?.message}") + Result.failure(error ?: Exception("Background sync failed")) + } } catch (e: Exception) { - Timber.e(e, "Failed to sync GOG library") + Timber.e(e, "Failed to sync GOG library in background") Result.failure(e) } } /** * Refresh the entire library (called manually by user) + * Fetches all games from GOG API and updates the database */ suspend fun refreshLibrary(context: Context): Result = withContext(Dispatchers.IO) { try { if (!GOGService.hasStoredCredentials(context)) { + Timber.w("Cannot refresh library: not authenticated with GOG") return@withContext Result.failure(Exception("Not authenticated with GOG")) } - // TODO: Implement full library refresh - Timber.i("Refreshing GOG library...") - Result.success(0) + Timber.i("Refreshing GOG library from GOG API...") + + // Fetch games from GOG via GOGDL Python backend + val listResult = GOGService.listGames(context) + + if (listResult.isFailure) { + val error = listResult.exceptionOrNull() + Timber.e(error, "Failed to fetch games from GOG: ${error?.message}") + return@withContext Result.failure(error ?: Exception("Failed to fetch GOG library")) + } + + val games = listResult.getOrNull() ?: emptyList() + Timber.i("Successfully fetched ${games.size} games from GOG") + + if (games.isEmpty()) { + Timber.w("No games found in GOG library") + return@withContext Result.success(0) + } + + // Log sample of fetched games + games.take(3).forEach { game -> + Timber.d("Fetched game: ${game.title} (${game.id}) - ${game.developer}") + } + + // Update database using upsert to preserve install status + Timber.d("Upserting ${games.size} games to database...") + gogGameDao.upsertPreservingInstallStatus(games) + + Timber.i("Successfully refreshed GOG library with ${games.size} games") + Result.success(games.size) } catch (e: Exception) { Timber.e(e, "Failed to refresh GOG library") Result.failure(e) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 0fe186362..b7093056a 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -462,6 +462,32 @@ class GOGService @Inject constructor() : Service() { } } + /** + * Update GOG game in database + */ + suspend fun updateGOGGame(game: GOGGame) { + getInstance()?.gogLibraryManager?.updateGame(game) + } + + /** + * Check if a GOG game is installed (synchronous for UI) + */ + fun isGameInstalled(gameId: String): Boolean { + return runBlocking(Dispatchers.IO) { + getInstance()?.gogLibraryManager?.getGameById(gameId)?.isInstalled == true + } + } + + /** + * Get install path for a GOG game (synchronous for UI) + */ + fun getInstallPath(gameId: String): String? { + return runBlocking(Dispatchers.IO) { + val game = getInstance()?.gogLibraryManager?.getGameById(gameId) + if (game?.isInstalled == true) game.installPath else null + } + } + /** * Clean up active download when game is deleted */ 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 index 6e503ef48..8dc426b05 100644 --- 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 @@ -18,7 +18,6 @@ import androidx.compose.ui.res.stringResource import app.gamenative.R import app.gamenative.data.GOGGame import app.gamenative.data.LibraryItem -import app.gamenative.db.PluviaDatabase import app.gamenative.service.gog.GOGService import app.gamenative.ui.data.AppMenuOption import app.gamenative.ui.data.GameDisplayInfo @@ -26,7 +25,6 @@ import app.gamenative.ui.enums.AppOptionMenuType import app.gamenative.utils.ContainerUtils import com.winlator.container.ContainerData import com.winlator.container.ContainerManager -import dagger.hilt.android.EntryPointAccessors import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull @@ -42,71 +40,98 @@ import java.io.File 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 { - return uninstallDialogAppIds.contains(appId) + val result = uninstallDialogAppIds.contains(appId) + Timber.tag(TAG).d("shouldShowUninstallDialog: appId=$appId, result=$result") + return result } // Shared state for install dialog - list of appIds that should show the dialog private val installDialogAppIds = mutableStateListOf() fun showInstallDialog(appId: String) { + Timber.tag(TAG).d("showInstallDialog: appId=$appId") if (!installDialogAppIds.contains(appId)) { installDialogAppIds.add(appId) + Timber.tag(TAG).d("Added to install dialog list: $appId") } } fun hideInstallDialog(appId: String) { + Timber.tag(TAG).d("hideInstallDialog: appId=$appId") installDialogAppIds.remove(appId) } fun shouldShowInstallDialog(appId: String): Boolean { - return installDialogAppIds.contains(appId) + val result = installDialogAppIds.contains(appId) + Timber.tag(TAG).d("shouldShowInstallDialog: appId=$appId, result=$result") + return result } } - /** - * Get PluviaDatabase instance using Hilt EntryPoint - */ - private fun getDatabase(context: Context): PluviaDatabase { - val appContext = context.applicationContext - val entryPoint = EntryPointAccessors.fromApplication( - appContext, - DatabaseEntryPoint::class.java - ) - return entryPoint.database() - } - @Composable override fun getGameDisplayInfo( context: Context, libraryItem: LibraryItem ): GameDisplayInfo { - var gogGame by remember { mutableStateOf(null) } - val coroutineScope = rememberCoroutineScope() - - LaunchedEffect(libraryItem.appId) { - coroutineScope.launch(Dispatchers.IO) { - val db = getDatabase(context) - val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() - gogGame = db.gogGameDao().getById(gameId) + Timber.tag(TAG).d("getGameDisplayInfo: appId=${libraryItem.appId}, name=${libraryItem.name}") + val gameId = remember(libraryItem.appId) { + ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + } + val gogGame = remember(gameId) { + val game = GOGService.getGOGGameOf(gameId) + if (game != null) { + Timber.tag(TAG).d(""" + |=== GOG Game Object === + |Game ID: $gameId + |Title: ${game.title} + |Developer: ${game.developer} + |Publisher: ${game.publisher} + |Release Date: ${game.releaseDate} + |Description: ${game.description.take(100)}... + |Icon URL: ${game.iconUrl} + |Image URL: ${game.imageUrl} + |Install Path: ${game.installPath} + |Is Installed: ${game.isInstalled} + |Download Size: ${game.downloadSize} bytes (${game.downloadSize / 1_000_000_000.0} GB) + |Install Size: ${game.installSize} bytes (${game.installSize / 1_000_000_000.0} GB) + |Genres: ${game.genres.joinToString(", ")} + |Languages: ${game.languages.joinToString(", ")} + |Play Time: ${game.playTime} seconds + |Last Played: ${game.lastPlayed} + |Type: ${game.type} + |====================== + """.trimMargin()) + } else { + Timber.tag(TAG).w(""" + |GOG game not found in database for gameId=$gameId + |This usually means the game was added as a container but GOG library hasn't synced yet. + |The game will use fallback data from the LibraryItem until GOG library is refreshed. + """.trimMargin()) } + game } val game = gogGame - return GameDisplayInfo( + val displayInfo = GameDisplayInfo( name = game?.title ?: libraryItem.name, iconUrl = game?.iconUrl ?: libraryItem.iconHash, heroImageUrl = game?.imageUrl ?: game?.iconUrl ?: libraryItem.iconHash, @@ -115,56 +140,71 @@ class GOGAppScreen : BaseAppScreen() { releaseDate = 0L, // GOG uses string release dates, would need parsing developer = game?.developer ?: "Unknown" ) + Timber.tag(TAG).d("Returning GameDisplayInfo: name=${displayInfo.name}, developer=${displayInfo.developer}") + return displayInfo } override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean { - // Check GOGGame.isInstalled from database (synchronous for UI) + Timber.tag(TAG).d("isInstalled: checking appId=${libraryItem.appId}") return try { - val db = getDatabase(context) val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() - val game = kotlinx.coroutines.runBlocking { - db.gogGameDao().getById(gameId) - } - game?.isInstalled == true + val installed = GOGService.isGameInstalled(gameId) + Timber.tag(TAG).d("isInstalled: appId=${libraryItem.appId}, gameId=$gameId, result=$installed") + installed } catch (e: Exception) { - Timber.e(e, "Failed to check install status for ${libraryItem.appId}") + 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 - return !isInstalled(context, libraryItem) && !isDownloading(context, libraryItem) + 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 val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) - return downloadInfo != null && (downloadInfo.getProgress() ?: 0f) in 0f..0.99f + val progress = downloadInfo?.getProgress() ?: 0f + val downloading = downloadInfo != null && progress in 0f..0.99f + Timber.tag(TAG).d("isDownloading: appId=${libraryItem.appId}, hasDownloadInfo=${downloadInfo != null}, progress=$progress, result=$downloading") + return downloading } override fun getDownloadProgress(context: Context, libraryItem: LibraryItem): Float { // Get actual download progress from GOGService val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) - return downloadInfo?.getProgress() ?: 0f + val progress = downloadInfo?.getProgress() ?: 0f + Timber.tag(TAG).d("getDownloadProgress: appId=${libraryItem.appId}, progress=$progress") + return progress } override fun onDownloadInstallClick(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { + Timber.tag(TAG).i("onDownloadInstallClick: appId=${libraryItem.appId}, name=${libraryItem.name}") val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f val installed = isInstalled(context, libraryItem) + Timber.tag(TAG).d("onDownloadInstallClick: gameId=$gameId, isDownloading=$isDownloading, installed=$installed") + if (isDownloading) { // Cancel ongoing download - Timber.d("Cancelling GOG download for: ${libraryItem.appId}") + Timber.tag(TAG).i("Cancelling GOG download for: ${libraryItem.appId}") downloadInfo.cancel() } else if (installed) { // Already installed: launch game - Timber.d("GOG game already installed, launching: ${libraryItem.appId}") + 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}") showInstallDialog(libraryItem.appId) } } @@ -220,11 +260,10 @@ class GOGAppScreen : BaseAppScreen() { // Download completed successfully Timber.i("GOG download completed: $gameId") - // Update database - val db = getDatabase(context) - val game = db.gogGameDao().getById(gameId) + // Update database via GOGService + val game = GOGService.getGOGGameOf(gameId) if (game != null) { - db.gogGameDao().update( + GOGService.updateGOGGame( game.copy( isInstalled = true, installPath = installPath @@ -265,28 +304,32 @@ class GOGAppScreen : BaseAppScreen() { } override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) { + Timber.tag(TAG).i("onPauseResumeClick: appId=${libraryItem.appId}") val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + Timber.tag(TAG).d("onPauseResumeClick: isDownloading=$isDownloading") if (isDownloading) { // Cancel/pause download - Timber.d("Pausing GOG download: ${libraryItem.appId}") + Timber.tag(TAG).i("Pausing GOG download: ${libraryItem.appId}") downloadInfo.cancel() } else { // Resume download (restart from beginning for now) - Timber.d("Resuming GOG download: ${libraryItem.appId}") + Timber.tag(TAG).i("Resuming GOG download: ${libraryItem.appId}") onDownloadInstallClick(context, libraryItem) {} } } override fun onDeleteDownloadClick(context: Context, libraryItem: LibraryItem) { + Timber.tag(TAG).i("onDeleteDownloadClick: appId=${libraryItem.appId}") val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f val isInstalled = isInstalled(context, libraryItem) + Timber.tag(TAG).d("onDeleteDownloadClick: isDownloading=$isDownloading, isInstalled=$isInstalled") if (isDownloading) { // Cancel download immediately if currently downloading - Timber.d("Cancelling active download for GOG game: ${libraryItem.appId}") + Timber.tag(TAG).i("Cancelling active download for GOG game: ${libraryItem.appId}") downloadInfo.cancel() android.widget.Toast.makeText( context, @@ -295,6 +338,7 @@ class GOGAppScreen : BaseAppScreen() { ).show() } else if (isInstalled) { // Show uninstall confirmation dialog + Timber.tag(TAG).i("Showing uninstall dialog for: ${libraryItem.appId}") showUninstallDialog(libraryItem.appId) } } @@ -308,9 +352,8 @@ class GOGAppScreen : BaseAppScreen() { try { val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() - // Get install path from database - val db = getDatabase(context) - val game = db.gogGameDao().getById(gameId) + // Get install path from GOGService + val game = GOGService.getGOGGameOf(gameId) if (game != null && game.installPath.isNotEmpty()) { val installDir = File(game.installPath) @@ -324,8 +367,8 @@ class GOGAppScreen : BaseAppScreen() { } } - // Update database - mark as not installed - db.gogGameDao().update( + // Update database via GOGService - mark as not installed + GOGService.updateGOGGame( game.copy( isInstalled = false, installPath = "" @@ -374,42 +417,49 @@ class GOGAppScreen : BaseAppScreen() { } 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.d("Update clicked for GOG game: ${libraryItem.appId}") + 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 { - val db = getDatabase(context) val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() - val game = kotlinx.coroutines.runBlocking { - db.gogGameDao().getById(gameId) - } - if (game?.isInstalled == true) game.installPath else null + val path = GOGService.getInstallPath(gameId) + Timber.tag(TAG).d("getInstallPath: gameId=$gameId, path=$path") + path } catch (e: Exception) { - Timber.e(e, "Failed to get install path for ${libraryItem.appId}") + 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) - return app.gamenative.utils.ContainerUtils.toContainerData(container) + 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 } @@ -427,13 +477,6 @@ class GOGAppScreen : BaseAppScreen() { isInstalled: Boolean ): List { val options = mutableListOf() - - // TODO: Add GOG-specific options like: - // - Verify game files - // - Check for updates - // - View game on GOG.com - // - Manage DLC - return options } @@ -461,7 +504,6 @@ class GOGAppScreen : BaseAppScreen() { libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit ) { - // TODO: Add PostHog analytics for GOG game launches super.onRunContainerClick(context, libraryItem, onClickPlay) } @@ -483,6 +525,7 @@ class GOGAppScreen : BaseAppScreen() { onEditContainer: () -> Unit, onBack: () -> Unit ) { + Timber.tag(TAG).d("AdditionalDialogs: composing for appId=${libraryItem.appId}") val context = LocalContext.current // Monitor uninstall dialog state @@ -601,12 +644,3 @@ class GOGAppScreen : BaseAppScreen() { } } } - -/** - * Hilt EntryPoint to access PluviaDatabase from non-Hilt components - */ -@dagger.hilt.EntryPoint -@dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) -interface DatabaseEntryPoint { - fun database(): PluviaDatabase -} 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 5a1664af3..583290a16 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 @@ -66,6 +66,7 @@ import kotlinx.coroutines.launch import app.gamenative.utils.LocaleHelper import app.gamenative.ui.component.dialog.GOGLoginDialog import app.gamenative.service.gog.GOGService +import app.gamenative.service.gog.GOGLibraryManager import dagger.hilt.android.EntryPointAccessors import app.gamenative.di.DatabaseModule @@ -78,7 +79,7 @@ fun SettingsGroupInterface( ) { val context = LocalContext.current - // Get GOGGameDao from Hilt + // Get GOGGameDao and GOGLibraryManager from Hilt val gogGameDao = remember { val appContext = context.applicationContext val entryPoint = EntryPointAccessors.fromApplication( @@ -87,6 +88,15 @@ fun SettingsGroupInterface( ) entryPoint.gogGameDao() } + + val gogLibraryManager = remember { + val appContext = context.applicationContext + val entryPoint = EntryPointAccessors.fromApplication( + appContext, + DatabaseEntryPoint::class.java + ) + entryPoint.gogLibraryManager() + } var openWebLinks by rememberSaveable { mutableStateOf(PrefManager.openWebLinksExternally) } @@ -315,35 +325,19 @@ fun SettingsGroupInterface( coroutineScope.launch { try { timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") - val libraryResult = app.gamenative.service.gog.GOGService.listGames(context) - - if (libraryResult.isSuccess) { - val games = libraryResult.getOrNull() ?: emptyList() - gogLibraryGameCount = games.size - timber.log.Timber.i("[SettingsGOG]: ✓ Synced ${games.size} games from GOG library") - - // Save games to database - try { - withContext(Dispatchers.IO) { - gogGameDao.upsertPreservingInstallStatus(games) - } - timber.log.Timber.i("[SettingsGOG]: ✓ Saved ${games.size} games to database") - } catch (e: Exception) { - timber.log.Timber.e(e, "[SettingsGOG]: Failed to save games to database") - } + + // Use GOGLibraryManager.refreshLibrary() which handles everything + val result = gogLibraryManager.refreshLibrary(context) - // Log first few games - games.take(5).forEach { game -> - timber.log.Timber.d("[SettingsGOG]: - ${game.title} (${game.id})") - } - if (games.size > 5) { - timber.log.Timber.d("[SettingsGOG]: ... and ${games.size - 5} more") - } + if (result.isSuccess) { + val count = result.getOrNull() ?: 0 + gogLibraryGameCount = count + timber.log.Timber.i("[SettingsGOG]: ✓ Successfully synced $count games from GOG") gogLibrarySyncing = false gogLibrarySyncSuccess = true } else { - val error = libraryResult.exceptionOrNull()?.message ?: "Failed to sync library" + val error = result.exceptionOrNull()?.message ?: "Failed to sync library" timber.log.Timber.e("[SettingsGOG]: Library sync failed: $error") gogLibrarySyncing = false gogLibrarySyncError = error @@ -760,8 +754,9 @@ private fun Preview_SettingsScreen() { /** * Hilt EntryPoint to access DAOs from Composables */ -@dagger.hilt.EntryPoint -@dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) -interface DatabaseEntryPoint { - fun gogGameDao(): app.gamenative.db.dao.GOGGameDao -} + @dagger.hilt.EntryPoint + @dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) + interface DatabaseEntryPoint { + fun gogGameDao(): app.gamenative.db.dao.GOGGameDao + fun gogLibraryManager(): GOGLibraryManager + } \ No newline at end of file From b0c07fd85525c249caa7ad31ae3fccea41dd70e8 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 8 Dec 2025 11:41:11 +0000 Subject: [PATCH 009/122] fixed issue with library parsing and images. --- .../service/gog/GOGLibraryManager.kt | 47 ++++++++---- .../app/gamenative/service/gog/GOGService.kt | 45 ++++------- .../gamenative/ui/model/LibraryViewModel.kt | 2 +- .../screen/library/appscreen/GOGAppScreen.kt | 2 +- .../library/components/LibraryAppItem.kt | 76 +++++++++++-------- app/src/main/python/gogdl/cli.py | 42 ++++++++-- 6 files changed, 129 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt index 90b49a04d..4440d2187 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt @@ -45,14 +45,14 @@ class GOGLibraryManager @Inject constructor( return@withContext Result.failure(Exception("No stored credentials found")) } - Timber.i("Starting GOG library background sync...") + Timber.tag("GOG").i("Starting GOG library background sync...") // Use the same refresh logic but don't block on completion val result = refreshLibrary(context) - + if (result.isSuccess) { val count = result.getOrNull() ?: 0 - Timber.i("Background sync completed: $count games synced") + Timber.tag("GOG").i("Background sync completed: $count games synced") Result.success(Unit) } else { val error = result.exceptionOrNull() @@ -76,35 +76,54 @@ class GOGLibraryManager @Inject constructor( return@withContext Result.failure(Exception("Not authenticated with GOG")) } - Timber.i("Refreshing GOG library from GOG API...") - + Timber.tag("GOG").i("Refreshing GOG library from GOG API...") + // Fetch games from GOG via GOGDL Python backend val listResult = GOGService.listGames(context) - + if (listResult.isFailure) { val error = listResult.exceptionOrNull() Timber.e(error, "Failed to fetch games from GOG: ${error?.message}") return@withContext Result.failure(error ?: Exception("Failed to fetch GOG library")) } - + val games = listResult.getOrNull() ?: emptyList() - Timber.i("Successfully fetched ${games.size} games from GOG") - + Timber.tag("GOG").i("Successfully fetched ${games.size} games from GOG") + if (games.isEmpty()) { Timber.w("No games found in GOG library") return@withContext Result.success(0) } - + // Log sample of fetched games games.take(3).forEach { game -> - Timber.d("Fetched game: ${game.title} (${game.id}) - ${game.developer}") + Timber.tag("GOG").d(""" + |=== Fetched GOG Game === + |ID: ${game.id} + |Title: ${game.title} + |Slug: ${game.slug} + |Developer: ${game.developer} + |Publisher: ${game.publisher} + |Description: ${game.description.take(100)}... + |Release Date: ${game.releaseDate} + |Image URL: ${game.imageUrl} + |Icon URL: ${game.iconUrl} + |Genres: ${game.genres.joinToString(", ")} + |Languages: ${game.languages.joinToString(", ")} + |Download Size: ${game.downloadSize} + |Install Size: ${game.installSize} + |Is Installed: ${game.isInstalled} + |Install Path: ${game.installPath} + |Type: ${game.type} + |======================= + """.trimMargin()) } - + // Update database using upsert to preserve install status Timber.d("Upserting ${games.size} games to database...") gogGameDao.upsertPreservingInstallStatus(games) - - Timber.i("Successfully refreshed GOG library with ${games.size} games") + + Timber.tag("GOG").i("Successfully refreshed GOG library with ${games.size} games") Result.success(games.size) } catch (e: Exception) { Timber.e(e, "Failed to refresh GOG library") diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index b7093056a..7121d6019 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -689,15 +689,15 @@ class GOGService @Inject constructor() : Service() { id = gameObj.optString("id", ""), title = gameObj.optString("title", "Unknown Game"), slug = gameObj.optString("slug", ""), - imageUrl = gameObj.optString("image", ""), - iconUrl = gameObj.optString("icon", ""), + imageUrl = gameObj.optString("imageUrl", ""), + iconUrl = gameObj.optString("iconUrl", ""), description = gameObj.optString("description", ""), releaseDate = gameObj.optString("releaseDate", ""), developer = gameObj.optString("developer", ""), publisher = gameObj.optString("publisher", ""), genres = genresList, languages = languagesList, - downloadSize = 0L, // Will be fetched separately when needed + downloadSize = gameObj.optLong("downloadSize", 0L), installSize = 0L, isInstalled = false, installPath = "", @@ -705,6 +705,18 @@ class GOGService @Inject constructor() : Service() { playTime = 0L, ) + // Debug: Log the raw developer/publisher data from API + if (i == 0) { // Only log first game to avoid spam + Timber.tag("GOG").d("=== DEBUG: First game API response ===") + Timber.tag("GOG").d("Game: ${game.title} (${game.id})") + Timber.tag("GOG").d("Developer field: ${gameObj.optString("developer", "EMPTY")}") + Timber.tag("GOG").d("Publisher field: ${gameObj.optString("publisher", "EMPTY")}") + Timber.tag("GOG").d("_debug_developers_raw: ${gameObj.opt("_debug_developers_raw")}") + Timber.tag("GOG").d("_debug_publisher_raw: ${gameObj.opt("_debug_publisher_raw")}") + Timber.tag("GOG").d("Full game object keys: ${gameObj.keys().asSequence().toList()}") + Timber.tag("GOG").d("=====================================") + } + games.add(game) } catch (e: Exception) { Timber.w(e, "Failed to parse game at index $i, skipping") @@ -795,7 +807,6 @@ class GOGService @Inject constructor() : Service() { } } - /** * Execute GOGDL command with progress callback */ @@ -905,33 +916,7 @@ class GOGService @Inject constructor() : Service() { * TODO: Implement cloud save sync */ suspend fun syncCloudSaves(gameId: String, savePath: String, authConfigPath: String, timestamp: Float = 0.0f): Result { - - // ! Keep out CloudSaves till we understand how they work. - // ! Return Result.success() - return Result.success(Unit) - // return try { - // Timber.i("Starting GOG cloud save sync for game $gameId") - - // val result = executeCommand( - // "--auth-config-path", authConfigPath, - // "save-sync", savePath, - // "--dirname", gameId, - // "--timestamp", timestamp.toString(), - // ) - - // if (result.isSuccess) { - // Timber.i("GOG cloud save sync completed successfully for game $gameId") - // Result.success(Unit) - // } else { - // val error = result.exceptionOrNull() ?: Exception("Save sync failed") - // Timber.e(error, "GOG cloud save sync failed for game $gameId") - // Result.failure(error) - // } - // } catch (e: Exception) { - // Timber.e(e, "GOG cloud save sync exception for game $gameId") - // Result.failure(e) - // } } /** 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 dad94cb6b..4996a53c4 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -347,7 +347,7 @@ class LibraryViewModel @Inject constructor( index = 0, appId = "${GameSource.GOG.name}_${game.id}", name = game.title, - iconHash = game.iconUrl, + iconHash = game.imageUrl.ifEmpty { game.iconUrl }, // Use imageUrl (banner) with iconUrl as fallback isShared = false, gameSource = GameSource.GOG, ), 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 index 8dc426b05..150a88f2d 100644 --- 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 @@ -140,7 +140,7 @@ class GOGAppScreen : BaseAppScreen() { releaseDate = 0L, // GOG uses string release dates, would need parsing developer = game?.developer ?: "Unknown" ) - Timber.tag(TAG).d("Returning GameDisplayInfo: name=${displayInfo.name}, developer=${displayInfo.developer}") + Timber.tag(TAG).d("Returning GameDisplayInfo: name=${displayInfo.name}, iconUrl=${displayInfo.iconUrl}, heroImageUrl=${displayInfo.heroImageUrl}, developer=${displayInfo.developer}") return displayInfo } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt index b9f1410b7..b876477c0 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 @@ -199,44 +199,56 @@ internal fun AppItem( } val imageUrl = remember(appInfo.appId, paneType, imageRefreshCounter) { - if (appInfo.gameSource == GameSource.CUSTOM_GAME) { - // For Custom Games, use SteamGridDB images - when (paneType) { - PaneType.GRID_CAPSULE -> { - // Vertical grid for capsule - findSteamGridDBImage("grid_capsule") - ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" - } - PaneType.GRID_HERO -> { - // Horizontal grid for hero view - findSteamGridDBImage("grid_hero") - ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" - } - else -> { - // For list view, use heroes endpoint (not grid_hero) - val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appInfo.appId) - val heroUrl = gameFolderPath?.let { path -> - val folder = java.io.File(path) - val heroFile = folder.listFiles()?.firstOrNull { file -> - file.name.startsWith("steamgriddb_hero") && - !file.name.contains("grid") && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) + val url = when (appInfo.gameSource) { + GameSource.CUSTOM_GAME -> { + // For Custom Games, use SteamGridDB images + when (paneType) { + PaneType.GRID_CAPSULE -> { + // Vertical grid for capsule + findSteamGridDBImage("grid_capsule") + ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" + } + PaneType.GRID_HERO -> { + // Horizontal grid for hero view + findSteamGridDBImage("grid_hero") + ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" + } + else -> { + // For list view, use heroes endpoint (not grid_hero) + val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appInfo.appId) + val heroUrl = gameFolderPath?.let { path -> + val folder = java.io.File(path) + val heroFile = folder.listFiles()?.firstOrNull { file -> + file.name.startsWith("steamgriddb_hero") && + !file.name.contains("grid") && + (file.name.endsWith(".png", ignoreCase = true) || + file.name.endsWith(".jpg", ignoreCase = true) || + file.name.endsWith(".webp", ignoreCase = true)) + } + heroFile?.let { android.net.Uri.fromFile(it).toString() } } - heroFile?.let { android.net.Uri.fromFile(it).toString() } + heroUrl ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" } - heroUrl ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" } } - } else { - // For Steam games, use standard Steam URLs - if (paneType == PaneType.GRID_CAPSULE) { - "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" - } else { - "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" + GameSource.GOG -> { + // For GOG games, use the iconHash which contains the full image URL + // GOG stores images directly in iconHash (populated from GOGGame.imageUrl or iconUrl) + // The imageUrl is typically a larger banner/hero image, iconUrl is smaller icon + val gogUrl = appInfo.iconHash.ifEmpty { appInfo.clientIconUrl } + timber.log.Timber.d("GOG image URL for ${appInfo.name}: iconHash='${appInfo.iconHash}', clientIconUrl='${appInfo.clientIconUrl}', final='$gogUrl'") + gogUrl + } + GameSource.STEAM -> { + // For Steam games, use standard Steam URLs + if (paneType == PaneType.GRID_CAPSULE) { + "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" + } else { + "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" + } } } + url } // Reset alpha and hideText when image URL changes (e.g., when new images are fetched) diff --git a/app/src/main/python/gogdl/cli.py b/app/src/main/python/gogdl/cli.py index fb95a3176..74dd476dd 100644 --- a/app/src/main/python/gogdl/cli.py +++ b/app/src/main/python/gogdl/cli.py @@ -52,22 +52,50 @@ def handle_list(arguments, api_handler): logger.info(f"Fetching details for game {index}/{len(owned_games)}: {game_id}") # Get full game info with expanded data - game_info = api_handler.get_item_data(game_id, expanded=['downloads']) + game_info = api_handler.get_item_data(game_id, expanded=['downloads', 'description', 'screenshots', 'videos']) + # Log what we got back if game_info: + logger.info(f"Game {game_id} API response keys: {list(game_info.keys())}") + logger.debug(f"Game {game_id} has developers: {'developers' in game_info}") + logger.debug(f"Game {game_id} has publisher: {'publisher' in game_info}") + logger.debug(f"Game {game_id} has genres: {'genres' in game_info}") + + if game_info: + # Extract image URLs and ensure they have protocol + logo2x = game_info.get('images', {}).get('logo2x', '') + logo = game_info.get('images', {}).get('logo', '') + icon = game_info.get('images', {}).get('icon', '') + + # Add https: protocol if missing + if logo2x and logo2x.startswith('//'): + logo2x = 'https:' + logo2x + if logo and logo.startswith('//'): + logo = 'https:' + logo + if icon and icon.startswith('//'): + icon = 'https:' + icon + + # Extract download size from first installer + download_size = 0 + downloads = game_info.get('downloads', {}) + installers = downloads.get('installers', []) + if installers and len(installers) > 0: + download_size = installers[0].get('total_size', 0) + # Extract relevant fields game_entry = { "id": game_id, "title": game_info.get('title', 'Unknown'), "slug": game_info.get('slug', ''), - "imageUrl": game_info.get('images', {}).get('logo2x', '') or game_info.get('images', {}).get('logo', ''), - "iconUrl": game_info.get('images', {}).get('icon', ''), + "imageUrl": logo2x or logo, + "iconUrl": icon, "developer": game_info.get('developers', [{}])[0].get('name', '') if game_info.get('developers') else '', - "publisher": game_info.get('publisher', {}).get('name', ''), - "genres": [g.get('name', '') for g in game_info.get('genres', [])], + "publisher": game_info.get('publisher', {}).get('name', '') if isinstance(game_info.get('publisher'), dict) else game_info.get('publisher', ''), + "genres": [g.get('name', '') if isinstance(g, dict) else str(g) for g in game_info.get('genres', [])], "languages": list(game_info.get('languages', {}).keys()), - "description": game_info.get('description', {}).get('lead', ''), - "releaseDate": game_info.get('release_date', '') + "description": game_info.get('description', {}).get('lead', '') if isinstance(game_info.get('description'), dict) else '', + "releaseDate": game_info.get('release_date', ''), + "downloadSize": download_size } games_list.append(game_entry) logger.debug(f" ✓ {game_entry['title']}") From 0dec3b1a411ac49846bfe039b04d61284dc1e8bc Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 8 Dec 2025 12:08:50 +0000 Subject: [PATCH 010/122] download progress --- .../screen/library/appscreen/GOGAppScreen.kt | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) 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 index 150a88f2d..9967bc483 100644 --- 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 @@ -247,6 +247,11 @@ class GOGAppScreen : BaseAppScreen() { if (result.isSuccess) { val info = result.getOrNull() Timber.i("GOG download started successfully for: $gameId") + + // Emit download started event to update UI state immediately + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(libraryItem.gameId, true) + ) // Monitor download completion info?.let { downloadInfo -> @@ -272,16 +277,29 @@ class GOGAppScreen : BaseAppScreen() { Timber.d("Updated GOG game install status in database") } - // Trigger library refresh + // Emit download stopped event + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(libraryItem.gameId, false) + ) + + // Trigger library refresh for install status app.gamenative.PluviaApp.events.emitJava( app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(libraryItem.gameId) ) } else { Timber.w("GOG download did not complete successfully: $finalProgress") + // Emit download stopped event even if failed/cancelled + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(libraryItem.gameId, false) + ) } } } else { Timber.e(result.exceptionOrNull(), "Failed to start GOG download") + // Emit download stopped event if download failed to start + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(libraryItem.gameId, false) + ) withContext(Dispatchers.Main) { android.widget.Toast.makeText( context, @@ -527,6 +545,25 @@ class GOGAppScreen : BaseAppScreen() { ) { Timber.tag(TAG).d("AdditionalDialogs: composing for appId=${libraryItem.appId}") val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Listen for download status changes to trigger UI refresh + LaunchedEffect(libraryItem.appId) { + val downloadListener: (app.gamenative.events.AndroidEvent.DownloadStatusChanged) -> Unit = { event -> + if (event.appId == libraryItem.gameId) { + Timber.tag(TAG).d("Download status changed for ${libraryItem.appId}: isDownloading=${event.isDownloading}") + // Trigger state refresh in BaseAppScreen by emitting install status event + scope.launch { + kotlinx.coroutines.delay(100) // Small delay to ensure download info is updated + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(libraryItem.gameId) + ) + } + } + } + app.gamenative.PluviaApp.events.on(downloadListener) + kotlinx.coroutines.awaitCancellation() + } // Monitor uninstall dialog state var showUninstallDialog by remember { mutableStateOf(shouldShowUninstallDialog(libraryItem.appId)) } From c6721a1463d9f9fccc7593f228ceb899a7078d8c Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 8 Dec 2025 17:30:03 +0000 Subject: [PATCH 011/122] finished download/delete --- app/src/main/AndroidManifest.xml | 6 + .../java/app/gamenative/data/LibraryItem.kt | 8 +- .../service/gog/GOGLibraryManager.kt | 10 + .../app/gamenative/service/gog/GOGService.kt | 120 +++++++++++- .../main/java/app/gamenative/ui/PluviaMain.kt | 8 + .../gamenative/ui/model/LibraryViewModel.kt | 2 +- .../screen/library/appscreen/GOGAppScreen.kt | 173 +++++++++++++----- .../app/gamenative/utils/ContainerUtils.kt | 4 +- 8 files changed, 274 insertions(+), 57 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5f0ac06e6..3bc1fe2de 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -122,6 +122,12 @@ android:exported="false" android:foregroundServiceType="dataSync" /> + + appId.toIntOrNull() ?: 0 + else -> appId.removePrefix("${gameSource.name}_").toIntOrNull() ?: 0 + } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt index 4440d2187..616560353 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt @@ -25,6 +25,16 @@ class GOGLibraryManager @Inject constructor( } } + /** + * Insert or update a GOG game in database + * Uses REPLACE strategy, so will update if exists + */ + suspend fun insertGame(game: GOGGame) { + withContext(Dispatchers.IO) { + gogGameDao.insert(game) + } + } + /** * Update a GOG game in database */ diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 7121d6019..c2ef9262a 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -4,9 +4,12 @@ import android.app.Service import android.content.Context import android.content.Intent import android.os.IBinder +import androidx.room.Room import app.gamenative.data.DownloadInfo import app.gamenative.data.GOGCredentials import app.gamenative.data.GOGGame +import app.gamenative.db.PluviaDatabase +import app.gamenative.db.DATABASE_NAME import app.gamenative.service.NotificationHelper import app.gamenative.utils.ContainerUtils import com.chaquo.python.Kwarg @@ -15,8 +18,6 @@ import com.chaquo.python.Python import com.chaquo.python.android.AndroidPlatform import java.io.File import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.* import okhttp3.OkHttpClient import org.json.JSONObject @@ -61,8 +62,7 @@ class ProgressCallback(private val downloadInfo: DownloadInfo) { } } -@Singleton -class GOGService @Inject constructor() : Service() { +class GOGService : Service() { companion object { private var instance: GOGService? = null @@ -469,6 +469,20 @@ class GOGService @Inject constructor() : Service() { getInstance()?.gogLibraryManager?.updateGame(game) } + /** + * Insert or update GOG game in database (uses REPLACE strategy) + */ + suspend fun insertOrUpdateGOGGame(game: GOGGame) { + val instance = getInstance() + if (instance == null) { + timber.log.Timber.e("GOGService instance is null, cannot insert game") + return + } + timber.log.Timber.d("Inserting game: id=${game.id}, isInstalled=${game.isInstalled}, installPath=${game.installPath}") + instance.gogLibraryManager.insertGame(game) + timber.log.Timber.d("Insert completed for game: ${game.id}") + } + /** * Check if a GOG game is installed (synchronous for UI) */ @@ -735,6 +749,89 @@ class GOGService @Inject constructor() : Service() { } } + /** + * Fetch a single game's metadata from GOG API and insert it into the database + * Used when a game is downloaded but not in the database + */ + suspend fun refreshSingleGame(gameId: String, context: Context): Result { + return try { + Timber.i("Fetching single game data for gameId: $gameId") + val authConfigPath = "${context.filesDir}/gog_auth.json" + + if (!hasStoredCredentials(context)) { + return Result.failure(Exception("Not authenticated")) + } + + // Execute gogdl list command and find this specific game + val result = executeCommand("--auth-config-path", authConfigPath, "list", "--pretty") + + if (result.isFailure) { + return Result.failure(result.exceptionOrNull() ?: Exception("Failed to fetch game data")) + } + + val output = result.getOrNull() ?: "" + val gamesArray = org.json.JSONArray(output.trim()) + + // Find the game with matching ID + for (i in 0 until gamesArray.length()) { + val gameObj = gamesArray.getJSONObject(i) + if (gameObj.optString("id", "") == gameId) { + // Parse genres + val genresList = mutableListOf() + gameObj.optJSONArray("genres")?.let { genresArray -> + for (j in 0 until genresArray.length()) { + genresList.add(genresArray.getString(j)) + } + } + + // Parse languages + val languagesList = mutableListOf() + gameObj.optJSONArray("languages")?.let { languagesArray -> + for (j in 0 until languagesArray.length()) { + languagesList.add(languagesArray.getString(j)) + } + } + + val game = GOGGame( + id = gameObj.optString("id", ""), + title = gameObj.optString("title", "Unknown Game"), + slug = gameObj.optString("slug", ""), + imageUrl = gameObj.optString("imageUrl", ""), + iconUrl = gameObj.optString("iconUrl", ""), + description = gameObj.optString("description", ""), + releaseDate = gameObj.optString("releaseDate", ""), + developer = gameObj.optString("developer", ""), + publisher = gameObj.optString("publisher", ""), + genres = genresList, + languages = languagesList, + downloadSize = gameObj.optLong("downloadSize", 0L), + installSize = 0L, + isInstalled = false, + installPath = "", + lastPlayed = 0L, + playTime = 0L, + ) + + // Insert into database + getInstance()?.gogLibraryManager?.let { manager -> + withContext(Dispatchers.IO) { + manager.insertGame(game) + } + } + + Timber.i("Successfully fetched and inserted game: ${game.title}") + return Result.success(game) + } + } + + Timber.w("Game $gameId not found in GOG library") + Result.success(null) + } catch (e: Exception) { + Timber.e(e, "Error fetching single game data for $gameId") + Result.failure(e) + } + } + /** * Download a GOG game with full progress tracking via GOGDL log parsing */ @@ -964,9 +1061,7 @@ class GOGService @Inject constructor() : Service() { // Add these for foreground service support private lateinit var notificationHelper: NotificationHelper - - @Inject - lateinit var gogLibraryManager: GOGLibraryManager + private lateinit var gogLibraryManager: GOGLibraryManager private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -977,11 +1072,22 @@ class GOGService @Inject constructor() : Service() { super.onCreate() instance = this + // Initialize GOGLibraryManager with database DAO + val database = Room.databaseBuilder( + applicationContext, + PluviaDatabase::class.java, + DATABASE_NAME + ).build() + gogLibraryManager = GOGLibraryManager(database.gogGameDao()) + + Timber.d("GOGService.onCreate() - instance and gogLibraryManager initialized") + // Initialize notification helper for foreground service notificationHelper = NotificationHelper(applicationContext) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Timber.d("GOGService.onStartCommand() - gogLibraryManager initialized: ${::gogLibraryManager.isInitialized}") // Start as foreground service val notification = notificationHelper.createForegroundNotification("GOG Service running...") startForeground(2, notification) // Use different ID than SteamService (which uses 1) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index d50af6be3..09d0f2071 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -401,6 +401,14 @@ fun PluviaMain( isConnecting = true context.startForegroundService(Intent(context, SteamService::class.java)) } + + // Start GOGService if user has GOG credentials + if (app.gamenative.service.gog.GOGService.hasStoredCredentials(context) && + !app.gamenative.service.gog.GOGService.isRunning) { + Timber.d("[PluviaMain]: Starting GOGService for logged-in user") + app.gamenative.service.gog.GOGService.start(context) + } + if (SteamService.isLoggedIn && !SteamService.isGameRunning && state.currentScreen == PluviaScreen.LoginUser) { navController.navigate(PluviaScreen.Home.route) } 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 4996a53c4..b37b9b6b5 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -345,7 +345,7 @@ class LibraryViewModel @Inject constructor( LibraryEntry( item = LibraryItem( index = 0, - appId = "${GameSource.GOG.name}_${game.id}", + appId = game.id, // Use plain game ID without GOG_ prefix name = game.title, iconHash = game.imageUrl.ifEmpty { game.iconUrl }, // Use imageUrl (banner) with iconUrl as fallback isShared = false, 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 index 9967bc483..825ce38be 100644 --- 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 @@ -93,9 +93,8 @@ class GOGAppScreen : BaseAppScreen() { libraryItem: LibraryItem ): GameDisplayInfo { Timber.tag(TAG).d("getGameDisplayInfo: appId=${libraryItem.appId}, name=${libraryItem.name}") - val gameId = remember(libraryItem.appId) { - ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() - } + // For GOG games, appId is already the numeric game ID (no prefix) + val gameId = libraryItem.appId val gogGame = remember(gameId) { val game = GOGService.getGOGGameOf(gameId) if (game != null) { @@ -135,7 +134,7 @@ class GOGAppScreen : BaseAppScreen() { name = game?.title ?: libraryItem.name, iconUrl = game?.iconUrl ?: libraryItem.iconHash, heroImageUrl = game?.imageUrl ?: game?.iconUrl ?: libraryItem.iconHash, - gameId = libraryItem.appId.removePrefix("GOG_").toIntOrNull() ?: 0, + gameId = libraryItem.gameId, // Use gameId property which handles conversion appId = libraryItem.appId, releaseDate = 0L, // GOG uses string release dates, would need parsing developer = game?.developer ?: "Unknown" @@ -147,9 +146,9 @@ class GOGAppScreen : BaseAppScreen() { override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean { Timber.tag(TAG).d("isInstalled: checking appId=${libraryItem.appId}") return try { - val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() - val installed = GOGService.isGameInstalled(gameId) - Timber.tag(TAG).d("isInstalled: appId=${libraryItem.appId}, gameId=$gameId, result=$installed") + // For GOG games, appId is already the numeric game ID + val installed = GOGService.isGameInstalled(libraryItem.appId) + 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}") @@ -170,6 +169,7 @@ class GOGAppScreen : BaseAppScreen() { 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 + // For GOG games, appId is already the numeric game ID val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) val progress = downloadInfo?.getProgress() ?: 0f val downloading = downloadInfo != null && progress in 0f..0.99f @@ -178,7 +178,7 @@ class GOGAppScreen : BaseAppScreen() { } override fun getDownloadProgress(context: Context, libraryItem: LibraryItem): Float { - // Get actual download progress from GOGService + // For GOG games, appId is already the numeric game ID val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) val progress = downloadInfo?.getProgress() ?: 0f Timber.tag(TAG).d("getDownloadProgress: appId=${libraryItem.appId}, progress=$progress") @@ -187,12 +187,12 @@ class GOGAppScreen : BaseAppScreen() { override fun onDownloadInstallClick(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { Timber.tag(TAG).i("onDownloadInstallClick: appId=${libraryItem.appId}, name=${libraryItem.name}") - val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + // For GOG games, appId is already the numeric game ID val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f val installed = isInstalled(context, libraryItem) - Timber.tag(TAG).d("onDownloadInstallClick: gameId=$gameId, isDownloading=$isDownloading, installed=$installed") + Timber.tag(TAG).d("onDownloadInstallClick: appId=${libraryItem.appId}, isDownloading=$isDownloading, installed=$installed") if (isDownloading) { // Cancel ongoing download @@ -213,7 +213,8 @@ class GOGAppScreen : BaseAppScreen() { * Perform the actual download after confirmation */ private fun performDownload(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { - val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + // For GOG games, appId is already the numeric game ID + val gameId = libraryItem.appId Timber.i("Starting GOG game download: ${libraryItem.appId}") CoroutineScope(Dispatchers.IO).launch { try { @@ -247,11 +248,13 @@ class GOGAppScreen : BaseAppScreen() { if (result.isSuccess) { val info = result.getOrNull() Timber.i("GOG download started successfully for: $gameId") - + // Emit download started event to update UI state immediately + Timber.tag(TAG).d("[EVENT] Emitting DownloadStatusChanged: appId=${libraryItem.gameId} (from appId=${libraryItem.appId}), isDownloading=true") app.gamenative.PluviaApp.events.emitJava( app.gamenative.events.AndroidEvent.DownloadStatusChanged(libraryItem.gameId, true) ) + Timber.tag(TAG).d("[EVENT] Emitted DownloadStatusChanged event") // Monitor download completion info?.let { downloadInfo -> @@ -261,31 +264,75 @@ class GOGAppScreen : BaseAppScreen() { } val finalProgress = downloadInfo.getProgress() + Timber.i("GOG download final progress: $finalProgress for game: $gameId") if (finalProgress >= 1.0f) { // Download completed successfully Timber.i("GOG download completed: $gameId") - // Update database via GOGService - val game = GOGService.getGOGGameOf(gameId) + // Update or create database entry + Timber.d("Attempting to fetch game from database for gameId: $gameId") + var game = GOGService.getGOGGameOf(gameId) + Timber.d("Fetched game from database: game=${game?.title}, isInstalled=${game?.isInstalled}, installPath=${game?.installPath}") + if (game != null) { + // Game exists in database - update install status + Timber.d("Updating existing game install status: isInstalled=true, installPath=$installPath") GOGService.updateGOGGame( game.copy( isInstalled = true, installPath = installPath ) ) - Timber.d("Updated GOG game install status in database") + Timber.i("Updated GOG game install status in database for ${game.title}") + } else { + // Game not in database - fetch from API and insert + Timber.w("Game not found in database, fetching from GOG API for gameId: $gameId") + try { + val result = GOGService.refreshSingleGame(gameId, context) + if (result.isSuccess) { + game = result.getOrNull() + if (game != null) { + // Insert/update the newly fetched game with install info using REPLACE strategy + val updatedGame = game.copy( + isInstalled = true, + installPath = installPath + ) + Timber.d("About to insert game with: isInstalled=true, installPath=$installPath") + + // Wait for database write to complete + withContext(Dispatchers.IO) { + GOGService.insertOrUpdateGOGGame(updatedGame) + } + + Timber.i("Fetched and inserted GOG game ${game.title} with install status") + Timber.d("Game install status in memory: isInstalled=${updatedGame.isInstalled}, installPath=${updatedGame.installPath}") + + // Verify database write + val verifyGame = GOGService.getGOGGameOf(gameId) + Timber.d("Verification read from database: isInstalled=${verifyGame?.isInstalled}, installPath=${verifyGame?.installPath}") + } else { + Timber.w("Failed to fetch game data from GOG API for gameId: $gameId") + } + } else { + Timber.e(result.exceptionOrNull(), "Error fetching game from GOG API: $gameId") + } + } catch (e: Exception) { + Timber.e(e, "Exception fetching game from GOG API: $gameId") + } } // Emit download stopped event + Timber.tag(TAG).d("[EVENT] Emitting DownloadStatusChanged: appId=${libraryItem.gameId}, isDownloading=false") app.gamenative.PluviaApp.events.emitJava( app.gamenative.events.AndroidEvent.DownloadStatusChanged(libraryItem.gameId, false) ) // Trigger library refresh for install status + Timber.tag(TAG).d("[EVENT] Emitting LibraryInstallStatusChanged: appId=${libraryItem.gameId}") app.gamenative.PluviaApp.events.emitJava( app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(libraryItem.gameId) ) + Timber.tag(TAG).d("[EVENT] All completion events emitted") } else { Timber.w("GOG download did not complete successfully: $finalProgress") // Emit download stopped event even if failed/cancelled @@ -323,9 +370,10 @@ class GOGAppScreen : BaseAppScreen() { override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) { Timber.tag(TAG).i("onPauseResumeClick: appId=${libraryItem.appId}") + // For GOG games, appId is already the numeric game ID val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f - Timber.tag(TAG).d("onPauseResumeClick: isDownloading=$isDownloading") + Timber.tag(TAG).d("onPauseResumeClick: appId=${libraryItem.appId}, isDownloading=$isDownloading") if (isDownloading) { // Cancel/pause download @@ -340,10 +388,11 @@ class GOGAppScreen : BaseAppScreen() { override fun onDeleteDownloadClick(context: Context, libraryItem: LibraryItem) { Timber.tag(TAG).i("onDeleteDownloadClick: appId=${libraryItem.appId}") + // For GOG games, appId is already the numeric game ID val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f val isInstalled = isInstalled(context, libraryItem) - Timber.tag(TAG).d("onDeleteDownloadClick: isDownloading=$isDownloading, isInstalled=$isInstalled") + Timber.tag(TAG).d("onDeleteDownloadClick: appId=${libraryItem.appId}, isDownloading=$isDownloading, isInstalled=$isInstalled") if (isDownloading) { // Cancel download immediately if currently downloading @@ -368,7 +417,8 @@ class GOGAppScreen : BaseAppScreen() { Timber.i("Uninstalling GOG game: ${libraryItem.appId}") CoroutineScope(Dispatchers.IO).launch { try { - val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() + // For GOG games, appId is already the numeric game ID + val gameId = libraryItem.appId // Get install path from GOGService val game = GOGService.getGOGGameOf(gameId) @@ -450,9 +500,9 @@ class GOGAppScreen : BaseAppScreen() { override fun getInstallPath(context: Context, libraryItem: LibraryItem): String? { Timber.tag(TAG).d("getInstallPath: appId=${libraryItem.appId}") return try { - val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() - val path = GOGService.getInstallPath(gameId) - Timber.tag(TAG).d("getInstallPath: gameId=$gameId, path=$path") + // For GOG games, appId is already the numeric game ID + val path = GOGService.getInstallPath(libraryItem.appId) + 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}") @@ -533,6 +583,58 @@ class GOGAppScreen : BaseAppScreen() { return null // GOG uses CDN images, not local files } + /** + * Observe GOG game state changes (download progress, install status) + */ + 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>() + + // 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 + // For GOG games, appId is already the numeric game ID + val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + downloadInfo?.addProgressListener { progress -> + onProgressChanged(progress) + } + } else { + // Download stopped/completed + 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) */ @@ -545,25 +647,6 @@ class GOGAppScreen : BaseAppScreen() { ) { Timber.tag(TAG).d("AdditionalDialogs: composing for appId=${libraryItem.appId}") val context = LocalContext.current - val scope = rememberCoroutineScope() - - // Listen for download status changes to trigger UI refresh - LaunchedEffect(libraryItem.appId) { - val downloadListener: (app.gamenative.events.AndroidEvent.DownloadStatusChanged) -> Unit = { event -> - if (event.appId == libraryItem.gameId) { - Timber.tag(TAG).d("Download status changed for ${libraryItem.appId}: isDownloading=${event.isDownloading}") - // Trigger state refresh in BaseAppScreen by emitting install status event - scope.launch { - kotlinx.coroutines.delay(100) // Small delay to ensure download info is updated - app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(libraryItem.gameId) - ) - } - } - } - app.gamenative.PluviaApp.events.on(downloadListener) - kotlinx.coroutines.awaitCancellation() - } // Monitor uninstall dialog state var showUninstallDialog by remember { mutableStateOf(shouldShowUninstallDialog(libraryItem.appId)) } @@ -586,9 +669,8 @@ class GOGAppScreen : BaseAppScreen() { } // Show install confirmation dialog if (showInstallDialog) { - val gameId = remember(libraryItem.appId) { - ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() - } + // For GOG games, appId is already the numeric game ID + val gameId = libraryItem.appId val gogGame = remember(gameId) { GOGService.getGOGGameOf(gameId) } @@ -638,9 +720,8 @@ class GOGAppScreen : BaseAppScreen() { // Show uninstall confirmation dialog if (showUninstallDialog) { - val gameId = remember(libraryItem.appId) { - ContainerUtils.extractGameIdFromContainerId(libraryItem.appId).toString() - } + // For GOG games, appId is already the numeric game ID + val gameId = libraryItem.appId val gogGame = remember(gameId) { GOGService.getGOGGameOf(gameId) } diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 903f12cfa..be162dc4a 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -976,12 +976,14 @@ object ContainerUtils { /** * Extracts the game source from a container ID string + * Note: GOG games use plain numeric IDs without prefix */ fun extractGameSourceFromContainerId(containerId: String): GameSource { return when { containerId.startsWith("STEAM_") -> GameSource.STEAM containerId.startsWith("CUSTOM_GAME_") -> GameSource.CUSTOM_GAME - containerId.startsWith("GOG_") -> GameSource.GOG + // GOG games use plain numeric IDs - check if it's just a number + containerId.toIntOrNull() != null -> GameSource.GOG // Add other platforms here.. else -> GameSource.STEAM // default fallback } From 543e9fea34ddd9b2353f94833e9e43b4f183e671 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 8 Dec 2025 23:31:58 +0000 Subject: [PATCH 012/122] configuring launching exe files --- .../gamenative/service/gog/GOGConstants.kt | 41 ++- .../gamenative/service/gog/GOGGameManager.kt | 81 ++--- .../app/gamenative/service/gog/GOGService.kt | 284 +++++++++++++++++- .../main/java/app/gamenative/ui/PluviaMain.kt | 9 + .../screen/library/appscreen/GOGAppScreen.kt | 35 ++- .../ui/screen/xserver/XServerScreen.kt | 43 ++- .../app/gamenative/utils/ContainerUtils.kt | 31 +- 7 files changed, 461 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt index 3a405d2ae..084c21306 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt @@ -1,5 +1,10 @@ package app.gamenative.service.gog +import app.gamenative.PrefManager +import java.io.File +import java.nio.file.Paths +import timber.log.Timber + /** * Constants for GOG integration */ @@ -19,16 +24,46 @@ object GOGConstants { // GOG OAuth authorization URL with redirect const val GOG_AUTH_LOGIN_URL = "https://auth.gog.com/auth?client_id=$GOG_CLIENT_ID&redirect_uri=$GOG_REDIRECT_URI&response_type=code&layout=client2" - // GOG paths - const val GOG_GAMES_BASE_PATH = "/data/data/app.gamenative/files/gog_games" + // GOG paths - following Steam's structure pattern + private const val INTERNAL_BASE_PATH = "/data/data/app.gamenative/files" + + /** + * Internal GOG games installation path (similar to Steam's internal path) + * /data/data/app.gamenative/files/GOG/games/common/ + */ + val internalGOGGamesPath: String + get() = Paths.get(INTERNAL_BASE_PATH, "GOG", "games", "common").toString() + + /** + * External GOG games installation path (similar to Steam's external path) + * {externalStoragePath}/GOG/games/common/ + */ + val externalGOGGamesPath: String + get() = Paths.get(PrefManager.externalStoragePath, "GOG", "games", "common").toString() + + /** + * Default GOG games installation path based on storage preference + * Follows the same logic as Steam games + */ + 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 + } + } /** * Get the install path for a specific GOG game + * Similar to Steam's pattern: {base}/GOG/games/common/{sanitized_title}/ */ fun getGameInstallPath(gameTitle: String): String { // Sanitize game title for filesystem val sanitizedTitle = gameTitle.replace(Regex("[^a-zA-Z0-9 ]"), "").trim() - return "$GOG_GAMES_BASE_PATH/$sanitizedTitle" + return Paths.get(defaultGOGGamesPath, sanitizedTitle).toString() } /** diff --git a/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt index 3df846cbb..d33e86128 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt @@ -307,11 +307,12 @@ class GOGGameManager @Inject constructor( // Fallback to base path if game not found (shouldn't happen normally) Timber.w("Could not find game for appId $appId, using base path") - return GOGConstants.GOG_GAMES_BASE_PATH + return GOGConstants.defaultGOGGamesPath } /** * Launch game with save sync + * TODO: Implement GOG cloud save sync - currently disabled */ suspend fun launchGameWithSaveSync( context: Context, @@ -321,38 +322,14 @@ class GOGGameManager @Inject constructor( preferredSave: Int?, ): PostSyncInfo = withContext(Dispatchers.IO) { try { - Timber.i("Starting GOG game launch with save sync for ${libraryItem.name}") + Timber.i("Starting GOG game launch for ${libraryItem.name} (cloud save sync disabled)") - // Check if GOG credentials exist - if (!GOGService.hasStoredCredentials(context)) { - Timber.w("No GOG credentials found, skipping cloud save sync") - return@withContext PostSyncInfo(SyncResult.Success) // Continue without sync - } - - // Determine save path for GOG game - val savePath = "${getGameInstallPath(context, libraryItem.appId, libraryItem.name)}/saves" - val authConfigPath = "${context.filesDir}/gog_auth.json" + // TODO: Implement GOG cloud save sync + // For now, just skip sync and return success to allow game launch + return@withContext PostSyncInfo(SyncResult.Success) - Timber.i("Starting GOG cloud save sync for game ${libraryItem.gameId}") - - // Perform GOG cloud save sync - val syncResult = GOGService.syncCloudSaves( - gameId = libraryItem.gameId.toString(), - savePath = savePath, - authConfigPath = authConfigPath, - timestamp = 0.0f, - ) - - if (syncResult.isSuccess) { - Timber.i("GOG cloud save sync completed successfully") - PostSyncInfo(SyncResult.Success) - } else { - val error = syncResult.exceptionOrNull() - Timber.e(error, "GOG cloud save sync failed") - PostSyncInfo(SyncResult.UnknownFail) - } } catch (e: Exception) { - Timber.e(e, "GOG cloud save sync exception for game ${libraryItem.gameId}") + Timber.e(e, "GOG game launch exception for game ${libraryItem.gameId}") PostSyncInfo(SyncResult.UnknownFail) } } @@ -378,12 +355,23 @@ class GOGGameManager @Inject constructor( envVars: EnvVars, guestProgramLauncherComponent: GuestProgramLauncherComponent, ): String { - // For GOG games, we always want to launch the actual game - // because GOG doesn't have appLaunchInfo like Steam does - // Extract the numeric game ID from appId using the existing utility function val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId) + // Verify installation before attempting launch + val (isValid, errorMessage) = GOGService.verifyInstallation(gameId.toString()) + if (!isValid) { + Timber.e("Installation verification failed for game $gameId: $errorMessage") + // Return explorer.exe to avoid crashing, but log the error clearly + // In production, you might want to show a user-facing error dialog here + return "\"explorer.exe\"" + } + + Timber.i("Installation verified successfully for game $gameId") + + // For GOG games, we always want to launch the actual game + // because GOG doesn't have appLaunchInfo like Steam does + // Get the game details to find the correct title val game = runBlocking { getGameById(gameId.toString()) } if (game == null) { @@ -412,15 +400,34 @@ class GOGGameManager @Inject constructor( return "\"explorer.exe\"" } + // Find which drive letter is mapped to the GOG games directory + val gogGamesPath = GOGConstants.defaultGOGGamesPath + var gogDriveLetter: String? = null + + for (drive in Container.drivesIterator(container.drives)) { + if (drive[1] == gogGamesPath) { + gogDriveLetter = drive[0] + break + } + } + + if (gogDriveLetter == null) { + Timber.e("GOG games directory not mapped in container drives: $gogGamesPath") + Timber.e("Container drives: ${container.drives}") + return "\"explorer.exe\"" + } + + Timber.i("Found GOG games directory mapped to $gogDriveLetter: drive") + // Calculate the Windows path for the game subdirectory - val gameSubDirRelativePath = gameDir.relativeTo(File(GOGConstants.GOG_GAMES_BASE_PATH)).path.replace('\\', '/') - val windowsGamePath = "E:/gog_games/$gameSubDirRelativePath" + val gameSubDirRelativePath = gameDir.relativeTo(File(GOGConstants.defaultGOGGamesPath)).path.replace('\\', '/') + val windowsGamePath = "$gogDriveLetter:/$gameSubDirRelativePath" - // Set WINEPATH to the game subdirectory on E: drive + // Set WINEPATH to the game subdirectory envVars.put("WINEPATH", windowsGamePath) // Set the working directory to the game directory - val gameWorkingDir = File(GOGConstants.GOG_GAMES_BASE_PATH, gameSubDirRelativePath) + val gameWorkingDir = File(GOGConstants.defaultGOGGamesPath, gameSubDirRelativePath) guestProgramLauncherComponent.workingDir = gameWorkingDir Timber.i("Setting working directory to: ${gameWorkingDir.absolutePath}") diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index c2ef9262a..19ad62d99 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -485,10 +485,23 @@ class GOGService : Service() { /** * Check if a GOG game is installed (synchronous for UI) + * Verifies both database state and file system integrity */ fun isGameInstalled(gameId: String): Boolean { return runBlocking(Dispatchers.IO) { - getInstance()?.gogLibraryManager?.getGameById(gameId)?.isInstalled == true + val dbInstalled = getInstance()?.gogLibraryManager?.getGameById(gameId)?.isInstalled == true + if (!dbInstalled) { + return@runBlocking false + } + + // Verify the installation is actually valid + val (isValid, errorMessage) = verifyInstallation(gameId) + if (!isValid) { + Timber.w("Game $gameId marked as installed but verification failed: $errorMessage") + // Consider updating database to mark as not installed + // For now, we just return false + } + isValid } } @@ -502,6 +515,275 @@ class GOGService : Service() { } } + /** + * Verify that a GOG game installation is valid and complete + * + * Checks: + * 1. Install directory exists + * 2. Installation directory contains files (not empty) + * 3. goggame-{gameId}.info file exists (optional - for GOG Galaxy installs) + * 4. Primary executable exists (if specified in info file) + * + * Note: gogdl downloads don't create goggame-*.info files, so we primarily + * verify that the directory exists and has content. + * + * @return Pair - (isValid, errorMessage) + */ + fun verifyInstallation(gameId: String): Pair { + val installPath = getInstallPath(gameId) + if (installPath == null) { + return Pair(false, "Game not marked as installed in database") + } + + val installDir = File(installPath) + if (!installDir.exists()) { + Timber.w("Install directory doesn't exist: $installPath") + return Pair(false, "Install directory not found: $installPath") + } + + if (!installDir.isDirectory) { + Timber.w("Install path is not a directory: $installPath") + return Pair(false, "Install path is not a directory: $installPath") + } + + // Check that the directory has content (at least some files/folders) + val contents = installDir.listFiles() + if (contents == null || contents.isEmpty()) { + Timber.w("Install directory is empty: $installPath") + return Pair(false, "Install directory is empty") + } + + // Check for goggame-{gameId}.info file (optional - gogdl doesn't create this) + val infoFile = File(installDir, "goggame-$gameId.info") + if (infoFile.exists()) { + Timber.d("Found GOG Galaxy info file: ${infoFile.absolutePath}") + + // Verify info file is valid JSON + try { + val json = JSONObject(infoFile.readText()) + val fileGameId = json.optString("gameId", "") + if (fileGameId.isEmpty()) { + Timber.w("Game info file missing gameId field") + return Pair(false, "Invalid game info file: missing gameId") + } + + // Verify primary executable exists if specified + val playTasks = json.optJSONArray("playTasks") + if (playTasks != null) { + for (i in 0 until playTasks.length()) { + val task = playTasks.getJSONObject(i) + if (task.optBoolean("isPrimary", false)) { + val exePath = task.optString("path", "") + if (exePath.isNotEmpty()) { + val fullPath = File(installDir, exePath.replace("\\", "/")) + if (!fullPath.exists()) { + Timber.w("Primary executable not found: ${fullPath.absolutePath}") + return Pair(false, "Primary executable missing: ${fullPath.name}") + } + } + } + } + } + } catch (e: Exception) { + Timber.w(e, "Failed to parse game info file: ${infoFile.absolutePath}") + // Don't fail verification - info file is optional + } + } else { + Timber.d("No GOG Galaxy info file found (expected for gogdl downloads): goggame-$gameId.info") + } + + Timber.i("Installation verified successfully for game $gameId at $installPath (${contents.size} items)") + return Pair(true, null) + } + + /** + * Get the primary executable path for a GOG game + * Similar to SteamService.getInstalledExe() + * + * Returns the full path to the .exe file, or null if not found + */ + fun getInstalledExe(gameId: String): String? { + val installPath = getInstallPath(gameId) ?: return null + val installDir = File(installPath) + + if (!installDir.exists()) { + Timber.w("Install directory doesn't exist: $installPath") + return null + } + + // GOG games have a goggame-{gameId}.info file with launch information + val infoFile = File(installDir, "goggame-$gameId.info") + + if (!infoFile.exists()) { + Timber.w("Game info file not found: ${infoFile.absolutePath}") + // Fallback: search for .exe files + return findExecutableByHeuristic(installDir) + } + + try { + val json = JSONObject(infoFile.readText()) + val playTasks = json.optJSONArray("playTasks") + + if (playTasks != null) { + // Find primary task + for (i in 0 until playTasks.length()) { + val task = playTasks.getJSONObject(i) + if (task.optBoolean("isPrimary", false)) { + val exePath = task.optString("path", "") + if (exePath.isNotEmpty()) { + val fullPath = File(installDir, exePath.replace("\\", "/")) + if (fullPath.exists()) { + Timber.i("Found primary executable via goggame info: ${fullPath.absolutePath}") + return fullPath.absolutePath + } + } + } + } + + // If no primary, use first task + if (playTasks.length() > 0) { + val task = playTasks.getJSONObject(0) + val exePath = task.optString("path", "") + if (exePath.isNotEmpty()) { + val fullPath = File(installDir, exePath.replace("\\", "/")) + if (fullPath.exists()) { + Timber.i("Found first executable via goggame info: ${fullPath.absolutePath}") + return fullPath.absolutePath + } + } + } + } + } catch (e: Exception) { + Timber.e(e, "Error parsing goggame info file") + } + + // Fallback: search for .exe files + return findExecutableByHeuristic(installDir) + } + + /** + * Get Wine start command for launching a GOG game + * Static version that doesn't require DI, for use in XServerScreen + */ + fun getWineStartCommand( + gameId: String, + container: com.winlator.container.Container, + envVars: com.winlator.core.envvars.EnvVars, + guestProgramLauncherComponent: com.winlator.xenvironment.components.GuestProgramLauncherComponent + ): String? { + Timber.i("Getting Wine start command for GOG game: $gameId") + + // Verify installation + val (isValid, errorMessage) = verifyInstallation(gameId) + if (!isValid) { + Timber.e("Installation verification failed for game $gameId: $errorMessage") + return null + } + + // Get game details + val game = getGOGGameOf(gameId) + if (game == null) { + Timber.e("Game not found for ID: $gameId") + return null + } + + // Get installation path + val installPath = getInstallPath(gameId) + if (installPath == null) { + Timber.e("No install path found for game: $gameId") + return null + } + + val gameDir = File(installPath) + if (!gameDir.exists()) { + Timber.e("Game installation directory does not exist: $installPath") + return null + } + + Timber.i("Found game directory: ${gameDir.absolutePath}") + + // Get executable + val executablePath = getInstalledExe(gameId) + if (executablePath == null || executablePath.isEmpty()) { + Timber.e("No executable found for GOG game $gameId") + return null + } + + // Find GOG drive letter mapping + val gogGamesPath = GOGConstants.defaultGOGGamesPath + var gogDriveLetter: String? = null + + for (drive in com.winlator.container.Container.drivesIterator(container.drives)) { + if (drive[1] == gogGamesPath) { + gogDriveLetter = drive[0] + break + } + } + + if (gogDriveLetter == null) { + Timber.e("GOG games directory not mapped in container drives: $gogGamesPath") + Timber.e("Container drives: ${container.drives}") + return null + } + + Timber.i("Found GOG games directory mapped to $gogDriveLetter: drive") + + // Calculate relative path from GOG games directory to executable + val gogGamesDir = File(gogGamesPath) + val execFile = File(executablePath) + val relativePath = execFile.relativeTo(gogGamesDir).path.replace('/', '\\') + + // Construct Windows path + val windowsPath = "$gogDriveLetter:\\$relativePath" + + // Set working directory to game folder + val gameWorkingDir = File(executablePath).parentFile + if (gameWorkingDir != null) { + guestProgramLauncherComponent.workingDir = gameWorkingDir + Timber.i("Setting working directory to: ${gameWorkingDir.absolutePath}") + + // Set WINEPATH + val workingDirRelative = gameWorkingDir.relativeTo(gogGamesDir).path.replace('/', '\\') + val workingDirWindows = "$gogDriveLetter:\\$workingDirRelative" + envVars.put("WINEPATH", workingDirWindows) + Timber.i("Setting WINEPATH to: $workingDirWindows") + } + + Timber.i("GOG launch command: \"$windowsPath\"") + return "\"$windowsPath\"" + } + + /** + * Heuristic-based executable finder when goggame info is not available + * Similar approach to Steam's scorer + */ + private fun findExecutableByHeuristic(installDir: File): String? { + val exeFiles = installDir.walk() + .filter { it.isFile && it.extension.equals("exe", ignoreCase = true) } + .filter { !it.name.contains("unins", ignoreCase = true) } // Skip uninstallers + .filter { !it.name.contains("crash", ignoreCase = true) } // Skip crash reporters + .filter { !it.name.contains("setup", ignoreCase = true) } // Skip setup + .toList() + + if (exeFiles.isEmpty()) { + Timber.w("No .exe files found in ${installDir.absolutePath}") + return null + } + + // Prefer root directory executables + val rootExes = exeFiles.filter { it.parentFile == installDir } + if (rootExes.isNotEmpty()) { + val largest = rootExes.maxByOrNull { it.length() } + Timber.i("Found executable in root by heuristic: ${largest?.absolutePath}") + return largest?.absolutePath + } + + // Otherwise, take the largest executable + val largest = exeFiles.maxByOrNull { it.length() } + Timber.i("Found executable by size heuristic: ${largest?.absolutePath}") + return largest?.absolutePath + } + /** * Clean up active download when game is deleted */ diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 09d0f2071..67a2bcc25 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1113,6 +1113,15 @@ fun preLaunchApp( return@launch } + // For GOG Games, bypass Steam Cloud operations entirely and proceed to launch + val isGOGGame = ContainerUtils.extractGameSourceFromContainerId(appId) == GameSource.GOG + if (isGOGGame) { + Timber.tag("preLaunchApp").i("GOG Game detected for $appId — skipping Steam Cloud sync and launching container") + setLoadingDialogVisible(false) + onSuccess(context, appId) + return@launch + } + // For Steam games, sync save files and check no pending remote operations are running val prefixToPath: (String) -> String = { prefix -> PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt index 825ce38be..2835b9fdb 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -233,12 +234,14 @@ class GOGAppScreen : BaseAppScreen() { return@launch } - // Determine install path (use same pattern as Steam) - val downloadDir = File(android.os.Environment.getExternalStorageDirectory(), "Download") - val gogGamesDir = File(downloadDir, "GOGGames") - gogGamesDir.mkdirs() // Ensure directory exists - val installDir = File(gogGamesDir, gameId) - val installPath = installDir.absolutePath + // Get install path using GOG's path structure (similar to Steam) + // This will use external storage if available, otherwise internal + val gameTitle = libraryItem.name + val installPath = GOGConstants.getGameInstallPath(gameTitle) + val installDir = File(installPath) + + // Ensure parent directories exist + installDir.parentFile?.let { it.mkdirs() } Timber.d("Downloading GOG game to: $installPath") @@ -269,7 +272,7 @@ class GOGAppScreen : BaseAppScreen() { // Download completed successfully Timber.i("GOG download completed: $gameId") - // Update or create database entry + // Update or create database entry FIRST, before verification Timber.d("Attempting to fetch game from database for gameId: $gameId") var game = GOGService.getGOGGameOf(gameId) Timber.d("Fetched game from database: game=${game?.title}, isInstalled=${game?.isInstalled}, installPath=${game?.installPath}") @@ -321,6 +324,17 @@ class GOGAppScreen : BaseAppScreen() { } } + // Now verify the installation is valid after database update + Timber.d("Verifying installation for game: $gameId") + val (isValid, errorMessage) = GOGService.verifyInstallation(gameId) + if (!isValid) { + Timber.w("Installation verification failed for game $gameId: $errorMessage") + // Note: We already marked it as installed in DB, but files may be incomplete + // The isGameInstalled() check will catch this on next access + } else { + Timber.i("Installation verified successfully for game: $gameId") + } + // Emit download stopped event Timber.tag(TAG).d("[EVENT] Emitting DownloadStatusChanged: appId=${libraryItem.gameId}, isDownloading=false") app.gamenative.PluviaApp.events.emitJava( @@ -565,14 +579,17 @@ class GOGAppScreen : BaseAppScreen() { } /** - * Override to add GOG-specific analytics + * Override to launch GOG games properly (not as boot-to-container) */ override fun onRunContainerClick( context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit ) { - super.onRunContainerClick(context, libraryItem, onClickPlay) + // GOG games should launch with bootToContainer=false so getWineStartCommand + // can construct the proper launch command via GOGGameManager + Timber.tag(TAG).i("Launching GOG game: ${libraryItem.appId}") + onClickPlay(false) } /** 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 68bb97ad7..0ece31c63 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 @@ -37,6 +37,7 @@ 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.data.XServerState import app.gamenative.utils.ContainerUtils import app.gamenative.utils.CustomGameScanner @@ -1112,7 +1113,7 @@ private fun setupXEnvironment( guestProgramLauncherComponent.setContainer(container); guestProgramLauncherComponent.setWineInfo(xServerState.value.wineInfo); val guestExecutable = "wine explorer /desktop=shell," + xServer.screenInfo + " " + - getWineStartCommand(appId, container, bootToContainer, appLaunchInfo, envVars, guestProgramLauncherComponent) + + getWineStartCommand(context, appId, container, bootToContainer, appLaunchInfo, envVars, guestProgramLauncherComponent) + (if (container.execArgs.isNotEmpty()) " " + container.execArgs else "") guestProgramLauncherComponent.isWoW64Mode = wow64Mode guestProgramLauncherComponent.guestExecutable = guestExecutable @@ -1264,6 +1265,7 @@ private fun setupXEnvironment( return environment } private fun getWineStartCommand( + context: Context, appId: String, container: Container, bootToContainer: Boolean, @@ -1276,23 +1278,44 @@ private fun getWineStartCommand( Timber.tag("XServerScreen").d("appLaunchInfo is $appLaunchInfo") - // Check if this is a Custom Game - val isCustomGame = ContainerUtils.extractGameSourceFromContainerId(appId) == GameSource.CUSTOM_GAME - val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) + // Check game source + val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) + val isCustomGame = gameSource == GameSource.CUSTOM_GAME + val isGOGGame = gameSource == GameSource.GOG + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) - if (!isCustomGame) { + if (!isCustomGame && !isGOGGame) { + // Steam-specific setup if (container.executablePath.isEmpty()){ - container.executablePath = SteamService.getInstalledExe(steamAppId) + container.executablePath = SteamService.getInstalledExe(gameId) container.saveData() } if (!container.isUseLegacyDRM){ // Create ColdClientLoader.ini file - SteamUtils.writeColdClientIni(steamAppId, container) + SteamUtils.writeColdClientIni(gameId, container) } } val args = 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") + + val gogCommand = GOGService.getWineStartCommand( + gameId = gameId.toString(), + container = container, + envVars = envVars, + guestProgramLauncherComponent = guestProgramLauncherComponent + ) + + if (gogCommand == null) { + Timber.tag("XServerScreen").e("Failed to get GOG launch command for game: $gameId") + return "winhandler.exe \"wfm.exe\"" + } + + 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 @@ -1347,18 +1370,18 @@ private fun getWineStartCommand( if (container.isLaunchRealSteam()) { // Launch Steam with the applaunch parameter to start the game "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" -silent -vgui -tcp " + - "-nobigpicture -nofriendsui -nochatui -nointro -applaunch $steamAppId" + "-nobigpicture -nofriendsui -nochatui -nointro -applaunch $gameId" } else { var executablePath = "" if (container.executablePath.isNotEmpty()) { executablePath = container.executablePath } else { - executablePath = SteamService.getInstalledExe(steamAppId) + executablePath = SteamService.getInstalledExe(gameId) container.executablePath = executablePath container.saveData() } if (container.isUseLegacyDRM) { - val appDirPath = SteamService.getAppDirPath(steamAppId) + val appDirPath = SteamService.getAppDirPath(gameId) val executableDir = appDirPath + "/" + executablePath.substringBeforeLast("/", "") guestProgramLauncherComponent.workingDir = File(executableDir); Timber.i("Working directory is ${executableDir}") diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index be162dc4a..3306bc45a 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -5,6 +5,7 @@ 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.utils.CustomGameScanner import com.winlator.container.Container import com.winlator.container.ContainerData @@ -517,9 +518,11 @@ object ContainerUtils { } } GameSource.GOG -> { - // Just use DefaultDrives. We can create a specific one later. - Timber.d("Sending to Default Drives for GOG: $defaultDrives") - defaultDrives + // For GOG games, map the GOG games directory to E: drive (similar to Steam) + val gogGamesPath = GOGConstants.defaultGOGGamesPath + val drive: Char = Container.getNextAvailableDriveLetter(defaultDrives) + Timber.d("Mapping GOG games directory to $drive: drive: $gogGamesPath") + "$defaultDrives$drive:$gogGamesPath" } } Timber.d("Prepared container drives: $drives") @@ -702,6 +705,7 @@ object ContainerUtils { } // 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) @@ -735,6 +739,27 @@ object ContainerUtils { Timber.d("Updated container drives to include A: drive mapping: $updatedDrives") } } + } else if (gameSource == GameSource.GOG) { + // Ensure GOG games have the GOG games directory mapped + val gogGamesPath = GOGConstants.defaultGOGGamesPath + var hasGOGDriveMapping = false + + // Check if any drive is already mapped to the GOG games directory + for (drive in Container.drivesIterator(container.drives)) { + if (drive[1] == gogGamesPath) { + hasGOGDriveMapping = true + break + } + } + + // If GOG games directory is not mapped, add it + if (!hasGOGDriveMapping) { + val driveLetter = Container.getNextAvailableDriveLetter(container.drives) + val updatedDrives = "${container.drives}$driveLetter:$gogGamesPath" + container.drives = updatedDrives + container.saveData() + Timber.d("Updated container drives to include $driveLetter: drive mapping for GOG: $updatedDrives") + } } return container From 15ea8a7c2b61c88f436a4a2e692cf73e36ac6507 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Tue, 9 Dec 2025 18:55:11 +0000 Subject: [PATCH 013/122] removed experimenting the auth flow. --- app/src/main/AndroidManifest.xml | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3bc1fe2de..136e09005 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,17 +50,6 @@ android:host="pluvia" android:scheme="home" /> - - - - - - - - @@ -84,16 +73,6 @@ - - - - - - - - - - - - - Date: Tue, 9 Dec 2025 18:58:11 +0000 Subject: [PATCH 014/122] removed the broken auth intent work --- .../main/java/app/gamenative/MainActivity.kt | 53 +------------------ 1 file changed, 2 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 6dbaab240..757a41bb7 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -130,21 +130,13 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) val isRestored = savedInstanceState != null - Timber.d("[MainActivity]: onCreate called - savedInstanceState=${if (isRestored) "restored" else "new"}") - Timber.d("[MainActivity]: intent.action=${intent.action}, intent.data=${intent.data}") // Initialize the controller management system ControllerManager.getInstance().init(getApplicationContext()); ContainerUtils.setContainerDefaults(applicationContext) - // Only handle launch intent if this is a fresh start (not restored from saved state) - // When restored, we want to preserve the navigation state - if (!isRestored) { - handleLaunchIntent(intent) - } else { - Timber.d("[MainActivity]: Skipping handleLaunchIntent because activity is being restored") - } + handleLaunchIntent(intent) // Prevent device from sleeping while app is open AppUtils.keepScreenOn(this) @@ -204,52 +196,11 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - Timber.d("[MainActivity]: onNewIntent called") - setIntent(intent) // Important: update the intent + setIntent(intent) handleLaunchIntent(intent) } private fun handleLaunchIntent(intent: Intent) { - Timber.d("[IntentLaunch]: handleLaunchIntent called") - Timber.d("[IntentLaunch]: action=${intent.action}") - Timber.d("[IntentLaunch]: data=${intent.data}") - Timber.d("[IntentLaunch]: data.scheme=${intent.data?.scheme}") - Timber.d("[IntentLaunch]: data.host=${intent.data?.host}") - Timber.d("[IntentLaunch]: data.path=${intent.data?.path}") - Timber.d("[IntentLaunch]: data.query=${intent.data?.query}") - - // Handle GOG OAuth callback - must be checked FIRST before any other intent handling - val intentData = intent.data - if (intentData != null) { - Timber.d("[GOG OAuth]: Checking if this is GOG callback...") - Timber.d("[GOG OAuth]: scheme=${intentData.scheme}, host=${intentData.host}, path=${intentData.path}") - - if (intentData.scheme == "https" && - intentData.host == "embed.gog.com" && - intentData.path?.startsWith("/on_login_success") == true) { - val code = intentData.getQueryParameter("code") - Timber.i("[GOG OAuth]: ✓ GOG callback detected! Code=${code?.take(20)}...") - if (code != null) { - // Emit event with authorization code - lifecycleScope.launch { - Timber.d("[GOG OAuth]: Emitting GOGAuthCodeReceived event") - PluviaApp.events.emit(app.gamenative.events.AndroidEvent.GOGAuthCodeReceived(code)) - Timber.d("[GOG OAuth]: Event emitted successfully") - } - // Clear the intent data to prevent re-processing - intent.data = null - } else { - Timber.e("[GOG OAuth]: Code parameter is null!") - } - // Return early - don't process as game launch intent - return - } else { - Timber.d("[GOG OAuth]: Not a GOG callback (scheme/host/path mismatch)") - } - } else { - Timber.d("[GOG OAuth]: Intent data is null") - } - try { val launchRequest = IntentLaunchManager.parseLaunchIntent(intent) if (launchRequest != null) { From df86782cd9ed8b92d7f8f3b7edb2bf7a2152d281 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Tue, 9 Dec 2025 18:58:40 +0000 Subject: [PATCH 015/122] again --- app/src/main/java/app/gamenative/MainActivity.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 757a41bb7..3ae3e6b88 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -196,7 +196,6 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - setIntent(intent) handleLaunchIntent(intent) } From 3590f9def5fbcc7f8b033ee9466e82a2020d2e4d Mon Sep 17 00:00:00 2001 From: phobos665 Date: Tue, 9 Dec 2025 19:36:34 +0000 Subject: [PATCH 016/122] Icon fix --- .../ui/screen/library/components/LibraryBottomSheet.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt index b957e70dd..eb85ad836 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.ui.res.painterResource import app.gamenative.ui.icons.CustomGame import app.gamenative.ui.icons.Steam import androidx.compose.runtime.Composable @@ -24,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.size import app.gamenative.R import app.gamenative.ui.component.FlowFilterChip import app.gamenative.ui.enums.AppFilter @@ -107,7 +109,13 @@ fun LibraryBottomSheet( onClick = { onSourceToggle(GameSource.GOG) }, label = { Text(text = "GOG") }, selected = showGOG, - leadingIcon = { Icon(imageVector = Icons.Filled.CustomGame, contentDescription = null) }, // TODO: Add GOG icon + leadingIcon = { + Icon( + painter = painterResource(R.drawable.ic_gog), + contentDescription = "GOG", + modifier = Modifier.size(24.dp) + ) + }, ) } From 87475d165b45c7d0f7f87406a0e51a115ee8fe5f Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 12 Dec 2025 16:13:47 +0000 Subject: [PATCH 017/122] WIP --- .../gamenative/service/gog/GOGGameManager.kt | 62 ++++++++-------- .../app/gamenative/service/gog/GOGService.kt | 32 ++++----- .../library/components/LibraryBottomSheet.kt | 2 +- .../ui/screen/xserver/XServerScreen.kt | 1 + .../app/gamenative/utils/ContainerUtils.kt | 72 +++++++++++++++++-- 5 files changed, 111 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt index d33e86128..7096932b7 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt @@ -400,49 +400,43 @@ class GOGGameManager @Inject constructor( return "\"explorer.exe\"" } - // Find which drive letter is mapped to the GOG games directory - val gogGamesPath = GOGConstants.defaultGOGGamesPath - var gogDriveLetter: String? = null - - for (drive in Container.drivesIterator(container.drives)) { - if (drive[1] == gogGamesPath) { - gogDriveLetter = drive[0] - break - } - } - + // Ensure this specific game directory is mapped (isolates from other GOG games) + val gogDriveLetter = app.gamenative.utils.ContainerUtils.ensureGOGGameDirectoryMapped( + context, + container, + gameInstallPath + ) + if (gogDriveLetter == null) { - Timber.e("GOG games directory not mapped in container drives: $gogGamesPath") - Timber.e("Container drives: ${container.drives}") + Timber.e("Failed to map GOG game directory: $gameInstallPath") return "\"explorer.exe\"" } - Timber.i("Found GOG games directory mapped to $gogDriveLetter: drive") - - // Calculate the Windows path for the game subdirectory - val gameSubDirRelativePath = gameDir.relativeTo(File(GOGConstants.defaultGOGGamesPath)).path.replace('\\', '/') - val windowsGamePath = "$gogDriveLetter:/$gameSubDirRelativePath" + Timber.i("GOG game directory mapped to $gogDriveLetter: drive") - // Set WINEPATH to the game subdirectory - envVars.put("WINEPATH", windowsGamePath) + // Calculate the Windows path relative to the game install directory + val gameInstallDir = File(gameInstallPath) + val execFile = File(gameInstallPath, executablePath) + val relativePath = execFile.relativeTo(gameInstallDir).path.replace('/', '\\') + val windowsPath = "$gogDriveLetter:\\$relativePath" - // Set the working directory to the game directory - val gameWorkingDir = File(GOGConstants.defaultGOGGamesPath, gameSubDirRelativePath) - guestProgramLauncherComponent.workingDir = gameWorkingDir - Timber.i("Setting working directory to: ${gameWorkingDir.absolutePath}") + // Set WINEPATH to the game directory root + envVars.put("WINEPATH", "$gogDriveLetter:\\") - val executableName = File(executablePath).name - Timber.i("GOG game executable name: $executableName") - Timber.i("GOG game Windows path: $windowsGamePath") - Timber.i("GOG game subdirectory relative path: $gameSubDirRelativePath") + // Set the working directory to the executable's directory + val execWorkingDir = execFile.parentFile + if (execWorkingDir != null) { + guestProgramLauncherComponent.workingDir = execWorkingDir + Timber.i("Setting working directory to: ${execWorkingDir.absolutePath}") + } else { + guestProgramLauncherComponent.workingDir = gameDir + Timber.i("Setting working directory to game root: ${gameDir.absolutePath}") + } - // Determine structure type by checking if game_* subdirectory exists - val isV2Structure = gameDir.listFiles()?.any { - it.isDirectory && it.name.startsWith("game_$gameId") - } ?: false - Timber.i("Game structure type: ${if (isV2Structure) "V2" else "V1"}") + Timber.i("GOG game executable: $executablePath") + Timber.i("GOG game Windows path: $windowsPath") - val fullCommand = "\"$windowsGamePath/$executablePath\"" + val fullCommand = "\"$windowsPath\"" Timber.i("Full Wine command will be: $fullCommand") return fullCommand diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 19ad62d99..fd57ae901 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -666,6 +666,7 @@ class GOGService : Service() { * Static version that doesn't require DI, for use in XServerScreen */ fun getWineStartCommand( + context: android.content.Context, gameId: String, container: com.winlator.container.Container, envVars: com.winlator.core.envvars.EnvVars, @@ -709,29 +710,24 @@ class GOGService : Service() { return null } - // Find GOG drive letter mapping - val gogGamesPath = GOGConstants.defaultGOGGamesPath - var gogDriveLetter: String? = null - - for (drive in com.winlator.container.Container.drivesIterator(container.drives)) { - if (drive[1] == gogGamesPath) { - gogDriveLetter = drive[0] - break - } - } - + // Ensure this specific game directory is mapped (isolates from other GOG games) + val gogDriveLetter = app.gamenative.utils.ContainerUtils.ensureGOGGameDirectoryMapped( + context, + container, + installPath + ) + if (gogDriveLetter == null) { - Timber.e("GOG games directory not mapped in container drives: $gogGamesPath") - Timber.e("Container drives: ${container.drives}") + Timber.e("Failed to map GOG game directory: $installPath") return null } - Timber.i("Found GOG games directory mapped to $gogDriveLetter: drive") + Timber.i("GOG game directory mapped to $gogDriveLetter: drive") - // Calculate relative path from GOG games directory to executable - val gogGamesDir = File(gogGamesPath) + // Calculate relative path from game install directory to executable + val gameInstallDir = File(installPath) val execFile = File(executablePath) - val relativePath = execFile.relativeTo(gogGamesDir).path.replace('/', '\\') + val relativePath = execFile.relativeTo(gameInstallDir).path.replace('/', '\\') // Construct Windows path val windowsPath = "$gogDriveLetter:\\$relativePath" @@ -743,7 +739,7 @@ class GOGService : Service() { Timber.i("Setting working directory to: ${gameWorkingDir.absolutePath}") // Set WINEPATH - val workingDirRelative = gameWorkingDir.relativeTo(gogGamesDir).path.replace('/', '\\') + val workingDirRelative = gameWorkingDir.relativeTo(gameInstallDir).path.replace('/', '\\') val workingDirWindows = "$gogDriveLetter:\\$workingDirRelative" envVars.put("WINEPATH", workingDirWindows) Timber.i("Setting WINEPATH to: $workingDirWindows") diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt index eb85ad836..f1b636d8c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt @@ -109,7 +109,7 @@ fun LibraryBottomSheet( onClick = { onSourceToggle(GameSource.GOG) }, label = { Text(text = "GOG") }, selected = showGOG, - leadingIcon = { + leadingIcon = { Icon( painter = painterResource(R.drawable.ic_gog), contentDescription = "GOG", 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 19dd17cec..cc96179bb 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 @@ -1303,6 +1303,7 @@ private fun getWineStartCommand( Timber.tag("XServerScreen").i("Launching GOG game: $gameId") val gogCommand = GOGService.getWineStartCommand( + context = context, gameId = gameId.toString(), container = container, envVars = envVars, diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 2b279f35c..5f88cf3ba 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -557,11 +557,9 @@ object ContainerUtils { } } GameSource.GOG -> { - // For GOG games, map the GOG games directory to E: drive (similar to Steam) - val gogGamesPath = GOGConstants.defaultGOGGamesPath - val drive: Char = Container.getNextAvailableDriveLetter(defaultDrives) - Timber.d("Mapping GOG games directory to $drive: drive: $gogGamesPath") - "$defaultDrives$drive:$gogGamesPath" + // For GOG games, initially use default drives + // The specific game directory will be mapped in getOrCreateContainer after we have game details + defaultDrives } } Timber.d("Prepared container drives: $drives") @@ -1125,4 +1123,68 @@ object ContainerUtils { else -> GameSource.STEAM // default fallback } } + + /** + * Ensures a GOG game container has the specific game directory mapped. + * This should be called before launching a GOG game to isolate it from other games. + * + * @param context Android context + * @param container The container to update + * @param gameInstallPath The absolute path to the specific game's install directory + * @return The drive letter that is mapped to the game directory, or null if update failed + */ + fun ensureGOGGameDirectoryMapped(context: Context, container: Container, gameInstallPath: String): String? { + Timber.i("ensureGOGGameDirectoryMapped called for container ${container.id}") + Timber.d("Current drives: ${container.drives}") + Timber.d("Target game install path: $gameInstallPath") + + // Check if this specific game directory is already mapped + for (drive in Container.drivesIterator(container.drives)) { + Timber.d("Checking drive ${drive[0]}: -> ${drive[1]}") + if (drive[1] == gameInstallPath) { + Timber.i("GOG game directory already mapped to ${drive[0]}: drive") + return drive[0] + } + } + + // Game directory not mapped - need to add or replace mapping + Timber.i("Game directory not yet mapped, updating container drives") + + val gogGamesPath = GOGConstants.defaultGOGGamesPath + val drivesBuilder = StringBuilder() + var replacedExistingMapping = false + var driveLetter: String? = null + + // Iterate through existing drives and rebuild the drives string + for (drive in Container.drivesIterator(container.drives)) { + if (drive[1] == gogGamesPath) { + // Replace parent directory mapping with specific game directory + driveLetter = drive[0] + drivesBuilder.append("$driveLetter:$gameInstallPath") + replacedExistingMapping = true + Timber.i("Replaced parent GOG directory (${drive[1]}) with game directory ($gameInstallPath) on $driveLetter: drive") + } else { + // Keep other drive mappings as-is + drivesBuilder.append("${drive[0]}:${drive[1]}") + Timber.d("Kept existing drive mapping: ${drive[0]}: -> ${drive[1]}") + } + } + + // If we didn't replace an existing mapping, add a new one + if (!replacedExistingMapping) { + driveLetter = Container.getNextAvailableDriveLetter(container.drives).toString() + drivesBuilder.append("$driveLetter:$gameInstallPath") + Timber.i("Added new drive mapping for GOG game on $driveLetter: drive (no parent mapping found)") + } + + // Update container with new drives + val newDrives = drivesBuilder.toString() + Timber.i("Updating container drives from '${container.drives}' to '$newDrives'") + container.drives = newDrives + container.saveData() + Timber.i("Container drives updated and saved successfully") + + return driveLetter + } } + From 92cc363557af86cd1d6684b325bf5f1fabbad559 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 12 Dec 2025 16:14:01 +0000 Subject: [PATCH 018/122] icon --- app/src/main/res/drawable/ic_gog.png | Bin 0 -> 875 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/src/main/res/drawable/ic_gog.png 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 0000000000000000000000000000000000000000..861288bd8ec0aa8cfd4a0e7af7a8970171a29866 GIT binary patch literal 875 zcmV-x1C;!UP)>fo)A=4NSo+^L`4s=%tb9+ zSy2QLR0wV=sECkgk<~tsBB)J7f(VJ5%7Ta%tqLoNK#&${X@)U!ZX7OiALIReT>JiT zHgnFIncw%_duHa|3sXb~fY-nvFg(d^0O$br>B)Zv_5d@P{3-w{fp5TmYRifS7IdVKar`7cXO zg|Dwp-&noM*X9DyD(%04B8&f$>e#G`MprFuT2;z#VvQC$bIj-Lg1_0;~o$Mc4|GYfg-DHUcyLyAzpL0hR$P1I%>bDNo8? zQ0#o{S`w1uH*f8T1+W(Q8gX5E)n_5_KE`$2r0$n_z&nQ<*jTd*z`Yn9dH@$3Cb3m= z>K!LP>o;bP1V*aaZSFXWrMW$Cjs~oqfBamVvD~f z0${ncZC7n)RepC80F#UZfbC{I5&=+b0q{0*Ie_l81N2*-h=l>HxA@OY1i&-ZSw zlV|3Q4szu5V*4y{L&{$@UI5o()MfA2JQ}l(n+H^@#tWbjxGN7P+JajNPiU) z+b;Klr*q1;K-$Vw+hG;kDs7D}0BKMr2Qb0POc{WIfD_MT*KF@KtEn`*NuMg@71+qW z-h67XHzl7MWMuMP>g~`7L`O<8_X%5(q#002ovPDHLkV1mU* BcNYKv literal 0 HcmV?d00001 From 77756000d3e7f01dad378aada0e83fbfdc68c953 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 12:11:31 +0000 Subject: [PATCH 019/122] WIP refactor to have separation of concerns for GOG-related functionality. --- .../gamenative/service/gog/GOGAuthManager.kt | 365 +++++ .../gamenative/service/gog/GOGGameManager.kt | 787 ---------- .../service/gog/GOGLibraryManager.kt | 143 -- .../app/gamenative/service/gog/GOGManager.kt | 836 +++++++++++ .../gamenative/service/gog/GOGPythonBridge.kt | 262 ++++ .../app/gamenative/service/gog/GOGService.kt | 1288 ++--------------- .../screen/library/appscreen/GOGAppScreen.kt | 2 +- .../screen/settings/SettingsGroupInterface.kt | 94 +- .../ui/screen/xserver/XServerScreen.kt | 17 +- 9 files changed, 1638 insertions(+), 2156 deletions(-) create mode 100644 app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt delete mode 100644 app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt delete mode 100644 app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt create mode 100644 app/src/main/java/app/gamenative/service/gog/GOGManager.kt create mode 100644 app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt new file mode 100644 index 000000000..693cb4ce4 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -0,0 +1,365 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.data.GOGCredentials +import org.json.JSONObject +import timber.log.Timber +import java.io.File + +/** + * Manages GOG authentication and account operations. + * + * Responsibilities: + * - OAuth2 authentication flow + * - Credential storage and validation + * - Token refresh + * - Account logout + * + * Uses GOGPythonBridge for all GOGDL command execution. + */ +object GOGAuthManager { + + // GOG OAuth2 client ID + private const val GOG_CLIENT_ID = "46899977096215655" + + /** + * Get the auth config file path for a context + */ + fun getAuthConfigPath(context: Context): String { + return "${context.filesDir}/gog_auth.json" + } + + /** + * Check if user is authenticated by checking if auth file exists + */ + fun hasStoredCredentials(context: Context): Boolean { + val authFile = File(getAuthConfigPath(context)) + return authFile.exists() + } + + /** + * Authenticate with GOG using authorization code from OAuth2 flow + * Users must visit GOG login page, authenticate, and copy the authorization code + * + * @param context Android context + * @param authorizationCode OAuth2 authorization code (or full redirect URL) + * @return Result containing GOGCredentials or error + */ + suspend fun authenticateWithCode(context: Context, authorizationCode: String): Result { + return try { + Timber.i("Starting GOG authentication with authorization code...") + + // Extract the actual authorization code from URL if needed + val actualCode = extractCodeFromInput(authorizationCode) + if (actualCode.isEmpty()) { + return Result.failure(Exception("Invalid authorization URL: no code parameter found")) + } + + val authConfigPath = getAuthConfigPath(context) + + // Create auth config directory + val authFile = File(authConfigPath) + val authDir = authFile.parentFile + if (authDir != null && !authDir.exists()) { + authDir.mkdirs() + Timber.d("Created auth config directory: ${authDir.absolutePath}") + } + + // Execute GOGDL auth command with the authorization code + Timber.d("Authenticating with auth config path: $authConfigPath, code: ${actualCode.take(10)}...") + + val result = GOGPythonBridge.executeCommand( + "--auth-config-path", authConfigPath, + "auth", "--code", actualCode + ) + + Timber.d("GOGDL executeCommand result: isSuccess=${result.isSuccess}") + + if (result.isSuccess) { + val gogdlOutput = result.getOrNull() ?: "" + Timber.i("GOGDL command completed, checking authentication result...") + Timber.d("GOGDL output for auth: $gogdlOutput") + + // Parse and validate the authentication result + return parseAuthenticationResult(authConfigPath, gogdlOutput) + } else { + val error = result.exceptionOrNull() + val errorMsg = error?.message ?: "Unknown authentication error" + Timber.e(error, "GOG authentication command failed: $errorMsg") + Result.failure(Exception("Authentication failed: $errorMsg", error)) + } + } catch (e: Exception) { + Timber.e(e, "GOG authentication exception: ${e.message}") + Result.failure(Exception("Authentication exception: ${e.message}", e)) + } + } + + /** + * Get user credentials by calling GOGDL auth command (without --code) + * This will automatically handle token refresh if needed + */ + suspend fun getStoredCredentials(context: Context): Result { + return try { + val authConfigPath = getAuthConfigPath(context) + + if (!hasStoredCredentials(context)) { + return Result.failure(Exception("No stored credentials found")) + } + + // Use GOGDL to get credentials - this will handle token refresh automatically + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "auth") + + if (result.isSuccess) { + val output = result.getOrNull() ?: "" + Timber.d("GOGDL credentials output: $output") + + return parseCredentialsFromOutput(output) + } else { + Timber.e("GOGDL credentials command failed") + Result.failure(Exception("Failed to get credentials from GOG")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to get stored credentials via GOGDL") + Result.failure(e) + } + } + + /** + * Validate credentials by calling GOGDL auth command (without --code) + * This will automatically refresh tokens if they're expired + */ + suspend fun validateCredentials(context: Context): Result { + return try { + val authConfigPath = getAuthConfigPath(context) + + if (!hasStoredCredentials(context)) { + Timber.d("No stored credentials found for validation") + return Result.success(false) + } + + Timber.d("Starting credentials validation with GOGDL") + + // Use GOGDL to validate credentials - this will handle token refresh automatically + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "auth") + + if (!result.isSuccess) { + val error = result.exceptionOrNull() + Timber.e("Credentials validation failed - command failed: ${error?.message}") + return Result.success(false) + } + + val output = result.getOrNull() ?: "" + Timber.d("GOGDL validation output: $output") + + try { + val credentialsJson = JSONObject(output.trim()) + + // Check if there's an error + if (credentialsJson.has("error") && credentialsJson.getBoolean("error")) { + val errorDesc = credentialsJson.optString("message", "Unknown error") + Timber.e("Credentials validation failed: $errorDesc") + return Result.success(false) + } + + Timber.d("Credentials validation successful") + return Result.success(true) + } catch (e: Exception) { + Timber.e(e, "Failed to parse validation response: $output") + return Result.success(false) + } + } catch (e: Exception) { + Timber.e(e, "Failed to validate credentials") + return Result.failure(e) + } + } + + /** + * Clear stored credentials (logout) + */ + fun clearStoredCredentials(context: Context): Boolean { + return try { + val authFile = File(getAuthConfigPath(context)) + if (authFile.exists()) { + authFile.delete() + } else { + true + } + } catch (e: Exception) { + Timber.e(e, "Failed to clear GOG credentials") + false + } + } + + // ========== Private Helper Methods ========== + + /** + * Extract authorization code from user input (URL or raw code) + */ + private fun extractCodeFromInput(input: String): String { + return if (input.startsWith("http")) { + // Extract code parameter from URL + val codeParam = input.substringAfter("code=", "") + if (codeParam.isEmpty()) { + "" + } else { + // Remove any additional parameters after the code + val cleanCode = codeParam.substringBefore("&") + Timber.d("Extracted authorization code from URL: ${cleanCode.take(20)}...") + cleanCode + } + } else { + input + } + } + + /** + * Parse authentication result from GOGDL output and auth file + */ + private fun parseAuthenticationResult(authConfigPath: String, gogdlOutput: String): Result { + try { + Timber.d("Attempting to parse GOGDL output as JSON (length: ${gogdlOutput.length})") + val outputJson = JSONObject(gogdlOutput.trim()) + Timber.d("Successfully parsed JSON, keys: ${outputJson.keys().asSequence().toList()}") + + // Check if the response indicates an error + if (outputJson.has("error") && outputJson.getBoolean("error")) { + val errorMsg = outputJson.optString("error_description", "Authentication failed") + val errorDetails = outputJson.optString("message", "No details available") + Timber.e("GOG authentication failed: $errorMsg - Details: $errorDetails") + return Result.failure(Exception("GOG authentication failed: $errorMsg")) + } + + // Check if we have the required fields for successful auth + val accessToken = outputJson.optString("access_token", "") + val userId = outputJson.optString("user_id", "") + + if (accessToken.isEmpty() || userId.isEmpty()) { + Timber.e("GOG authentication incomplete: missing access_token or user_id in output") + return Result.failure(Exception("Authentication incomplete: missing required data")) + } + + // GOGDL output looks good, now check if auth file was created + val authFile = File(authConfigPath) + if (authFile.exists()) { + // Parse authentication result from file + val authData = parseFullCredentialsFromFile(authConfigPath) + Timber.i("GOG authentication successful for user: ${authData.username}") + return Result.success(authData) + } else { + Timber.w("GOGDL returned success but no auth file created, using output data") + // Create credentials from GOGDL output + val credentials = createCredentialsFromJson(outputJson) + return Result.success(credentials) + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse GOGDL output") + // Fallback: check if auth file exists + val authFile = File(authConfigPath) + if (authFile.exists()) { + try { + val authData = parseFullCredentialsFromFile(authConfigPath) + Timber.i("GOG authentication successful (fallback) for user: ${authData.username}") + return Result.success(authData) + } catch (ex: Exception) { + Timber.e(ex, "Failed to parse auth file") + return Result.failure(Exception("Failed to parse authentication result: ${ex.message}")) + } + } else { + Timber.e("GOG authentication failed: no auth file created and failed to parse output") + return Result.failure(Exception("Authentication failed: no credentials available")) + } + } + } + + /** + * Parse GOGCredentials from GOGDL command output + */ + private fun parseCredentialsFromOutput(output: String): Result { + try { + val credentialsJson = JSONObject(output.trim()) + + // Check if there's an error + if (credentialsJson.has("error") && credentialsJson.getBoolean("error")) { + val errorMsg = credentialsJson.optString("message", "Authentication failed") + Timber.e("GOGDL credentials failed: $errorMsg") + return Result.failure(Exception("Authentication failed: $errorMsg")) + } + + // Extract credentials from GOGDL response + val accessToken = credentialsJson.optString("access_token", "") + val refreshToken = credentialsJson.optString("refresh_token", "") + val username = credentialsJson.optString("username", "GOG User") + val userId = credentialsJson.optString("user_id", "") + + val credentials = GOGCredentials( + accessToken = accessToken, + refreshToken = refreshToken, + username = username, + userId = userId, + ) + + Timber.d("Got credentials for user: $username") + return Result.success(credentials) + } catch (e: Exception) { + Timber.e(e, "Failed to parse GOGDL credentials response") + return Result.failure(e) + } + } + + /** + * Parse full GOGCredentials from auth file + */ + private fun parseFullCredentialsFromFile(authConfigPath: String): GOGCredentials { + return try { + val authFile = File(authConfigPath) + if (authFile.exists()) { + val authContent = authFile.readText() + val authJson = JSONObject(authContent) + + // GOGDL stores credentials nested under client ID + val credentialsJson = if (authJson.has(GOG_CLIENT_ID)) { + authJson.getJSONObject(GOG_CLIENT_ID) + } else { + // Fallback: try to read from root level + authJson + } + + GOGCredentials( + accessToken = credentialsJson.optString("access_token", ""), + refreshToken = credentialsJson.optString("refresh_token", ""), + userId = credentialsJson.optString("user_id", ""), + username = credentialsJson.optString("username", "GOG User"), + ) + } else { + // Return dummy credentials for successful auth + GOGCredentials( + accessToken = "authenticated_${System.currentTimeMillis()}", + refreshToken = "refresh_${System.currentTimeMillis()}", + userId = "user_123", + username = "GOG User", + ) + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse auth result from file") + // Return dummy credentials as fallback + GOGCredentials( + accessToken = "fallback_token", + refreshToken = "fallback_refresh", + userId = "fallback_user", + username = "GOG User", + ) + } + } + + /** + * Create GOGCredentials from JSON output + */ + private fun createCredentialsFromJson(outputJson: JSONObject): GOGCredentials { + return GOGCredentials( + accessToken = outputJson.optString("access_token", ""), + refreshToken = outputJson.optString("refresh_token", ""), + userId = outputJson.optString("user_id", ""), + username = "GOG User", // We don't have username in the token response + ) + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt deleted file mode 100644 index 7096932b7..000000000 --- a/app/src/main/java/app/gamenative/service/gog/GOGGameManager.kt +++ /dev/null @@ -1,787 +0,0 @@ -package app.gamenative.service.gog - -import android.content.Context -import android.net.Uri -import androidx.core.net.toUri -import app.gamenative.R -import app.gamenative.data.DownloadInfo -import app.gamenative.data.GOGGame -import app.gamenative.data.LaunchInfo -import app.gamenative.data.LibraryItem -import app.gamenative.data.PostSyncInfo -import app.gamenative.data.SteamApp -import app.gamenative.data.GameSource -import app.gamenative.db.dao.GOGGameDao -import app.gamenative.enums.AppType -import app.gamenative.enums.ControllerSupport -import app.gamenative.enums.Marker -import app.gamenative.enums.OS -import app.gamenative.enums.ReleaseState -import app.gamenative.enums.SyncResult -import app.gamenative.ui.component.dialog.state.MessageDialogState -import app.gamenative.ui.enums.DialogType -import app.gamenative.utils.ContainerUtils -import app.gamenative.utils.MarkerUtils -import app.gamenative.utils.StorageUtils -import com.winlator.container.Container -import com.winlator.core.envvars.EnvVars -import com.winlator.xenvironment.components.GuestProgramLauncherComponent -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.EnumSet -import java.util.Locale -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import timber.log.Timber - -/** - * Manager for GOG game operations - * - * This class handles GOG game library management, authentication, - * downloads, and installation via the Python gogdl backend. - */ -@Singleton -class GOGGameManager @Inject constructor( - private val gogGameDao: GOGGameDao, -) { - - /** - * Download a GOG game - */ - fun downloadGame(context: Context, libraryItem: LibraryItem): Result { - try { - // Check if another download is already in progress - if (GOGService.hasActiveDownload()) { - return Result.failure(Exception("Another GOG game is already downloading. Please wait for it to finish before starting a new download.")) - } - - // Check authentication first - if (!GOGService.hasStoredCredentials(context)) { - return Result.failure(Exception("GOG authentication required. Please log in to your GOG account first.")) - } - - // Validate credentials and refresh if needed - val validationResult = runBlocking { GOGService.validateCredentials(context) } - if (!validationResult.isSuccess || !validationResult.getOrDefault(false)) { - return Result.failure(Exception("GOG authentication is invalid. Please re-authenticate.")) - } - - val installPath = getGameInstallPath(context, libraryItem.appId, libraryItem.name) - val authConfigPath = "${context.filesDir}/gog_auth.json" - - Timber.i("Starting GOG game installation: ${libraryItem.name} to $installPath") - - // Use the new download method that returns DownloadInfo - val result = runBlocking { GOGService.downloadGame(libraryItem.appId, installPath, authConfigPath) } - - if (result.isSuccess) { - val downloadInfo = result.getOrNull() - if (downloadInfo != null) { - // Add download in progress marker and remove completion marker - val appDirPath = getAppDirPath(libraryItem.appId) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - - // Add a progress listener to update markers when download completes - downloadInfo.addProgressListener { progress -> - when { - progress >= 1.0f -> { - // Download completed successfully - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - Timber.i("GOG game installation completed: ${libraryItem.name}") - } - progress < 0.0f -> { - // Download failed or cancelled - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - Timber.i("GOG game installation failed/cancelled: ${libraryItem.name}") - } - } - } - - Timber.i("GOG game installation started successfully: ${libraryItem.name}") - } - return Result.success(downloadInfo) - } else { - val error = result.exceptionOrNull() ?: Exception("Unknown download error") - Timber.e(error, "Failed to install GOG game: ${libraryItem.name}") - return Result.failure(error) - } - } catch (e: Exception) { - Timber.e(e, "Failed to install GOG game: ${libraryItem.name}") - return Result.failure(e) - } - } - - /** - * Delete a GOG game - */ - fun deleteGame(context: Context, libraryItem: LibraryItem): Result { - try { - val gameId = libraryItem.gameId.toString() - val installPath = getGameInstallPath(context, gameId, libraryItem.name) - val installDir = File(installPath) - - // Delete the manifest file to ensure fresh downloads on reinstall - val manifestPath = File(context.filesDir, "manifests/$gameId") - if (manifestPath.exists()) { - val manifestDeleted = manifestPath.delete() - if (manifestDeleted) { - Timber.i("Deleted manifest file for game $gameId") - } else { - Timber.w("Failed to delete manifest file for game $gameId") - } - } - - if (installDir.exists()) { - val success = installDir.deleteRecursively() - if (success) { - // Remove all markers - val appDirPath = getAppDirPath(libraryItem.appId) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - - // Cancel and clean up any active download - GOGService.cancelDownload(libraryItem.appId) - GOGService.cleanupDownload(libraryItem.appId) - - // Update database to mark as not installed - val game = runBlocking { getGameById(gameId) } - if (game != null) { - val updatedGame = game.copy( - isInstalled = false, - installPath = "", - ) - runBlocking { gogGameDao.update(updatedGame) } - } - - Timber.i("GOG game ${libraryItem.name} deleted successfully") - return Result.success(Unit) - } else { - return Result.failure(Exception("Failed to delete GOG game directory")) - } - } else { - Timber.w("GOG game directory doesn't exist: $installPath") - // Remove all markers even if directory doesn't exist - val appDirPath = getAppDirPath(libraryItem.appId) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - - // Cancel and clean up any active download - GOGService.cancelDownload(libraryItem.appId) - GOGService.cleanupDownload(libraryItem.appId) - - // Update database anyway to ensure consistency - val game = runBlocking { getGameById(gameId) } - if (game != null) { - val updatedGame = game.copy( - isInstalled = false, - installPath = "", - ) - runBlocking { gogGameDao.update(updatedGame) } - } - - return Result.success(Unit) // Consider it already deleted - } - } catch (e: Exception) { - Timber.e(e, "Failed to delete GOG game ${libraryItem.gameId}") - return Result.failure(e) - } - } - - /** - * Check if a GOG game is installed - */ - fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean { - try { - val appDirPath = getAppDirPath(libraryItem.appId) - - // Use marker-based approach for reliable state tracking - val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - - // Game is installed only if download is complete and not in progress - val isInstalled = isDownloadComplete && !isDownloadInProgress - - // Update database if the install status has changed - val gameId = libraryItem.gameId.toString() - val game = runBlocking { getGameById(gameId) } - if (game != null && isInstalled != game.isInstalled) { - val installPath = if (isInstalled) getGameInstallPath(context, gameId, libraryItem.name) else "" - val updatedGame = game.copy( - isInstalled = isInstalled, - installPath = installPath, - ) - runBlocking { gogGameDao.update(updatedGame) } - } - - return isInstalled - } catch (e: Exception) { - Timber.e(e, "Error checking if GOG game is installed") - return false - } - } - - /** - * Check if update is pending for a game - */ - suspend fun isUpdatePending(libraryItem: LibraryItem): Boolean { - return false // Not implemented yet - } - - /** - * Get download info for a game - */ - fun getDownloadInfo(libraryItem: LibraryItem): DownloadInfo? { - return GOGService.getDownloadInfo(libraryItem.appId) - } - - /** - * Check if game has a partial download - */ - fun hasPartialDownload(libraryItem: LibraryItem): Boolean { - try { - val appDirPath = getAppDirPath(libraryItem.appId) - - // Use marker-based approach for reliable state tracking - val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - - // Has partial download if download is in progress or if there are files but no completion marker - if (isDownloadInProgress) { - return true - } - - // Also check if there are files in the directory but no completion marker (interrupted download) - if (!isDownloadComplete) { - val gameId = libraryItem.gameId.toString() - val gameName = libraryItem.name - // Use GOGConstants directly since we don't have context here and it's not needed - val installPath = GOGConstants.getGameInstallPath(gameName) - val installDir = File(installPath) - - // If directory has files but no completion marker, it's a partial download - return installDir.exists() && installDir.listFiles()?.isNotEmpty() == true - } - - return false - } catch (e: Exception) { - Timber.w(e, "Error checking partial download status for ${libraryItem.name}") - return false - } - } - - /** - * Get disk size of installed game - */ - suspend fun getGameDiskSize(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { - // Calculate size from install directory - val installPath = getGameInstallPath(context, libraryItem.appId, libraryItem.name) - val folderSize = StorageUtils.getFolderSize(installPath) - StorageUtils.formatBinarySize(folderSize) - } - - /** - * Get app directory path for a game - */ - fun getAppDirPath(appId: String): String { - // Extract the numeric game ID from the appId - val gameId = ContainerUtils.extractGameIdFromContainerId(appId) - - // Get the game details to find the correct title - val game = runBlocking { getGameById(gameId.toString()) } - if (game != null) { - // Return the specific game installation path - val gamePath = GOGConstants.getGameInstallPath(game.title) - Timber.d("GOG getAppDirPath for appId $appId (game: ${game.title}) -> $gamePath") - return gamePath - } - - // Fallback to base path if game not found (shouldn't happen normally) - Timber.w("Could not find game for appId $appId, using base path") - return GOGConstants.defaultGOGGamesPath - } - - /** - * Launch game with save sync - * TODO: Implement GOG cloud save sync - currently disabled - */ - suspend fun launchGameWithSaveSync( - context: Context, - libraryItem: LibraryItem, - parentScope: CoroutineScope, - ignorePendingOperations: Boolean, - preferredSave: Int?, - ): PostSyncInfo = withContext(Dispatchers.IO) { - try { - Timber.i("Starting GOG game launch for ${libraryItem.name} (cloud save sync disabled)") - - // TODO: Implement GOG cloud save sync - // For now, just skip sync and return success to allow game launch - return@withContext PostSyncInfo(SyncResult.Success) - - } catch (e: Exception) { - Timber.e(e, "GOG game launch exception for game ${libraryItem.gameId}") - PostSyncInfo(SyncResult.UnknownFail) - } - } - - /** - * Get store URL for game - */ - fun getStoreUrl(libraryItem: LibraryItem): Uri { - val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } - val slug = gogGame?.slug ?: "" - return "https://www.gog.com/en/game/$slug".toUri() - } - - /** - * Get Wine start command for launching a game - */ - fun getWineStartCommand( - context: Context, - libraryItem: LibraryItem, - container: Container, - bootToContainer: Boolean, - appLaunchInfo: LaunchInfo?, - envVars: EnvVars, - guestProgramLauncherComponent: GuestProgramLauncherComponent, - ): String { - // Extract the numeric game ID from appId using the existing utility function - val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId) - - // Verify installation before attempting launch - val (isValid, errorMessage) = GOGService.verifyInstallation(gameId.toString()) - if (!isValid) { - Timber.e("Installation verification failed for game $gameId: $errorMessage") - // Return explorer.exe to avoid crashing, but log the error clearly - // In production, you might want to show a user-facing error dialog here - return "\"explorer.exe\"" - } - - Timber.i("Installation verified successfully for game $gameId") - - // For GOG games, we always want to launch the actual game - // because GOG doesn't have appLaunchInfo like Steam does - - // Get the game details to find the correct title - val game = runBlocking { getGameById(gameId.toString()) } - if (game == null) { - Timber.e("Game not found for ID: $gameId") - return "\"explorer.exe\"" - } - - Timber.i("Looking for GOG game '${game.title}' with ID: $gameId") - - // Get the specific game installation directory using the existing function - val gameInstallPath = getGameInstallPath(context, gameId.toString(), game.title) - val gameDir = File(gameInstallPath) - - if (!gameDir.exists()) { - Timber.e("Game installation directory does not exist: $gameInstallPath") - return "\"explorer.exe\"" - } - - Timber.i("Found game directory: ${gameDir.absolutePath}") - - // Use GOGGameManager to get the correct executable - val executablePath = runBlocking { getInstalledExe(context, libraryItem) } - - if (executablePath.isEmpty()) { - Timber.w("No executable found for GOG game ${libraryItem.name}, opening file manager") - return "\"explorer.exe\"" - } - - // Ensure this specific game directory is mapped (isolates from other GOG games) - val gogDriveLetter = app.gamenative.utils.ContainerUtils.ensureGOGGameDirectoryMapped( - context, - container, - gameInstallPath - ) - - if (gogDriveLetter == null) { - Timber.e("Failed to map GOG game directory: $gameInstallPath") - return "\"explorer.exe\"" - } - - Timber.i("GOG game directory mapped to $gogDriveLetter: drive") - - // Calculate the Windows path relative to the game install directory - val gameInstallDir = File(gameInstallPath) - val execFile = File(gameInstallPath, executablePath) - val relativePath = execFile.relativeTo(gameInstallDir).path.replace('/', '\\') - val windowsPath = "$gogDriveLetter:\\$relativePath" - - // Set WINEPATH to the game directory root - envVars.put("WINEPATH", "$gogDriveLetter:\\") - - // Set the working directory to the executable's directory - val execWorkingDir = execFile.parentFile - if (execWorkingDir != null) { - guestProgramLauncherComponent.workingDir = execWorkingDir - Timber.i("Setting working directory to: ${execWorkingDir.absolutePath}") - } else { - guestProgramLauncherComponent.workingDir = gameDir - Timber.i("Setting working directory to game root: ${gameDir.absolutePath}") - } - - Timber.i("GOG game executable: $executablePath") - Timber.i("GOG game Windows path: $windowsPath") - - val fullCommand = "\"$windowsPath\"" - - Timber.i("Full Wine command will be: $fullCommand") - return fullCommand - } - - /** - * Create a LibraryItem from GOG game data - */ - fun createLibraryItem(appId: String, gameId: String, context: Context): LibraryItem { - val gogGame = runBlocking { getGameById(gameId) } - - return LibraryItem( - appId = appId, - name = gogGame?.title ?: "Unknown GOG Game", - iconHash = "", // GOG games don't have icon hashes like Steam - gameSource = GameSource.GOG, - ) - } - - // Simple cache for download sizes - private val downloadSizeCache = mutableMapOf() - - /** - * Get download size for a game - */ - suspend fun getDownloadSize(libraryItem: LibraryItem): String { - val gameId = libraryItem.gameId.toString() - - // Return cached result if available - downloadSizeCache[gameId]?.let { return it } - - // Get size info directly (now properly async) - return try { - Timber.d("Getting download size for game $gameId") - val sizeInfo = GOGService.getGameSizeInfo(gameId) - val formattedSize = sizeInfo?.let { StorageUtils.formatBinarySize(it.downloadSize) } ?: "Unknown" - - // Cache the result - downloadSizeCache[gameId] = formattedSize - Timber.d("Got download size for game $gameId: $formattedSize") - - formattedSize - } catch (e: Exception) { - Timber.w(e, "Failed to get download size for game $gameId") - val errorResult = "Unknown" - downloadSizeCache[gameId] = errorResult - errorResult - } - } - - /** - * Get cached download size if available - */ - fun getCachedDownloadSize(gameId: String): String? { - return downloadSizeCache[gameId] - } - - /** - * Check if game is valid to download - */ - fun isValidToDownload(library: LibraryItem): Boolean { - return true // GOG games are always downloadable if owned - } - - /** - * Get app info (convert GOG game to SteamApp format for UI compatibility) - */ - fun getAppInfo(libraryItem: LibraryItem): SteamApp? { - val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } - return if (gogGame != null) { - convertGOGGameToSteamApp(gogGame) - } else { - null - } - } - - /** - * Get release date for a game - */ - fun getReleaseDate(libraryItem: LibraryItem): String { - val appInfo = getAppInfo(libraryItem) - if (appInfo?.releaseDate == null || appInfo.releaseDate == 0L) { - return "Unknown" - } - val date = Date(appInfo.releaseDate) - return SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(date) - } - - /** - * Get hero image for a game - */ - fun getHeroImage(libraryItem: LibraryItem): String { - val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } - val imageUrl = gogGame?.imageUrl ?: "" - - // Fix GOG URLs that are missing the protocol - return if (imageUrl.startsWith("//")) { - "https:$imageUrl" - } else { - imageUrl - } - } - - /** - * Get icon image for a game - */ - fun getIconImage(libraryItem: LibraryItem): String { - return libraryItem.iconHash - } - - /** - * Get install info dialog state - */ - fun getInstallInfoDialog(context: Context, libraryItem: LibraryItem): MessageDialogState { - // GOG install logic - val gogInstallPath = "${context.dataDir.path}/gog_games" - val availableBytes = StorageUtils.getAvailableSpace(context.dataDir.path) - val availableSpace = StorageUtils.formatBinarySize(availableBytes) - - // Get cached download size if available, otherwise show "Calculating..." - val gameId = libraryItem.gameId.toString() - val downloadSize = getCachedDownloadSize(gameId) ?: "Calculating..." - - return MessageDialogState( - visible = true, - type = DialogType.INSTALL_APP, - title = context.getString(R.string.download_prompt_title), - message = "Install ${libraryItem.name} from GOG?" + - "\n\nDownload Size: $downloadSize" + - "\nInstall Path: $gogInstallPath/${libraryItem.name}" + - "\nAvailable Space: $availableSpace", - confirmBtnText = context.getString(R.string.proceed), - dismissBtnText = context.getString(R.string.cancel), - ) - } - - /** - * Run before launch (no-op for GOG games) - */ - fun runBeforeLaunch(context: Context, libraryItem: LibraryItem) { - // Don't run anything before launch for GOG games - } - - /** - * Get all GOG games as a Flow - */ - fun getAllGames(): Flow> { - return gogGameDao.getAll() - } - - /** - * Get install path for a specific GOG game - */ - fun getGameInstallPath(context: Context, gameId: String, gameTitle: String): String { - return GOGConstants.getGameInstallPath(gameTitle) - } - - /** - * Get GOG game by ID from database - */ - suspend fun getGameById(gameId: String): GOGGame? = withContext(Dispatchers.IO) { - try { - gogGameDao.getById(gameId) - } catch (e: Exception) { - Timber.e(e, "Failed to get GOG game by ID: $gameId") - null - } - } - - /** - * Get the executable path for an installed GOG game. - * Handles both V1 and V2 game directory structures. - */ - suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { - val gameId = libraryItem.gameId - try { - val game = runBlocking { getGameById(gameId.toString()) } ?: return@withContext "" - val installPath = getGameInstallPath(context, game.id, game.title) - - // Try V2 structure first (game_$gameId subdirectory) - val v2GameDir = File(installPath, "game_$gameId") - if (v2GameDir.exists()) { - Timber.i("Found V2 game structure: ${v2GameDir.absolutePath}") - return@withContext getGameExecutable(installPath, v2GameDir) - } else { - // Try V1 structure (look for any subdirectory in the install path) - val installDirFile = File(installPath) - val subdirs = installDirFile.listFiles()?.filter { - it.isDirectory && it.name != "saves" - } ?: emptyList() - - if (subdirs.isNotEmpty()) { - // For V1 games, find the subdirectory with .exe files - val v1GameDir = subdirs.find { subdir -> - val exeFiles = subdir.listFiles()?.filter { - it.isFile && - it.name.endsWith(".exe", ignoreCase = true) && - !isGOGUtilityExecutable(it.name) - } ?: emptyList() - exeFiles.isNotEmpty() - } - - if (v1GameDir != null) { - Timber.i("Found V1 game structure: ${v1GameDir.absolutePath}") - return@withContext getGameExecutable(installPath, v1GameDir) - } else { - Timber.w("No V1 game subdirectories with executables found in: $installPath") - return@withContext "" - } - } else { - Timber.w("No game directories found in: $installPath") - return@withContext "" - } - } - } catch (e: Exception) { - Timber.e(e, "Failed to get executable for GOG game $gameId") - "" - } - } - - /** - * Check if an executable is a GOG utility (should be skipped) - */ - private fun isGOGUtilityExecutable(filename: String): Boolean { - return filename.equals("unins000.exe", ignoreCase = true) || - filename.equals("CheckApplication.exe", ignoreCase = true) || - filename.equals("SettingsApplication.exe", ignoreCase = true) - } - - private fun getGameExecutable(installPath: String, gameDir: File): String { - // Get the main executable from GOG game info file - val mainExe = getMainExecutableFromGOGInfo(gameDir, installPath) - - if (mainExe.isNotEmpty()) { - Timber.i("Found GOG game executable from info file: $mainExe") - return mainExe - } - - Timber.e("Failed to find executable from GOG info file in: ${gameDir.absolutePath}") - return "" - } - - private fun getMainExecutableFromGOGInfo(gameDir: File, installPath: String): String { - // Look for goggame-*.info file - val infoFile = gameDir.listFiles()?.find { - it.isFile && it.name.startsWith("goggame-") && it.name.endsWith(".info") - } - - if (infoFile == null) { - throw Exception("GOG info file not found in: ${gameDir.absolutePath}") - } - - val content = infoFile.readText() - Timber.d("GOG info file content: $content") - - // Parse JSON to find the primary task - val jsonObject = org.json.JSONObject(content) - - // Look for playTasks array - if (!jsonObject.has("playTasks")) { - throw Exception("GOG info file does not contain playTasks array") - } - - val playTasks = jsonObject.getJSONArray("playTasks") - - // Find the primary task - for (i in 0 until playTasks.length()) { - val task = playTasks.getJSONObject(i) - if (task.has("isPrimary") && task.getBoolean("isPrimary")) { - val executablePath = task.getString("path") - - Timber.i("Found primary task executable path: $executablePath") - - // Check if the executable actually exists (case-insensitive) - val actualExeFile = gameDir.listFiles()?.find { - it.name.equals(executablePath, ignoreCase = true) - } - if (actualExeFile != null && actualExeFile.exists()) { - return "${gameDir.name}/${actualExeFile.name}" - } else { - Timber.w("Primary task executable '$executablePath' not found in game directory") - } - break - } - } - - return "" - } - - /** - * Convert GOGGame to SteamApp format for compatibility with existing UI components. - * This allows GOG games to be displayed using the same UI components as Steam games. - */ - private fun convertGOGGameToSteamApp(gogGame: GOGGame): SteamApp { - // Convert release date string (ISO format like "2021-06-17T15:55:+0300") to timestamp - val releaseTimestamp = try { - if (gogGame.releaseDate.isNotEmpty()) { - // Try different date formats that GOG might use - val formats = arrayOf( - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ZZZZZ", Locale.US), // 2021-06-17T15:55:+0300 - SimpleDateFormat("yyyy-MM-dd'T'HH:mmZ", Locale.US), // 2021-06-17T15:55+0300 - SimpleDateFormat("yyyy-MM-dd", Locale.US), // 2021-06-17 - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US), // 2021-06-17T15:55:30 - ) - - var parsedDate: Date? = null - for (format in formats) { - try { - parsedDate = format.parse(gogGame.releaseDate) - break - } catch (e: Exception) { - // Try next format - } - } - - parsedDate?.time ?: 0L - } else { - 0L - } - } catch (e: Exception) { - Timber.w(e, "Failed to parse release date: ${gogGame.releaseDate}") - 0L - } - - // Convert GOG game ID (string) to integer for SteamApp compatibility - val appId = try { - gogGame.id.toIntOrNull() ?: gogGame.id.hashCode() - } catch (e: Exception) { - gogGame.id.hashCode() - } - - return SteamApp( - id = appId, - name = gogGame.title, - type = AppType.game, - osList = EnumSet.of(OS.windows), - releaseState = ReleaseState.released, - releaseDate = releaseTimestamp, - developer = gogGame.developer.takeIf { it.isNotEmpty() } ?: "Unknown Developer", - publisher = gogGame.publisher.takeIf { it.isNotEmpty() } ?: "Unknown Publisher", - controllerSupport = ControllerSupport.none, - logoHash = "", - iconHash = "", - clientIconHash = "", - installDir = gogGame.title.replace(Regex("[^a-zA-Z0-9 ]"), "").trim(), - ) - } -} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt deleted file mode 100644 index 616560353..000000000 --- a/app/src/main/java/app/gamenative/service/gog/GOGLibraryManager.kt +++ /dev/null @@ -1,143 +0,0 @@ -package app.gamenative.service.gog - -import android.content.Context -import app.gamenative.data.GOGGame -import app.gamenative.db.dao.GOGGameDao -import kotlinx.coroutines.withContext -import kotlinx.coroutines.Dispatchers -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Manager for GOG library operations - fetching, caching, and syncing the user's GOG library - */ -@Singleton -class GOGLibraryManager @Inject constructor( - private val gogGameDao: GOGGameDao, -) { - /** - * Get a GOG game by ID from database - */ - suspend fun getGameById(gameId: String): GOGGame? { - return withContext(Dispatchers.IO) { - gogGameDao.getById(gameId) - } - } - - /** - * Insert or update a GOG game in database - * Uses REPLACE strategy, so will update if exists - */ - suspend fun insertGame(game: GOGGame) { - withContext(Dispatchers.IO) { - gogGameDao.insert(game) - } - } - - /** - * Update a GOG game in database - */ - suspend fun updateGame(game: GOGGame) { - withContext(Dispatchers.IO) { - gogGameDao.update(game) - } - } - - /** - * Start background library sync - * Progressively fetches and updates the GOG library in the background - */ - suspend fun startBackgroundSync(context: Context): Result = withContext(Dispatchers.IO) { - try { - if (!GOGService.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...") - - // Use the same refresh logic but don't block on completion - val result = refreshLibrary(context) - - if (result.isSuccess) { - val count = result.getOrNull() ?: 0 - Timber.tag("GOG").i("Background sync completed: $count games synced") - Result.success(Unit) - } else { - val error = result.exceptionOrNull() - Timber.e(error, "Background sync failed: ${error?.message}") - Result.failure(error ?: Exception("Background sync failed")) - } - } catch (e: Exception) { - Timber.e(e, "Failed to sync GOG library in background") - Result.failure(e) - } - } - - /** - * Refresh the entire library (called manually by user) - * Fetches all games from GOG API and updates the database - */ - suspend fun refreshLibrary(context: Context): Result = withContext(Dispatchers.IO) { - try { - if (!GOGService.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 - val listResult = GOGService.listGames(context) - - if (listResult.isFailure) { - val error = listResult.exceptionOrNull() - Timber.e(error, "Failed to fetch games from GOG: ${error?.message}") - return@withContext Result.failure(error ?: Exception("Failed to fetch GOG library")) - } - - val games = listResult.getOrNull() ?: emptyList() - Timber.tag("GOG").i("Successfully fetched ${games.size} games from GOG") - - if (games.isEmpty()) { - Timber.w("No games found in GOG library") - return@withContext Result.success(0) - } - - // Log sample of fetched games - games.take(3).forEach { game -> - Timber.tag("GOG").d(""" - |=== Fetched GOG Game === - |ID: ${game.id} - |Title: ${game.title} - |Slug: ${game.slug} - |Developer: ${game.developer} - |Publisher: ${game.publisher} - |Description: ${game.description.take(100)}... - |Release Date: ${game.releaseDate} - |Image URL: ${game.imageUrl} - |Icon URL: ${game.iconUrl} - |Genres: ${game.genres.joinToString(", ")} - |Languages: ${game.languages.joinToString(", ")} - |Download Size: ${game.downloadSize} - |Install Size: ${game.installSize} - |Is Installed: ${game.isInstalled} - |Install Path: ${game.installPath} - |Type: ${game.type} - |======================= - """.trimMargin()) - } - - // Update database using upsert to preserve install status - Timber.d("Upserting ${games.size} games to database...") - gogGameDao.upsertPreservingInstallStatus(games) - - Timber.tag("GOG").i("Successfully refreshed GOG library with ${games.size} games") - Result.success(games.size) - } catch (e: Exception) { - Timber.e(e, "Failed to refresh GOG library") - Result.failure(e) - } - } -} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt new file mode 100644 index 000000000..7d9b10be2 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -0,0 +1,836 @@ +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.GOGGame +import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem +import app.gamenative.data.PostSyncInfo +import app.gamenative.data.SteamApp +import app.gamenative.data.GameSource +import app.gamenative.db.dao.GOGGameDao +import app.gamenative.enums.AppType +import app.gamenative.enums.ControllerSupport +import app.gamenative.enums.Marker +import app.gamenative.enums.OS +import app.gamenative.enums.ReleaseState +import app.gamenative.enums.SyncResult +import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.MarkerUtils +import app.gamenative.utils.StorageUtils +import com.winlator.container.Container +import com.winlator.core.envvars.EnvVars +import com.winlator.xenvironment.components.GuestProgramLauncherComponent +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.EnumSet +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +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, +) { + + // Simple cache for download sizes + private val downloadSizeCache = mutableMapOf() + + // ========================================================================== + // DATABASE OPERATIONS + // ========================================================================== + + /** + * Get a GOG game by ID from database + */ + suspend fun getGameById(gameId: String): GOGGame? { + return withContext(Dispatchers.IO) { + try { + gogGameDao.getById(gameId) + } catch (e: Exception) { + Timber.e(e, "Failed to get GOG game by ID: $gameId") + null + } + } + } + + /** + * Insert or update a GOG game in database + * Uses REPLACE strategy, so will update if exists + */ + suspend fun insertGame(game: GOGGame) { + withContext(Dispatchers.IO) { + gogGameDao.insert(game) + } + } + + /** + * Update a GOG game in database + */ + suspend fun updateGame(game: GOGGame) { + withContext(Dispatchers.IO) { + gogGameDao.update(game) + } + } + + /** + * Get all GOG games as a Flow + */ + fun getAllGames(): Flow> { + return gogGameDao.getAll() + } + + // ========================================================================== + // LIBRARY SYNCING + // ========================================================================== + + /** + * Start background library sync + * Progressively fetches and updates the GOG library in the background + */ + 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") + Result.success(Unit) + } else { + val error = result.exceptionOrNull() + Timber.e(error, "Background sync failed: ${error?.message}") + Result.failure(error ?: Exception("Background sync failed")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to sync GOG library in background") + Result.failure(e) + } + } + + /** + * Refresh the entire library (called manually by user) + * Fetches all games from GOG API and updates the database + */ + suspend fun refreshLibrary(context: Context): Result = withContext(Dispatchers.IO) { + try { + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.w("Cannot refresh library: not authenticated with GOG") + return@withContext Result.failure(Exception("Not authenticated with GOG")) + } + + Timber.tag("GOG").i("Refreshing GOG library from GOG API...") + + // Fetch games from GOG via GOGDL Python backend + val listResult = listGames(context) + + if (listResult.isFailure) { + val error = listResult.exceptionOrNull() + Timber.e(error, "Failed to fetch games from GOG: ${error?.message}") + return@withContext Result.failure(error ?: Exception("Failed to fetch GOG library")) + } + + val games = listResult.getOrNull() ?: emptyList() + Timber.tag("GOG").i("Successfully fetched ${games.size} games from GOG") + + if (games.isEmpty()) { + Timber.w("No games found in GOG library") + return@withContext Result.success(0) + } + + // Update database using upsert to preserve install status + Timber.d("Upserting ${games.size} games to database...") + gogGameDao.upsertPreservingInstallStatus(games) + + Timber.tag("GOG").i("Successfully refreshed GOG library with ${games.size} games") + Result.success(games.size) + } catch (e: Exception) { + Timber.e(e, "Failed to refresh GOG library") + Result.failure(e) + } + } + + /** + * Fetch the user's GOG library (list of owned games) + * Returns a list of GOGGame objects with basic metadata + */ + private suspend fun listGames(context: Context): Result> { + return try { + Timber.i("Fetching GOG library via GOGDL...") + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.e("Cannot list games: not authenticated") + return Result.failure(Exception("Not authenticated. Please log in first.")) + } + + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "list", "--pretty") + + if (result.isFailure) { + val error = result.exceptionOrNull() + Timber.e(error, "Failed to fetch GOG library: ${error?.message}") + return Result.failure(error ?: Exception("Failed to fetch GOG library")) + } + + val output = result.getOrNull() ?: "" + parseGamesFromJson(output) + } catch (e: Exception) { + Timber.e(e, "Unexpected error while fetching GOG library") + Result.failure(e) + } + } + + /** + * Parse games from GOGDL JSON output + */ + private fun parseGamesFromJson(output: String): Result> { + return try { + val gamesArray = org.json.JSONArray(output.trim()) + val games = mutableListOf() + + for (i in 0 until gamesArray.length()) { + try { + val gameObj = gamesArray.getJSONObject(i) + games.add(parseGameObject(gameObj)) + } catch (e: Exception) { + Timber.w(e, "Failed to parse game at index $i, skipping") + } + } + + Timber.i("Successfully parsed ${games.size} games from GOG library") + Result.success(games) + } catch (e: Exception) { + Timber.e(e, "Failed to parse GOG library JSON") + Result.failure(Exception("Failed to parse GOG library: ${e.message}", e)) + } + } + + /** + * Parse a single game object from JSON + */ + private fun parseGameObject(gameObj: JSONObject): GOGGame { + val genresList = parseJsonArray(gameObj.optJSONArray("genres")) + val languagesList = parseJsonArray(gameObj.optJSONArray("languages")) + + return GOGGame( + id = gameObj.optString("id", ""), + title = gameObj.optString("title", "Unknown Game"), + slug = gameObj.optString("slug", ""), + imageUrl = gameObj.optString("imageUrl", ""), + iconUrl = gameObj.optString("iconUrl", ""), + description = gameObj.optString("description", ""), + releaseDate = gameObj.optString("releaseDate", ""), + developer = gameObj.optString("developer", ""), + publisher = gameObj.optString("publisher", ""), + genres = genresList, + languages = languagesList, + downloadSize = gameObj.optLong("downloadSize", 0L), + installSize = 0L, + isInstalled = false, + installPath = "", + lastPlayed = 0L, + playTime = 0L, + ) + } + + /** + * Parse a JSON array into a list of strings + */ + private fun parseJsonArray(jsonArray: org.json.JSONArray?): List { + val result = mutableListOf() + if (jsonArray != null) { + for (j in 0 until jsonArray.length()) { + result.add(jsonArray.getString(j)) + } + } + return result + } + + /** + * Fetch a single game's metadata from GOG API and insert it into the database + */ + suspend fun refreshSingleGame(gameId: String, context: Context): Result { + return try { + Timber.i("Fetching single game data for gameId: $gameId") + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + + if (!GOGAuthManager.hasStoredCredentials(context)) { + return Result.failure(Exception("Not authenticated")) + } + + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "list", "--pretty") + + if (result.isFailure) { + return Result.failure(result.exceptionOrNull() ?: Exception("Failed to fetch game data")) + } + + val output = result.getOrNull() ?: "" + val gamesArray = org.json.JSONArray(output.trim()) + + // Find the game with matching ID + for (i in 0 until gamesArray.length()) { + val gameObj = gamesArray.getJSONObject(i) + if (gameObj.optString("id", "") == gameId) { + val game = parseGameObject(gameObj) + insertGame(game) + Timber.i("Successfully fetched and inserted game: ${game.title}") + return Result.success(game) + } + } + + Timber.w("Game $gameId not found in GOG library") + Result.success(null) + } catch (e: Exception) { + Timber.e(e, "Error fetching single game data for $gameId") + Result.failure(e) + } + } + + // ========================================================================== + // DOWNLOAD & INSTALLATION + // ========================================================================== + + /** + * Download a GOG game with full progress tracking + */ + suspend fun downloadGame(context: Context, gameId: String, installPath: String, downloadInfo: DownloadInfo): Result { + return try { + Timber.i("Starting GOGDL download for game $gameId") + + val installDir = File(installPath) + if (!installDir.exists()) { + installDir.mkdirs() + } + + // Create support directory for redistributables + val supportDir = File(installDir.parentFile, "gog-support") + supportDir.mkdirs() + + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + val numericGameId = ContainerUtils.extractGameIdFromContainerId(gameId).toString() + + val result = GOGPythonBridge.executeCommandWithCallback( + downloadInfo, + "--auth-config-path", authConfigPath, + "download", numericGameId, + "--platform", "windows", + "--path", installPath, + "--support", supportDir.absolutePath, + "--skip-dlcs", + "--lang", "en-US", + "--max-workers", "1", + ) + + if (result.isSuccess) { + downloadInfo.setProgress(1.0f) + Timber.i("GOGDL download completed successfully") + Result.success(Unit) + } else { + downloadInfo.setProgress(-1.0f) + val error = result.exceptionOrNull() + Timber.e(error, "GOGDL download failed") + Result.failure(error ?: Exception("Download failed")) + } + } catch (e: Exception) { + Timber.e(e, "Failed to start GOG game download") + Result.failure(e) + } + } + + /** + * Delete a GOG game + */ + fun deleteGame(context: Context, libraryItem: LibraryItem): Result { + try { + val gameId = libraryItem.gameId.toString() + val installPath = getGameInstallPath(context, gameId, libraryItem.name) + val installDir = File(installPath) + + // Delete the manifest file + val manifestPath = File(context.filesDir, "manifests/$gameId") + if (manifestPath.exists()) { + manifestPath.delete() + Timber.i("Deleted manifest file for game $gameId") + } + + if (installDir.exists()) { + val success = installDir.deleteRecursively() + if (success) { + Timber.i("Successfully deleted game directory: $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 + val game = runBlocking { getGameById(gameId) } + if (game != null) { + val updatedGame = game.copy(isInstalled = false, installPath = "") + runBlocking { gogGameDao.update(updatedGame) } + } + + return Result.success(Unit) + } else { + return Result.failure(Exception("Failed to delete game directory")) + } + } else { + Timber.w("GOG game directory doesn't exist: $installPath") + // Clean up markers anyway + val appDirPath = getAppDirPath(libraryItem.appId) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + // Update database + val game = runBlocking { getGameById(gameId) } + if (game != null) { + val updatedGame = game.copy(isInstalled = false, installPath = "") + runBlocking { gogGameDao.update(updatedGame) } + } + + return Result.success(Unit) + } + } catch (e: Exception) { + Timber.e(e, "Failed to delete GOG game ${libraryItem.gameId}") + return Result.failure(e) + } + } + + // ========================================================================== + // INSTALLATION STATUS & VERIFICATION + // ========================================================================== + + /** + * Check if a GOG game is installed + */ + fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean { + try { + val appDirPath = getAppDirPath(libraryItem.appId) + + // Use marker-based approach + val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + + val isInstalled = isDownloadComplete && !isDownloadInProgress + + // Update database if status changed + val gameId = libraryItem.gameId.toString() + val game = runBlocking { getGameById(gameId) } + if (game != null && isInstalled != game.isInstalled) { + val installPath = if (isInstalled) getGameInstallPath(context, gameId, libraryItem.name) else "" + val updatedGame = game.copy(isInstalled = isInstalled, installPath = installPath) + runBlocking { gogGameDao.update(updatedGame) } + } + + return isInstalled + } catch (e: Exception) { + Timber.e(e, "Error checking if GOG game is installed") + return false + } + } + + /** + * Verify that a GOG game installation is valid and complete + */ + fun verifyInstallation(gameId: String): Pair { + val game = runBlocking { getGameById(gameId) } + val installPath = game?.installPath + + if (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) + } + + /** + * Check if game has a partial download + */ + fun hasPartialDownload(libraryItem: LibraryItem): Boolean { + try { + val appDirPath = getAppDirPath(libraryItem.appId) + + val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + + if (isDownloadInProgress) { + return true + } + + if (!isDownloadComplete) { + val installPath = GOGConstants.getGameInstallPath(libraryItem.name) + val installDir = File(installPath) + return installDir.exists() && installDir.listFiles()?.isNotEmpty() == true + } + + return false + } catch (e: Exception) { + Timber.w(e, "Error checking partial download status") + return false + } + } + + // ========================================================================== + // EXECUTABLE DISCOVERY & LAUNCH + // ========================================================================== + + /** + * Get the executable path for an installed GOG game + */ + suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { + val gameId = libraryItem.gameId.toString() + try { + val game = getGameById(gameId) ?: return@withContext "" + val installPath = getGameInstallPath(context, game.id, game.title) + + // Try V2 structure first (game_$gameId subdirectory) + val v2GameDir = File(installPath, "game_$gameId") + if (v2GameDir.exists()) { + return@withContext getGameExecutable(installPath, v2GameDir) + } + + // Try V1 structure + val installDirFile = File(installPath) + val subdirs = installDirFile.listFiles()?.filter { + it.isDirectory && it.name != "saves" + } ?: emptyList() + + if (subdirs.isNotEmpty()) { + return@withContext getGameExecutable(installPath, subdirs.first()) + } + + "" + } catch (e: Exception) { + Timber.e(e, "Failed to get executable for GOG game $gameId") + "" + } + } + + private fun getGameExecutable(installPath: String, gameDir: File): String { + val mainExe = getMainExecutableFromGOGInfo(gameDir, installPath) + if (mainExe.isNotEmpty()) { + Timber.i("Found GOG game executable from info file: $mainExe") + return mainExe + } + Timber.e("Failed to find executable from GOG info file in: ${gameDir.absolutePath}") + return "" + } + + private fun getMainExecutableFromGOGInfo(gameDir: File, installPath: String): String { + val infoFile = gameDir.listFiles()?.find { + it.isFile && it.name.startsWith("goggame-") && it.name.endsWith(".info") + } ?: throw Exception("GOG info file not found") + + val content = infoFile.readText() + val jsonObject = JSONObject(content) + + if (!jsonObject.has("playTasks")) { + throw Exception("playTasks array not found in info file") + } + + 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") + val actualExeFile = gameDir.listFiles()?.find { + it.name.equals(executablePath, ignoreCase = true) + } + if (actualExeFile != null && actualExeFile.exists()) { + return "${gameDir.name}/${actualExeFile.name}" + } + break + } + } + return "" + } + + /** + * Get Wine start command for launching a game + */ + fun getWineStartCommand( + context: Context, + libraryItem: LibraryItem, + container: Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, + envVars: EnvVars, + guestProgramLauncherComponent: GuestProgramLauncherComponent, + ): String { + val gameId = ContainerUtils.extractGameIdFromContainerId(libraryItem.appId) + + // Verify installation + val (isValid, errorMessage) = verifyInstallation(gameId.toString()) + if (!isValid) { + Timber.e("Installation verification failed: $errorMessage") + return "\"explorer.exe\"" + } + + val game = runBlocking { getGameById(gameId.toString()) } + if (game == null) { + Timber.e("Game not found for ID: $gameId") + return "\"explorer.exe\"" + } + + val gameInstallPath = getGameInstallPath(context, gameId.toString(), game.title) + val gameDir = File(gameInstallPath) + + if (!gameDir.exists()) { + Timber.e("Game directory does not exist: $gameInstallPath") + return "\"explorer.exe\"" + } + + val executablePath = runBlocking { getInstalledExe(context, libraryItem) } + if (executablePath.isEmpty()) { + Timber.w("No executable found, opening file manager") + return "\"explorer.exe\"" + } + + // Map game directory + val gogDriveLetter = ContainerUtils.ensureGOGGameDirectoryMapped(context, container, gameInstallPath) + if (gogDriveLetter == null) { + Timber.e("Failed to map GOG game directory") + return "\"explorer.exe\"" + } + + val gameInstallDir = File(gameInstallPath) + val execFile = File(gameInstallPath, executablePath) + val relativePath = execFile.relativeTo(gameInstallDir).path.replace('/', '\\') + 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.i("GOG Wine command: \"$windowsPath\"") + return "\"$windowsPath\"" + } + + /** + * Launch game with save sync (stub - cloud saves not implemented) + */ + suspend fun launchGameWithSaveSync( + context: Context, + libraryItem: LibraryItem, + parentScope: CoroutineScope, + ignorePendingOperations: Boolean, + preferredSave: Int?, + ): PostSyncInfo = withContext(Dispatchers.IO) { + try { + Timber.i("GOG game launch for ${libraryItem.name} (cloud save sync disabled)") + // TODO: Implement GOG cloud save sync + PostSyncInfo(SyncResult.Success) + } catch (e: Exception) { + Timber.e(e, "GOG game launch exception") + PostSyncInfo(SyncResult.UnknownFail) + } + } + + // ========================================================================== + // FILE SYSTEM & PATHS + // ========================================================================== + + /** + * Get app directory path for a game + */ + fun getAppDirPath(appId: String): String { + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val game = runBlocking { getGameById(gameId.toString()) } + + if (game != null) { + return GOGConstants.getGameInstallPath(game.title) + } + + Timber.w("Could not find game for appId $appId") + return GOGConstants.defaultGOGGamesPath + } + + /** + * Get install path for a specific GOG game + */ + fun getGameInstallPath(context: Context, gameId: String, gameTitle: String): String { + return GOGConstants.getGameInstallPath(gameTitle) + } + + /** + * Get disk size of installed game + */ + suspend fun getGameDiskSize(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { + val installPath = getGameInstallPath(context, libraryItem.appId, libraryItem.name) + val folderSize = StorageUtils.getFolderSize(installPath) + StorageUtils.formatBinarySize(folderSize) + } + + /** + * Get download size for a game + */ + suspend fun getDownloadSize(libraryItem: LibraryItem): String { + val gameId = libraryItem.gameId.toString() + + // Return cached result if available + downloadSizeCache[gameId]?.let { return it } + + // TODO: Implement via GOGPythonBridge + val formattedSize = "Unknown" + downloadSizeCache[gameId] = formattedSize + return formattedSize + } + + /** + * Get cached download size if available + */ + fun getCachedDownloadSize(gameId: String): String? { + return downloadSizeCache[gameId] + } + + // ========================================================================== + // UTILITY & CONVERSION + // ========================================================================== + + /** + * Create a LibraryItem from GOG game data + */ + fun createLibraryItem(appId: String, gameId: String, context: Context): LibraryItem { + val gogGame = runBlocking { getGameById(gameId) } + return LibraryItem( + appId = appId, + name = gogGame?.title ?: "Unknown GOG Game", + iconHash = gogGame?.iconUrl ?: "", + gameSource = GameSource.GOG, + ) + } + + /** + * Get store URL for game + */ + fun getStoreUrl(libraryItem: LibraryItem): Uri { + val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } + val slug = gogGame?.slug ?: "" + return "https://www.gog.com/en/game/$slug".toUri() + } + + /** + * Convert GOGGame to SteamApp format for UI compatibility + */ + fun convertToSteamApp(gogGame: GOGGame): SteamApp { + val releaseTimestamp = parseReleaseDate(gogGame.releaseDate) + val appId = gogGame.id.toIntOrNull() ?: gogGame.id.hashCode() + + return SteamApp( + id = appId, + name = gogGame.title, + type = AppType.game, + osList = EnumSet.of(OS.windows), + releaseState = ReleaseState.released, + releaseDate = releaseTimestamp, + developer = gogGame.developer.takeIf { it.isNotEmpty() } ?: "Unknown Developer", + publisher = gogGame.publisher.takeIf { it.isNotEmpty() } ?: "Unknown Publisher", + controllerSupport = ControllerSupport.none, + logoHash = "", + iconHash = "", + clientIconHash = "", + installDir = gogGame.title.replace(Regex("[^a-zA-Z0-9 ]"), "").trim(), + ) + } + + private fun parseReleaseDate(releaseDate: String): Long { + if (releaseDate.isEmpty()) return 0L + + val formats = arrayOf( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US), + SimpleDateFormat("yyyy-MM-dd", Locale.US), + SimpleDateFormat("MMM dd, yyyy", Locale.US), + ) + + for (format in formats) { + try { + return format.parse(releaseDate)?.time ?: 0L + } catch (e: Exception) { + // Try next format + } + } + + return 0L + } + + /** + * Check if game is valid to download + */ + fun isValidToDownload(library: LibraryItem): Boolean { + return true // GOG games are always downloadable if owned + } + + /** + * Check if update is pending for a game (stub) + */ + suspend fun isUpdatePending(libraryItem: LibraryItem): Boolean { + return false // Not implemented yet + } + + /** + * Run before launch (no-op for GOG games) + */ + fun runBeforeLaunch(context: Context, libraryItem: LibraryItem) { + // Don't run anything before launch for GOG games + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt new file mode 100644 index 000000000..af1922332 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt @@ -0,0 +1,262 @@ +package app.gamenative.service.gog + +import android.content.Context +import app.gamenative.data.DownloadInfo +import com.chaquo.python.PyObject +import com.chaquo.python.Python +import com.chaquo.python.android.AndroidPlatform +import kotlinx.coroutines.* +import timber.log.Timber + +/** + * Progress callback that Python code can invoke to report download progress + */ +class ProgressCallback(private val downloadInfo: DownloadInfo) { + @JvmOverloads + fun update(percent: Float = 0f, downloadedMB: Float = 0f, totalMB: Float = 0f, downloadSpeedMBps: Float = 0f, eta: String = "") { + try { + val progress = (percent / 100.0f).coerceIn(0.0f, 1.0f) + downloadInfo.setProgress(progress) + + if (percent > 0f) { + Timber.d("Download progress: %.1f%% (%.1f/%.1f MB) Speed: %.2f MB/s ETA: %s", + percent, downloadedMB, totalMB, downloadSpeedMBps, eta) + } + } catch (e: Exception) { + Timber.w(e, "Error updating download progress") + } + } +} + +/** + * Low-level Python execution bridge for GOGDL commands. + * + * This is a pure abstraction layer over Chaquopy Python interpreter. + * Contains NO business logic - just Python initialization and command execution. + * + * All GOG-specific functionality should use this bridge but NOT be implemented here. + */ +object GOGPythonBridge { + private var python: Python? = null + private var isInitialized = false + + /** + * Initialize the Chaquopy Python environment + */ + fun initialize(context: Context): Boolean { + if (isInitialized) return true + + return try { + Timber.i("Initializing GOGPythonBridge with Chaquopy...") + + if (!Python.isStarted()) { + Python.start(AndroidPlatform(context)) + } + python = Python.getInstance() + + isInitialized = true + Timber.i("GOGPythonBridge initialized successfully") + true + } catch (e: Exception) { + Timber.e(e, "Failed to initialize GOGPythonBridge") + false + } + } + + /** + * Check if Python environment is initialized + */ + fun isReady(): Boolean = isInitialized && Python.isStarted() + + /** + * Execute GOGDL command using Chaquopy + * + * This is the foundational method that all GOGDL operations use. + * + * @param args Command line arguments to pass to gogdl CLI + * @return Result containing command output or error + */ + suspend fun executeCommand(vararg args: String): Result { + return withContext(Dispatchers.IO) { + try { + Timber.d("executeCommand called with args: ${args.joinToString(" ")}") + + if (!Python.isStarted()) { + Timber.e("Python is not started! Cannot execute GOGDL command") + return@withContext Result.failure(Exception("Python environment not initialized")) + } + + val python = Python.getInstance() + Timber.d("Python instance obtained successfully") + + val sys = python.getModule("sys") + val io = python.getModule("io") + val originalArgv = sys.get("argv") + + try { + // Import gogdl.cli module + Timber.d("Importing gogdl.cli module...") + val gogdlCli = python.getModule("gogdl.cli") + Timber.d("gogdl.cli module imported successfully") + + // Set up arguments for argparse + val argsList = listOf("gogdl") + args.toList() + Timber.d("Setting GOGDL arguments for argparse: ${args.joinToString(" ")}") + val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) + sys.put("argv", pythonList) + Timber.d("sys.argv set to: $argsList") + + // Capture stdout + val stdoutCapture = io.callAttr("StringIO") + val originalStdout = sys.get("stdout") + sys.put("stdout", stdoutCapture) + Timber.d("stdout capture configured") + + // Execute the main function + Timber.d("Calling gogdl.cli.main()...") + gogdlCli.callAttr("main") + Timber.d("gogdl.cli.main() completed") + + // Get the captured output + val output = stdoutCapture.callAttr("getvalue").toString() + Timber.d("GOGDL raw output (length: ${output.length}): $output") + + // Restore original stdout + sys.put("stdout", originalStdout) + + if (output.isNotEmpty()) { + Timber.d("Returning success with output") + Result.success(output) + } else { + Timber.w("GOGDL execution completed but output is empty") + Result.success("GOGDL execution completed") + } + + } catch (e: Exception) { + Timber.e(e, "GOGDL execution exception: ${e.javaClass.simpleName} - ${e.message}") + Timber.e("Exception stack trace: ${e.stackTraceToString()}") + Result.failure(Exception("GOGDL execution failed: ${e.message}", e)) + } finally { + // Restore original sys.argv + sys.put("argv", originalArgv) + Timber.d("sys.argv restored") + } + } catch (e: Exception) { + Timber.e(e, "Failed to execute GOGDL command: ${args.joinToString(" ")}") + Timber.e("Outer exception stack trace: ${e.stackTraceToString()}") + Result.failure(Exception("GOGDL execution failed: ${e.message}", e)) + } + } + } + + /** + * Execute GOGDL command with progress callback for downloads + * + * This variant allows Python code to report progress via a callback object. + * + * @param downloadInfo DownloadInfo object to track progress + * @param args Command line arguments to pass to gogdl CLI + * @return Result containing command output or error + */ + suspend fun executeCommandWithCallback(downloadInfo: DownloadInfo, vararg args: String): Result { + return withContext(Dispatchers.IO) { + try { + val python = Python.getInstance() + val sys = python.getModule("sys") + val originalArgv = sys.get("argv") + + try { + // Create progress callback that Python can invoke + val progressCallback = ProgressCallback(downloadInfo) + + // Get the gogdl module and set up callback + val gogdlModule = python.getModule("gogdl") + + // Try to set progress callback if gogdl supports it + try { + gogdlModule.put("_progress_callback", progressCallback) + Timber.d("Progress callback registered with GOGDL") + } catch (e: Exception) { + Timber.w(e, "Could not register progress callback, will use estimation") + } + + val gogdlCli = python.getModule("gogdl.cli") + + // Set up arguments for argparse + val argsList = listOf("gogdl") + args.toList() + Timber.d("Setting GOGDL arguments: ${args.joinToString(" ")}") + val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) + sys.put("argv", pythonList) + + // Check for cancellation before starting + ensureActive() + + // Start a simple progress estimator in case callback doesn't work + val estimatorJob = CoroutineScope(Dispatchers.IO).launch { + estimateProgress(downloadInfo) + } + + try { + // Execute the main function + gogdlCli.callAttr("main") + Timber.i("GOGDL execution completed successfully") + Result.success("Download completed") + } finally { + estimatorJob.cancel() + } + } catch (e: Exception) { + Timber.e(e, "GOGDL execution failed: ${e.message}") + Result.failure(e) + } finally { + sys.put("argv", originalArgv) + } + } catch (e: CancellationException) { + Timber.i("GOGDL command cancelled") + throw e // Re-throw to propagate cancellation + } catch (e: Exception) { + Timber.e(e, "Failed to execute GOGDL command: ${args.joinToString(" ")}") + Result.failure(e) + } + } + } + + /** + * Estimate progress when callback isn't available + * Shows gradual progress to indicate activity + */ + private suspend fun estimateProgress(downloadInfo: DownloadInfo) { + try { + var lastProgress = 0.0f + val startTime = System.currentTimeMillis() + + while (downloadInfo.getProgress() < 1.0f && downloadInfo.getProgress() >= 0.0f) { + delay(3000L) // Update every 3 seconds + + val elapsed = System.currentTimeMillis() - startTime + val estimatedProgress = when { + elapsed < 5000 -> 0.05f + elapsed < 15000 -> 0.15f + elapsed < 30000 -> 0.30f + elapsed < 60000 -> 0.50f + elapsed < 120000 -> 0.70f + elapsed < 180000 -> 0.85f + else -> 0.95f + }.coerceAtLeast(lastProgress) + + // Only update if progress hasn't been set by callback + if (downloadInfo.getProgress() <= lastProgress + 0.01f) { + downloadInfo.setProgress(estimatedProgress) + lastProgress = estimatedProgress + Timber.d("Estimated progress: %.1f%%", estimatedProgress * 100) + } else { + // Callback is working, update our tracking + lastProgress = downloadInfo.getProgress() + } + } + } catch (e: CancellationException) { + Timber.d("Progress estimation cancelled") + } catch (e: Exception) { + Timber.w(e, "Error in progress estimation") + } + } +} diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index fd57ae901..4007e9a33 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -8,73 +8,32 @@ import androidx.room.Room 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.db.PluviaDatabase import app.gamenative.db.DATABASE_NAME import app.gamenative.service.NotificationHelper -import app.gamenative.utils.ContainerUtils -import com.chaquo.python.Kwarg -import com.chaquo.python.PyObject -import com.chaquo.python.Python -import com.chaquo.python.android.AndroidPlatform -import java.io.File import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.* -import okhttp3.OkHttpClient -import org.json.JSONObject import timber.log.Timber -import java.util.function.Function /** - * Data class to hold metadata extracted from GOG GamesDB + * GOG Service - thin coordinator that delegates to specialized managers. + * + * Architecture: + * - GOGPythonBridge: Low-level Python/GOGDL command execution + * - GOGAuthManager: Authentication and account management + * - GOGManager: Game library, downloads, and installation + * + * This service maintains backward compatibility through static accessors + * while delegating all operations to the appropriate managers. */ -private data class GameMetadata( - val developer: String = "Unknown Developer", - val publisher: String = "Unknown Publisher", - val title: String? = null, - val description: String? = null -) - -/** - * Data class to hold size information from gogdl info command - */ -data class GameSizeInfo( - val downloadSize: Long, - val diskSize: Long -) - -/** - * Progress callback that Python code can invoke to report download progress - */ -class ProgressCallback(private val downloadInfo: DownloadInfo) { - @JvmOverloads - fun update(percent: Float = 0f, downloadedMB: Float = 0f, totalMB: Float = 0f, downloadSpeedMBps: Float = 0f, eta: String = "") { - try { - val progress = (percent / 100.0f).coerceIn(0.0f, 1.0f) - downloadInfo.setProgress(progress) - - if (percent > 0f) { - Timber.d("Download progress: %.1f%% (%.1f/%.1f MB) Speed: %.2f MB/s ETA: %s", - percent, downloadedMB, totalMB, downloadSpeedMBps, eta) - } - } catch (e: Exception) { - Timber.w(e, "Error updating download progress") - } - } -} - class GOGService : Service() { companion object { private var instance: GOGService? = null - private var appContext: Context? = null - private var isInitialized = false - private var httpClient: OkHttpClient? = null - private var python: Python? = null - - // Constants - private const val GOG_CLIENT_ID = "46899977096215655" - // Add sync tracking variables + // Sync tracking variables private var syncInProgress: Boolean = false private var backgroundSyncJob: Job? = null @@ -94,335 +53,61 @@ class GOGService : Service() { } } - fun setHttpClient(client: OkHttpClient) { - httpClient = client - } - /** * Initialize the GOG service with Chaquopy Python + * Delegates to GOGPythonBridge */ fun initialize(context: Context): Boolean { - if (isInitialized) return true - - try { - // Store the application context - appContext = context.applicationContext - - Timber.i("Initializing GOG service with Chaquopy...") - - // Initialize Python if not already started - if (!Python.isStarted()) { - Python.start(AndroidPlatform(context)) - } - python = Python.getInstance() - - isInitialized = true - Timber.i("GOG service initialized successfully with Chaquopy") - - return isInitialized - } catch (e: Exception) { - Timber.e(e, "Exception during GOG service initialization") - return false - } + return GOGPythonBridge.initialize(context) } + // ========================================================================== + // AUTHENTICATION - Delegate to GOGAuthManager + // ========================================================================== + /** - * Execute GOGDL command using Chaquopy + * Authenticate with GOG using authorization code */ - suspend fun executeCommand(vararg args: String): Result { - return withContext(Dispatchers.IO) { - try { - Timber.d("executeCommand called with args: ${args.joinToString(" ")}") - - if (!Python.isStarted()) { - Timber.e("Python is not started! Cannot execute GOGDL command") - return@withContext Result.failure(Exception("Python environment not initialized")) - } - - val python = Python.getInstance() - Timber.d("Python instance obtained successfully") - - val sys = python.getModule("sys") - val io = python.getModule("io") - val originalArgv = sys.get("argv") - - try { - // Now import our Android-compatible GOGDL CLI module - Timber.d("Importing gogdl.cli module...") - val gogdlCli = python.getModule("gogdl.cli") - Timber.d("gogdl.cli module imported successfully") - - // Set up arguments for argparse - val argsList = listOf("gogdl") + args.toList() - Timber.d("Setting GOGDL arguments for argparse: ${args.joinToString(" ")}") - // Convert to Python list to avoid jarray issues - val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) - sys.put("argv", pythonList) - Timber.d("sys.argv set to: $argsList") - - // Capture stdout - val stdoutCapture = io.callAttr("StringIO") - val originalStdout = sys.get("stdout") - sys.put("stdout", stdoutCapture) - Timber.d("stdout capture configured") - - // Execute the main function - Timber.d("Calling gogdl.cli.main()...") - gogdlCli.callAttr("main") - Timber.d("gogdl.cli.main() completed") - - // Get the captured output - val output = stdoutCapture.callAttr("getvalue").toString() - Timber.d("GOGDL raw output (length: ${output.length}): $output") - - // Restore original stdout - sys.put("stdout", originalStdout) - - if (output.isNotEmpty()) { - Timber.d("Returning success with output") - Result.success(output) - } else { - Timber.w("GOGDL execution completed but output is empty") - Result.success("GOGDL execution completed") - } - - } catch (e: Exception) { - Timber.e(e, "GOGDL execution exception: ${e.javaClass.simpleName} - ${e.message}") - Timber.e("Exception stack trace: ${e.stackTraceToString()}") - Result.failure(Exception("GOGDL execution failed: ${e.message}", e)) - } finally { - // Restore original sys.argv - sys.put("argv", originalArgv) - Timber.d("sys.argv restored") - } - } catch (e: Exception) { - Timber.e(e, "Failed to execute GOGDL command: ${args.joinToString(" ")}") - Timber.e("Outer exception stack trace: ${e.stackTraceToString()}") - Result.failure(Exception("GOGDL execution failed: ${e.message}", e)) - } - } + suspend fun authenticateWithCode(context: Context, authorizationCode: String): Result { + return GOGAuthManager.authenticateWithCode(context, authorizationCode) } /** - * Read and parse auth credentials from file + * Check if user has stored credentials */ - private fun readAuthCredentials(authConfigPath: String): Result> { - return try { - val authFile = File(authConfigPath) - Timber.d("Checking auth file at: ${authFile.absolutePath}") - Timber.d("Auth file exists: ${authFile.exists()}") - - if (!authFile.exists()) { - return Result.failure(Exception("No authentication found. Please log in first.")) - } - - val authContent = authFile.readText() - Timber.d("Auth file content: $authContent") - - val authJson = JSONObject(authContent) - - // GOGDL stores credentials nested under client ID - val credentialsJson = if (authJson.has(GOG_CLIENT_ID)) { - authJson.getJSONObject(GOG_CLIENT_ID) - } else { - // Fallback: try to read from root level - authJson - } - - val accessToken = credentialsJson.optString("access_token", "") - val userId = credentialsJson.optString("user_id", "") - - Timber.d("Parsed access_token: ${if (accessToken.isNotEmpty()) "${accessToken.take(20)}..." else "EMPTY"}") - Timber.d("Parsed user_id: $userId") - - if (accessToken.isEmpty() || userId.isEmpty()) { - Timber.e("Auth data validation failed - accessToken empty: ${accessToken.isEmpty()}, userId empty: ${userId.isEmpty()}") - return Result.failure(Exception("Invalid authentication data. Please log in again.")) - } - - Result.success(Pair(accessToken, userId)) - } catch (e: Exception) { - Timber.e(e, "Failed to read auth credentials") - Result.failure(e) - } + fun hasStoredCredentials(context: Context): Boolean { + return GOGAuthManager.hasStoredCredentials(context) } /** - * Parse full GOGCredentials from auth file + * Get user credentials - automatically handles token refresh if needed */ - private fun parseFullCredentials(authConfigPath: String): GOGCredentials { - return try { - val authFile = File(authConfigPath) - if (authFile.exists()) { - val authContent = authFile.readText() - val authJson = JSONObject(authContent) - - // GOGDL stores credentials nested under client ID - val credentialsJson = if (authJson.has(GOG_CLIENT_ID)) { - authJson.getJSONObject(GOG_CLIENT_ID) - } else { - // Fallback: try to read from root level - authJson - } - - GOGCredentials( - accessToken = credentialsJson.optString("access_token", ""), - refreshToken = credentialsJson.optString("refresh_token", ""), - userId = credentialsJson.optString("user_id", ""), - username = credentialsJson.optString("username", "GOG User"), - ) - } else { - // Return dummy credentials for successful auth - GOGCredentials( - accessToken = "authenticated_${System.currentTimeMillis()}", - refreshToken = "refresh_${System.currentTimeMillis()}", - userId = "user_123", - username = "GOG User", - ) - } - } catch (e: Exception) { - Timber.e(e, "Failed to parse auth result") - // Return dummy credentials as fallback - GOGCredentials( - accessToken = "fallback_token", - refreshToken = "fallback_refresh", - userId = "fallback_user", - username = "GOG User", - ) - } + suspend fun getStoredCredentials(context: Context): Result { + return GOGAuthManager.getStoredCredentials(context) } /** - * Create GOGCredentials from JSON output + * Validate credentials - automatically refreshes tokens if they're expired */ - private fun createCredentialsFromJson(outputJson: JSONObject): GOGCredentials { - return GOGCredentials( - accessToken = outputJson.optString("access_token", ""), - refreshToken = outputJson.optString("refresh_token", ""), - userId = outputJson.optString("user_id", ""), - username = "GOG User", // We don't have username in the token response - ) + suspend fun validateCredentials(context: Context): Result { + return GOGAuthManager.validateCredentials(context) } /** - * Authenticate with GOG using authorization code from OAuth2 flow - * Users must visit GOG login page, authenticate, and copy the authorization code + * Clear stored credentials */ - suspend fun authenticateWithCode(authConfigPath: String, authorizationCode: String): Result { - return try { - Timber.i("Starting GOG authentication with authorization code...") - - // Extract the actual authorization code from URL if needed - val actualCode = if (authorizationCode.startsWith("http")) { - // Extract code parameter from URL - val codeParam = authorizationCode.substringAfter("code=", "") - if (codeParam.isEmpty()) { - return Result.failure(Exception("Invalid authorization URL: no code parameter found")) - } - // Remove any additional parameters after the code - val cleanCode = codeParam.substringBefore("&") - Timber.d("Extracted authorization code from URL: ${cleanCode.take(20)}...") - cleanCode - } else { - authorizationCode - } - - // Create auth config directory - val authFile = File(authConfigPath) - val authDir = authFile.parentFile - if (authDir != null && !authDir.exists()) { - authDir.mkdirs() - Timber.d("Created auth config directory: ${authDir.absolutePath}") - } - - // Execute GOGDL auth command with the authorization code - Timber.d("Authenticating with auth config path: $authConfigPath, code: ${actualCode.take(10)}...") - Timber.d("Full auth command: --auth-config-path $authConfigPath auth --code ${actualCode.take(20)}...") - - val result = executeCommand("--auth-config-path", authConfigPath, "auth", "--code", actualCode) - - Timber.d("GOGDL executeCommand result: isSuccess=${result.isSuccess}, exception=${result.exceptionOrNull()?.message}") - - if (result.isSuccess) { - val gogdlOutput = result.getOrNull() ?: "" - Timber.i("GOGDL command completed, checking authentication result...") - Timber.d("GOGDL output for auth: $gogdlOutput") - - // First, check if GOGDL output indicates success - try { - Timber.d("Attempting to parse GOGDL output as JSON (length: ${gogdlOutput.length})") - val outputJson = JSONObject(gogdlOutput.trim()) - Timber.d("Successfully parsed JSON, keys: ${outputJson.keys().asSequence().toList()}") - - // Check if the response indicates an error - if (outputJson.has("error") && outputJson.getBoolean("error")) { - val errorMsg = outputJson.optString("error_description", "Authentication failed") - val errorDetails = outputJson.optString("message", "No details available") - Timber.e("GOG authentication failed: $errorMsg - Details: $errorDetails") - Timber.e("Full error JSON: $outputJson") - return Result.failure(Exception("GOG authentication failed: $errorMsg")) - } - - // Check if we have the required fields for successful auth - val accessToken = outputJson.optString("access_token", "") - val userId = outputJson.optString("user_id", "") - - if (accessToken.isEmpty() || userId.isEmpty()) { - Timber.e("GOG authentication incomplete: missing access_token or user_id in output") - return Result.failure(Exception("Authentication incomplete: missing required data")) - } - - // GOGDL output looks good, now check if auth file was created - val authFile = File(authConfigPath) - if (authFile.exists()) { - // Parse authentication result from file - val authData = parseFullCredentials(authConfigPath) - Timber.i("GOG authentication successful for user: ${authData.username}") - Result.success(authData) - } else { - Timber.w("GOGDL returned success but no auth file created, using output data") - // Create credentials from GOGDL output - val credentials = createCredentialsFromJson(outputJson) - Result.success(credentials) - } - } catch (e: Exception) { - Timber.e(e, "Failed to parse GOGDL output") - // Fallback: check if auth file exists - val authFile = File(authConfigPath) - if (authFile.exists()) { - try { - val authData = parseFullCredentials(authConfigPath) - Timber.i("GOG authentication successful (fallback) for user: ${authData.username}") - Result.success(authData) - } catch (ex: Exception) { - Timber.e(ex, "Failed to parse auth file") - Result.failure(Exception("Failed to parse authentication result: ${ex.message}")) - } - } else { - Timber.e("GOG authentication failed: no auth file created and failed to parse output") - Result.failure(Exception("Authentication failed: no credentials available")) - } - } - } else { - val error = result.exceptionOrNull() - val errorMsg = error?.message ?: "Unknown authentication error" - Timber.e(error, "GOG authentication command failed: $errorMsg") - Timber.e("Full error details: ${error?.stackTraceToString()}") - Result.failure(Exception("Authentication failed: $errorMsg", error)) - } - } catch (e: Exception) { - Timber.e(e, "GOG authentication exception: ${e.message}") - Timber.e("Exception stack trace: ${e.stackTraceToString()}") - Result.failure(Exception("Authentication exception: ${e.message}", e)) - } + fun clearStoredCredentials(context: Context): Boolean { + return GOGAuthManager.clearStoredCredentials(context) } - // Enhanced hasActiveOperations to track background sync + // ========================================================================== + // SYNC & OPERATIONS + // ========================================================================== + fun hasActiveOperations(): Boolean { return syncInProgress || backgroundSyncJob?.isActive == true } - // Add methods to control sync state private fun setSyncInProgress(inProgress: Boolean) { syncInProgress = inProgress } @@ -431,6 +116,10 @@ class GOGService : Service() { fun getInstance(): GOGService? = instance + // ========================================================================== + // DOWNLOAD OPERATIONS - Delegate to instance GOGManager + // ========================================================================== + /** * Check if any download is currently active */ @@ -439,7 +128,7 @@ class GOGService : Service() { } /** - * Get the currently downloading game ID (for error messages) + * Get the currently downloading game ID */ fun getCurrentlyDownloadingGame(): String? { return getInstance()?.activeDownloads?.keys?.firstOrNull() @@ -452,13 +141,42 @@ class GOGService : Service() { return getInstance()?.activeDownloads?.get(gameId) } + /** + * Clean up active download when game is deleted + */ + fun cleanupDownload(gameId: String) { + getInstance()?.activeDownloads?.remove(gameId) + } + + /** + * Cancel an active download for a specific game + */ + fun cancelDownload(gameId: String): Boolean { + val instance = getInstance() + val downloadInfo = instance?.activeDownloads?.get(gameId) + + return if (downloadInfo != null) { + Timber.i("Cancelling download for game: $gameId") + 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 + // ========================================================================== + /** * Get GOG game info by game ID (synchronously for UI) - * Similar to SteamService.getAppInfoOf() */ fun getGOGGameOf(gameId: String): GOGGame? { return runBlocking(Dispatchers.IO) { - getInstance()?.gogLibraryManager?.getGameById(gameId) + getInstance()?.gogManager?.getGameById(gameId) } } @@ -466,40 +184,37 @@ class GOGService : Service() { * Update GOG game in database */ suspend fun updateGOGGame(game: GOGGame) { - getInstance()?.gogLibraryManager?.updateGame(game) + getInstance()?.gogManager?.updateGame(game) } /** - * Insert or update GOG game in database (uses REPLACE strategy) + * Insert or update GOG game in database */ suspend fun insertOrUpdateGOGGame(game: GOGGame) { val instance = getInstance() if (instance == null) { - timber.log.Timber.e("GOGService instance is null, cannot insert game") + Timber.e("GOGService instance is null, cannot insert game") return } - timber.log.Timber.d("Inserting game: id=${game.id}, isInstalled=${game.isInstalled}, installPath=${game.installPath}") - instance.gogLibraryManager.insertGame(game) - timber.log.Timber.d("Insert completed for game: ${game.id}") + Timber.d("Inserting game: id=${game.id}, isInstalled=${game.isInstalled}") + instance.gogManager.insertGame(game) } /** * Check if a GOG game is installed (synchronous for UI) - * Verifies both database state and file system integrity */ fun isGameInstalled(gameId: String): Boolean { return runBlocking(Dispatchers.IO) { - val dbInstalled = getInstance()?.gogLibraryManager?.getGameById(gameId)?.isInstalled == true - if (!dbInstalled) { + val game = getInstance()?.gogManager?.getGameById(gameId) + if (game?.isInstalled != true) { return@runBlocking false } // Verify the installation is actually valid - val (isValid, errorMessage) = verifyInstallation(gameId) + 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") - // Consider updating database to mark as not installed - // For now, we just return false } isValid } @@ -510,837 +225,91 @@ class GOGService : Service() { */ fun getInstallPath(gameId: String): String? { return runBlocking(Dispatchers.IO) { - val game = getInstance()?.gogLibraryManager?.getGameById(gameId) + val game = getInstance()?.gogManager?.getGameById(gameId) if (game?.isInstalled == true) game.installPath else null } } /** * Verify that a GOG game installation is valid and complete - * - * Checks: - * 1. Install directory exists - * 2. Installation directory contains files (not empty) - * 3. goggame-{gameId}.info file exists (optional - for GOG Galaxy installs) - * 4. Primary executable exists (if specified in info file) - * - * Note: gogdl downloads don't create goggame-*.info files, so we primarily - * verify that the directory exists and has content. - * - * @return Pair - (isValid, errorMessage) */ fun verifyInstallation(gameId: String): Pair { - val installPath = getInstallPath(gameId) - if (installPath == null) { - return Pair(false, "Game not marked as installed in database") - } - - val installDir = File(installPath) - if (!installDir.exists()) { - Timber.w("Install directory doesn't exist: $installPath") - return Pair(false, "Install directory not found: $installPath") - } - - if (!installDir.isDirectory) { - Timber.w("Install path is not a directory: $installPath") - return Pair(false, "Install path is not a directory: $installPath") - } - - // Check that the directory has content (at least some files/folders) - val contents = installDir.listFiles() - if (contents == null || contents.isEmpty()) { - Timber.w("Install directory is empty: $installPath") - return Pair(false, "Install directory is empty") - } - - // Check for goggame-{gameId}.info file (optional - gogdl doesn't create this) - val infoFile = File(installDir, "goggame-$gameId.info") - if (infoFile.exists()) { - Timber.d("Found GOG Galaxy info file: ${infoFile.absolutePath}") - - // Verify info file is valid JSON - try { - val json = JSONObject(infoFile.readText()) - val fileGameId = json.optString("gameId", "") - if (fileGameId.isEmpty()) { - Timber.w("Game info file missing gameId field") - return Pair(false, "Invalid game info file: missing gameId") - } - - // Verify primary executable exists if specified - val playTasks = json.optJSONArray("playTasks") - if (playTasks != null) { - for (i in 0 until playTasks.length()) { - val task = playTasks.getJSONObject(i) - if (task.optBoolean("isPrimary", false)) { - val exePath = task.optString("path", "") - if (exePath.isNotEmpty()) { - val fullPath = File(installDir, exePath.replace("\\", "/")) - if (!fullPath.exists()) { - Timber.w("Primary executable not found: ${fullPath.absolutePath}") - return Pair(false, "Primary executable missing: ${fullPath.name}") - } - } - } - } - } - } catch (e: Exception) { - Timber.w(e, "Failed to parse game info file: ${infoFile.absolutePath}") - // Don't fail verification - info file is optional - } - } else { - Timber.d("No GOG Galaxy info file found (expected for gogdl downloads): goggame-$gameId.info") - } - - Timber.i("Installation verified successfully for game $gameId at $installPath (${contents.size} items)") - return Pair(true, null) + return getInstance()?.gogManager?.verifyInstallation(gameId) + ?: Pair(false, "Service not available") } /** * Get the primary executable path for a GOG game - * Similar to SteamService.getInstalledExe() - * - * Returns the full path to the .exe file, or null if not found */ - fun getInstalledExe(gameId: String): String? { - val installPath = getInstallPath(gameId) ?: return null - val installDir = File(installPath) - - if (!installDir.exists()) { - Timber.w("Install directory doesn't exist: $installPath") - return null - } - - // GOG games have a goggame-{gameId}.info file with launch information - val infoFile = File(installDir, "goggame-$gameId.info") - - if (!infoFile.exists()) { - Timber.w("Game info file not found: ${infoFile.absolutePath}") - // Fallback: search for .exe files - return findExecutableByHeuristic(installDir) - } - - try { - val json = JSONObject(infoFile.readText()) - val playTasks = json.optJSONArray("playTasks") - - if (playTasks != null) { - // Find primary task - for (i in 0 until playTasks.length()) { - val task = playTasks.getJSONObject(i) - if (task.optBoolean("isPrimary", false)) { - val exePath = task.optString("path", "") - if (exePath.isNotEmpty()) { - val fullPath = File(installDir, exePath.replace("\\", "/")) - if (fullPath.exists()) { - Timber.i("Found primary executable via goggame info: ${fullPath.absolutePath}") - return fullPath.absolutePath - } - } - } - } - - // If no primary, use first task - if (playTasks.length() > 0) { - val task = playTasks.getJSONObject(0) - val exePath = task.optString("path", "") - if (exePath.isNotEmpty()) { - val fullPath = File(installDir, exePath.replace("\\", "/")) - if (fullPath.exists()) { - Timber.i("Found first executable via goggame info: ${fullPath.absolutePath}") - return fullPath.absolutePath - } - } - } - } - } catch (e: Exception) { - Timber.e(e, "Error parsing goggame info file") - } - - // Fallback: search for .exe files - return findExecutableByHeuristic(installDir) + suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String { + return getInstance()?.gogManager?.getInstalledExe(context, libraryItem) + ?: "" } /** * Get Wine start command for launching a GOG game - * Static version that doesn't require DI, for use in XServerScreen */ fun getWineStartCommand( - context: android.content.Context, - gameId: String, + context: Context, + libraryItem: LibraryItem, container: com.winlator.container.Container, + bootToContainer: Boolean, + appLaunchInfo: LaunchInfo?, envVars: com.winlator.core.envvars.EnvVars, guestProgramLauncherComponent: com.winlator.xenvironment.components.GuestProgramLauncherComponent - ): String? { - Timber.i("Getting Wine start command for GOG game: $gameId") - - // Verify installation - val (isValid, errorMessage) = verifyInstallation(gameId) - if (!isValid) { - Timber.e("Installation verification failed for game $gameId: $errorMessage") - return null - } - - // Get game details - val game = getGOGGameOf(gameId) - if (game == null) { - Timber.e("Game not found for ID: $gameId") - return null - } - - // Get installation path - val installPath = getInstallPath(gameId) - if (installPath == null) { - Timber.e("No install path found for game: $gameId") - return null - } - - val gameDir = File(installPath) - if (!gameDir.exists()) { - Timber.e("Game installation directory does not exist: $installPath") - return null - } - - Timber.i("Found game directory: ${gameDir.absolutePath}") - - // Get executable - val executablePath = getInstalledExe(gameId) - if (executablePath == null || executablePath.isEmpty()) { - Timber.e("No executable found for GOG game $gameId") - return null - } - - // Ensure this specific game directory is mapped (isolates from other GOG games) - val gogDriveLetter = app.gamenative.utils.ContainerUtils.ensureGOGGameDirectoryMapped( - context, - container, - installPath - ) - - if (gogDriveLetter == null) { - Timber.e("Failed to map GOG game directory: $installPath") - return null - } - - Timber.i("GOG game directory mapped to $gogDriveLetter: drive") - - // Calculate relative path from game install directory to executable - val gameInstallDir = File(installPath) - val execFile = File(executablePath) - val relativePath = execFile.relativeTo(gameInstallDir).path.replace('/', '\\') - - // Construct Windows path - val windowsPath = "$gogDriveLetter:\\$relativePath" - - // Set working directory to game folder - val gameWorkingDir = File(executablePath).parentFile - if (gameWorkingDir != null) { - guestProgramLauncherComponent.workingDir = gameWorkingDir - Timber.i("Setting working directory to: ${gameWorkingDir.absolutePath}") - - // Set WINEPATH - val workingDirRelative = gameWorkingDir.relativeTo(gameInstallDir).path.replace('/', '\\') - val workingDirWindows = "$gogDriveLetter:\\$workingDirRelative" - envVars.put("WINEPATH", workingDirWindows) - Timber.i("Setting WINEPATH to: $workingDirWindows") - } - - Timber.i("GOG launch command: \"$windowsPath\"") - return "\"$windowsPath\"" - } - - /** - * Heuristic-based executable finder when goggame info is not available - * Similar approach to Steam's scorer - */ - private fun findExecutableByHeuristic(installDir: File): String? { - val exeFiles = installDir.walk() - .filter { it.isFile && it.extension.equals("exe", ignoreCase = true) } - .filter { !it.name.contains("unins", ignoreCase = true) } // Skip uninstallers - .filter { !it.name.contains("crash", ignoreCase = true) } // Skip crash reporters - .filter { !it.name.contains("setup", ignoreCase = true) } // Skip setup - .toList() - - if (exeFiles.isEmpty()) { - Timber.w("No .exe files found in ${installDir.absolutePath}") - return null - } - - // Prefer root directory executables - val rootExes = exeFiles.filter { it.parentFile == installDir } - if (rootExes.isNotEmpty()) { - val largest = rootExes.maxByOrNull { it.length() } - Timber.i("Found executable in root by heuristic: ${largest?.absolutePath}") - return largest?.absolutePath - } - - // Otherwise, take the largest executable - val largest = exeFiles.maxByOrNull { it.length() } - Timber.i("Found executable by size heuristic: ${largest?.absolutePath}") - return largest?.absolutePath - } - - /** - * Clean up active download when game is deleted - */ - fun cleanupDownload(gameId: String) { - getInstance()?.activeDownloads?.remove(gameId) - } - - /** - * Check if user is authenticated by testing GOGDL command - */ - fun hasStoredCredentials(context: Context): Boolean { - val authFile = File(context.filesDir, "gog_auth.json") - return authFile.exists() + ): String { + return getInstance()?.gogManager?.getWineStartCommand( + context, libraryItem, container, bootToContainer, appLaunchInfo, envVars, guestProgramLauncherComponent + ) ?: "\"explorer.exe\"" } /** - * Get user credentials by calling GOGDL auth command (without --code) - * This will automatically handle token refresh if needed + * Sync GOG library with database */ - suspend fun getStoredCredentials(context: Context): Result { - return try { - val authConfigPath = "${context.filesDir}/gog_auth.json" - - if (!hasStoredCredentials(context)) { - return Result.failure(Exception("No stored credentials found")) - } - - // Use GOGDL to get credentials - this will handle token refresh automatically - val result = executeCommand("--auth-config-path", authConfigPath, "auth") - - if (result.isSuccess) { - val output = result.getOrNull() ?: "" - Timber.d("GOGDL credentials output: $output") - - try { - val credentialsJson = JSONObject(output.trim()) - - // Check if there's an error - if (credentialsJson.has("error") && credentialsJson.getBoolean("error")) { - val errorMsg = credentialsJson.optString("message", "Authentication failed") - Timber.e("GOGDL credentials failed: $errorMsg") - return Result.failure(Exception("Authentication failed: $errorMsg")) - } - - // Extract credentials from GOGDL response - val accessToken = credentialsJson.optString("access_token", "") - val refreshToken = credentialsJson.optString("refresh_token", "") - val username = credentialsJson.optString("username", "GOG User") - val userId = credentialsJson.optString("user_id", "") - - val credentials = GOGCredentials( - accessToken = accessToken, - refreshToken = refreshToken, - username = username, - userId = userId, - ) - - Timber.d("Got credentials for user: $username") - Result.success(credentials) - } catch (e: Exception) { - Timber.e(e, "Failed to parse GOGDL credentials response") - Result.failure(e) - } - } else { - Timber.e("GOGDL credentials command failed") - Result.failure(Exception("Failed to get credentials from GOG")) - } - } catch (e: Exception) { - Timber.e(e, "Failed to get stored credentials via GOGDL") - Result.failure(e) - } + suspend fun refreshLibrary(context: Context): Result { + return getInstance()?.gogManager?.refreshLibrary(context) + ?: Result.failure(Exception("Service not available")) } /** - * Validate credentials by calling GOGDL auth command (without --code) - * This will automatically refresh tokens if they're expired + * Download a GOG game with full progress tracking */ - suspend fun validateCredentials(context: Context): Result { - return try { - val authConfigPath = "${context.filesDir}/gog_auth.json" - - if (!hasStoredCredentials(context)) { - Timber.d("No stored credentials found for validation") - return Result.success(false) - } - - Timber.d("Starting credentials validation with GOGDL") - - // Use GOGDL to get credentials - this will handle token refresh automatically - val result = executeCommand("--auth-config-path", authConfigPath, "auth") + suspend fun downloadGame(context: Context, gameId: String, installPath: String): Result { + val instance = getInstance() ?: return Result.failure(Exception("Service not available")) - if (!result.isSuccess) { - val error = result.exceptionOrNull() - Timber.e("Credentials validation failed - command failed: ${error?.message}") - return Result.success(false) - } - - val output = result.getOrNull() ?: "" - Timber.d("GOGDL validation output: $output") - - try { - val credentialsJson = JSONObject(output.trim()) + // Create DownloadInfo for progress tracking + val downloadInfo = DownloadInfo(jobCount = 1) - // Check if there's an error - if (credentialsJson.has("error") && credentialsJson.getBoolean("error")) { - val errorDesc = credentialsJson.optString("message", "Unknown error") - Timber.e("Credentials validation failed: $errorDesc") - return Result.success(false) - } + // Track in activeDownloads first + instance.activeDownloads[gameId] = downloadInfo - Timber.d("Credentials validation successful") - return Result.success(true) - } catch (e: Exception) { - Timber.e(e, "Failed to parse validation response: $output") - return Result.success(false) - } - } catch (e: Exception) { - Timber.e(e, "Failed to validate credentials") - return Result.failure(e) - } - } + // Delegate to GOGManager + val result = instance.gogManager.downloadGame(context, gameId, installPath, downloadInfo) - fun clearStoredCredentials(context: Context): Boolean { - return try { - val authFile = File(context.filesDir, "gog_auth.json") - if (authFile.exists()) { - authFile.delete() - } else { - true - } - } catch (e: Exception) { - Timber.e(e, "Failed to clear GOG credentials") - false + if (result.isFailure) { + // Remove from active downloads on failure + instance.activeDownloads.remove(gameId) + return Result.failure(result.exceptionOrNull() ?: Exception("Download failed")) } - } - - /** - * Fetch the user's GOG library (list of owned games) - * Returns a list of GOGGame objects with basic metadata - */ - suspend fun listGames(context: Context): Result> { - return try { - Timber.i("Fetching GOG library via GOGDL...") - val authConfigPath = "${context.filesDir}/gog_auth.json" - - if (!hasStoredCredentials(context)) { - Timber.e("Cannot list games: not authenticated") - return Result.failure(Exception("Not authenticated. Please log in first.")) - } - - // Execute gogdl list command - auth-config-path must come BEFORE the command - val result = executeCommand("--auth-config-path", authConfigPath, "list", "--pretty") - - if (result.isFailure) { - val error = result.exceptionOrNull() - Timber.e(error, "Failed to fetch GOG library: ${error?.message}") - return Result.failure(error ?: Exception("Failed to fetch GOG library")) - } - - val output = result.getOrNull() ?: "" - Timber.d("GOGDL list output length: ${output.length}") - Timber.d("GOGDL list output preview: ${output.take(500)}") - - // Parse the JSON output - try { - // GOGDL list returns a JSON array of games - val gamesArray = org.json.JSONArray(output.trim()) - val games = mutableListOf() - - Timber.d("Found ${gamesArray.length()} games in GOG library") - for (i in 0 until gamesArray.length()) { - try { - val gameObj = gamesArray.getJSONObject(i) - - // Parse genres array if present - val genresList = mutableListOf() - if (gameObj.has("genres")) { - val genresArray = gameObj.optJSONArray("genres") - if (genresArray != null) { - for (j in 0 until genresArray.length()) { - genresList.add(genresArray.getString(j)) - } - } - } - - // Parse languages array if present - val languagesList = mutableListOf() - if (gameObj.has("languages")) { - val languagesArray = gameObj.optJSONArray("languages") - if (languagesArray != null) { - for (j in 0 until languagesArray.length()) { - languagesList.add(languagesArray.getString(j)) - } - } - } - - val game = GOGGame( - id = gameObj.optString("id", ""), - title = gameObj.optString("title", "Unknown Game"), - slug = gameObj.optString("slug", ""), - imageUrl = gameObj.optString("imageUrl", ""), - iconUrl = gameObj.optString("iconUrl", ""), - description = gameObj.optString("description", ""), - releaseDate = gameObj.optString("releaseDate", ""), - developer = gameObj.optString("developer", ""), - publisher = gameObj.optString("publisher", ""), - genres = genresList, - languages = languagesList, - downloadSize = gameObj.optLong("downloadSize", 0L), - installSize = 0L, - isInstalled = false, - installPath = "", - lastPlayed = 0L, - playTime = 0L, - ) - - // Debug: Log the raw developer/publisher data from API - if (i == 0) { // Only log first game to avoid spam - Timber.tag("GOG").d("=== DEBUG: First game API response ===") - Timber.tag("GOG").d("Game: ${game.title} (${game.id})") - Timber.tag("GOG").d("Developer field: ${gameObj.optString("developer", "EMPTY")}") - Timber.tag("GOG").d("Publisher field: ${gameObj.optString("publisher", "EMPTY")}") - Timber.tag("GOG").d("_debug_developers_raw: ${gameObj.opt("_debug_developers_raw")}") - Timber.tag("GOG").d("_debug_publisher_raw: ${gameObj.opt("_debug_publisher_raw")}") - Timber.tag("GOG").d("Full game object keys: ${gameObj.keys().asSequence().toList()}") - Timber.tag("GOG").d("=====================================") - } - - games.add(game) - } catch (e: Exception) { - Timber.w(e, "Failed to parse game at index $i, skipping") - } - } - - Timber.i("Successfully parsed ${games.size} games from GOG library") - Result.success(games) - } catch (e: Exception) { - Timber.e(e, "Failed to parse GOG library JSON: $output") - Result.failure(Exception("Failed to parse GOG library: ${e.message}", e)) - } - } catch (e: Exception) { - Timber.e(e, "Unexpected error while fetching GOG library") - Result.failure(e) - } + return Result.success(downloadInfo) } /** - * Fetch a single game's metadata from GOG API and insert it into the database - * Used when a game is downloaded but not in the database + * Refresh a single game's metadata from GOG API */ suspend fun refreshSingleGame(gameId: String, context: Context): Result { - return try { - Timber.i("Fetching single game data for gameId: $gameId") - val authConfigPath = "${context.filesDir}/gog_auth.json" - - if (!hasStoredCredentials(context)) { - return Result.failure(Exception("Not authenticated")) - } - - // Execute gogdl list command and find this specific game - val result = executeCommand("--auth-config-path", authConfigPath, "list", "--pretty") - - if (result.isFailure) { - return Result.failure(result.exceptionOrNull() ?: Exception("Failed to fetch game data")) - } - - val output = result.getOrNull() ?: "" - val gamesArray = org.json.JSONArray(output.trim()) - - // Find the game with matching ID - for (i in 0 until gamesArray.length()) { - val gameObj = gamesArray.getJSONObject(i) - if (gameObj.optString("id", "") == gameId) { - // Parse genres - val genresList = mutableListOf() - gameObj.optJSONArray("genres")?.let { genresArray -> - for (j in 0 until genresArray.length()) { - genresList.add(genresArray.getString(j)) - } - } - - // Parse languages - val languagesList = mutableListOf() - gameObj.optJSONArray("languages")?.let { languagesArray -> - for (j in 0 until languagesArray.length()) { - languagesList.add(languagesArray.getString(j)) - } - } - - val game = GOGGame( - id = gameObj.optString("id", ""), - title = gameObj.optString("title", "Unknown Game"), - slug = gameObj.optString("slug", ""), - imageUrl = gameObj.optString("imageUrl", ""), - iconUrl = gameObj.optString("iconUrl", ""), - description = gameObj.optString("description", ""), - releaseDate = gameObj.optString("releaseDate", ""), - developer = gameObj.optString("developer", ""), - publisher = gameObj.optString("publisher", ""), - genres = genresList, - languages = languagesList, - downloadSize = gameObj.optLong("downloadSize", 0L), - installSize = 0L, - isInstalled = false, - installPath = "", - lastPlayed = 0L, - playTime = 0L, - ) - - // Insert into database - getInstance()?.gogLibraryManager?.let { manager -> - withContext(Dispatchers.IO) { - manager.insertGame(game) - } - } - - Timber.i("Successfully fetched and inserted game: ${game.title}") - return Result.success(game) - } - } - - Timber.w("Game $gameId not found in GOG library") - Result.success(null) - } catch (e: Exception) { - Timber.e(e, "Error fetching single game data for $gameId") - Result.failure(e) - } - } - - /** - * Download a GOG game with full progress tracking via GOGDL log parsing - */ - suspend fun downloadGame(gameId: String, installPath: String, authConfigPath: String): Result { - return try { - Timber.i("Starting GOGDL download with progress parsing for game $gameId") - - val installDir = File(installPath) - if (!installDir.exists()) { - installDir.mkdirs() - } - - // Create DownloadInfo for progress tracking - val downloadInfo = DownloadInfo(jobCount = 1) - - // Track this download in the active downloads map - getInstance()?.activeDownloads?.put(gameId, downloadInfo) - - // Start GOGDL download with progress parsing - val downloadJob = CoroutineScope(Dispatchers.IO).launch { - try { - // Create support directory for redistributables (like Heroic does) - val supportDir = File(installDir.parentFile, "gog-support") - supportDir.mkdirs() - - val result = executeCommandWithCallback( - downloadInfo, - "--auth-config-path", authConfigPath, - "download", ContainerUtils.extractGameIdFromContainerId(gameId).toString(), - "--platform", "windows", - "--path", installPath, - "--support", supportDir.absolutePath, - "--skip-dlcs", - "--lang", "en-US", - "--max-workers", "1", - ) - - if (result.isSuccess) { - // Check if the download was actually cancelled - if (downloadInfo.getProgress() < 0.0f) { - Timber.i("GOGDL download was cancelled by user") - } else { - downloadInfo.setProgress(1.0f) // Mark as complete - Timber.i("GOGDL download completed successfully") - } - } else { - downloadInfo.setProgress(-1.0f) // Mark as failed - Timber.e("GOGDL download failed: ${result.exceptionOrNull()?.message}") - } - } catch (e: CancellationException) { - Timber.i("GOGDL download cancelled by user") - downloadInfo.setProgress(-1.0f) // Mark as cancelled - } catch (e: Exception) { - Timber.e(e, "GOGDL download failed") - downloadInfo.setProgress(-1.0f) // Mark as failed - } finally { - // Clean up the download from active downloads - getInstance()?.activeDownloads?.remove(gameId) - Timber.d("Cleaned up download for game: $gameId") - } - } - - // Store the job in DownloadInfo so it can be cancelled - downloadInfo.setDownloadJob(downloadJob) - - Result.success(downloadInfo) - } catch (e: Exception) { - Timber.e(e, "Failed to start GOG game download") - Result.failure(e) - } - } - - /** - * Execute GOGDL command with progress callback - */ - private suspend fun executeCommandWithCallback(downloadInfo: DownloadInfo, vararg args: String): Result { - return withContext(Dispatchers.IO) { - try { - val python = Python.getInstance() - val sys = python.getModule("sys") - val originalArgv = sys.get("argv") - - try { - // Create progress callback that Python can invoke - val progressCallback = ProgressCallback(downloadInfo) - // Get the gogdl module and set up callback - val gogdlModule = python.getModule("gogdl") - - // Try to set progress callback if gogdl supports it - try { - gogdlModule.put("_progress_callback", progressCallback) - Timber.d("Progress callback registered with GOGDL") - } catch (e: Exception) { - Timber.w(e, "Could not register progress callback, will use estimation") - } - - val gogdlCli = python.getModule("gogdl.cli") - - // Set up arguments for argparse - val argsList = listOf("gogdl") + args.toList() - Timber.d("Setting GOGDL arguments: ${args.joinToString(" ")}") - val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) - sys.put("argv", pythonList) - - // Check for cancellation before starting - ensureActive() - - // Start a simple progress estimator in case callback doesn't work - val estimatorJob = CoroutineScope(Dispatchers.IO).launch { - estimateProgress(downloadInfo) - } - - try { - // Execute the main function - gogdlCli.callAttr("main") - Timber.i("GOGDL execution completed successfully") - Result.success("Download completed") - } finally { - estimatorJob.cancel() - } - } catch (e: Exception) { - Timber.e(e, "GOGDL execution failed: ${e.message}") - Result.failure(e) - } finally { - sys.put("argv", originalArgv) - } - } catch (e: CancellationException) { - Timber.i("GOGDL command cancelled") - throw e // Re-throw to propagate cancellation - } catch (e: Exception) { - Timber.e(e, "Failed to execute GOGDL command: ${args.joinToString(" ")}") - Result.failure(e) - } - } - } - - /** - * Estimate progress when callback isn't available - * Shows gradual progress to indicate activity - */ - private suspend fun estimateProgress(downloadInfo: DownloadInfo) { - try { - var lastProgress = 0.0f - val startTime = System.currentTimeMillis() - - while (downloadInfo.getProgress() < 1.0f && downloadInfo.getProgress() >= 0.0f) { - delay(3000L) // Update every 3 seconds - - val elapsed = System.currentTimeMillis() - startTime - val estimatedProgress = when { - elapsed < 5000 -> 0.05f - elapsed < 15000 -> 0.15f - elapsed < 30000 -> 0.30f - elapsed < 60000 -> 0.50f - elapsed < 120000 -> 0.70f - elapsed < 180000 -> 0.85f - else -> 0.95f - }.coerceAtLeast(lastProgress) - - // Only update if progress hasn't been set by callback - if (downloadInfo.getProgress() <= lastProgress + 0.01f) { - downloadInfo.setProgress(estimatedProgress) - lastProgress = estimatedProgress - Timber.d("Estimated progress: %.1f%%", estimatedProgress * 100) - } else { - // Callback is working, update our tracking - lastProgress = downloadInfo.getProgress() - } - } - } catch (e: CancellationException) { - Timber.d("Progress estimation cancelled") - } catch (e: Exception) { - Timber.w(e, "Error in progress estimation") - } - } - - /** - * Sync GOG cloud saves for a game (stub) - * TODO: Implement cloud save sync - */ - suspend fun syncCloudSaves(gameId: String, savePath: String, authConfigPath: String, timestamp: Float = 0.0f): Result { - return Result.success(Unit) - } - - /** - * Get download and install size information using gogdl info command (stub) - * TODO: Implement size info fetching - */ - suspend fun getGameSizeInfo(gameId: String): GameSizeInfo? = withContext(Dispatchers.IO) { - try { - val authConfigPath = "/data/data/app.gamenative/files/gog_config.json" - - Timber.d("Getting size info for GOG game: $gameId") - - // TODO: Use executeCommand to get game size info - // For now, return null - Timber.w("GOG size info not fully implemented yet") - null - } catch (e: Exception) { - Timber.w(e, "Failed to get size info for game $gameId") - null - } - } - - /** - * Cancel an active download for a specific game - */ - fun cancelDownload(gameId: String): Boolean { - val instance = getInstance() - val downloadInfo = instance?.activeDownloads?.get(gameId) - - return if (downloadInfo != null) { - Timber.i("Cancelling download for game: $gameId") - downloadInfo.cancel() - Timber.d("Cancelled download job for game: $gameId") - - // Clean up immediately - instance.activeDownloads.remove(gameId) - Timber.d("Removed game from active downloads: $gameId") - true - } else { - Timber.w("No active download found for game: $gameId") - false - } + return getInstance()?.gogManager?.refreshSingleGame(gameId, context) + ?: Result.failure(Exception("Service not available")) } } - // Add these for foreground service support - private lateinit var notificationHelper: NotificationHelper - private lateinit var gogLibraryManager: GOGLibraryManager + // ========================================================================== + // Instance members + // ========================================================================== + private lateinit var notificationHelper: NotificationHelper + private lateinit var gogManager: GOGManager private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // Track active downloads by game ID @@ -1350,33 +319,34 @@ class GOGService : Service() { super.onCreate() instance = this - // Initialize GOGLibraryManager with database DAO + // Initialize GOGManager with database DAO val database = Room.databaseBuilder( applicationContext, PluviaDatabase::class.java, DATABASE_NAME ).build() - gogLibraryManager = GOGLibraryManager(database.gogGameDao()) + gogManager = GOGManager(database.gogGameDao()) - Timber.d("GOGService.onCreate() - instance and gogLibraryManager initialized") + Timber.d("GOGService.onCreate() - gogManager initialized") // Initialize notification helper for foreground service notificationHelper = NotificationHelper(applicationContext) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Timber.d("GOGService.onStartCommand() - gogLibraryManager initialized: ${::gogLibraryManager.isInitialized}") + Timber.d("GOGService.onStartCommand()") + // Start as foreground service val notification = notificationHelper.createForegroundNotification("GOG Service running...") startForeground(2, notification) // Use different ID than SteamService (which uses 1) - // Start background library sync automatically when service starts with tracking + // Start background library sync automatically when service starts backgroundSyncJob = scope.launch { try { setSyncInProgress(true) Timber.d("[GOGService]: Starting background library sync") - val syncResult = gogLibraryManager.startBackgroundSync(applicationContext) + val syncResult = gogManager.startBackgroundSync(applicationContext) if (syncResult.isFailure) { Timber.w("[GOGService]: Failed to start background sync: ${syncResult.exceptionOrNull()?.message}") } else { 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 index 2835b9fdb..db3aa730a 100644 --- 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 @@ -246,7 +246,7 @@ class GOGAppScreen : BaseAppScreen() { Timber.d("Downloading GOG game to: $installPath") // Start download - val result = GOGService.downloadGame(gameId, installPath, authConfigPath) + val result = GOGService.downloadGame(context, gameId, installPath) if (result.isSuccess) { val info = result.getOrNull() 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 583290a16..15749c405 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 @@ -66,7 +66,7 @@ import kotlinx.coroutines.launch import app.gamenative.utils.LocaleHelper import app.gamenative.ui.component.dialog.GOGLoginDialog import app.gamenative.service.gog.GOGService -import app.gamenative.service.gog.GOGLibraryManager +import app.gamenative.service.gog.GOGManager import dagger.hilt.android.EntryPointAccessors import app.gamenative.di.DatabaseModule @@ -79,7 +79,7 @@ fun SettingsGroupInterface( ) { val context = LocalContext.current - // Get GOGGameDao and GOGLibraryManager from Hilt + // Get GOGGameDao and GOGManager from Hilt val gogGameDao = remember { val appContext = context.applicationContext val entryPoint = EntryPointAccessors.fromApplication( @@ -88,14 +88,14 @@ fun SettingsGroupInterface( ) entryPoint.gogGameDao() } - - val gogLibraryManager = remember { + + val gogManager = remember { val appContext = context.applicationContext val entryPoint = EntryPointAccessors.fromApplication( appContext, DatabaseEntryPoint::class.java ) - entryPoint.gogLibraryManager() + entryPoint.gogManager() } var openWebLinks by rememberSaveable { mutableStateOf(PrefManager.openWebLinksExternally) } @@ -166,41 +166,23 @@ fun SettingsGroupInterface( coroutineScope.launch { try { timber.log.Timber.d("[SettingsGOG]: Starting authentication...") - val authConfigPath = "${context.filesDir}/gog_auth.json" - val result = app.gamenative.service.gog.GOGService.authenticateWithCode(authConfigPath, event.authCode) + val result = app.gamenative.service.gog.GOGService.authenticateWithCode(context, event.authCode) if (result.isSuccess) { timber.log.Timber.i("[SettingsGOG]: ✓ Authentication successful!") - // Fetch the user's GOG library - timber.log.Timber.i("[SettingsGOG]: Fetching GOG library...") - val libraryResult = app.gamenative.service.gog.GOGService.listGames(context) - - if (libraryResult.isSuccess) { - val games = libraryResult.getOrNull() ?: emptyList() - timber.log.Timber.i("[SettingsGOG]: ✓ Fetched ${games.size} games from GOG library") - - // Save games to database - try { - withContext(Dispatchers.IO) { - gogGameDao.upsertPreservingInstallStatus(games) - } - timber.log.Timber.i("[SettingsGOG]: ✓ Saved ${games.size} games to database") - } catch (e: Exception) { - timber.log.Timber.e(e, "[SettingsGOG]: Failed to save games to database") - } - - // Log first few games - games.take(5).forEach { game -> - timber.log.Timber.d("[SettingsGOG]: - ${game.title} (${game.id})") - } - if (games.size > 5) { - timber.log.Timber.d("[SettingsGOG]: ... and ${games.size - 5} more") - } + // Sync the library using refreshLibrary which handles database updates + timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") + val syncResult = gogManager.refreshLibrary(context) + + if (syncResult.isSuccess) { + val count = syncResult.getOrNull() ?: 0 + timber.log.Timber.i("[SettingsGOG]: ✓ Synced $count games from GOG library") + gogLibraryGameCount = count } else { - val error = libraryResult.exceptionOrNull()?.message ?: "Failed to fetch library" - timber.log.Timber.w("[SettingsGOG]: Failed to fetch library: $error") - // Don't fail authentication if library fetch fails + val error = syncResult.exceptionOrNull()?.message ?: "Failed to sync library" + timber.log.Timber.w("[SettingsGOG]: Failed to sync library: $error") + // Don't fail authentication if library sync fails } gogLoginLoading = false @@ -325,9 +307,9 @@ fun SettingsGroupInterface( coroutineScope.launch { try { timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") - - // Use GOGLibraryManager.refreshLibrary() which handles everything - val result = gogLibraryManager.refreshLibrary(context) + + // Use GOGManager.refreshLibrary() which handles everything + val result = gogManager.refreshLibrary(context) if (result.isSuccess) { val count = result.getOrNull() ?: 0 @@ -637,31 +619,23 @@ fun SettingsGroupInterface( coroutineScope.launch { try { timber.log.Timber.d("[SettingsGOG]: Starting manual authentication...") - val authConfigPath = "${context.filesDir}/gog_auth.json" - val result = GOGService.authenticateWithCode(authConfigPath, authCode) + val result = GOGService.authenticateWithCode(context, authCode) if (result.isSuccess) { timber.log.Timber.i("[SettingsGOG]: ✓ Manual authentication successful!") - // Fetch the user's GOG library - timber.log.Timber.i("[SettingsGOG]: Fetching GOG library...") - val libraryResult = GOGService.listGames(context) - - if (libraryResult.isSuccess) { - val games = libraryResult.getOrNull() ?: emptyList() - timber.log.Timber.i("[SettingsGOG]: ✓ Fetched ${games.size} games from GOG library") - - // Log first 5 games - games.take(5).forEach { game -> - timber.log.Timber.d("[SettingsGOG]: - ${game.title} (${game.id})") - } - if (games.size > 5) { - timber.log.Timber.d("[SettingsGOG]: ... and ${games.size - 5} more") - } + // Sync the library + timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") + val syncResult = gogManager.refreshLibrary(context) + + if (syncResult.isSuccess) { + val count = syncResult.getOrNull() ?: 0 + timber.log.Timber.i("[SettingsGOG]: ✓ Synced $count games from GOG library") + gogLibraryGameCount = count } else { - val error = libraryResult.exceptionOrNull()?.message ?: "Failed to fetch library" - timber.log.Timber.w("[SettingsGOG]: Failed to fetch library: $error") - // Don't fail authentication if library fetch fails + val error = syncResult.exceptionOrNull()?.message ?: "Failed to sync library" + timber.log.Timber.w("[SettingsGOG]: Failed to sync library: $error") + // Don't fail authentication if library sync fails } gogLoginLoading = false @@ -758,5 +732,5 @@ private fun Preview_SettingsScreen() { @dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) interface DatabaseEntryPoint { fun gogGameDao(): app.gamenative.db.dao.GOGGameDao - fun gogLibraryManager(): GOGLibraryManager - } \ No newline at end of file + fun gogManager(): GOGManager + } 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 cc96179bb..05cacc2f0 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 @@ -33,6 +33,7 @@ import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.data.GameSource import app.gamenative.data.LaunchInfo +import app.gamenative.data.LibraryItem import app.gamenative.data.SteamApp import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent @@ -1302,19 +1303,23 @@ private fun getWineStartCommand( // 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.getWineStartCommand( context = context, - gameId = gameId.toString(), + libraryItem = libraryItem, container = container, + bootToContainer = bootToContainer, + appLaunchInfo = appLaunchInfo, envVars = envVars, guestProgramLauncherComponent = guestProgramLauncherComponent ) - if (gogCommand == null) { - Timber.tag("XServerScreen").e("Failed to get GOG launch command for game: $gameId") - return "winhandler.exe \"wfm.exe\"" - } - Timber.tag("XServerScreen").i("GOG launch command: $gogCommand") return "winhandler.exe $gogCommand" } else if (isCustomGame) { From 92c60265c3f2d96780629f49686e2e0678375db1 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 12:39:54 +0000 Subject: [PATCH 020/122] WIP refactor --- .../app/gamenative/service/gog/GOGAuthManager.kt | 7 ++----- .../app/gamenative/service/gog/GOGConstants.kt | 14 -------------- .../app/gamenative/service/gog/GOGPythonBridge.kt | 13 +++---------- .../gamenative/ui/screen/xserver/XServerScreen.kt | 6 +++--- 4 files changed, 8 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt index 693cb4ce4..d5bc375b0 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -19,9 +19,6 @@ import java.io.File */ object GOGAuthManager { - // GOG OAuth2 client ID - private const val GOG_CLIENT_ID = "46899977096215655" - /** * Get the auth config file path for a context */ @@ -317,8 +314,8 @@ object GOGAuthManager { val authJson = JSONObject(authContent) // GOGDL stores credentials nested under client ID - val credentialsJson = if (authJson.has(GOG_CLIENT_ID)) { - authJson.getJSONObject(GOG_CLIENT_ID) + val credentialsJson = if (authJson.has(GOGConstants.GOG_CLIENT_ID)) { + authJson.getJSONObject(GOGConstants.GOG_CLIENT_ID) } else { // Fallback: try to read from root level authJson diff --git a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt index 084c21306..d704fa15d 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt @@ -65,18 +65,4 @@ object GOGConstants { val sanitizedTitle = gameTitle.replace(Regex("[^a-zA-Z0-9 ]"), "").trim() return Paths.get(defaultGOGGamesPath, sanitizedTitle).toString() } - - /** - * Get the auth config path - */ - fun getAuthConfigPath(): String { - return "/data/data/app.gamenative/files/gog_auth.json" - } - - /** - * Get the support directory path (for redistributables) - */ - fun getSupportPath(): String { - return "/data/data/app.gamenative/files/gog-support" - } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt index af1922332..28a7eaf7f 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt @@ -29,12 +29,9 @@ class ProgressCallback(private val downloadInfo: DownloadInfo) { } /** - * Low-level Python execution bridge for GOGDL commands. + * This an execution Bridge for Python GOGDL functionality * - * This is a pure abstraction layer over Chaquopy Python interpreter. - * Contains NO business logic - just Python initialization and command execution. - * - * All GOG-specific functionality should use this bridge but NOT be implemented here. + * This is purely to initialize and execute GOGDL commands as an abstraction layer to reduce duplication. */ object GOGPythonBridge { private var python: Python? = null @@ -69,10 +66,7 @@ object GOGPythonBridge { fun isReady(): Boolean = isInitialized && Python.isStarted() /** - * Execute GOGDL command using Chaquopy - * - * This is the foundational method that all GOGDL operations use. - * + * Executes Python GOGDL commands using Chaquopy (Java-Python lib) * @param args Command line arguments to pass to gogdl CLI * @return Result containing command output or error */ @@ -94,7 +88,6 @@ object GOGPythonBridge { val originalArgv = sys.get("argv") try { - // Import gogdl.cli module Timber.d("Importing gogdl.cli module...") val gogdlCli = python.getModule("gogdl.cli") Timber.d("gogdl.cli module imported successfully") 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 05cacc2f0..a5ceb2c7c 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 @@ -1302,14 +1302,14 @@ private fun getWineStartCommand( } 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.getWineStartCommand( context = context, libraryItem = libraryItem, @@ -1319,7 +1319,7 @@ private fun getWineStartCommand( envVars = envVars, guestProgramLauncherComponent = guestProgramLauncherComponent ) - + Timber.tag("XServerScreen").i("GOG launch command: $gogCommand") return "winhandler.exe $gogCommand" } else if (isCustomGame) { From f80e93ef8fe36f5cf1084f9b9fd6c332a9ccb50c Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 13:01:43 +0000 Subject: [PATCH 021/122] removed hilt code. Not required. --- .../screen/settings/SettingsGroupInterface.kt | 40 +++---------------- 1 file changed, 5 insertions(+), 35 deletions(-) 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 15749c405..46ed9d3dd 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 @@ -66,9 +66,6 @@ import kotlinx.coroutines.launch import app.gamenative.utils.LocaleHelper import app.gamenative.ui.component.dialog.GOGLoginDialog import app.gamenative.service.gog.GOGService -import app.gamenative.service.gog.GOGManager -import dagger.hilt.android.EntryPointAccessors -import app.gamenative.di.DatabaseModule @Composable fun SettingsGroupInterface( @@ -79,25 +76,6 @@ fun SettingsGroupInterface( ) { val context = LocalContext.current - // Get GOGGameDao and GOGManager from Hilt - val gogGameDao = remember { - val appContext = context.applicationContext - val entryPoint = EntryPointAccessors.fromApplication( - appContext, - DatabaseEntryPoint::class.java - ) - entryPoint.gogGameDao() - } - - val gogManager = remember { - val appContext = context.applicationContext - val entryPoint = EntryPointAccessors.fromApplication( - appContext, - DatabaseEntryPoint::class.java - ) - entryPoint.gogManager() - } - var openWebLinks by rememberSaveable { mutableStateOf(PrefManager.openWebLinksExternally) } var openAppThemeDialog by rememberSaveable { mutableStateOf(false) } @@ -173,7 +151,7 @@ fun SettingsGroupInterface( // Sync the library using refreshLibrary which handles database updates timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") - val syncResult = gogManager.refreshLibrary(context) + val syncResult = GOGService.refreshLibrary(context) if (syncResult.isSuccess) { val count = syncResult.getOrNull() ?: 0 @@ -308,8 +286,8 @@ fun SettingsGroupInterface( try { timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") - // Use GOGManager.refreshLibrary() which handles everything - val result = gogManager.refreshLibrary(context) + // Use GOGService.refreshLibrary() which handles everything + val result = GOGService.refreshLibrary(context) if (result.isSuccess) { val count = result.getOrNull() ?: 0 @@ -626,7 +604,7 @@ fun SettingsGroupInterface( // Sync the library timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") - val syncResult = gogManager.refreshLibrary(context) + val syncResult = GOGService.refreshLibrary(context) if (syncResult.isSuccess) { val count = syncResult.getOrNull() ?: 0 @@ -725,12 +703,4 @@ private fun Preview_SettingsScreen() { } } -/** - * Hilt EntryPoint to access DAOs from Composables - */ - @dagger.hilt.EntryPoint - @dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) - interface DatabaseEntryPoint { - fun gogGameDao(): app.gamenative.db.dao.GOGGameDao - fun gogManager(): GOGManager - } + From c67c148b4411e98924ce14fbaf32e0813e88723a Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 13:24:14 +0000 Subject: [PATCH 022/122] revert changes on MainActivity --- app/src/main/java/app/gamenative/MainActivity.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 6236da22e..e68dbc5dd 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -129,8 +129,6 @@ class MainActivity : ComponentActivity() { ) super.onCreate(savedInstanceState) - val isRestored = savedInstanceState != null - // Initialize the controller management system ControllerManager.getInstance().init(getApplicationContext()); @@ -200,6 +198,7 @@ class MainActivity : ComponentActivity() { } private fun handleLaunchIntent(intent: Intent) { + Timber.d("[IntentLaunch]: handleLaunchIntent called with action=${intent.action}") try { val launchRequest = IntentLaunchManager.parseLaunchIntent(intent) if (launchRequest != null) { From d50f0ea2a88d1dfd61834410ce62e1f666f6a1ab Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 15:13:05 +0000 Subject: [PATCH 023/122] Fixed downloading and the install count. --- .../app/gamenative/service/gog/GOGManager.kt | 82 +++++++++++-------- .../app/gamenative/service/gog/GOGService.kt | 50 ++++++----- .../app/gamenative/ui/data/LibraryState.kt | 3 + .../gamenative/ui/model/LibraryViewModel.kt | 11 ++- .../library/components/LibraryListPane.kt | 8 +- 5 files changed, 91 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 7d9b10be2..4c6860e92 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -334,46 +334,58 @@ class GOGManager @Inject constructor( * Download a GOG game with full progress tracking */ suspend fun downloadGame(context: Context, gameId: String, installPath: String, downloadInfo: DownloadInfo): Result { - return try { - Timber.i("Starting GOGDL download for game $gameId") - - val installDir = File(installPath) - if (!installDir.exists()) { - installDir.mkdirs() - } + return withContext(Dispatchers.IO) { + try { + Timber.i("[Download] Starting GOGDL download for game $gameId to $installPath") - // Create support directory for redistributables - val supportDir = File(installDir.parentFile, "gog-support") - supportDir.mkdirs() + val installDir = File(installPath) + if (!installDir.exists()) { + Timber.d("[Download] Creating install directory: $installPath") + installDir.mkdirs() + } - val authConfigPath = GOGAuthManager.getAuthConfigPath(context) - val numericGameId = ContainerUtils.extractGameIdFromContainerId(gameId).toString() - - val result = GOGPythonBridge.executeCommandWithCallback( - downloadInfo, - "--auth-config-path", authConfigPath, - "download", numericGameId, - "--platform", "windows", - "--path", installPath, - "--support", supportDir.absolutePath, - "--skip-dlcs", - "--lang", "en-US", - "--max-workers", "1", - ) + // Create support directory for redistributables + val supportDir = File(installDir.parentFile, "gog-support") + if (!supportDir.exists()) { + Timber.d("[Download] Creating support directory: ${supportDir.absolutePath}") + supportDir.mkdirs() + } - if (result.isSuccess) { - downloadInfo.setProgress(1.0f) - Timber.i("GOGDL download completed successfully") - Result.success(Unit) - } else { + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + val numericGameId = ContainerUtils.extractGameIdFromContainerId(gameId).toString() + + Timber.d("[Download] Calling GOGPythonBridge with gameId=$numericGameId, authConfig=$authConfigPath") + + // Initialize progress + downloadInfo.setProgress(0.0f) + + val result = GOGPythonBridge.executeCommandWithCallback( + downloadInfo, + "--auth-config-path", authConfigPath, + "download", numericGameId, + "--platform", "windows", + "--path", installPath, + "--support", supportDir.absolutePath, + "--skip-dlcs", + "--lang", "en-US", + "--max-workers", "1", + ) + + if (result.isSuccess) { + downloadInfo.setProgress(1.0f) + Timber.i("[Download] GOGDL download completed successfully for game $gameId") + Result.success(Unit) + } else { + downloadInfo.setProgress(-1.0f) + val error = result.exceptionOrNull() + Timber.e(error, "[Download] GOGDL download failed for game $gameId") + Result.failure(error ?: Exception("Download failed")) + } + } catch (e: Exception) { + Timber.e(e, "[Download] Exception during download for game $gameId") downloadInfo.setProgress(-1.0f) - val error = result.exceptionOrNull() - Timber.e(error, "GOGDL download failed") - Result.failure(error ?: Exception("Download failed")) + Result.failure(e) } - } catch (e: Exception) { - Timber.e(e, "Failed to start GOG game download") - Result.failure(e) } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 4007e9a33..ac420de82 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -4,18 +4,17 @@ import android.app.Service import android.content.Context import android.content.Intent import android.os.IBinder -import androidx.room.Room 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.db.PluviaDatabase -import app.gamenative.db.DATABASE_NAME import app.gamenative.service.NotificationHelper +import dagger.hilt.android.AndroidEntryPoint import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.* import timber.log.Timber +import javax.inject.Inject /** * GOG Service - thin coordinator that delegates to specialized managers. @@ -28,6 +27,7 @@ import timber.log.Timber * This service maintains backward compatibility through static accessors * while delegating all operations to the appropriate managers. */ +@AndroidEntryPoint class GOGService : Service() { companion object { @@ -273,8 +273,9 @@ class GOGService : Service() { /** * Download a GOG game with full progress tracking + * Launches download in service scope so it runs independently */ - suspend fun downloadGame(context: Context, gameId: String, installPath: String): Result { + fun downloadGame(context: Context, gameId: String, installPath: String): Result { val instance = getInstance() ?: return Result.failure(Exception("Service not available")) // Create DownloadInfo for progress tracking @@ -283,13 +284,25 @@ class GOGService : Service() { // Track in activeDownloads first instance.activeDownloads[gameId] = downloadInfo - // Delegate to GOGManager - val result = instance.gogManager.downloadGame(context, gameId, installPath, downloadInfo) - - if (result.isFailure) { - // Remove from active downloads on failure - instance.activeDownloads.remove(gameId) - return Result.failure(result.exceptionOrNull() ?: Exception("Download failed")) + // Launch download in service scope so it runs independently + instance.scope.launch { + try { + Timber.d("[Download] Starting download for game $gameId") + val result = instance.gogManager.downloadGame(context, gameId, installPath, downloadInfo) + + if (result.isFailure) { + Timber.e(result.exceptionOrNull(), "[Download] Failed for game $gameId") + downloadInfo.setProgress(-1.0f) + } else { + Timber.i("[Download] Completed successfully for game $gameId") + } + } catch (e: Exception) { + Timber.e(e, "[Download] Exception for game $gameId") + downloadInfo.setProgress(-1.0f) + } finally { + // Keep in activeDownloads so UI can check status + Timber.d("[Download] Finished for game $gameId, progress: ${downloadInfo.getProgress()}") + } } return Result.success(downloadInfo) @@ -309,25 +322,20 @@ class GOGService : Service() { // ========================================================================== private lateinit var notificationHelper: NotificationHelper - private lateinit var gogManager: GOGManager + + @Inject + lateinit var gogManager: GOGManager + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // Track active downloads by game ID private val activeDownloads = ConcurrentHashMap() + // GOGManager is injected by Hilt override fun onCreate() { super.onCreate() instance = this - // Initialize GOGManager with database DAO - val database = Room.databaseBuilder( - applicationContext, - PluviaDatabase::class.java, - DATABASE_NAME - ).build() - gogManager = GOGManager(database.gogGameDao()) - - Timber.d("GOGService.onCreate() - gogManager initialized") // Initialize notification helper for foreground service notificationHelper = NotificationHelper(applicationContext) 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 d6e751cfd..5807077ff 100644 --- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt +++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt @@ -26,6 +26,9 @@ data class LibraryState( val showCustomGamesInLibrary: Boolean = PrefManager.showCustomGamesInLibrary, val showGOGInLibrary: Boolean = PrefManager.showGOGInLibrary, + // Installed counts by source + val gogInstalledCount: Int = 0, + // Loading state for skeleton loaders val isLoading: Boolean = false, 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 b37b9b6b5..37a1c832a 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -114,8 +114,10 @@ class LibraryViewModel @Inject constructor( gogGameDao.getAll().collect { games -> Timber.tag("LibraryViewModel").d("Collecting ${games.size} GOG games") - if (gogGameList.size != games.size) { - gogGameList = games + val sizeChanged = gogGameList.size != games.size + gogGameList = games + + if (sizeChanged) { onFilterApps(paginationCurrentPage) } } @@ -354,6 +356,8 @@ class LibraryViewModel @Inject constructor( isInstalled = game.isInstalled, ) } + // Calculate GOG installed count + 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 @@ -361,7 +365,7 @@ class LibraryViewModel @Inject constructor( PrefManager.customGamesCount = customGameItems.size PrefManager.steamGamesCount = filteredSteamApps.size PrefManager.gogGamesCount = filteredGOGGames.size - Timber.tag("LibraryViewModel").d("Saved counts - Custom: ${customGameItems.size}, Steam: ${filteredSteamApps.size}, GOG: ${filteredGOGGames.size}") + Timber.tag("LibraryViewModel").d("Saved counts - Custom: ${customGameItems.size}, Steam: ${filteredSteamApps.size}, GOG: ${filteredGOGGames.size}, GOG installed: $gogInstalledCount") } // Apply App Source filters @@ -408,6 +412,7 @@ class LibraryViewModel @Inject constructor( currentPaginationPage = paginationPage + 1, // visual display is not 0 indexed lastPaginationPage = lastPageInCurrentFilter + 1, totalAppsInFilter = totalFound, + gogInstalledCount = gogInstalledCount, isLoading = false, // Loading complete ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt index 9f9280eb1..38be559e8 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt @@ -103,10 +103,9 @@ private fun calculateInstalledCount(state: LibraryState): Int { 0 } - // Count GOG games that are installed + // Count GOG games that are installed (from LibraryState) val gogCount = if (state.showGOGInLibrary) { - // For now, count all GOG games since we don't track their install status separately yet - PrefManager.gogGamesCount + state.gogInstalledCount } else { 0 } @@ -142,7 +141,8 @@ internal fun LibraryListPane( state.showSteamInLibrary, state.showCustomGamesInLibrary, state.showGOGInLibrary, - state.totalAppsInFilter + state.totalAppsInFilter, + state.gogInstalledCount ) { calculateInstalledCount(state) } From 6219f8866b162f10ebb8fe9a04a9835a4dddd322 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 15:18:51 +0000 Subject: [PATCH 024/122] Fixing counts for GOG based on PrefManager. Will work on adding a signifier for games being installed --- app/src/main/java/app/gamenative/PrefManager.kt | 7 +++++++ app/src/main/java/app/gamenative/ui/data/LibraryState.kt | 3 --- .../main/java/app/gamenative/ui/model/LibraryViewModel.kt | 2 +- .../ui/screen/library/components/LibraryListPane.kt | 5 ++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index e5ed89771..f074db5e9 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -701,6 +701,13 @@ object PrefManager { 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 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 5807077ff..d6e751cfd 100644 --- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt +++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt @@ -26,9 +26,6 @@ data class LibraryState( val showCustomGamesInLibrary: Boolean = PrefManager.showCustomGamesInLibrary, val showGOGInLibrary: Boolean = PrefManager.showGOGInLibrary, - // Installed counts by source - val gogInstalledCount: Int = 0, - // Loading state for skeleton loaders val isLoading: Boolean = false, 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 37a1c832a..62f38f088 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -365,6 +365,7 @@ class LibraryViewModel @Inject constructor( PrefManager.customGamesCount = customGameItems.size PrefManager.steamGamesCount = 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") } @@ -412,7 +413,6 @@ class LibraryViewModel @Inject constructor( currentPaginationPage = paginationPage + 1, // visual display is not 0 indexed lastPaginationPage = lastPageInCurrentFilter + 1, totalAppsInFilter = totalFound, - gogInstalledCount = gogInstalledCount, isLoading = false, // Loading complete ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt index 38be559e8..49fb66762 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt @@ -103,9 +103,9 @@ private fun calculateInstalledCount(state: LibraryState): Int { 0 } - // Count GOG games that are installed (from LibraryState) + // Count GOG games that are installed (from PrefManager) val gogCount = if (state.showGOGInLibrary) { - state.gogInstalledCount + PrefManager.gogInstalledGamesCount } else { 0 } @@ -142,7 +142,6 @@ internal fun LibraryListPane( state.showCustomGamesInLibrary, state.showGOGInLibrary, state.totalAppsInFilter, - state.gogInstalledCount ) { calculateInstalledCount(state) } From c00a390abd88e30ddd6fa8f3e6587eb7f72cfaf8 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 15:32:25 +0000 Subject: [PATCH 025/122] Now visually shows title is installed. --- .../gamenative/ui/screen/library/components/LibraryAppItem.kt | 3 +++ 1 file changed, 3 insertions(+) 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 b876477c0..b66185bf0 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt @@ -64,6 +64,7 @@ import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.LibraryItem import app.gamenative.service.DownloadService import app.gamenative.service.SteamService +import app.gamenative.service.gog.GOGService import app.gamenative.ui.enums.PaneType import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.theme.PluviaTheme @@ -313,6 +314,7 @@ internal fun AppItem( var isInstalled by remember(appInfo.appId, appInfo.gameSource) { when (appInfo.gameSource) { GameSource.STEAM -> mutableStateOf(SteamService.isAppInstalled(appInfo.gameId)) + GameSource.GOG -> mutableStateOf(GOGService.isGameInstalled(appInfo.appId)) GameSource.CUSTOM_GAME -> mutableStateOf(true) // Custom Games are always considered installed else -> mutableStateOf(false) } @@ -323,6 +325,7 @@ internal fun AppItem( // Refresh just completed, check installation status isInstalled = when (appInfo.gameSource) { GameSource.STEAM -> SteamService.isAppInstalled(appInfo.gameId) + GameSource.GOG -> GOGService.isGameInstalled(appInfo.appId) GameSource.CUSTOM_GAME -> true else -> false } From 67a5d70f34c9e59c91ef9167fabcb665da86d4c4 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 16:46:49 +0000 Subject: [PATCH 026/122] Working through fix for install dir --- .../app/gamenative/service/gog/GOGManager.kt | 14 +- .../app/gamenative/utils/ContainerUtils.kt | 140 +++++++----------- 2 files changed, 68 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 4c6860e92..a542204b1 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -653,10 +653,18 @@ class GOGManager @Inject constructor( return "\"explorer.exe\"" } - // Map game directory - val gogDriveLetter = ContainerUtils.ensureGOGGameDirectoryMapped(context, container, gameInstallPath) + // 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.i("Found GOG game mapped to ${drive[0]}: drive") + break + } + } + if (gogDriveLetter == null) { - Timber.e("Failed to map GOG game directory") + Timber.e("GOG game directory not mapped to any drive: $gameInstallPath") return "\"explorer.exe\"" } diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 5f88cf3ba..97b004624 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -6,6 +6,7 @@ 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 @@ -557,9 +558,21 @@ object ContainerUtils { } } GameSource.GOG -> { - // For GOG games, initially use default drives - // The specific game directory will be mapped in getOrCreateContainer after we have game details - defaultDrives + // For GOG games, map the specific game directory to A: drive + val gameId = extractGameIdFromContainerId(appId) + val game = runBlocking { GOGService.getGOGGameOf(gameId.toString()) } + if (game != null) { + val gameInstallPath = GOGConstants.getGameInstallPath(game.title) + 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 + } } } Timber.d("Prepared container drives: $drives") @@ -832,25 +845,49 @@ object ContainerUtils { } } } else if (gameSource == GameSource.GOG) { - // Ensure GOG games have the GOG games directory mapped - val gogGamesPath = GOGConstants.defaultGOGGamesPath - var hasGOGDriveMapping = false - - // Check if any drive is already mapped to the GOG games directory - for (drive in Container.drivesIterator(container.drives)) { - if (drive[1] == gogGamesPath) { - hasGOGDriveMapping = true - break + // Ensure GOG games have the specific game directory mapped + val gameId = extractGameIdFromContainerId(appId) + val game = runBlocking { GOGService.getGOGGameOf(gameId.toString()) } + if (game != null) { + val gameInstallPath = GOGConstants.getGameInstallPath(game.title) + var hasCorrectDriveMapping = false + + // Check if the specific game directory is already mapped + for (drive in Container.drivesIterator(container.drives)) { + if (drive[1] == gameInstallPath) { + hasCorrectDriveMapping = true + break + } } - } - // If GOG games directory is not mapped, add it - if (!hasGOGDriveMapping) { - val driveLetter = Container.getNextAvailableDriveLetter(container.drives) - val updatedDrives = "${container.drives}$driveLetter:$gogGamesPath" - container.drives = updatedDrives - container.saveData() - Timber.d("Updated container drives to include $driveLetter: drive mapping for GOG: $updatedDrives") + // If specific game directory is not mapped, add/update it + if (!hasCorrectDriveMapping) { + val currentDrives = container.drives + val drivesBuilder = StringBuilder() + + // Use A: drive for game, or next available + val drive: Char = if (!currentDrives.contains("A:")) { + 'A' + } else { + Container.getNextAvailableDriveLetter(currentDrives) + } + + drivesBuilder.append("$drive:$gameInstallPath") + + // Add all other drives (excluding the one we just used) + for (existingDrive in Container.drivesIterator(currentDrives)) { + if (existingDrive[0] != drive.toString()) { + drivesBuilder.append("${existingDrive[0]}:${existingDrive[1]}") + } + } + + val updatedDrives = drivesBuilder.toString() + container.drives = updatedDrives + container.saveData() + Timber.d("Updated container drives to include $drive: drive mapping for GOG game: $updatedDrives") + } + } else { + Timber.w("Could not find GOG game info for $gameId, skipping drive mapping update") } } @@ -1123,68 +1160,5 @@ object ContainerUtils { else -> GameSource.STEAM // default fallback } } - - /** - * Ensures a GOG game container has the specific game directory mapped. - * This should be called before launching a GOG game to isolate it from other games. - * - * @param context Android context - * @param container The container to update - * @param gameInstallPath The absolute path to the specific game's install directory - * @return The drive letter that is mapped to the game directory, or null if update failed - */ - fun ensureGOGGameDirectoryMapped(context: Context, container: Container, gameInstallPath: String): String? { - Timber.i("ensureGOGGameDirectoryMapped called for container ${container.id}") - Timber.d("Current drives: ${container.drives}") - Timber.d("Target game install path: $gameInstallPath") - - // Check if this specific game directory is already mapped - for (drive in Container.drivesIterator(container.drives)) { - Timber.d("Checking drive ${drive[0]}: -> ${drive[1]}") - if (drive[1] == gameInstallPath) { - Timber.i("GOG game directory already mapped to ${drive[0]}: drive") - return drive[0] - } - } - - // Game directory not mapped - need to add or replace mapping - Timber.i("Game directory not yet mapped, updating container drives") - - val gogGamesPath = GOGConstants.defaultGOGGamesPath - val drivesBuilder = StringBuilder() - var replacedExistingMapping = false - var driveLetter: String? = null - - // Iterate through existing drives and rebuild the drives string - for (drive in Container.drivesIterator(container.drives)) { - if (drive[1] == gogGamesPath) { - // Replace parent directory mapping with specific game directory - driveLetter = drive[0] - drivesBuilder.append("$driveLetter:$gameInstallPath") - replacedExistingMapping = true - Timber.i("Replaced parent GOG directory (${drive[1]}) with game directory ($gameInstallPath) on $driveLetter: drive") - } else { - // Keep other drive mappings as-is - drivesBuilder.append("${drive[0]}:${drive[1]}") - Timber.d("Kept existing drive mapping: ${drive[0]}: -> ${drive[1]}") - } - } - - // If we didn't replace an existing mapping, add a new one - if (!replacedExistingMapping) { - driveLetter = Container.getNextAvailableDriveLetter(container.drives).toString() - drivesBuilder.append("$driveLetter:$gameInstallPath") - Timber.i("Added new drive mapping for GOG game on $driveLetter: drive (no parent mapping found)") - } - - // Update container with new drives - val newDrives = drivesBuilder.toString() - Timber.i("Updating container drives from '${container.drives}' to '$newDrives'") - container.drives = newDrives - container.saveData() - Timber.i("Container drives updated and saved successfully") - - return driveLetter - } } From a83605a1be364bf002ccd8c13426a5cab6fa9d2c Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 16:59:43 +0000 Subject: [PATCH 027/122] remvoed unneeded code --- .../gamenative/service/gog/GOGAuthManager.kt | 32 +------ .../gamenative/service/gog/GOGConstants.kt | 8 -- .../app/gamenative/service/gog/GOGManager.kt | 93 ++----------------- 3 files changed, 8 insertions(+), 125 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt index d5bc375b0..de61a2b96 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -9,26 +9,20 @@ import java.io.File /** * Manages GOG authentication and account operations. * - * Responsibilities: * - OAuth2 authentication flow * - Credential storage and validation * - Token refresh * - Account logout - * + * ! Note: We currently don't use redirect flow due to Pluvia Issues * Uses GOGPythonBridge for all GOGDL command execution. */ object GOGAuthManager { - /** - * Get the auth config file path for a context - */ + fun getAuthConfigPath(context: Context): String { return "${context.filesDir}/gog_auth.json" } - /** - * Check if user is authenticated by checking if auth file exists - */ fun hasStoredCredentials(context: Context): Boolean { val authFile = File(getAuthConfigPath(context)) return authFile.exists() @@ -37,10 +31,6 @@ object GOGAuthManager { /** * Authenticate with GOG using authorization code from OAuth2 flow * Users must visit GOG login page, authenticate, and copy the authorization code - * - * @param context Android context - * @param authorizationCode OAuth2 authorization code (or full redirect URL) - * @return Result containing GOGCredentials or error */ suspend fun authenticateWithCode(context: Context, authorizationCode: String): Result { return try { @@ -187,11 +177,6 @@ object GOGAuthManager { } } - // ========== Private Helper Methods ========== - - /** - * Extract authorization code from user input (URL or raw code) - */ private fun extractCodeFromInput(input: String): String { return if (input.startsWith("http")) { // Extract code parameter from URL @@ -209,9 +194,6 @@ object GOGAuthManager { } } - /** - * Parse authentication result from GOGDL output and auth file - */ private fun parseAuthenticationResult(authConfigPath: String, gogdlOutput: String): Result { try { Timber.d("Attempting to parse GOGDL output as JSON (length: ${gogdlOutput.length})") @@ -268,9 +250,6 @@ object GOGAuthManager { } } - /** - * Parse GOGCredentials from GOGDL command output - */ private fun parseCredentialsFromOutput(output: String): Result { try { val credentialsJson = JSONObject(output.trim()) @@ -303,9 +282,6 @@ object GOGAuthManager { } } - /** - * Parse full GOGCredentials from auth file - */ private fun parseFullCredentialsFromFile(authConfigPath: String): GOGCredentials { return try { val authFile = File(authConfigPath) @@ -347,10 +323,6 @@ object GOGAuthManager { ) } } - - /** - * Create GOGCredentials from JSON output - */ private fun createCredentialsFromJson(outputJson: JSONObject): GOGCredentials { return GOGCredentials( accessToken = outputJson.optString("access_token", ""), diff --git a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt index d704fa15d..bd8d2560c 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt @@ -41,10 +41,6 @@ object GOGConstants { val externalGOGGamesPath: String get() = Paths.get(PrefManager.externalStoragePath, "GOG", "games", "common").toString() - /** - * Default GOG games installation path based on storage preference - * Follows the same logic as Steam games - */ val defaultGOGGamesPath: String get() { return if (PrefManager.useExternalStorage && File(PrefManager.externalStoragePath).exists()) { @@ -56,10 +52,6 @@ object GOGConstants { } } - /** - * Get the install path for a specific GOG game - * Similar to Steam's pattern: {base}/GOG/games/common/{sanitized_title}/ - */ fun getGameInstallPath(gameTitle: String): String { // Sanitize game title for filesystem val sanitizedTitle = gameTitle.replace(Regex("[^a-zA-Z0-9 ]"), "").trim() diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index a542204b1..7f498cff5 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -70,13 +70,6 @@ class GOGManager @Inject constructor( // Simple cache for download sizes private val downloadSizeCache = mutableMapOf() - // ========================================================================== - // DATABASE OPERATIONS - // ========================================================================== - - /** - * Get a GOG game by ID from database - */ suspend fun getGameById(gameId: String): GOGGame? { return withContext(Dispatchers.IO) { try { @@ -98,30 +91,18 @@ class GOGManager @Inject constructor( } } - /** - * Update a GOG game in database - */ suspend fun updateGame(game: GOGGame) { withContext(Dispatchers.IO) { gogGameDao.update(game) } } - /** - * Get all GOG games as a Flow - */ + fun getAllGames(): Flow> { return gogGameDao.getAll() } - // ========================================================================== - // LIBRARY SYNCING - // ========================================================================== - /** - * Start background library sync - * Progressively fetches and updates the GOG library in the background - */ suspend fun startBackgroundSync(context: Context): Result = withContext(Dispatchers.IO) { try { if (!GOGAuthManager.hasStoredCredentials(context)) { @@ -245,9 +226,6 @@ class GOGManager @Inject constructor( } } - /** - * Parse a single game object from JSON - */ private fun parseGameObject(gameObj: JSONObject): GOGGame { val genresList = parseJsonArray(gameObj.optJSONArray("genres")) val languagesList = parseJsonArray(gameObj.optJSONArray("languages")) @@ -273,9 +251,6 @@ class GOGManager @Inject constructor( ) } - /** - * Parse a JSON array into a list of strings - */ private fun parseJsonArray(jsonArray: org.json.JSONArray?): List { val result = mutableListOf() if (jsonArray != null) { @@ -286,9 +261,6 @@ class GOGManager @Inject constructor( return result } - /** - * Fetch a single game's metadata from GOG API and insert it into the database - */ suspend fun refreshSingleGame(gameId: String, context: Context): Result { return try { Timber.i("Fetching single game data for gameId: $gameId") @@ -326,13 +298,6 @@ class GOGManager @Inject constructor( } } - // ========================================================================== - // DOWNLOAD & INSTALLATION - // ========================================================================== - - /** - * Download a GOG game with full progress tracking - */ suspend fun downloadGame(context: Context, gameId: String, installPath: String, downloadInfo: DownloadInfo): Result { return withContext(Dispatchers.IO) { try { @@ -389,9 +354,6 @@ class GOGManager @Inject constructor( } } - /** - * Delete a GOG game - */ fun deleteGame(context: Context, libraryItem: LibraryItem): Result { try { val gameId = libraryItem.gameId.toString() @@ -448,13 +410,6 @@ class GOGManager @Inject constructor( } } - // ========================================================================== - // INSTALLATION STATUS & VERIFICATION - // ========================================================================== - - /** - * Check if a GOG game is installed - */ fun isGameInstalled(context: Context, libraryItem: LibraryItem): Boolean { try { val appDirPath = getAppDirPath(libraryItem.appId) @@ -481,9 +436,7 @@ class GOGManager @Inject constructor( } } - /** - * Verify that a GOG game installation is valid and complete - */ + fun verifyInstallation(gameId: String): Pair { val game = runBlocking { getGameById(gameId) } val installPath = game?.installPath @@ -510,9 +463,7 @@ class GOGManager @Inject constructor( return Pair(true, null) } - /** - * Check if game has a partial download - */ + fun hasPartialDownload(libraryItem: LibraryItem): Boolean { try { val appDirPath = getAppDirPath(libraryItem.appId) @@ -537,13 +488,6 @@ class GOGManager @Inject constructor( } } - // ========================================================================== - // EXECUTABLE DISCOVERY & LAUNCH - // ========================================================================== - - /** - * Get the executable path for an installed GOG game - */ suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { val gameId = libraryItem.gameId.toString() try { @@ -662,7 +606,7 @@ class GOGManager @Inject constructor( break } } - + if (gogDriveLetter == null) { Timber.e("GOG game directory not mapped to any drive: $gameInstallPath") return "\"explorer.exe\"" @@ -756,20 +700,12 @@ class GOGManager @Inject constructor( return formattedSize } - /** - * Get cached download size if available - */ + fun getCachedDownloadSize(gameId: String): String? { return downloadSizeCache[gameId] } - // ========================================================================== - // UTILITY & CONVERSION - // ========================================================================== - /** - * Create a LibraryItem from GOG game data - */ fun createLibraryItem(appId: String, gameId: String, context: Context): LibraryItem { val gogGame = runBlocking { getGameById(gameId) } return LibraryItem( @@ -780,18 +716,12 @@ class GOGManager @Inject constructor( ) } - /** - * Get store URL for game - */ fun getStoreUrl(libraryItem: LibraryItem): Uri { val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } val slug = gogGame?.slug ?: "" return "https://www.gog.com/en/game/$slug".toUri() } - /** - * Convert GOGGame to SteamApp format for UI compatibility - */ fun convertToSteamApp(gogGame: GOGGame): SteamApp { val releaseTimestamp = parseReleaseDate(gogGame.releaseDate) val appId = gogGame.id.toIntOrNull() ?: gogGame.id.hashCode() @@ -833,24 +763,13 @@ class GOGManager @Inject constructor( return 0L } - /** - * Check if game is valid to download - */ + fun isValidToDownload(library: LibraryItem): Boolean { return true // GOG games are always downloadable if owned } - /** - * Check if update is pending for a game (stub) - */ suspend fun isUpdatePending(libraryItem: LibraryItem): Boolean { return false // Not implemented yet } - /** - * Run before launch (no-op for GOG games) - */ - fun runBeforeLaunch(context: Context, libraryItem: LibraryItem) { - // Don't run anything before launch for GOG games - } } From 6e08583211ca0b9a6ebd30654cb6d04a9959c450 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 17:03:41 +0000 Subject: [PATCH 028/122] Removing unneeded comments --- .../app/gamenative/service/gog/GOGService.kt | 89 ++++--------------- 1 file changed, 17 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index ac420de82..1b9a480c4 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -53,10 +53,7 @@ class GOGService : Service() { } } - /** - * Initialize the GOG service with Chaquopy Python - * Delegates to GOGPythonBridge - */ + fun initialize(context: Context): Boolean { return GOGPythonBridge.initialize(context) } @@ -65,37 +62,26 @@ class GOGService : Service() { // AUTHENTICATION - Delegate to GOGAuthManager // ========================================================================== - /** - * Authenticate with GOG using authorization code - */ suspend fun authenticateWithCode(context: Context, authorizationCode: String): Result { return GOGAuthManager.authenticateWithCode(context, authorizationCode) } - /** - * Check if user has stored credentials - */ + fun hasStoredCredentials(context: Context): Boolean { return GOGAuthManager.hasStoredCredentials(context) } - /** - * Get user credentials - automatically handles token refresh if needed - */ + suspend fun getStoredCredentials(context: Context): Result { return GOGAuthManager.getStoredCredentials(context) } - /** - * Validate credentials - automatically refreshes tokens if they're expired - */ + suspend fun validateCredentials(context: Context): Result { return GOGAuthManager.validateCredentials(context) } - /** - * Clear stored credentials - */ + fun clearStoredCredentials(context: Context): Boolean { return GOGAuthManager.clearStoredCredentials(context) } @@ -120,37 +106,26 @@ class GOGService : Service() { // DOWNLOAD OPERATIONS - Delegate to instance GOGManager // ========================================================================== - /** - * Check if any download is currently active - */ fun hasActiveDownload(): Boolean { return getInstance()?.activeDownloads?.isNotEmpty() ?: false } - /** - * Get the currently downloading game ID - */ + fun getCurrentlyDownloadingGame(): String? { return getInstance()?.activeDownloads?.keys?.firstOrNull() } - /** - * Get download info for a specific game - */ + fun getDownloadInfo(gameId: String): DownloadInfo? { return getInstance()?.activeDownloads?.get(gameId) } - /** - * Clean up active download when game is deleted - */ + fun cleanupDownload(gameId: String) { getInstance()?.activeDownloads?.remove(gameId) } - /** - * Cancel an active download for a specific game - */ + fun cancelDownload(gameId: String): Boolean { val instance = getInstance() val downloadInfo = instance?.activeDownloads?.get(gameId) @@ -171,25 +146,16 @@ class GOGService : Service() { // GAME & LIBRARY OPERATIONS - Delegate to instance GOGManager // ========================================================================== - /** - * Get GOG game info by game ID (synchronously for UI) - */ fun getGOGGameOf(gameId: String): GOGGame? { return runBlocking(Dispatchers.IO) { getInstance()?.gogManager?.getGameById(gameId) } } - /** - * Update GOG game in database - */ suspend fun updateGOGGame(game: GOGGame) { getInstance()?.gogManager?.updateGame(game) } - /** - * Insert or update GOG game in database - */ suspend fun insertOrUpdateGOGGame(game: GOGGame) { val instance = getInstance() if (instance == null) { @@ -200,9 +166,7 @@ class GOGService : Service() { instance.gogManager.insertGame(game) } - /** - * Check if a GOG game is installed (synchronous for UI) - */ + fun isGameInstalled(gameId: String): Boolean { return runBlocking(Dispatchers.IO) { val game = getInstance()?.gogManager?.getGameById(gameId) @@ -220,9 +184,7 @@ class GOGService : Service() { } } - /** - * Get install path for a GOG game (synchronous for UI) - */ + fun getInstallPath(gameId: String): String? { return runBlocking(Dispatchers.IO) { val game = getInstance()?.gogManager?.getGameById(gameId) @@ -230,25 +192,19 @@ class GOGService : Service() { } } - /** - * Verify that a GOG game installation is valid and complete - */ + fun verifyInstallation(gameId: String): Pair { return getInstance()?.gogManager?.verifyInstallation(gameId) ?: Pair(false, "Service not available") } - /** - * Get the primary executable path for a GOG game - */ + suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String { return getInstance()?.gogManager?.getInstalledExe(context, libraryItem) ?: "" } - /** - * Get Wine start command for launching a GOG game - */ + fun getWineStartCommand( context: Context, libraryItem: LibraryItem, @@ -263,18 +219,13 @@ class GOGService : Service() { ) ?: "\"explorer.exe\"" } - /** - * Sync GOG library with database - */ + suspend fun refreshLibrary(context: Context): Result { return getInstance()?.gogManager?.refreshLibrary(context) ?: Result.failure(Exception("Service not available")) } - /** - * Download a GOG game with full progress tracking - * Launches download in service scope so it runs independently - */ + fun downloadGame(context: Context, gameId: String, installPath: String): Result { val instance = getInstance() ?: return Result.failure(Exception("Service not available")) @@ -308,19 +259,13 @@ class GOGService : Service() { return Result.success(downloadInfo) } - /** - * Refresh a single game's metadata from GOG API - */ + suspend fun refreshSingleGame(gameId: String, context: Context): Result { return getInstance()?.gogManager?.refreshSingleGame(gameId, context) ?: Result.failure(Exception("Service not available")) } } - // ========================================================================== - // Instance members - // ========================================================================== - private lateinit var notificationHelper: NotificationHelper @Inject From 7a9f730b333f64995c822987f55c18c1df71f6ba Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 20:59:40 +0000 Subject: [PATCH 029/122] Migrated the strings to templating and adjusted some conditional logic to make it more readable --- .../java/app/gamenative/data/LibraryItem.kt | 11 +++----- .../ui/component/dialog/GOGLoginDialog.kt | 26 +++++++++++-------- app/src/main/res/values/strings.xml | 11 ++++++++ 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/LibraryItem.kt b/app/src/main/java/app/gamenative/data/LibraryItem.kt index 2320c135c..5e056bf3d 100644 --- a/app/src/main/java/app/gamenative/data/LibraryItem.kt +++ b/app/src/main/java/app/gamenative/data/LibraryItem.kt @@ -47,15 +47,10 @@ data class LibraryItem( } GameSource.GOG -> { // GoG Images are typically the full URL, but have fallback just in case. - if (iconHash.isNotEmpty()) { - if (iconHash.startsWith("http")) { - iconHash - } else { - "${GOGGame.GOG_IMAGE_BASE_URL}/$iconHash" - } - } else { - "" + if (iconHash.isEmpty()) { + return "" } + if (iconHash.startsWith("http")) iconHash else "${GOGGame.GOG_IMAGE_BASE_URL}/$iconHash" } } 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 index 304bd12c6..6945f6b81 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -10,8 +10,10 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier 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 @@ -24,6 +26,7 @@ import android.net.Uri * 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( @@ -36,11 +39,11 @@ fun GOGLoginDialog( val context = LocalContext.current var authCode by rememberSaveable { mutableStateOf("") } - if (visible) { + if (!visible) return AlertDialog( onDismissRequest = onDismissRequest, icon = { Icon(imageVector = Icons.Default.Login, contentDescription = null) }, - title = { Text("Sign in to GOG") }, + title = { Text(stringResource(R.string.gog_login_title)) }, text = { Column( modifier = Modifier.fillMaxWidth(), @@ -48,12 +51,12 @@ fun GOGLoginDialog( ) { // Instructions Text( - text = "Sign in with your GOG account:", + text = stringResource(R.string.gog_login_instruction), style = MaterialTheme.typography.bodyMedium ) Text( - text = "Tap 'Open GOG Login' and sign in. The app will automatically receive your authorization.", + text = stringResource(R.string.gog_login_auto_auth_info), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -77,14 +80,14 @@ fun GOGLoginDialog( modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(8.dp)) - Text("Open GOG Login") + Text(stringResource(R.string.gog_login_open_button)) } Divider(modifier = Modifier.padding(vertical = 8.dp)) // Manual code entry fallback Text( - text = "Or manually paste authorization code:", + text = stringResource(R.string.gog_login_manual_entry), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -93,8 +96,8 @@ fun GOGLoginDialog( OutlinedTextField( value = authCode, onValueChange = { authCode = it.trim() }, - label = { Text("Authorization Code (optional)") }, - placeholder = { Text("Paste code here if needed...") }, + 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() @@ -126,7 +129,7 @@ fun GOGLoginDialog( }, enabled = !isLoading && authCode.isNotBlank() ) { - Text("Login") + Text(stringResource(R.string.gog_login_button)) } }, dismissButton = { @@ -134,12 +137,13 @@ fun GOGLoginDialog( onClick = onDismissRequest, enabled = !isLoading ) { - Text("Cancel") + Text(stringResource(R.string.gog_login_cancel)) } } ) } -}@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable private fun Preview_GOGLoginDialog() { PluviaTheme { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cffb56d2b..12b93ab1a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -932,6 +932,17 @@ Containers using this version: No containers are currently using this version. These containers will no longer work if you proceed: + + + Sign in to GOG + Sign in with your GOG account: + Tap \'Open GOG Login\' and sign in. The app will automatically receive your authorization. + Open GOG Login + Or manually paste authorization code: + Authorization Code (optional) + Paste code here if needed… + Login + Cancel From f63fcd5810a8fe1ec598248e1f967bda8efe0b37 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 21:01:16 +0000 Subject: [PATCH 030/122] Making some more readable changes with early return pattern --- .../java/app/gamenative/db/converters/GOGConverter.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt b/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt index 12ff6bf98..21f975581 100644 --- a/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt +++ b/app/src/main/java/app/gamenative/db/converters/GOGConverter.kt @@ -16,10 +16,10 @@ class GOGConverter { @TypeConverter fun toStringList(value: String): List { - return if (value.isEmpty()) { - emptyList() - } else { - Json.decodeFromString>(value) + + if (value.isEmpty()) { + return emptyList() } + return Json.decodeFromString>(value) } } From e578b4c9c987f33bfd2508c9ef58b7eb2418513d Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 21:08:38 +0000 Subject: [PATCH 031/122] Adjusted logs and comments. Removed stubbed function that we can do in a follow-up branch --- .../app/gamenative/service/gog/GOGManager.kt | 53 +++---------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 7f498cff5..450ac0764 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -177,7 +177,7 @@ class GOGManager @Inject constructor( */ private suspend fun listGames(context: Context): Result> { return try { - Timber.i("Fetching GOG library via GOGDL...") + Timber.d("Fetching GOG library via GOGDL...") val authConfigPath = GOGAuthManager.getAuthConfigPath(context) if (!GOGAuthManager.hasStoredCredentials(context)) { @@ -201,9 +201,6 @@ class GOGManager @Inject constructor( } } - /** - * Parse games from GOGDL JSON output - */ private fun parseGamesFromJson(output: String): Result> { return try { val gamesArray = org.json.JSONArray(output.trim()) @@ -263,7 +260,7 @@ class GOGManager @Inject constructor( suspend fun refreshSingleGame(gameId: String, context: Context): Result { return try { - Timber.i("Fetching single game data for gameId: $gameId") + Timber.d("Fetching single game data for gameId: $gameId") val authConfigPath = GOGAuthManager.getAuthConfigPath(context) if (!GOGAuthManager.hasStoredCredentials(context)) { @@ -285,7 +282,6 @@ class GOGManager @Inject constructor( if (gameObj.optString("id", "") == gameId) { val game = parseGameObject(gameObj) insertGame(game) - Timber.i("Successfully fetched and inserted game: ${game.title}") return Result.success(game) } } @@ -338,7 +334,7 @@ class GOGManager @Inject constructor( if (result.isSuccess) { downloadInfo.setProgress(1.0f) - Timber.i("[Download] GOGDL download completed successfully for game $gameId") + Timber.d("[Download] GOGDL download completed successfully for game $gameId") Result.success(Unit) } else { downloadInfo.setProgress(-1.0f) @@ -488,6 +484,7 @@ class GOGManager @Inject constructor( } } + // Get the exe. There is a v1 and v2 depending on the age of the game. suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { val gameId = libraryItem.gameId.toString() try { @@ -520,7 +517,7 @@ class GOGManager @Inject constructor( private fun getGameExecutable(installPath: String, gameDir: File): String { val mainExe = getMainExecutableFromGOGInfo(gameDir, installPath) if (mainExe.isNotEmpty()) { - Timber.i("Found GOG game executable from info file: $mainExe") + Timber.d("Found GOG game executable from info file: $mainExe") return mainExe } Timber.e("Failed to find executable from GOG info file in: ${gameDir.absolutePath}") @@ -556,9 +553,6 @@ class GOGManager @Inject constructor( return "" } - /** - * Get Wine start command for launching a game - */ fun getWineStartCommand( context: Context, libraryItem: LibraryItem, @@ -602,7 +596,7 @@ class GOGManager @Inject constructor( for (drive in com.winlator.container.Container.drivesIterator(container.drives)) { if (drive[1] == gameInstallPath) { gogDriveLetter = drive[0] - Timber.i("Found GOG game mapped to ${drive[0]}: drive") + Timber.d("Found GOG game mapped to ${drive[0]}: drive") break } } @@ -626,37 +620,16 @@ class GOGManager @Inject constructor( guestProgramLauncherComponent.workingDir = gameDir } - Timber.i("GOG Wine command: \"$windowsPath\"") + Timber.d("GOG Wine command: \"$windowsPath\"") return "\"$windowsPath\"" } - /** - * Launch game with save sync (stub - cloud saves not implemented) - */ - suspend fun launchGameWithSaveSync( - context: Context, - libraryItem: LibraryItem, - parentScope: CoroutineScope, - ignorePendingOperations: Boolean, - preferredSave: Int?, - ): PostSyncInfo = withContext(Dispatchers.IO) { - try { - Timber.i("GOG game launch for ${libraryItem.name} (cloud save sync disabled)") - // TODO: Implement GOG cloud save sync - PostSyncInfo(SyncResult.Success) - } catch (e: Exception) { - Timber.e(e, "GOG game launch exception") - PostSyncInfo(SyncResult.UnknownFail) - } - } + // TODO: Implement Cloud Saves here // ========================================================================== // FILE SYSTEM & PATHS // ========================================================================== - /** - * Get app directory path for a game - */ fun getAppDirPath(appId: String): String { val gameId = ContainerUtils.extractGameIdFromContainerId(appId) val game = runBlocking { getGameById(gameId.toString()) } @@ -669,32 +642,22 @@ class GOGManager @Inject constructor( return GOGConstants.defaultGOGGamesPath } - /** - * Get install path for a specific GOG game - */ fun getGameInstallPath(context: Context, gameId: String, gameTitle: String): String { return GOGConstants.getGameInstallPath(gameTitle) } - /** - * Get disk size of installed game - */ suspend fun getGameDiskSize(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { val installPath = getGameInstallPath(context, libraryItem.appId, libraryItem.name) val folderSize = StorageUtils.getFolderSize(installPath) StorageUtils.formatBinarySize(folderSize) } - /** - * Get download size for a game - */ suspend fun getDownloadSize(libraryItem: LibraryItem): String { val gameId = libraryItem.gameId.toString() // Return cached result if available downloadSizeCache[gameId]?.let { return it } - // TODO: Implement via GOGPythonBridge val formattedSize = "Unknown" downloadSizeCache[gameId] = formattedSize return formattedSize From fc6ae191d7b43abe9f81eca70a809a02f6532517 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 21:10:57 +0000 Subject: [PATCH 032/122] Added error toast when it could not start browser --- .../app/gamenative/ui/component/dialog/GOGLoginDialog.kt | 9 +++++++-- app/src/main/res/values/strings.xml | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) 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 index 6945f6b81..70733e983 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -18,6 +18,7 @@ import app.gamenative.service.gog.GOGConstants import app.gamenative.ui.theme.PluviaTheme import android.content.Intent import android.net.Uri +import android.widget.Toast /** * GOG Login Dialog @@ -67,8 +68,12 @@ fun GOGLoginDialog( try { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(GOGConstants.GOG_AUTH_LOGIN_URL)) context.startActivity(intent) - } catch (e: Exception) { - // Browser not available + } caToast.makeText( + context, + context.getString(R.string.gog_login_browser_error), + Toast.LENGTH_SHORT + ).show()tch (e: Exception) { + } }, enabled = !isLoading, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12b93ab1a..466ceef22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -943,6 +943,7 @@ Paste code here if needed… Login Cancel + Could not open browser From 5e89db6ecbb3ef6c6c7c4e07642b185566ccb149 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 15 Dec 2025 21:18:10 +0000 Subject: [PATCH 033/122] fixing conditional and some formatting mistakes --- app/src/main/java/app/gamenative/data/LibraryItem.kt | 7 +++++-- .../app/gamenative/ui/component/dialog/GOGLoginDialog.kt | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/LibraryItem.kt b/app/src/main/java/app/gamenative/data/LibraryItem.kt index 5e056bf3d..c6619959d 100644 --- a/app/src/main/java/app/gamenative/data/LibraryItem.kt +++ b/app/src/main/java/app/gamenative/data/LibraryItem.kt @@ -48,9 +48,12 @@ data class LibraryItem( GameSource.GOG -> { // GoG Images are typically the full URL, but have fallback just in case. if (iconHash.isEmpty()) { - return "" + "" + } else if (iconHash.startsWith("http")) { + iconHash + } else { + "${GOGGame.GOG_IMAGE_BASE_URL}/$iconHash" } - if (iconHash.startsWith("http")) iconHash else "${GOGGame.GOG_IMAGE_BASE_URL}/$iconHash" } } 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 index 70733e983..3c980be59 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -68,12 +68,12 @@ fun GOGLoginDialog( try { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(GOGConstants.GOG_AUTH_LOGIN_URL)) context.startActivity(intent) - } caToast.makeText( + } catch (e: Exception) { + Toast.makeText( context, context.getString(R.string.gog_login_browser_error), Toast.LENGTH_SHORT - ).show()tch (e: Exception) { - + ).show() } }, enabled = !isLoading, From 8712d9bb2d9521362cc022061753b5540493e937 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 12:10:24 +0000 Subject: [PATCH 034/122] fix(): removing some logs and bringing in some early returns to reduce code footprint --- .../main/java/app/gamenative/data/GOGGame.kt | 9 ----- .../gamenative/service/gog/GOGAuthManager.kt | 38 +++++++++---------- 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/GOGGame.kt b/app/src/main/java/app/gamenative/data/GOGGame.kt index a78006e66..9750b2e16 100644 --- a/app/src/main/java/app/gamenative/data/GOGGame.kt +++ b/app/src/main/java/app/gamenative/data/GOGGame.kt @@ -83,16 +83,10 @@ data class GOGGame( "" } - /** - * Get the icon URL for this game - */ val gogIconUrl: String get() = iconUrl.ifEmpty { gogImageUrl } } -/** - * GOG user credentials for authentication - */ data class GOGCredentials( val accessToken: String, val refreshToken: String, @@ -100,9 +94,6 @@ data class GOGCredentials( val username: String, ) -/** - * GOG download progress information - */ data class GOGDownloadInfo( val gameId: String, val totalSize: Long, diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt index de61a2b96..8094b6e14 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -65,7 +65,6 @@ object GOGAuthManager { if (result.isSuccess) { val gogdlOutput = result.getOrNull() ?: "" Timber.i("GOGDL command completed, checking authentication result...") - Timber.d("GOGDL output for auth: $gogdlOutput") // Parse and validate the authentication result return parseAuthenticationResult(authConfigPath, gogdlOutput) @@ -98,8 +97,6 @@ object GOGAuthManager { if (result.isSuccess) { val output = result.getOrNull() ?: "" - Timber.d("GOGDL credentials output: $output") - return parseCredentialsFromOutput(output) } else { Timber.e("GOGDL credentials command failed") @@ -136,7 +133,6 @@ object GOGAuthManager { } val output = result.getOrNull() ?: "" - Timber.d("GOGDL validation output: $output") try { val credentialsJson = JSONObject(output.trim()) @@ -151,7 +147,7 @@ object GOGAuthManager { Timber.d("Credentials validation successful") return Result.success(true) } catch (e: Exception) { - Timber.e(e, "Failed to parse validation response: $output") + Timber.e(e, "Failed to parse validation response") return Result.success(false) } } catch (e: Exception) { @@ -224,29 +220,29 @@ object GOGAuthManager { val authData = parseFullCredentialsFromFile(authConfigPath) Timber.i("GOG authentication successful for user: ${authData.username}") return Result.success(authData) - } else { - Timber.w("GOGDL returned success but no auth file created, using output data") - // Create credentials from GOGDL output - val credentials = createCredentialsFromJson(outputJson) - return Result.success(credentials) } + + Timber.w("GOGDL returned success but no auth file created, using output data") + // Create credentials from GOGDL output + val credentials = createCredentialsFromJson(outputJson) + return Result.success(credentials) + } catch (e: Exception) { Timber.e(e, "Failed to parse GOGDL output") // Fallback: check if auth file exists val authFile = File(authConfigPath) - if (authFile.exists()) { - try { - val authData = parseFullCredentialsFromFile(authConfigPath) - Timber.i("GOG authentication successful (fallback) for user: ${authData.username}") - return Result.success(authData) - } catch (ex: Exception) { - Timber.e(ex, "Failed to parse auth file") - return Result.failure(Exception("Failed to parse authentication result: ${ex.message}")) - } - } else { + if (!authFile.exists()) { Timber.e("GOG authentication failed: no auth file created and failed to parse output") return Result.failure(Exception("Authentication failed: no credentials available")) } + try { + val authData = parseFullCredentialsFromFile(authConfigPath) + Timber.i("GOG authentication successful (fallback) for user: ${authData.username}") + return Result.success(authData) + } catch (ex: Exception) { + Timber.e(ex, "Failed to parse auth file") + return Result.failure(Exception("Failed to parse authentication result: ${ex.message}")) + } } } @@ -274,7 +270,7 @@ object GOGAuthManager { userId = userId, ) - Timber.d("Got credentials for user: $username") + Timber.d("Got credentials for user") return Result.success(credentials) } catch (e: Exception) { Timber.e(e, "Failed to parse GOGDL credentials response") From 08e33fb0d7056c0048aa1f53e837481161569fcd Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 12:26:23 +0000 Subject: [PATCH 035/122] Updated python files and library based on critical coderabbitai feedback. Will avoid minor adjustments that aren't required. We should look to eventually strangle out the Python lib with a Kotlin implementation in a follow-up. But will be a big project. --- .../app/gamenative/service/gog/GOGManager.kt | 5 +- .../ui/component/dialog/GOGLoginDialog.kt | 2 +- .../library/components/LibraryListPane.kt | 6 +- app/src/main/python/gogdl/api.py | 55 ++++++++++++----- .../main/python/gogdl/dl/managers/linux.py | 8 +-- .../main/python/gogdl/dl/managers/manager.py | 43 ++++++------- app/src/main/python/gogdl/dl/objects/linux.py | 61 ++++++++++++++----- app/src/main/python/gogdl/imports.py | 19 +++++- app/src/main/python/gogdl/saves.py | 58 +++++++++--------- app/src/main/python/gogdl/xdelta/objects.py | 18 +++--- 10 files changed, 171 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 450ac0764..4002511a7 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -28,6 +28,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.EnumSet import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope @@ -67,8 +68,8 @@ class GOGManager @Inject constructor( private val gogGameDao: GOGGameDao, ) { - // Simple cache for download sizes - private val downloadSizeCache = mutableMapOf() + // Thread-safe cache for download sizes + private val downloadSizeCache = ConcurrentHashMap() suspend fun getGameById(gameId: String): GOGGame? { return withContext(Dispatchers.IO) { 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 index 3c980be59..8e53e7d25 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -88,7 +88,7 @@ fun GOGLoginDialog( Text(stringResource(R.string.gog_login_open_button)) } - Divider(modifier = Modifier.padding(vertical = 8.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) // Manual code entry fallback Text( diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt index 49fb66762..f58f89a7d 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt @@ -329,9 +329,9 @@ internal fun LibraryListPane( val totalSkeletonCount = remember(state.showSteamInLibrary, state.showCustomGamesInLibrary, state.showGOGInLibrary) { val customCount = if (state.showCustomGamesInLibrary) PrefManager.customGamesCount else 0 val steamCount = if (state.showSteamInLibrary) PrefManager.steamGamesCount else 0 - val gogCount = if (state.showGOGInLibrary) PrefManager.gogGamesCount else 0 - val total = customCount + steamCount + gogCount - Timber.tag("LibraryListPane").d("Skeleton calculation - Custom: $customCount, Steam: $steamCount, GOG: $gogCount, Total: $total") + val gogInstalledCount = if (state.showGOGInLibrary) PrefManager.gogInstalledGamesCount else 0 + val total = customCount + steamCount + gogInstalledCount + Timber.tag("LibraryListPane").d("Skeleton calculation - Custom: $customCount, Steam: $steamCount, GOG installed: $gogInstalledCount, Total: $total") // Show at least a few skeletons, but not more than a reasonable amount if (total == 0) 6 else minOf(total, 20) } diff --git a/app/src/main/python/gogdl/api.py b/app/src/main/python/gogdl/api.py index 9de95e973..17b9d798e 100644 --- a/app/src/main/python/gogdl/api.py +++ b/app/src/main/python/gogdl/api.py @@ -64,7 +64,7 @@ def get_game_details(self, id): def get_user_data(self): # Refresh auth header before making request self._update_auth_header() - + # Try the embed endpoint which is more reliable for getting owned games url = f'{constants.GOG_EMBED}/user/data/games' self.logger.info(f"Fetching user data from: {url}") @@ -109,29 +109,52 @@ def get_dependencies_repo(self, depot_version=2): json_data = json.loads(response.content) return json_data - def get_secure_link(self, product_id, path="", generation=2, root=None): - """Get secure download links from GOG API""" + def get_secure_link(self, product_id, path="", generation=2, root=None, attempt=0, max_retries=3): + """Get secure download links from GOG API with bounded retry + + Args: + product_id: GOG product ID + path: Optional path parameter + generation: API generation version (1 or 2) + root: Optional root parameter + attempt: Current attempt number (internal, default: 0) + max_retries: Maximum number of retry attempts (default: 3) + + Returns: + List of secure URLs, or empty list if all retries exhausted + """ + if attempt >= max_retries: + self.logger.error(f"Failed to get secure link after {max_retries} attempts for product {product_id}") + return [] + url = "" if generation == 2: url = f"{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/secure_link?_version=2&generation=2&path={path}" elif generation == 1: url = f"{constants.GOG_CONTENT_SYSTEM}/products/{product_id}/secure_link?_version=2&type=depot&path={path}" - + if root: url += f"&root={root}" - + try: response = self.get_authenticated_request(url) - + if response.status_code != 200: - self.logger.warning(f"Invalid secure link response: {response.status_code}") - time.sleep(0.2) - return self.get_secure_link(product_id, path, generation, root) - - js = response.json() - return js.get('urls', []) - + self.logger.warning( + f"Invalid secure link response: {response.status_code} " + f"(attempt {attempt + 1}/{max_retries}) for product {product_id}" + ) + sleep_time = 0.2 * (2 ** attempt) + time.sleep(sleep_time) + return self.get_secure_link(product_id, path, generation, root, attempt + 1, max_retries) + + return response.json().get('urls', []) + except Exception as e: - self.logger.error(f"Failed to get secure link: {e}") - time.sleep(0.2) - return self.get_secure_link(product_id, path, generation, root) + self.logger.error( + f"Failed to get secure link: {e} " + f"(attempt {attempt + 1}/{max_retries}) for product {product_id}" + ) + sleep_time = 0.2 * (2 ** attempt) + time.sleep(sleep_time) + return self.get_secure_link(product_id, path, generation, root, attempt + 1, max_retries) diff --git a/app/src/main/python/gogdl/dl/managers/linux.py b/app/src/main/python/gogdl/dl/managers/linux.py index 26c97708e..ca7f5a57a 100644 --- a/app/src/main/python/gogdl/dl/managers/linux.py +++ b/app/src/main/python/gogdl/dl/managers/linux.py @@ -7,11 +7,11 @@ class LinuxManager(Manager): """Android-compatible Linux download manager""" - - def __init__(self, arguments, unknown_arguments, api_handler, max_workers=2): - super().__init__(arguments, unknown_arguments, api_handler, max_workers) + + def __init__(self, generic_manager): + super().__init__(generic_manager) self.logger = logging.getLogger("LinuxManager") - + def download(self): """Download Linux game (uses similar logic to Windows)""" self.logger.info(f"Starting Linux download for game {self.game_id}") diff --git a/app/src/main/python/gogdl/dl/managers/manager.py b/app/src/main/python/gogdl/dl/managers/manager.py index f65849799..73c72a13a 100644 --- a/app/src/main/python/gogdl/dl/managers/manager.py +++ b/app/src/main/python/gogdl/dl/managers/manager.py @@ -19,7 +19,7 @@ class UnsupportedPlatform(Exception): class AndroidManager: """Android-compatible version of GOGDL Manager that uses threading instead of multiprocessing""" - + def __init__(self, arguments, unknown_arguments, api_handler): self.arguments = arguments self.unknown_arguments = unknown_arguments @@ -30,7 +30,7 @@ def __init__(self, arguments, unknown_arguments, api_handler): self.is_verifying = self.arguments.command == "repair" self.game_id = arguments.id self.branch = getattr(arguments, 'branch', None) - + # Use a reasonable number of threads for Android if hasattr(arguments, "workers_count"): self.allowed_threads = min(int(arguments.workers_count), 4) # Limit threads on mobile @@ -43,46 +43,41 @@ def download(self): """Download game using Android-compatible threading""" try: self.logger.info(f"Starting Android download for game {self.game_id}") - + if self.platform == "linux": - # Use Linux manager with threading - manager = linux.LinuxManager( - self.arguments, - self.unknown_arguments, - self.api_handler, - max_workers=self.allowed_threads - ) + # Use Linux manager - pass self as generic_manager like v2.Manager + manager = linux.LinuxManager(self) manager.download() return - + # Get builds to determine generation builds = self.get_builds(self.platform) if not builds or len(builds['items']) == 0: raise Exception("No builds found") - + # Select target build (same logic as heroic-gogdl) target_build = builds['items'][0] # Default to first build - + # Check for specific branch for build in builds['items']: if build.get("branch") == self.branch: target_build = build break - + # Check for specific build ID if hasattr(self.arguments, 'build') and self.arguments.build: for build in builds['items']: if build.get("build_id") == self.arguments.build: target_build = build break - + # Store builds and target_build as instance attributes for V2 Manager self.builds = builds self.target_build = target_build - + generation = target_build.get("generation", 2) self.logger.info(f"Using build {target_build.get('build_id', 'unknown')} for download (generation: {generation})") - + # Use the correct manager based on generation - same as heroic-gogdl if generation == 1: self.logger.info("Using V1Manager for generation 1 game") @@ -92,9 +87,9 @@ def download(self): manager = v2.Manager(self) else: raise Exception(f"Unsupported generation: {generation}") - + manager.download() - + except Exception as e: self.logger.error(f"Download failed: {e}") raise @@ -130,7 +125,7 @@ def setup_download_manager(self): # If Linux download ever progresses to this point, then it's time for some good party if len(self.builds["items"]) == 0: - self.logger.error("No builds found") + self.logger.error("No builds found") exit(1) self.target_build = self.builds["items"][0] @@ -177,18 +172,18 @@ def calculate_download_size(self, arguments, unknown_arguments): """Calculate download size - same as heroic-gogdl""" try: self.setup_download_manager() - + download_size_response = self.download_manager.get_download_size() download_size_response['builds'] = self.builds - + # Print JSON output like heroic-gogdl does import json print(json.dumps(download_size_response)) - + except Exception as e: self.logger.error(f"Calculate download size failed: {e}") raise - + def get_builds(self, build_platform): password_arg = getattr(self.arguments, 'password', None) password = '' if not password_arg else '&password=' + password_arg diff --git a/app/src/main/python/gogdl/dl/objects/linux.py b/app/src/main/python/gogdl/dl/objects/linux.py index 9cd9df2e9..94bdbbfa8 100644 --- a/app/src/main/python/gogdl/dl/objects/linux.py +++ b/app/src/main/python/gogdl/dl/objects/linux.py @@ -13,7 +13,7 @@ class LocalFile: def __init__(self) -> None: self.relative_local_file_offset: int - self.version_needed: bytes + self.version_needed: bytes self.general_purpose_bit_flag: bytes self.compression_method: int self.last_modification_time: bytes @@ -77,7 +77,7 @@ def __init__(self, product): self.version_made_by: bytes self.version_needed_to_extract: bytes self.general_purpose_bit_flag: bytes - self.compression_method: int + self.compression_method: int self.last_modification_time: bytes self.last_modification_date: bytes self.crc32: int @@ -91,7 +91,7 @@ def __init__(self, product): self.ext_file_attrs: bytes self.relative_local_file_offset: int self.file_name: str - self.extra_field: BytesIO + self.extra_field: BytesIO self.comment: bytes self.last_byte: int self.file_data_offset: int @@ -134,12 +134,12 @@ def from_bytes(cls, data, product): if cd_file.extra_field_length - cd_file.extra_field.tell() >= size: field = BytesIO(cd_file.extra_field.read(size)) break - + cd_file.extra_field.seek(size, 1) if cd_file.extra_field_length - cd_file.extra_field.tell() == 0: break - + if field: if cd_file.uncompressed_size == 0xFFFFFFFF: @@ -159,7 +159,7 @@ def from_bytes(cls, data, product): cd_file.last_byte = comment_start + cd_file.file_comment_length return cd_file, comment_start + cd_file.file_comment_length - + def is_symlink(self): return stat.S_ISLNK(int.from_bytes(self.ext_file_attrs, "little") >> 16) @@ -191,13 +191,13 @@ def from_bytes(cls, data, n, product): data = data[next_offset:] if record == 0: continue - + prev_i = record - 1 if not (prev_i >= 0 and prev_i < len(central_dir.files)): continue prev = central_dir.files[prev_i] prev.file_data_offset = cd_file.relative_local_file_offset - prev.compressed_size - + return central_dir class Zip64EndOfCentralDirLocator: @@ -213,7 +213,7 @@ def from_bytes(cls, data): zip64_end_of_cd.zip64_end_of_cd_offset = int.from_bytes(data[8:16], "little") zip64_end_of_cd.total_number_of_disks = int.from_bytes(data[16:20], "little") return zip64_end_of_cd - + def __str__(self): return f"\nZIP64EOCDLocator\nDisk Number: {self.number_of_disk}\nZ64_EOCD Offset: {self.zip64_end_of_cd_offset}\nNumber of disks: {self.total_number_of_disks}" @@ -292,7 +292,7 @@ def __init__(self, url, product_id, session): beginning_of_file = self.get_bytes_from_file( from_b=SEARCH_OFFSET, size=SEARCH_RANGE, add_archive_index=False ) - + self.start_of_archive_index = beginning_of_file.find(LOCAL_FILE_HEADER) + SEARCH_OFFSET # ZIP contents @@ -301,7 +301,26 @@ def __init__(self, url, product_id, session): self.size_of_central_directory: int self.central_directory: CentralDirectory - def get_bytes_from_file(self, from_b=-1, size=None, add_archive_index=True, raw_response=False): + def get_bytes_from_file(self, from_b=-1, size=None, add_archive_index=True, raw_response=False, redirect_count=0): + """Get bytes from file with redirect handling and bounds checking + + Args: + from_b: Starting byte offset + size: Number of bytes to read + add_archive_index: Whether to add archive index offset + raw_response: Whether to return raw response object + redirect_count: Current redirect depth (internal, max 5) + + Returns: + Response object or bytes data + """ + MAX_REDIRECTS = 5 + + if redirect_count >= MAX_REDIRECTS: + raise Exception(f"Too many redirects ({MAX_REDIRECTS}) when fetching bytes from {self.url}") + + # Store original from_b before applying archive index + original_from_b = from_b if add_archive_index: from_b += self.start_of_archive_index @@ -315,11 +334,23 @@ def get_bytes_from_file(self, from_b=-1, size=None, add_archive_index=True, raw_ response = self.session.get(self.url, headers={'Range': range_header}, allow_redirects=False, stream=raw_response) if response.status_code == 302: - # Skip content-system API + # Skip content-system API and follow redirect self.url = response.headers.get('Location') or self.url - return self.get_bytes_from_file(from_b, size, add_archive_index, raw_response) + # Use original from_b and set add_archive_index=False to prevent double-applying the index + return self.get_bytes_from_file(original_from_b, size, add_archive_index=False, + raw_response=raw_response, redirect_count=redirect_count + 1) + + # Safely extract file_size from Content-Range header if present if not self.file_size: - self.file_size = int(response.headers.get("Content-Range").split("/")[-1]) + content_range = response.headers.get("Content-Range") + if content_range: + try: + self.file_size = int(content_range.split("/")[-1]) + except (ValueError, IndexError) as e: + raise Exception(f"Invalid Content-Range header: {content_range}") from e + else: + raise Exception("Content-Range header missing and file_size not set") + if raw_response: return response else: @@ -357,7 +388,7 @@ def __find_end_of_cd(self): else: self.central_directory_offset = end_of_cd.central_directory_offset self.size_of_central_directory = end_of_cd.size_of_central_directory - self.central_directory_records = end_of_cd.central_directory_records + self.central_directory_records = end_of_cd.central_directory_records def __find_central_directory(self): central_directory_data = self.get_bytes_from_file( diff --git a/app/src/main/python/gogdl/imports.py b/app/src/main/python/gogdl/imports.py index b633c0864..c99d836b3 100644 --- a/app/src/main/python/gogdl/imports.py +++ b/app/src/main/python/gogdl/imports.py @@ -22,8 +22,15 @@ def get_info(args, unknown_args): build_id = "" installed_language = None info = {} + + # Initialize variables to safe defaults to prevent UnboundLocalError + title = None + game_id = None + version_name = None + if platform != "linux": if not info_file: + logger.error("Error importing, no info file found") print("Error importing, no info file") return f = open(info_file, "r") @@ -77,6 +84,16 @@ def get_info(args, unknown_args): else: game_id = None build_id = None + + # Validate that metadata was successfully loaded + if title is None or game_id is None or version_name is None: + logger.error( + f"Failed to load game metadata from path: {path}. " + f"Platform: {platform}, gameinfo exists: {os.path.exists(os.path.join(path, 'gameinfo'))}" + ) + print(f"Error: Unable to load game metadata. Missing gameinfo file or invalid installation.") + return + print( json.dumps( { @@ -124,7 +141,7 @@ def load_game_details(path): root_id = data.get("rootGameId") if data["gameId"] == root_id: continue - + dlcs.append(data["gameId"]) return (os.path.join(base_path, f"goggame-{root_id}.info"), os.path.join(base_path, f"goggame-{root_id}.id") if build_id else None, platform, dlcs) diff --git a/app/src/main/python/gogdl/saves.py b/app/src/main/python/gogdl/saves.py index 9f2994247..27fff8b64 100644 --- a/app/src/main/python/gogdl/saves.py +++ b/app/src/main/python/gogdl/saves.py @@ -42,10 +42,10 @@ def get_file_metadata(self): date_time_obj = datetime.datetime.fromtimestamp( ts, tz=LOCAL_TIMEZONE ).astimezone(datetime.timezone.utc) - self.md5 = hashlib.md5( - gzip.compress(open(self.absolute_path, "rb").read(), 6, mtime=0) - ).hexdigest() - + with open(self.absolute_path, "rb") as f: + self.md5 = hashlib.md5( + gzip.compress(f.read(), 6, mtime=0) + ).hexdigest() self.update_time = date_time_obj.isoformat(timespec="seconds") self.update_ts = date_time_obj.timestamp() @@ -106,7 +106,7 @@ def sync(self, arguments, unknown_args): if not os.path.exists(self.sync_path): self.logger.warning("Provided path doesn't exist, creating") os.makedirs(self.sync_path, exist_ok=True) - + dir_list = self.create_directory_map(self.sync_path) if len(dir_list) == 0: self.logger.info("No files in directory") @@ -122,7 +122,7 @@ def sync(self, arguments, unknown_args): self.logger.warning(f"Failed to get metadata for {f.absolute_path}: {e}") self.logger.info(f"Local files: {len(dir_list)}") - + # Get authentication credentials try: self.client_id, self.client_secret = self.get_auth_ids() @@ -151,7 +151,7 @@ def sync(self, arguments, unknown_args): sys.stdout.write(str(datetime.datetime.now().timestamp())) sys.stdout.flush() return - + elif len(local_files) == 0 and len(cloud_files) > 0: self.logger.info("No files locally, downloading") for f in downloadable_cloud: @@ -167,7 +167,7 @@ def sync(self, arguments, unknown_args): # Handle more complex sync scenarios timestamp = float(getattr(arguments, 'timestamp', 0.0)) classifier = SyncClassifier.classify(local_files, cloud_files, timestamp) - + action = classifier.get_action() if action == SyncAction.DOWNLOAD: self.logger.info("Downloading newer cloud files") @@ -176,7 +176,7 @@ def sync(self, arguments, unknown_args): self.download_file(f) except Exception as e: self.logger.error(f"Failed to download {f.relative_path}: {e}") - + elif action == SyncAction.UPLOAD: self.logger.info("Uploading newer local files") for f in classifier.updated_local: @@ -184,14 +184,14 @@ def sync(self, arguments, unknown_args): self.upload_file(f) except Exception as e: self.logger.error(f"Failed to upload {f.relative_path}: {e}") - + elif action == SyncAction.CONFLICT: self.logger.warning("Sync conflict detected - manual intervention required") - + self.logger.info("Sync completed") sys.stdout.write(str(datetime.datetime.now().timestamp())) sys.stdout.flush() - + except Exception as e: self.logger.error(f"Sync failed: {e}") raise @@ -214,22 +214,22 @@ def get_auth_token(self): import json with open(self.auth_manager.config_path, 'r') as f: auth_data = json.load(f) - + # Extract credentials for our client ID client_creds = auth_data.get(self.client_id, {}) self.credentials = { 'access_token': client_creds.get('access_token', ''), 'user_id': client_creds.get('user_id', '') } - + if not self.credentials['access_token']: raise Exception("No valid access token found") - + # Update session headers self.session.headers.update({ 'Authorization': f"Bearer {self.credentials['access_token']}" }) - + except Exception as e: self.logger.error(f"Failed to get auth token: {e}") raise @@ -239,14 +239,14 @@ def get_cloud_files_list(self): try: url = f"{constants.GOG_CLOUDSTORAGE}/v1/{self.credentials['user_id']}/{self.client_id}" response = self.session.get(url) - + if not response.ok: self.logger.error(f"Failed to get cloud files: {response.status_code}") return [] - + cloud_data = response.json() cloud_files = [] - + for item in cloud_data.get('items', []): if self.is_save_file(item): cloud_file = SyncFile( @@ -256,9 +256,9 @@ def get_cloud_files_list(self): item.get('last_modified') ) cloud_files.append(cloud_file) - + return cloud_files - + except Exception as e: self.logger.error(f"Failed to get cloud files list: {e}") return [] @@ -271,17 +271,17 @@ def upload_file(self, file: SyncFile): """Upload file to GOG cloud storage""" try: url = f"{constants.GOG_CLOUDSTORAGE}/v1/{self.credentials['user_id']}/{self.client_id}/{self.cloud_save_dir_name}/{file.relative_path}" - + with open(file.absolute_path, 'rb') as f: headers = { 'X-Object-Meta-LocalLastModified': file.update_time, 'Content-Type': 'application/octet-stream' } response = self.session.put(url, data=f, headers=headers) - + if not response.ok: self.logger.error(f"Upload failed for {file.relative_path}: {response.status_code}") - + except Exception as e: self.logger.error(f"Failed to upload {file.relative_path}: {e}") @@ -290,20 +290,20 @@ def download_file(self, file: SyncFile, retries=3): try: url = f"{constants.GOG_CLOUDSTORAGE}/v1/{self.credentials['user_id']}/{self.client_id}/{self.cloud_save_dir_name}/{file.relative_path}" response = self.session.get(url, stream=True) - + if not response.ok: self.logger.error(f"Download failed for {file.relative_path}: {response.status_code}") return - + # Create local directory structure local_path = os.path.join(self.sync_path, file.relative_path) os.makedirs(os.path.dirname(local_path), exist_ok=True) - + # Download file with open(local_path, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) - + # Set file timestamp if available if 'X-Object-Meta-LocalLastModified' in response.headers: try: @@ -313,7 +313,7 @@ def download_file(self, file: SyncFile, retries=3): os.utime(local_path, (timestamp, timestamp)) except Exception as e: self.logger.warning(f"Failed to set timestamp for {file.relative_path}: {e}") - + except Exception as e: if retries > 1: self.logger.debug(f"Failed sync of {file.relative_path}, retrying (retries left {retries - 1})") diff --git a/app/src/main/python/gogdl/xdelta/objects.py b/app/src/main/python/gogdl/xdelta/objects.py index f2bb9b691..14ed48858 100644 --- a/app/src/main/python/gogdl/xdelta/objects.py +++ b/app/src/main/python/gogdl/xdelta/objects.py @@ -1,6 +1,6 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from io import IOBase, BytesIO -from typing import Optional +from typing import Optional, List @dataclass class CodeTable: @@ -44,11 +44,11 @@ class HalfInstruction: @dataclass class AddressCache: - s_near = CodeTable.near_modes - s_same = CodeTable.same_modes - next_slot = 0 - near_array = [0 for _ in range(s_near)] - same_array = [0 for _ in range(s_same * 256)] + s_near: int = field(default=CodeTable.near_modes) + s_same: int = field(default=CodeTable.same_modes) + next_slot: int = field(default=0) + near_array: List[int] = field(default_factory=lambda: [0] * CodeTable.near_modes) + same_array: List[int] = field(default_factory=lambda: [0] * (CodeTable.same_modes * 256)) def update(self, addr): self.near_array[self.next_slot] = addr @@ -72,7 +72,7 @@ class Context: dec_winoff: int = 0 target_buffer: Optional[bytearray] = None - + def build_code_table(): table: list[Instruction] = [] for _ in range(256): @@ -80,7 +80,7 @@ def build_code_table(): cpy_modes = 2 + CodeTable.near_modes + CodeTable.same_modes i = 0 - + table[i].type1 = XD3_RUN i+=1 table[i].type1 = XD3_ADD From 2a1d6ee8ab92ff107bade7d12318df910173f6f6 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 13:02:46 +0000 Subject: [PATCH 036/122] Pull out deletion logic from UI and utilize the GOGManager deleteGame. --- .../app/gamenative/service/gog/GOGManager.kt | 81 ++++++++++--------- .../app/gamenative/service/gog/GOGService.kt | 9 +++ .../screen/library/appscreen/GOGAppScreen.kt | 48 +++-------- 3 files changed, 59 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 4002511a7..5fee6eed0 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -351,59 +351,60 @@ class GOGManager @Inject constructor( } } - fun deleteGame(context: Context, libraryItem: LibraryItem): Result { - try { - val gameId = libraryItem.gameId.toString() - val installPath = getGameInstallPath(context, gameId, libraryItem.name) - val installDir = File(installPath) - - // Delete the manifest file - val manifestPath = File(context.filesDir, "manifests/$gameId") - if (manifestPath.exists()) { - manifestPath.delete() - Timber.i("Deleted manifest file for game $gameId") - } + suspend fun deleteGame(context: Context, libraryItem: LibraryItem): Result { + return withContext(Dispatchers.IO) { + try { + val gameId = libraryItem.gameId.toString() + val installPath = getGameInstallPath(context, gameId, libraryItem.name) + val installDir = File(installPath) - if (installDir.exists()) { - val success = installDir.deleteRecursively() - if (success) { - Timber.i("Successfully deleted game directory: $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 - val game = runBlocking { getGameById(gameId) } - if (game != null) { - val updatedGame = game.copy(isInstalled = false, installPath = "") - runBlocking { gogGameDao.update(updatedGame) } - } + // Delete the manifest file + val manifestPath = File(context.filesDir, "manifests/$gameId") + if (manifestPath.exists()) { + manifestPath.delete() + Timber.i("Deleted manifest file for game $gameId") + } - return Result.success(Unit) + // 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 { - return Result.failure(Exception("Failed to delete game directory")) + Timber.w("GOG game directory doesn't exist: $installPath") } - } else { - Timber.w("GOG game directory doesn't exist: $installPath") - // Clean up markers anyway + + // 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 - val game = runBlocking { getGameById(gameId) } + // Update database - mark as not installed + val game = getGameById(gameId) if (game != null) { val updatedGame = game.copy(isInstalled = false, installPath = "") - runBlocking { gogGameDao.update(updatedGame) } + 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) } - return Result.success(Unit) + // 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) } - } catch (e: Exception) { - Timber.e(e, "Failed to delete GOG game ${libraryItem.gameId}") - return Result.failure(e) } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 1b9a480c4..8cbe81ea4 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -264,6 +264,15 @@ class GOGService : Service() { 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")) + } } private lateinit var notificationHelper: NotificationHelper 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 index db3aa730a..0af659d1d 100644 --- 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 @@ -426,48 +426,17 @@ class GOGAppScreen : BaseAppScreen() { /** * Perform the actual uninstall of a GOG game + * Delegates to GOGService/GOGManager for proper service layer separation */ private fun performUninstall(context: Context, libraryItem: LibraryItem) { Timber.i("Uninstalling GOG game: ${libraryItem.appId}") CoroutineScope(Dispatchers.IO).launch { try { - // For GOG games, appId is already the numeric game ID - val gameId = libraryItem.appId - - // Get install path from GOGService - val game = GOGService.getGOGGameOf(gameId) - - if (game != null && game.installPath.isNotEmpty()) { - val installDir = File(game.installPath) - if (installDir.exists()) { - Timber.d("Deleting game files from: ${game.installPath}") - val deleted = installDir.deleteRecursively() - if (deleted) { - Timber.i("Successfully deleted game files") - } else { - Timber.w("Failed to delete some game files") - } - } - - // Update database via GOGService - mark as not installed - GOGService.updateGOGGame( - game.copy( - isInstalled = false, - installPath = "" - ) - ) - Timber.d("Updated database: game marked as not installed") - - // Delete container - withContext(Dispatchers.Main) { - ContainerUtils.deleteContainer(context, libraryItem.appId) - } - - // Trigger library refresh - app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(libraryItem.gameId) - ) + // 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, @@ -476,12 +445,13 @@ class GOGAppScreen : BaseAppScreen() { ).show() } } else { - Timber.w("Game not found in database or no install path") + val error = result.exceptionOrNull() + Timber.e(error, "Failed to uninstall GOG game: ${libraryItem.appId}") withContext(Dispatchers.Main) { android.widget.Toast.makeText( context, - "Game not found", - android.widget.Toast.LENGTH_SHORT + "Failed to uninstall game: ${error?.message}", + android.widget.Toast.LENGTH_LONG ).show() } } From 2e6c6ad63149b7646049cfb37d9701d84c479ed5 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 13:06:17 +0000 Subject: [PATCH 037/122] Refactored out the performDownload to the GOGManager. --- .../app/gamenative/service/gog/GOGManager.kt | 77 ++++++++- .../screen/library/appscreen/GOGAppScreen.kt | 157 ++---------------- 2 files changed, 92 insertions(+), 142 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 5fee6eed0..5b1edbfe3 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -298,6 +298,11 @@ class GOGManager @Inject constructor( suspend fun downloadGame(context: Context, gameId: String, installPath: String, downloadInfo: DownloadInfo): Result { return withContext(Dispatchers.IO) { try { + // Check authentication first + if (!GOGAuthManager.hasStoredCredentials(context)) { + return@withContext Result.failure(Exception("Not authenticated. Please login to GOG first.")) + } + Timber.i("[Download] Starting GOGDL download for game $gameId to $installPath") val installDir = File(installPath) @@ -318,8 +323,11 @@ class GOGManager @Inject constructor( Timber.d("[Download] Calling GOGPythonBridge with gameId=$numericGameId, authConfig=$authConfigPath") - // Initialize progress + // Initialize progress and emit download started event downloadInfo.setProgress(0.0f) + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toLongOrNull() ?: 0L, true) + ) val result = GOGPythonBridge.executeCommandWithCallback( downloadInfo, @@ -335,17 +343,82 @@ class GOGManager @Inject constructor( if (result.isSuccess) { downloadInfo.setProgress(1.0f) - Timber.d("[Download] GOGDL download completed successfully for game $gameId") + Timber.i("[Download] GOGDL download completed successfully for game $gameId") + + // Update or create database entry + var game = getGameById(gameId) + if (game != null) { + // Game exists - update install status + Timber.d("Updating existing game install status: isInstalled=true, installPath=$installPath") + val updatedGame = game.copy( + isInstalled = true, + installPath = installPath + ) + updateGame(updatedGame) + Timber.i("Updated GOG game install status in database for ${game.title}") + } else { + // Game not in database - fetch from API and insert + Timber.w("Game not found in database, fetching from GOG API for gameId: $gameId") + try { + val refreshResult = refreshSingleGame(gameId, context) + if (refreshResult.isSuccess) { + game = refreshResult.getOrNull() + if (game != null) { + val updatedGame = game.copy( + isInstalled = true, + installPath = installPath + ) + insertGame(updatedGame) + Timber.i("Fetched and inserted GOG game ${game.title} with install status") + } else { + Timber.w("Failed to fetch game data from GOG API for gameId: $gameId") + } + } else { + Timber.e(refreshResult.exceptionOrNull(), "Error fetching game from GOG API: $gameId") + } + } catch (e: Exception) { + Timber.e(e, "Exception fetching game from GOG API: $gameId") + } + } + + // Verify installation + val (isValid, errorMessage) = verifyInstallation(gameId) + if (!isValid) { + Timber.w("Installation verification failed for game $gameId: $errorMessage") + } else { + Timber.i("Installation verified successfully for game: $gameId") + } + + // Emit completion events + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toLongOrNull() ?: 0L, false) + ) + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(gameId.toLongOrNull() ?: 0L) + ) + Result.success(Unit) } else { downloadInfo.setProgress(-1.0f) val error = result.exceptionOrNull() Timber.e(error, "[Download] GOGDL download failed for game $gameId") + + // Emit download stopped event on failure + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toLongOrNull() ?: 0L, false) + ) + Result.failure(error ?: Exception("Download failed")) } } catch (e: Exception) { Timber.e(e, "[Download] Exception during download for game $gameId") downloadInfo.setProgress(-1.0f) + + // Emit download stopped event on exception + app.gamenative.PluviaApp.events.emitJava( + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toLongOrNull() ?: 0L, false) + ) + Result.failure(e) } } 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 index 0af659d1d..a8b9c4a76 100644 --- 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 @@ -212,159 +212,39 @@ class GOGAppScreen : BaseAppScreen() { /** * Perform the actual download after confirmation + * Delegates to GOGService/GOGManager for proper service layer separation */ private fun performDownload(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { - // For GOG games, appId is already the numeric game ID val gameId = libraryItem.appId Timber.i("Starting GOG game download: ${libraryItem.appId}") CoroutineScope(Dispatchers.IO).launch { try { - // Get auth config path - val authConfigPath = "${context.filesDir}/gog_auth.json" - val authFile = File(authConfigPath) - if (!authFile.exists()) { - Timber.e("GOG authentication file not found. User needs to login first.") - withContext(Dispatchers.Main) { - android.widget.Toast.makeText( - context, - "Please login to GOG first in Settings", - android.widget.Toast.LENGTH_LONG - ).show() - } - return@launch - } - - // Get install path using GOG's path structure (similar to Steam) - // This will use external storage if available, otherwise internal - val gameTitle = libraryItem.name - val installPath = GOGConstants.getGameInstallPath(gameTitle) - val installDir = File(installPath) - - // Ensure parent directories exist - installDir.parentFile?.let { it.mkdirs() } - + // Get install path + val installPath = GOGConstants.getGameInstallPath(libraryItem.name) Timber.d("Downloading GOG game to: $installPath") - // Start download + // 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) { - val info = result.getOrNull() Timber.i("GOG download started successfully for: $gameId") - - // Emit download started event to update UI state immediately - Timber.tag(TAG).d("[EVENT] Emitting DownloadStatusChanged: appId=${libraryItem.gameId} (from appId=${libraryItem.appId}), isDownloading=true") - app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.DownloadStatusChanged(libraryItem.gameId, true) - ) - Timber.tag(TAG).d("[EVENT] Emitted DownloadStatusChanged event") - - // Monitor download completion - info?.let { downloadInfo -> - // Wait for download to complete - while (downloadInfo.getProgress() in 0f..0.99f) { - kotlinx.coroutines.delay(1000) - } - - val finalProgress = downloadInfo.getProgress() - Timber.i("GOG download final progress: $finalProgress for game: $gameId") - if (finalProgress >= 1.0f) { - // Download completed successfully - Timber.i("GOG download completed: $gameId") - - // Update or create database entry FIRST, before verification - Timber.d("Attempting to fetch game from database for gameId: $gameId") - var game = GOGService.getGOGGameOf(gameId) - Timber.d("Fetched game from database: game=${game?.title}, isInstalled=${game?.isInstalled}, installPath=${game?.installPath}") - - if (game != null) { - // Game exists in database - update install status - Timber.d("Updating existing game install status: isInstalled=true, installPath=$installPath") - GOGService.updateGOGGame( - game.copy( - isInstalled = true, - installPath = installPath - ) - ) - Timber.i("Updated GOG game install status in database for ${game.title}") - } else { - // Game not in database - fetch from API and insert - Timber.w("Game not found in database, fetching from GOG API for gameId: $gameId") - try { - val result = GOGService.refreshSingleGame(gameId, context) - if (result.isSuccess) { - game = result.getOrNull() - if (game != null) { - // Insert/update the newly fetched game with install info using REPLACE strategy - val updatedGame = game.copy( - isInstalled = true, - installPath = installPath - ) - Timber.d("About to insert game with: isInstalled=true, installPath=$installPath") - - // Wait for database write to complete - withContext(Dispatchers.IO) { - GOGService.insertOrUpdateGOGGame(updatedGame) - } - - Timber.i("Fetched and inserted GOG game ${game.title} with install status") - Timber.d("Game install status in memory: isInstalled=${updatedGame.isInstalled}, installPath=${updatedGame.installPath}") - - // Verify database write - val verifyGame = GOGService.getGOGGameOf(gameId) - Timber.d("Verification read from database: isInstalled=${verifyGame?.isInstalled}, installPath=${verifyGame?.installPath}") - } else { - Timber.w("Failed to fetch game data from GOG API for gameId: $gameId") - } - } else { - Timber.e(result.exceptionOrNull(), "Error fetching game from GOG API: $gameId") - } - } catch (e: Exception) { - Timber.e(e, "Exception fetching game from GOG API: $gameId") - } - } - - // Now verify the installation is valid after database update - Timber.d("Verifying installation for game: $gameId") - val (isValid, errorMessage) = GOGService.verifyInstallation(gameId) - if (!isValid) { - Timber.w("Installation verification failed for game $gameId: $errorMessage") - // Note: We already marked it as installed in DB, but files may be incomplete - // The isGameInstalled() check will catch this on next access - } else { - Timber.i("Installation verified successfully for game: $gameId") - } - - // Emit download stopped event - Timber.tag(TAG).d("[EVENT] Emitting DownloadStatusChanged: appId=${libraryItem.gameId}, isDownloading=false") - app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.DownloadStatusChanged(libraryItem.gameId, false) - ) - - // Trigger library refresh for install status - Timber.tag(TAG).d("[EVENT] Emitting LibraryInstallStatusChanged: appId=${libraryItem.gameId}") - app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(libraryItem.gameId) - ) - Timber.tag(TAG).d("[EVENT] All completion events emitted") - } else { - Timber.w("GOG download did not complete successfully: $finalProgress") - // Emit download stopped event even if failed/cancelled - app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.DownloadStatusChanged(libraryItem.gameId, false) - ) - } - } + // Success toast will be shown when download completes (monitored by GOGService) } else { - Timber.e(result.exceptionOrNull(), "Failed to start GOG download") - // Emit download stopped event if download failed to start - app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.DownloadStatusChanged(libraryItem.gameId, false) - ) + val error = result.exceptionOrNull() + Timber.e(error, "Failed to start GOG download") withContext(Dispatchers.Main) { android.widget.Toast.makeText( context, - "Failed to start download: ${result.exceptionOrNull()?.message}", + "Failed to start download: ${error?.message}", android.widget.Toast.LENGTH_LONG ).show() } @@ -570,9 +450,6 @@ class GOGAppScreen : BaseAppScreen() { return null // GOG uses CDN images, not local files } - /** - * Observe GOG game state changes (download progress, install status) - */ override fun observeGameState( context: Context, libraryItem: LibraryItem, From e8fa26305eebbfcd941755cb2d517ed7c39293ab Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 13:08:52 +0000 Subject: [PATCH 038/122] removed credential logging. Whoops! --- app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt index 8094b6e14..c57c0628e 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -182,7 +182,7 @@ object GOGAuthManager { } else { // Remove any additional parameters after the code val cleanCode = codeParam.substringBefore("&") - Timber.d("Extracted authorization code from URL: ${cleanCode.take(20)}...") + Timber.d("Extracted authorization code") cleanCode } } else { From 429db90c5b9ce28485bf95d2f580705f826d5a7b Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 13:10:31 +0000 Subject: [PATCH 039/122] remove logging username debug --- .../main/java/app/gamenative/service/gog/GOGAuthManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt index c57c0628e..4de0a3435 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -218,7 +218,7 @@ object GOGAuthManager { if (authFile.exists()) { // Parse authentication result from file val authData = parseFullCredentialsFromFile(authConfigPath) - Timber.i("GOG authentication successful for user: ${authData.username}") + Timber.i("GOG authentication successful for user") return Result.success(authData) } @@ -237,7 +237,7 @@ object GOGAuthManager { } try { val authData = parseFullCredentialsFromFile(authConfigPath) - Timber.i("GOG authentication successful (fallback) for user: ${authData.username}") + Timber.i("GOG authentication successful (fallback) for user") return Result.success(authData) } catch (ex: Exception) { Timber.e(ex, "Failed to parse auth file") From b4893da401987bb33a6069b509f55272a20dd5a0 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 13:11:00 +0000 Subject: [PATCH 040/122] removed authcode from debug log --- app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt index 4de0a3435..0b180c7ae 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -53,7 +53,7 @@ object GOGAuthManager { } // Execute GOGDL auth command with the authorization code - Timber.d("Authenticating with auth config path: $authConfigPath, code: ${actualCode.take(10)}...") + Timber.d("Authenticating with auth config path") val result = GOGPythonBridge.executeCommand( "--auth-config-path", authConfigPath, From bd0ddd4c6922c82e98bc21f4a58a44d1f6ad32d3 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 13:24:01 +0000 Subject: [PATCH 041/122] python timeouts and removed some unused functions from GOGManager. --- .../gamenative/service/gog/GOGAuthManager.kt | 79 ++++++------ .../app/gamenative/service/gog/GOGManager.kt | 119 ------------------ .../screen/library/appscreen/GOGAppScreen.kt | 3 +- app/src/main/python/gogdl/api.py | 1 - app/src/main/python/gogdl/imports.py | 38 ++++-- 5 files changed, 71 insertions(+), 169 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt index 0b180c7ae..cb859e599 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -218,8 +218,13 @@ object GOGAuthManager { if (authFile.exists()) { // Parse authentication result from file val authData = parseFullCredentialsFromFile(authConfigPath) - Timber.i("GOG authentication successful for user") - return Result.success(authData) + if (authData != null) { + Timber.i("GOG authentication successful for user") + return Result.success(authData) + } else { + Timber.e("Failed to parse auth file despite file existing") + return Result.failure(Exception("Failed to parse authentication file")) + } } Timber.w("GOGDL returned success but no auth file created, using output data") @@ -237,8 +242,13 @@ object GOGAuthManager { } try { val authData = parseFullCredentialsFromFile(authConfigPath) - Timber.i("GOG authentication successful (fallback) for user") - return Result.success(authData) + if (authData != null) { + Timber.i("GOG authentication successful (fallback) for user") + return Result.success(authData) + } else { + Timber.e("Failed to parse auth file (fallback path)") + return Result.failure(Exception("Failed to parse authentication file")) + } } catch (ex: Exception) { Timber.e(ex, "Failed to parse auth file") return Result.failure(Exception("Failed to parse authentication result: ${ex.message}")) @@ -278,45 +288,44 @@ object GOGAuthManager { } } - private fun parseFullCredentialsFromFile(authConfigPath: String): GOGCredentials { + private fun parseFullCredentialsFromFile(authConfigPath: String): GOGCredentials? { return try { val authFile = File(authConfigPath) - if (authFile.exists()) { - val authContent = authFile.readText() - val authJson = JSONObject(authContent) + if (!authFile.exists()) { + Timber.e("Auth file does not exist: $authConfigPath") + return null + } - // GOGDL stores credentials nested under client ID - val credentialsJson = if (authJson.has(GOGConstants.GOG_CLIENT_ID)) { - authJson.getJSONObject(GOGConstants.GOG_CLIENT_ID) - } else { - // Fallback: try to read from root level - authJson - } + val authContent = authFile.readText() + val authJson = JSONObject(authContent) - GOGCredentials( - accessToken = credentialsJson.optString("access_token", ""), - refreshToken = credentialsJson.optString("refresh_token", ""), - userId = credentialsJson.optString("user_id", ""), - username = credentialsJson.optString("username", "GOG User"), - ) + // GOGDL stores credentials nested under client ID + val credentialsJson = if (authJson.has(GOGConstants.GOG_CLIENT_ID)) { + authJson.getJSONObject(GOGConstants.GOG_CLIENT_ID) } else { - // Return dummy credentials for successful auth - GOGCredentials( - accessToken = "authenticated_${System.currentTimeMillis()}", - refreshToken = "refresh_${System.currentTimeMillis()}", - userId = "user_123", - username = "GOG User", - ) + // Fallback: try to read from root level + authJson } - } catch (e: Exception) { - Timber.e(e, "Failed to parse auth result from file") - // Return dummy credentials as fallback + + val accessToken = credentialsJson.optString("access_token", "") + val refreshToken = credentialsJson.optString("refresh_token", "") + val userId = credentialsJson.optString("user_id", "") + + // Validate required fields + if (accessToken.isEmpty() || userId.isEmpty()) { + Timber.e("Auth file missing required fields (access_token or user_id)") + return null + } + GOGCredentials( - accessToken = "fallback_token", - refreshToken = "fallback_refresh", - userId = "fallback_user", - username = "GOG User", + accessToken = accessToken, + refreshToken = refreshToken, + userId = userId, + username = credentialsJson.optString("username", "GOG User"), ) + } catch (e: Exception) { + Timber.e(e, "Failed to parse auth result from file: ${e.message}") + null } } private fun createCredentialsFromJson(outputJson: JSONObject): GOGCredentials { diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 5b1edbfe3..ff1c537fb 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -99,11 +99,6 @@ class GOGManager @Inject constructor( } - fun getAllGames(): Flow> { - return gogGameDao.getAll() - } - - suspend fun startBackgroundSync(context: Context): Result = withContext(Dispatchers.IO) { try { if (!GOGAuthManager.hasStoredCredentials(context)) { @@ -534,31 +529,6 @@ class GOGManager @Inject constructor( return Pair(true, null) } - - fun hasPartialDownload(libraryItem: LibraryItem): Boolean { - try { - val appDirPath = getAppDirPath(libraryItem.appId) - - val isDownloadInProgress = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - val isDownloadComplete = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - - if (isDownloadInProgress) { - return true - } - - if (!isDownloadComplete) { - val installPath = GOGConstants.getGameInstallPath(libraryItem.name) - val installDir = File(installPath) - return installDir.exists() && installDir.listFiles()?.isNotEmpty() == true - } - - return false - } catch (e: Exception) { - Timber.w(e, "Error checking partial download status") - return false - } - } - // Get the exe. There is a v1 and v2 depending on the age of the game. suspend fun getInstalledExe(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { val gameId = libraryItem.gameId.toString() @@ -721,93 +691,4 @@ class GOGManager @Inject constructor( return GOGConstants.getGameInstallPath(gameTitle) } - suspend fun getGameDiskSize(context: Context, libraryItem: LibraryItem): String = withContext(Dispatchers.IO) { - val installPath = getGameInstallPath(context, libraryItem.appId, libraryItem.name) - val folderSize = StorageUtils.getFolderSize(installPath) - StorageUtils.formatBinarySize(folderSize) - } - - suspend fun getDownloadSize(libraryItem: LibraryItem): String { - val gameId = libraryItem.gameId.toString() - - // Return cached result if available - downloadSizeCache[gameId]?.let { return it } - - val formattedSize = "Unknown" - downloadSizeCache[gameId] = formattedSize - return formattedSize - } - - - fun getCachedDownloadSize(gameId: String): String? { - return downloadSizeCache[gameId] - } - - - fun createLibraryItem(appId: String, gameId: String, context: Context): LibraryItem { - val gogGame = runBlocking { getGameById(gameId) } - return LibraryItem( - appId = appId, - name = gogGame?.title ?: "Unknown GOG Game", - iconHash = gogGame?.iconUrl ?: "", - gameSource = GameSource.GOG, - ) - } - - fun getStoreUrl(libraryItem: LibraryItem): Uri { - val gogGame = runBlocking { getGameById(libraryItem.gameId.toString()) } - val slug = gogGame?.slug ?: "" - return "https://www.gog.com/en/game/$slug".toUri() - } - - fun convertToSteamApp(gogGame: GOGGame): SteamApp { - val releaseTimestamp = parseReleaseDate(gogGame.releaseDate) - val appId = gogGame.id.toIntOrNull() ?: gogGame.id.hashCode() - - return SteamApp( - id = appId, - name = gogGame.title, - type = AppType.game, - osList = EnumSet.of(OS.windows), - releaseState = ReleaseState.released, - releaseDate = releaseTimestamp, - developer = gogGame.developer.takeIf { it.isNotEmpty() } ?: "Unknown Developer", - publisher = gogGame.publisher.takeIf { it.isNotEmpty() } ?: "Unknown Publisher", - controllerSupport = ControllerSupport.none, - logoHash = "", - iconHash = "", - clientIconHash = "", - installDir = gogGame.title.replace(Regex("[^a-zA-Z0-9 ]"), "").trim(), - ) - } - - private fun parseReleaseDate(releaseDate: String): Long { - if (releaseDate.isEmpty()) return 0L - - val formats = arrayOf( - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US), - SimpleDateFormat("yyyy-MM-dd", Locale.US), - SimpleDateFormat("MMM dd, yyyy", Locale.US), - ) - - for (format in formats) { - try { - return format.parse(releaseDate)?.time ?: 0L - } catch (e: Exception) { - // Try next format - } - } - - return 0L - } - - - fun isValidToDownload(library: LibraryItem): Boolean { - return true // GOG games are always downloadable if owned - } - - suspend fun isUpdatePending(libraryItem: LibraryItem): Boolean { - return false // Not implemented yet - } - } 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 index a8b9c4a76..4a02bd7f3 100644 --- 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 @@ -33,6 +33,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File +import java.util.Locale /** * GOG-specific implementation of BaseAppScreen @@ -541,7 +542,7 @@ class GOGAppScreen : BaseAppScreen() { val downloadSizeGB = (gogGame?.downloadSize ?: 0L) / 1_000_000_000.0 val sizeText = if (downloadSizeGB > 0) { - String.format("%.2f GB", downloadSizeGB) + String.format(Locale.US, "%.2f GB", downloadSizeGB) } else { "Unknown size" } diff --git a/app/src/main/python/gogdl/api.py b/app/src/main/python/gogdl/api.py index 17b9d798e..317824054 100644 --- a/app/src/main/python/gogdl/api.py +++ b/app/src/main/python/gogdl/api.py @@ -4,7 +4,6 @@ import json from multiprocessing import cpu_count from gogdl.dl import dl_utils -from gogdl import constants import gogdl.constants as constants diff --git a/app/src/main/python/gogdl/imports.py b/app/src/main/python/gogdl/imports.py index c99d836b3..27af656e3 100644 --- a/app/src/main/python/gogdl/imports.py +++ b/app/src/main/python/gogdl/imports.py @@ -55,19 +55,31 @@ def get_info(args, unknown_args): version_name = build_id if build_id and platform != "linux": # Get version name - builds_res = requests.get( - f"{constants.GOG_CONTENT_SYSTEM}/products/{game_id}/os/{platform}/builds?generation=2", - headers={ - "User-Agent": "GOGGalaxyCommunicationService/2.0.4.164 (Windows_32bit)" - }, - ) - builds = builds_res.json() - target_build = builds["items"][0] - for build in builds["items"]: - if build["build_id"] == build_id: - target_build = build - break - version_name = target_build["version_name"] + try: + builds_res = requests.get( + f"{constants.GOG_CONTENT_SYSTEM}/products/{game_id}/os/{platform}/builds?generation=2", + headers={ + "User-Agent": "GOGGalaxyCommunicationService/2.0.4.164 (Windows_32bit)" + }, + timeout=30 + ) + builds_res.raise_for_status() + builds = builds_res.json() + target_build = builds["items"][0] + for build in builds["items"]: + if build["build_id"] == build_id: + target_build = build + break + version_name = target_build["version_name"] + except requests.exceptions.Timeout: + logger.warning(f"Timeout fetching build info for game {game_id}, using build_id as version") + version_name = build_id + except requests.exceptions.RequestException as e: + logger.warning(f"Error fetching build info for game {game_id}: {e}, using build_id as version") + version_name = build_id + except (KeyError, IndexError, json.JSONDecodeError) as e: + logger.warning(f"Error parsing build info for game {game_id}: {e}, using build_id as version") + version_name = build_id if platform == "linux" and os.path.exists(os.path.join(path, "gameinfo")): # Linux version installed using installer gameinfo_file = open(os.path.join(path, "gameinfo"), "r") From e5f885353b172ddf4d4eb2864ac75a228ced4195 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 13:25:55 +0000 Subject: [PATCH 042/122] removed function from GOGService --- .../java/app/gamenative/service/gog/GOGService.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 8cbe81ea4..ca14a6ef0 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -156,17 +156,6 @@ class GOGService : Service() { getInstance()?.gogManager?.updateGame(game) } - suspend fun insertOrUpdateGOGGame(game: GOGGame) { - val instance = getInstance() - if (instance == null) { - Timber.e("GOGService instance is null, cannot insert game") - return - } - Timber.d("Inserting game: id=${game.id}, isInstalled=${game.isInstalled}") - instance.gogManager.insertGame(game) - } - - fun isGameInstalled(gameId: String): Boolean { return runBlocking(Dispatchers.IO) { val game = getInstance()?.gogManager?.getGameById(gameId) From f52a76e64d1b35c262e7dba55b573feaa8e68cfa Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 13:35:36 +0000 Subject: [PATCH 043/122] Adjusting game executable for better error handling --- .../app/gamenative/service/gog/GOGManager.kt | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index ff1c537fb..518711cb3 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -560,42 +560,47 @@ class GOGManager @Inject constructor( } private fun getGameExecutable(installPath: String, gameDir: File): String { - val mainExe = getMainExecutableFromGOGInfo(gameDir, installPath) - if (mainExe.isNotEmpty()) { - Timber.d("Found GOG game executable from info file: $mainExe") - return mainExe + 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("Failed to find executable from GOG info file in: ${gameDir.absolutePath}") + Timber.e(result.exceptionOrNull(), "Failed to find executable from GOG info file in: ${gameDir.absolutePath}") return "" } - private fun getMainExecutableFromGOGInfo(gameDir: File, installPath: String): String { - val infoFile = gameDir.listFiles()?.find { - it.isFile && it.name.startsWith("goggame-") && it.name.endsWith(".info") - } ?: throw Exception("GOG info file not found") + private fun getMainExecutableFromGOGInfo(gameDir: File, installPath: String): Result { + return try { + val infoFile = gameDir.listFiles()?.find { + it.isFile && it.name.startsWith("goggame-") && it.name.endsWith(".info") + } ?: return Result.failure(Exception("GOG info file not found in ${gameDir.absolutePath}")) - val content = infoFile.readText() - val jsonObject = JSONObject(content) + val content = infoFile.readText() + val jsonObject = JSONObject(content) - if (!jsonObject.has("playTasks")) { - throw Exception("playTasks array not found in info file") - } + 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") - val actualExeFile = gameDir.listFiles()?.find { - it.name.equals(executablePath, ignoreCase = true) - } - if (actualExeFile != null && actualExeFile.exists()) { - return "${gameDir.name}/${actualExeFile.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") + val actualExeFile = gameDir.listFiles()?.find { + it.name.equals(executablePath, ignoreCase = true) + } + if (actualExeFile != null && actualExeFile.exists()) { + return Result.success("${gameDir.name}/${actualExeFile.name}") + } + return Result.failure(Exception("Primary executable '$executablePath' not found in ${gameDir.absolutePath}")) } - break } + 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)) } - return "" } fun getWineStartCommand( From 0e868a048a1b1002c8535190c38b108d5edcd3f3 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 13:39:33 +0000 Subject: [PATCH 044/122] fixing types for the events --- .../main/java/app/gamenative/service/gog/GOGManager.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 518711cb3..7d809cf45 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -321,7 +321,7 @@ class GOGManager @Inject constructor( // Initialize progress and emit download started event downloadInfo.setProgress(0.0f) app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toLongOrNull() ?: 0L, true) + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, true) ) val result = GOGPythonBridge.executeCommandWithCallback( @@ -386,10 +386,10 @@ class GOGManager @Inject constructor( // Emit completion events app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toLongOrNull() ?: 0L, false) + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, false) ) app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(gameId.toLongOrNull() ?: 0L) + app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged(gameId.toIntOrNull() ?: 0) ) Result.success(Unit) @@ -400,7 +400,7 @@ class GOGManager @Inject constructor( // Emit download stopped event on failure app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toLongOrNull() ?: 0L, false) + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, false) ) Result.failure(error ?: Exception("Download failed")) @@ -411,7 +411,7 @@ class GOGManager @Inject constructor( // Emit download stopped event on exception app.gamenative.PluviaApp.events.emitJava( - app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toLongOrNull() ?: 0L, false) + app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, false) ) Result.failure(e) From 880ae1fab71b64fda0cd5c31cc2e68799d03582b Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 13:48:13 +0000 Subject: [PATCH 045/122] Adjusted the text strings for the GOG Login to tell them about having to manually paste in. --- app/src/main/res/values/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 466ceef22..211ae7a35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -936,10 +936,10 @@ Sign in to GOG Sign in with your GOG account: - Tap \'Open GOG Login\' and sign in. The app will automatically receive your authorization. + Tap \'Open GOG Login\' and sign in. Once logged in, please take the token from the success URL and paste it below Open GOG Login - Or manually paste authorization code: - Authorization Code (optional) + Paste your code below + Authorization Code Paste code here if needed… Login Cancel From 232b1ec59948c856914d504734d876072fbf3820 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 14:25:32 +0000 Subject: [PATCH 046/122] Fixing up game details, syncing and downloading bugs. --- .../app/gamenative/service/gog/GOGManager.kt | 183 +++++++++++++++++- .../gamenative/service/gog/GOGPythonBridge.kt | 23 +++ .../app/gamenative/service/gog/GOGService.kt | 9 +- .../ui/screen/library/LibraryAppScreen.kt | 6 +- .../screen/library/appscreen/BaseAppScreen.kt | 8 + .../screen/library/appscreen/GOGAppScreen.kt | 41 +++- app/src/main/python/gogdl/dl/progressbar.py | 20 +- 7 files changed, 275 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 7d809cf45..b6c43d8bd 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -159,6 +159,13 @@ class GOGManager @Inject constructor( Timber.d("Upserting ${games.size} games to database...") gogGameDao.upsertPreservingInstallStatus(games) + // Scan for existing installations on filesystem + Timber.d("Scanning for existing installations...") + val detectedCount = detectAndUpdateExistingInstallations() + if (detectedCount > 0) { + Timber.i("Detected and updated $detectedCount existing installations") + } + Timber.tag("GOG").i("Successfully refreshed GOG library with ${games.size} games") Result.success(games.size) } catch (e: Exception) { @@ -254,6 +261,160 @@ class GOGManager @Inject constructor( return result } + /** + * Scan the GOG games directories for existing installations + * and update the database with installation info + * + * @return Number of installations detected and updated + */ + private suspend fun detectAndUpdateExistingInstallations(): Int = withContext(Dispatchers.IO) { + var detectedCount = 0 + + try { + // Check both internal and external storage paths + val pathsToCheck = listOf( + GOGConstants.internalGOGGamesPath, + GOGConstants.externalGOGGamesPath + ) + + for (basePath in pathsToCheck) { + val baseDir = File(basePath) + if (!baseDir.exists() || !baseDir.isDirectory) { + Timber.d("Skipping non-existent path: $basePath") + continue + } + + Timber.d("Scanning for installations in: $basePath") + val installDirs = baseDir.listFiles { file -> file.isDirectory } ?: emptyArray() + + for (installDir in installDirs) { + try { + val detectedGame = detectGameFromDirectory(installDir) + if (detectedGame != null) { + // Update database with installation info + val existingGame = getGameById(detectedGame.id) + if (existingGame != null && !existingGame.isInstalled) { + val updatedGame = existingGame.copy( + isInstalled = true, + installPath = detectedGame.installPath, + installSize = detectedGame.installSize + ) + updateGame(updatedGame) + detectedCount++ + Timber.i("Detected existing installation: ${existingGame.title} at ${installDir.absolutePath}") + } else if (existingGame != null) { + Timber.d("Game ${existingGame.title} already marked as installed") + } + } + } catch (e: Exception) { + Timber.w(e, "Error detecting game in ${installDir.name}") + } + } + } + } catch (e: Exception) { + Timber.e(e, "Error during installation detection") + } + + detectedCount + } + + /** + * Try to detect which game is installed in the given directory + * by looking for GOG-specific files and matching against the database + * + * @param installDir The directory to check + * @return GOGGame with installation info, or null if no game detected + */ + private suspend fun detectGameFromDirectory(installDir: File): GOGGame? { + if (!installDir.exists() || !installDir.isDirectory) { + return null + } + + val dirName = installDir.name + Timber.d("Checking directory: $dirName") + + // Look for .info files which contain game metadata + val infoFiles = installDir.listFiles { file -> + file.isFile && file.extension == "info" + } ?: emptyArray() + + if (infoFiles.isNotEmpty()) { + // Try to parse game ID from .info file + val infoFile = infoFiles.first() + try { + val infoContent = infoFile.readText() + val infoJson = JSONObject(infoContent) + val gameId = infoJson.optString("gameId", "") + if (gameId.isNotEmpty()) { + val game = getGameById(gameId) + if (game != null) { + val installSize = calculateDirectorySize(installDir) + return game.copy( + isInstalled = true, + installPath = installDir.absolutePath, + installSize = installSize + ) + } + } + } catch (e: Exception) { + Timber.w(e, "Error parsing .info file: ${infoFile.name}") + } + } + + // Fallback: Try to match by directory name with game titles in database + val allGames = gogGameDao.getAllAsList() + for (game in allGames) { + // Sanitize game title to match directory naming convention + val sanitizedTitle = game.title.replace(Regex("[^a-zA-Z0-9 ]"), "").trim() + + if (dirName.equals(sanitizedTitle, ignoreCase = true)) { + // Verify it's actually a game directory (has executables or subdirectories) + val hasContent = installDir.listFiles()?.any { + it.isDirectory || it.extension in listOf("exe", "dll", "bat") + } == true + + if (hasContent) { + val installSize = calculateDirectorySize(installDir) + Timber.d("Matched directory '$dirName' to game '${game.title}'") + return game.copy( + isInstalled = true, + installPath = installDir.absolutePath, + installSize = installSize + ) + } + } + } + + return null + } + + /** + * Calculate the total size of a directory recursively + * + * @param directory The directory to calculate size for + * @return Total size in bytes + */ + private fun calculateDirectorySize(directory: File): Long { + var size = 0L + try { + if (!directory.exists() || !directory.isDirectory) { + return 0L + } + + val files = directory.listFiles() ?: return 0L + for (file in files) { + size += if (file.isDirectory) { + calculateDirectorySize(file) + } else { + file.length() + } + } + } catch (e: Exception) { + Timber.w(e, "Error calculating directory size for ${directory.name}") + } + return size + } + suspend fun refreshSingleGame(gameId: String, context: Context): Result { return try { Timber.d("Fetching single game data for gameId: $gameId") @@ -313,6 +474,15 @@ class GOGManager @Inject constructor( supportDir.mkdirs() } + // Get expected download size from database for accurate progress tracking + val game = getGameById(gameId) + if (game != null && game.downloadSize > 0L) { + downloadInfo.setTotalExpectedBytes(game.downloadSize) + Timber.d("[Download] Set total expected bytes: ${game.downloadSize} (${game.downloadSize / 1_000_000} MB)") + } else { + Timber.w("[Download] Could not determine download size for game $gameId") + } + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) val numericGameId = ContainerUtils.extractGameIdFromContainerId(gameId).toString() @@ -320,6 +490,7 @@ class GOGManager @Inject constructor( // Initialize progress and emit download started event downloadInfo.setProgress(0.0f) + downloadInfo.setActive(true) app.gamenative.PluviaApp.events.emitJava( app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId.toIntOrNull() ?: 0, true) ) @@ -340,14 +511,19 @@ class GOGManager @Inject constructor( downloadInfo.setProgress(1.0f) Timber.i("[Download] GOGDL download completed successfully for game $gameId") + // Calculate actual disk size + val diskSize = calculateDirectorySize(File(installPath)) + Timber.d("[Download] Calculated install size: $diskSize bytes (${diskSize / 1_000_000} MB)") + // Update or create database entry var game = getGameById(gameId) if (game != null) { // Game exists - update install status - Timber.d("Updating existing game install status: isInstalled=true, installPath=$installPath") + Timber.d("Updating existing game install status: isInstalled=true, installPath=$installPath, installSize=$diskSize") val updatedGame = game.copy( isInstalled = true, - installPath = installPath + installPath = installPath, + installSize = diskSize ) updateGame(updatedGame) Timber.i("Updated GOG game install status in database for ${game.title}") @@ -361,7 +537,8 @@ class GOGManager @Inject constructor( if (game != null) { val updatedGame = game.copy( isInstalled = true, - installPath = installPath + installPath = installPath, + installSize = diskSize ) insertGame(updatedGame) Timber.i("Fetched and inserted GOG game ${game.title} with install status") diff --git a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt index 28a7eaf7f..9f854fa51 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt @@ -16,8 +16,31 @@ class ProgressCallback(private val downloadInfo: DownloadInfo) { fun update(percent: Float = 0f, downloadedMB: Float = 0f, totalMB: Float = 0f, downloadSpeedMBps: Float = 0f, eta: String = "") { try { val progress = (percent / 100.0f).coerceIn(0.0f, 1.0f) + + // Update byte-level progress for more accurate tracking + val downloadedBytes = (downloadedMB * 1_000_000).toLong() + val totalBytes = (totalMB * 1_000_000).toLong() + + // Set total bytes if we haven't already and it's available + if (totalBytes > 0 && downloadInfo.getTotalExpectedBytes() == 0L) { + downloadInfo.setTotalExpectedBytes(totalBytes) + } + + // Update bytes downloaded (delta from previous update) + val previousBytes = downloadInfo.getBytesDownloaded() + if (downloadedBytes > previousBytes) { + val deltaBytes = downloadedBytes - previousBytes + downloadInfo.updateBytesDownloaded(deltaBytes) + } + + // Also set percentage-based progress for compatibility downloadInfo.setProgress(progress) + // Update status message with ETA + if (eta.isNotEmpty() && eta != "00:00:00") { + downloadInfo.updateStatusMessage("ETA: $eta") + } + if (percent > 0f) { Timber.d("Download progress: %.1f%% (%.1f/%.1f MB) Speed: %.2f MB/s ETA: %s", percent, downloadedMB, totalMB, downloadSpeedMBps, eta) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index ca14a6ef0..7bd49ea47 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -233,15 +233,20 @@ class GOGService : Service() { if (result.isFailure) { Timber.e(result.exceptionOrNull(), "[Download] Failed for game $gameId") downloadInfo.setProgress(-1.0f) + downloadInfo.setActive(false) } else { Timber.i("[Download] Completed successfully for game $gameId") + downloadInfo.setProgress(1.0f) + downloadInfo.setActive(false) + // Remove from activeDownloads so UI knows download is complete + instance.activeDownloads.remove(gameId) } } catch (e: Exception) { Timber.e(e, "[Download] Exception for game $gameId") downloadInfo.setProgress(-1.0f) + downloadInfo.setActive(false) } finally { - // Keep in activeDownloads so UI can check status - Timber.d("[Download] Finished for game $gameId, progress: ${downloadInfo.getProgress()}") + Timber.d("[Download] Finished for game $gameId, progress: ${downloadInfo.getProgress()}, active: ${downloadInfo.isActive()}") } } 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 ff672baf4..c0bfdfe1d 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 @@ -225,6 +225,7 @@ internal fun AppScreenContent( downloadProgress: Float, hasPartialDownload: Boolean, isUpdatePending: Boolean, + downloadInfo: app.gamenative.data.DownloadInfo? = null, onDownloadInstallClick: () -> Unit, onPauseResumeClick: () -> Unit, onDeleteDownloadClick: () -> Unit, @@ -510,7 +511,7 @@ internal fun AppScreenContent( // Download progress section if (isDownloading) { - val downloadInfo = SteamService.getAppDownloadInfo(displayInfo.gameId) + // downloadInfo passed from BaseAppScreen based on game source val statusMessageFlow = downloadInfo?.getStatusMessageFlow() val statusMessageState = statusMessageFlow?.collectAsState(initial = statusMessageFlow.value) val statusMessage = statusMessageState?.value @@ -919,8 +920,7 @@ private fun Preview_AppScreen() { isDownloading = isDownloading, downloadProgress = .50f, hasPartialDownload = false, - isUpdatePending = false, - onDownloadInstallClick = { isDownloading = !isDownloading }, + isUpdatePending = false, downloadInfo = null, onDownloadInstallClick = { isDownloading = !isDownloading }, onPauseResumeClick = { }, onDeleteDownloadClick = { }, onUpdateClick = { }, 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 879a213ec..6cfdf6f72 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 @@ -585,6 +585,13 @@ abstract class BaseAppScreen { val optionsMenu = getOptionsMenu(context, libraryItem, onEditContainer, onBack, onClickPlay, 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.appId) + app.gamenative.data.GameSource.CUSTOM_GAME -> null // Custom games don't support downloads yet + } + DisposableEffect(libraryItem.appId) { val dispose = observeGameState( context = context, @@ -613,6 +620,7 @@ abstract class BaseAppScreen { downloadProgress = downloadProgressState, hasPartialDownload = hasPartialDownloadState, isUpdatePending = isUpdatePendingState, + downloadInfo = downloadInfo, onDownloadInstallClick = { onDownloadInstallClick(context, libraryItem, onClickPlay) 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 index 4a02bd7f3..607818bc7 100644 --- 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 @@ -87,6 +87,22 @@ class GOGAppScreen : BaseAppScreen() { Timber.tag(TAG).d("shouldShowInstallDialog: 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 @@ -132,6 +148,16 @@ class GOGAppScreen : BaseAppScreen() { } 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 + val displayInfo = GameDisplayInfo( name = game?.title ?: libraryItem.name, iconUrl = game?.iconUrl ?: libraryItem.iconHash, @@ -139,9 +165,12 @@ class GOGAppScreen : BaseAppScreen() { gameId = libraryItem.gameId, // Use gameId property which handles conversion appId = libraryItem.appId, releaseDate = 0L, // GOG uses string release dates, would need parsing - developer = game?.developer ?: "Unknown" + developer = game?.developer ?: "Unknown", + 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}") + Timber.tag(TAG).d("Returning GameDisplayInfo: name=${displayInfo.name}, iconUrl=${displayInfo.iconUrl}, heroImageUrl=${displayInfo.heroImageUrl}, developer=${displayInfo.developer}, installLocation=${displayInfo.installLocation}") return displayInfo } @@ -174,8 +203,9 @@ class GOGAppScreen : BaseAppScreen() { // For GOG games, appId is already the numeric game ID val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) val progress = downloadInfo?.getProgress() ?: 0f - val downloading = downloadInfo != null && progress in 0f..0.99f - Timber.tag(TAG).d("isDownloading: appId=${libraryItem.appId}, hasDownloadInfo=${downloadInfo != null}, progress=$progress, result=$downloading") + 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 } @@ -200,6 +230,7 @@ class GOGAppScreen : BaseAppScreen() { // Cancel ongoing download Timber.tag(TAG).i("Cancelling GOG download for: ${libraryItem.appId}") downloadInfo.cancel() + GOGService.cleanupDownload(libraryItem.appId) } else if (installed) { // Already installed: launch game Timber.tag(TAG).i("GOG game already installed, launching: ${libraryItem.appId}") @@ -273,6 +304,7 @@ class GOGAppScreen : BaseAppScreen() { if (isDownloading) { // Cancel/pause download Timber.tag(TAG).i("Pausing GOG download: ${libraryItem.appId}") + GOGService.cleanupDownload(libraryItem.appId) downloadInfo.cancel() } else { // Resume download (restart from beginning for now) @@ -292,6 +324,7 @@ class GOGAppScreen : BaseAppScreen() { if (isDownloading) { // Cancel download immediately if currently downloading Timber.tag(TAG).i("Cancelling active download for GOG game: ${libraryItem.appId}") + GOGService.cleanupDownload(libraryItem.appId) downloadInfo.cancel() android.widget.Toast.makeText( context, diff --git a/app/src/main/python/gogdl/dl/progressbar.py b/app/src/main/python/gogdl/dl/progressbar.py index 6cd0470e7..215ddc977 100644 --- a/app/src/main/python/gogdl/dl/progressbar.py +++ b/app/src/main/python/gogdl/dl/progressbar.py @@ -41,7 +41,7 @@ def loop(self): break except: pass - + self.print_progressbar() self.downloaded_since_last_update = self.decompressed_since_last_update = 0 self.written_since_last_update = self.read_since_last_update = 0 @@ -59,7 +59,7 @@ def loop(self): self.read_since_last_update += r except queue.Empty: pass - + self.print_progressbar() def print_progressbar(self): percentage = (self.written_total / self.total) * 100 @@ -69,7 +69,7 @@ def print_progressbar(self): runtime_s = int((running_time % 3600) % 60) print_time_delta = time() - self.last_update - + current_dl_speed = 0 current_decompress = 0 if print_time_delta: @@ -113,6 +113,20 @@ def print_progressbar(self): f"{current_r_speed / 1024 / 1024:.02f} MiB/s (read)" ) + # Call Android progress callback if available + try: + import gogdl + if hasattr(gogdl, '_progress_callback'): + callback = gogdl._progress_callback + downloaded_mb = self.downloaded / 1024 / 1024 + total_mb = self.total / 1024 / 1024 + speed_mbps = current_dl_speed / 1024 / 1024 + eta_str = f"{estimated_h:02d}:{estimated_m:02d}:{estimated_s:02d}" + callback.update(percentage, downloaded_mb, total_mb, speed_mbps, eta_str) + except Exception as e: + # Silently ignore if callback not available (e.g. running standalone) + pass + self.last_update = time() def update_downloaded_size(self, addition): From 431a818d815ca5b91ee535852753cf522ca09c4e Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 14:35:59 +0000 Subject: [PATCH 047/122] File dir fixes, detection of already installed gog games and progress bar optimisations --- .../java/app/gamenative/db/dao/GOGGameDao.kt | 3 ++- .../app/gamenative/service/gog/GOGManager.kt | 8 +++++++- .../gamenative/service/gog/GOGPythonBridge.kt | 5 +++-- .../app/gamenative/ui/model/LibraryViewModel.kt | 6 ++---- app/src/main/python/gogdl/dl/progressbar.py | 16 ++++++++++------ 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index 4e685b5f8..aa93f77cf 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -67,10 +67,11 @@ interface GOGGameDao { games.forEach { newGame -> val existingGame = getById(newGame.id) if (existingGame != null) { - // Preserve installation status and path from existing game + // 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, ) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index b6c43d8bd..d7d49d6a7 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -468,7 +468,13 @@ class GOGManager @Inject constructor( } // Create support directory for redistributables - val supportDir = File(installDir.parentFile, "gog-support") + val parentDir = installDir.parentFile + val supportDir = if (parentDir != null) { + File(parentDir, "gog-support") + } else { + Timber.w("[Download] installDir.parentFile is null for $installPath, using installDir as fallback parent") + File(installDir, "gog-support") + } if (!supportDir.exists()) { Timber.d("[Download] Creating support directory: ${supportDir.absolutePath}") supportDir.mkdirs() diff --git a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt index 9f854fa51..275307b0f 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt @@ -18,8 +18,9 @@ class ProgressCallback(private val downloadInfo: DownloadInfo) { val progress = (percent / 100.0f).coerceIn(0.0f, 1.0f) // Update byte-level progress for more accurate tracking - val downloadedBytes = (downloadedMB * 1_000_000).toLong() - val totalBytes = (totalMB * 1_000_000).toLong() + // GOGDL uses binary mebibytes (MiB), so convert using 1024*1024 not 1_000_000 + val downloadedBytes = (downloadedMB * 1024 * 1024).toLong() + val totalBytes = (totalMB * 1024 * 1024).toLong() // Set total bytes if we haven't already and it's available if (totalBytes > 0 && downloadInfo.getTotalExpectedBytes() == 0L) { 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 62f38f088..ab607e6a8 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -99,11 +99,9 @@ class LibraryViewModel @Inject constructor( // ownerIds = SteamService.familyMembers.ifEmpty { listOf(SteamService.userSteamId!!.accountID.toInt()) }, ).collect { apps -> Timber.tag("LibraryViewModel").d("Collecting ${apps.size} apps") - if (appList.size != apps.size) { // Don't filter if it's no change appList = apps - onFilterApps(paginationCurrentPage) } } @@ -114,10 +112,10 @@ class LibraryViewModel @Inject constructor( gogGameDao.getAll().collect { games -> Timber.tag("LibraryViewModel").d("Collecting ${games.size} GOG games") - val sizeChanged = gogGameList.size != games.size + val hasChanges = gogGameList.size != games.size || gogGameList != games gogGameList = games - if (sizeChanged) { + if (hasChanges) { onFilterApps(paginationCurrentPage) } } diff --git a/app/src/main/python/gogdl/dl/progressbar.py b/app/src/main/python/gogdl/dl/progressbar.py index 215ddc977..5d759610e 100644 --- a/app/src/main/python/gogdl/dl/progressbar.py +++ b/app/src/main/python/gogdl/dl/progressbar.py @@ -39,8 +39,8 @@ def loop(self): self.logger.info(f"Progress reporting cancelled for game {self.game_id}") self.completed = True break - except: - pass + except (ImportError, AttributeError) as e: + self.logger.debug(f"Failed to check cancellation flag: {e}") self.print_progressbar() self.downloaded_since_last_update = self.decompressed_since_last_update = 0 @@ -48,13 +48,13 @@ def loop(self): timestamp = time() while not self.completed and (time() - timestamp) < 1: try: - dl, dec = self.speed_queue.get(timeout=1) + dl, dec = self.speed_queue.get(timeout=0.5) self.downloaded_since_last_update += dl self.decompressed_since_last_update += dec except queue.Empty: pass try: - wr, r = self.write_queue.get(timeout=1) + wr, r = self.write_queue.get(timeout=0.5) self.written_since_last_update += wr self.read_since_last_update += r except queue.Empty: @@ -62,7 +62,11 @@ def loop(self): self.print_progressbar() def print_progressbar(self): - percentage = (self.written_total / self.total) * 100 + # Guard against division by zero when total is 0 + if self.total: + percentage = (self.written_total / self.total) * 100 + else: + percentage = 0 running_time = time() - self.started_at runtime_h = int(running_time // 3600) runtime_m = int((running_time % 3600) // 60) @@ -85,7 +89,7 @@ def print_progressbar(self): estimated_time = (100 * running_time) / percentage - running_time else: estimated_time = 0 - estimated_time = max(estimated_time, 0) # Cap to 0 + estimated_time = max(estimated_time, 0) # Floor at 0 estimated_h = int(estimated_time // 3600) estimated_time = estimated_time % 3600 From 3c4695799a6a29752e20e114913625700e9b3074 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 15:25:29 +0000 Subject: [PATCH 048/122] Fixed install size when game finishes downloading. --- .../screen/library/appscreen/GOGAppScreen.kt | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) 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 index 607818bc7..c8f9d41dd 100644 --- 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 @@ -113,7 +113,22 @@ class GOGAppScreen : BaseAppScreen() { Timber.tag(TAG).d("getGameDisplayInfo: appId=${libraryItem.appId}, name=${libraryItem.name}") // For GOG games, appId is already the numeric game ID (no prefix) val gameId = libraryItem.appId - val gogGame = remember(gameId) { + + // 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) + } + + val gogGame = remember(gameId, refreshTrigger) { val game = GOGService.getGOGGameOf(gameId) if (game != null) { Timber.tag(TAG).d(""" @@ -158,14 +173,32 @@ class GOGAppScreen : BaseAppScreen() { 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 = 0L, // GOG uses string release dates, would need parsing - developer = game?.developer ?: "Unknown", + releaseDate = releaseDateTimestamp, + developer = game?.developer?.takeIf { it.isNotEmpty() } ?: "", // GOG API doesn't provide this installLocation = game?.installPath?.takeIf { it.isNotEmpty() }, sizeOnDisk = sizeOnDisk, sizeFromStore = sizeFromStore From 5d2b64e889bd857f1528396c5b6f327e265ca649 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 20:27:45 +0000 Subject: [PATCH 049/122] Fixed issue where GOGService did not start after logging into GOG. Fixed issue where Download Progress was not changing due to refactor. --- .../screen/library/appscreen/GOGAppScreen.kt | 29 +++++++++++++++++-- .../screen/settings/SettingsGroupInterface.kt | 8 +++++ 2 files changed, 34 insertions(+), 3 deletions(-) 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 index c8f9d41dd..218378694 100644 --- 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 @@ -526,6 +526,7 @@ class GOGAppScreen : BaseAppScreen() { ): (() -> 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 -> @@ -536,11 +537,33 @@ class GOGAppScreen : BaseAppScreen() { // Download started - attach progress listener // For GOG games, appId is already the numeric game ID val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) - downloadInfo?.addProgressListener { progress -> - onProgressChanged(progress) + 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 + // Download stopped/completed - clean up listener + currentProgressListener?.let { listener -> + val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + downloadInfo?.removeProgressListener(listener) + currentProgressListener = null + } onHasPartialDownloadChanged?.invoke(false) } onStateChanged() 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 46ed9d3dd..79b22b592 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 @@ -149,6 +149,10 @@ fun SettingsGroupInterface( if (result.isSuccess) { timber.log.Timber.i("[SettingsGOG]: ✓ Authentication successful!") + // Start GOGService before syncing so service is running for operations + timber.log.Timber.i("[SettingsGOG]: Starting GOGService") + GOGService.start(context) + // Sync the library using refreshLibrary which handles database updates timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") val syncResult = GOGService.refreshLibrary(context) @@ -602,6 +606,10 @@ fun SettingsGroupInterface( if (result.isSuccess) { timber.log.Timber.i("[SettingsGOG]: ✓ Manual authentication successful!") + // Start GOGService before syncing so service is running for operations + timber.log.Timber.i("[SettingsGOG]: Starting GOGService") + GOGService.start(context) + // Sync the library timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") val syncResult = GOGService.refreshLibrary(context) From a149486bd7f289e432ba970168d6f2a223d2ec12 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 20:34:14 +0000 Subject: [PATCH 050/122] Updated text to give better instructions --- .../app/gamenative/ui/component/dialog/GOGLoginDialog.kt | 5 +++++ app/src/main/res/values/strings.xml | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) 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 index 8e53e7d25..c53636f51 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -91,6 +91,11 @@ fun GOGLoginDialog( HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) // Manual code entry fallback + Text( + text = stringResource(R.string.gog_login_auth_example), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) Text( text = stringResource(R.string.gog_login_manual_entry), style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 211ae7a35..f52be196c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -936,7 +936,8 @@ Sign in to GOG Sign in with your GOG account: - Tap \'Open GOG Login\' and sign in. Once logged in, please take the token from the success URL and paste it below + Tap \'Open GOG Login\' and sign in. Once logged in, please take the code from the success URL and paste it below + Example: https://embed.gog.com/on_login_success?origin=client&code=COPY_THIS_CODE Open GOG Login Paste your code below Authorization Code From 7356f34b5d79bd789ba872384436f146e80aeba7 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 18 Dec 2025 23:47:21 +0000 Subject: [PATCH 051/122] strings, memory leak fixx and reduplication of code in settings --- .../screen/settings/SettingsGroupInterface.kt | 215 ++++++++++-------- app/src/main/res/values/strings.xml | 12 + 2 files changed, 130 insertions(+), 97 deletions(-) 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 79b22b592..9b0a32b97 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 @@ -58,6 +58,7 @@ 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 @@ -66,6 +67,75 @@ 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 before syncing so service is running for operations + Timber.i("[SettingsGOG]: Starting GOGService") + GOGService.start(context) + + // Sync the library using refreshLibrary which handles database updates + Timber.i("[SettingsGOG]: Syncing GOG library...") + val syncResult = GOGService.refreshLibrary(context) + + if (syncResult.isSuccess) { + val count = syncResult.getOrNull() ?: 0 + Timber.i("[SettingsGOG]: ✓ Synced $count games from GOG library") + onSuccess(count) + } else { + val error = syncResult.exceptionOrNull()?.message ?: "Failed to sync library" + Timber.w("[SettingsGOG]: Failed to sync library: $error") + // Don't fail authentication if library sync fails + 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( @@ -134,56 +204,34 @@ fun SettingsGroupInterface( val coroutineScope = rememberCoroutineScope() // Listen for GOG OAuth callback - LaunchedEffect(Unit) { - timber.log.Timber.d("[SettingsGOG]: Setting up GOG auth code event listener") - app.gamenative.PluviaApp.events.on { event -> - timber.log.Timber.i("[SettingsGOG]: ✓ Received GOG auth code event! Code: ${event.authCode.take(20)}...") - gogLoginLoading = true - gogLoginError = null + 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 { - try { - timber.log.Timber.d("[SettingsGOG]: Starting authentication...") - val result = app.gamenative.service.gog.GOGService.authenticateWithCode(context, event.authCode) - - if (result.isSuccess) { - timber.log.Timber.i("[SettingsGOG]: ✓ Authentication successful!") - - // Start GOGService before syncing so service is running for operations - timber.log.Timber.i("[SettingsGOG]: Starting GOGService") - GOGService.start(context) - - // Sync the library using refreshLibrary which handles database updates - timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") - val syncResult = GOGService.refreshLibrary(context) - - if (syncResult.isSuccess) { - val count = syncResult.getOrNull() ?: 0 - timber.log.Timber.i("[SettingsGOG]: ✓ Synced $count games from GOG library") - gogLibraryGameCount = count - } else { - val error = syncResult.exceptionOrNull()?.message ?: "Failed to sync library" - timber.log.Timber.w("[SettingsGOG]: Failed to sync library: $error") - // Don't fail authentication if library sync fails - } - - gogLoginLoading = false + handleGogAuthentication( + context = context, + authCode = event.authCode, + coroutineScope = coroutineScope, + onLoadingChange = { gogLoginLoading = it }, + onError = { gogLoginError = it }, + onSuccess = { count -> + gogLibraryGameCount = count gogLoginSuccess = true - openGOGLoginDialog = false - } else { - val error = result.exceptionOrNull()?.message ?: "Authentication failed" - timber.log.Timber.e("[SettingsGOG]: Authentication failed: $error") - gogLoginLoading = false - gogLoginError = error - } - } catch (e: Exception) { - timber.log.Timber.e(e, "[SettingsGOG]: Authentication exception: ${e.message}") - gogLoginLoading = false - gogLoginError = e.message ?: "Authentication failed" - } + }, + onDialogClose = { openGOGLoginDialog = false } + ) } } - timber.log.Timber.d("[SettingsGOG]: GOG auth code event listener registered") + + PluviaApp.events.on(onGOGAuthCodeReceived) + Timber.d("[SettingsGOG]: GOG auth code event listener registered") + + onDispose { + PluviaApp.events.off(onGOGAuthCodeReceived) + Timber.d("[SettingsGOG]: GOG auth code event listener unregistered") + } } SettingsGroup(title = { Text(text = stringResource(R.string.settings_interface_title)) }) { @@ -255,11 +303,11 @@ fun SettingsGroupInterface( } // GOG Integration - SettingsGroup(title = { Text(text = "GOG Integration") }) { + SettingsGroup(title = { Text(text = stringResource(R.string.gog_integration_title)) }) { SettingsMenuLink( colors = settingsTileColorsAlt(), - title = { Text(text = "GOG Login") }, - subtitle = { Text(text = "Sign in to your GOG account") }, + 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 @@ -269,14 +317,14 @@ fun SettingsGroupInterface( SettingsMenuLink( colors = settingsTileColorsAlt(), - title = { Text(text = "Sync GOG Library") }, + title = { Text(text = stringResource(R.string.gog_settings_sync_title)) }, subtitle = { Text( text = when { - gogLibrarySyncing -> "Syncing..." - gogLibrarySyncError != null -> "Error: ${gogLibrarySyncError}" - gogLibrarySyncSuccess -> "✓ Synced ${gogLibraryGameCount} games" - else -> "Fetch your GOG games library" + gogLibrarySyncing -> stringResource(R.string.gog_settings_sync_subtitle_syncing) + gogLibrarySyncError != null -> stringResource(R.string.gog_settings_sync_subtitle_error, gogLibrarySyncError!!) + gogLibrarySyncSuccess -> stringResource(R.string.gog_settings_sync_subtitle_success, gogLibraryGameCount) + else -> stringResource(R.string.gog_settings_sync_subtitle_default) } ) }, @@ -288,7 +336,7 @@ fun SettingsGroupInterface( coroutineScope.launch { try { - timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") + Timber.i("[SettingsGOG]: Syncing GOG library...") // Use GOGService.refreshLibrary() which handles everything val result = GOGService.refreshLibrary(context) @@ -296,18 +344,18 @@ fun SettingsGroupInterface( if (result.isSuccess) { val count = result.getOrNull() ?: 0 gogLibraryGameCount = count - timber.log.Timber.i("[SettingsGOG]: ✓ Successfully synced $count games from GOG") + Timber.i("[SettingsGOG]: ✓ Successfully synced $count games from GOG") gogLibrarySyncing = false gogLibrarySyncSuccess = true } else { val error = result.exceptionOrNull()?.message ?: "Failed to sync library" - timber.log.Timber.e("[SettingsGOG]: Library sync failed: $error") + Timber.e("[SettingsGOG]: Library sync failed: $error") gogLibrarySyncing = false gogLibrarySyncError = error } } catch (e: Exception) { - timber.log.Timber.e(e, "[SettingsGOG]: Library sync exception: ${e.message}") + Timber.e(e, "[SettingsGOG]: Library sync exception: ${e.message}") gogLibrarySyncing = false gogLibrarySyncError = e.message ?: "Sync failed" } @@ -596,46 +644,19 @@ fun SettingsGroupInterface( gogLoginLoading = false }, onAuthCodeClick = { authCode -> - gogLoginLoading = true - gogLoginError = null coroutineScope.launch { - try { - timber.log.Timber.d("[SettingsGOG]: Starting manual authentication...") - val result = GOGService.authenticateWithCode(context, authCode) - - if (result.isSuccess) { - timber.log.Timber.i("[SettingsGOG]: ✓ Manual authentication successful!") - - // Start GOGService before syncing so service is running for operations - timber.log.Timber.i("[SettingsGOG]: Starting GOGService") - GOGService.start(context) - - // Sync the library - timber.log.Timber.i("[SettingsGOG]: Syncing GOG library...") - val syncResult = GOGService.refreshLibrary(context) - - if (syncResult.isSuccess) { - val count = syncResult.getOrNull() ?: 0 - timber.log.Timber.i("[SettingsGOG]: ✓ Synced $count games from GOG library") - gogLibraryGameCount = count - } else { - val error = syncResult.exceptionOrNull()?.message ?: "Failed to sync library" - timber.log.Timber.w("[SettingsGOG]: Failed to sync library: $error") - // Don't fail authentication if library sync fails - } - - gogLoginLoading = false + handleGogAuthentication( + context = context, + authCode = authCode, + coroutineScope = coroutineScope, + onLoadingChange = { gogLoginLoading = it }, + onError = { gogLoginError = it }, + onSuccess = { count -> + gogLibraryGameCount = count gogLoginSuccess = true - openGOGLoginDialog = false - } else { - gogLoginLoading = false - gogLoginError = result.exceptionOrNull()?.message ?: "Authentication failed" - } - } catch (e: Exception) { - timber.log.Timber.e(e, "[SettingsGOG]: Manual authentication exception: ${e.message}") - gogLoginLoading = false - gogLoginError = e.message ?: "Authentication failed" - } + }, + onDialogClose = { openGOGLoginDialog = false } + ) } }, isLoading = gogLoginLoading, @@ -650,8 +671,8 @@ fun SettingsGroupInterface( onConfirmClick = { gogLoginSuccess = false }, confirmBtnText = "OK", icon = Icons.Default.Login, - title = "Login Successful", - message = "You are now signed in to GOG." + title = stringResource(R.string.gog_login_success_title), + message = stringResource(R.string.gog_login_success_message) ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f52be196c..3a6c3c066 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -933,6 +933,18 @@ No containers are currently using this version. These containers will no longer work if you proceed: + + GOG Integration + GOG Login + Sign in to your GOG account + Sync GOG Library + Syncing… + Error: %1$s + ✓ Synced %1$d games + Fetch your GOG games library + Login Successful + You are now signed in to GOG. + Sign in to GOG Sign in with your GOG account: From f9ad4ebe13a7652b2a0263c8bf7ab34f6ed04313 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 19 Dec 2025 19:30:32 +0000 Subject: [PATCH 052/122] Updated the dialog to parse the code using regex if they paste the entire url --- .../ui/component/dialog/GOGLoginDialog.kt | 21 ++++++++++++++++++- app/src/main/res/values/strings.xml | 8 +++---- 2 files changed, 24 insertions(+), 5 deletions(-) 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 index c53636f51..d2b5e40e6 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -20,6 +20,22 @@ import android.content.Intent import android.net.Uri import android.widget.Toast +/** + * Extract authorization code from various input formats: + * - Full URL: https://embed.gog.com/on_login_success?origin=client&code=ABC123... + * - Just code: ABC123... + */ +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 * @@ -134,7 +150,10 @@ fun GOGLoginDialog( TextButton( onClick = { if (authCode.isNotBlank()) { - onAuthCodeClick(authCode) + val extractedCode = extractCodeFromInput(authCode) + if (extractedCode.isNotEmpty()) { + onAuthCodeClick(extractedCode) + } } }, enabled = !isLoading && authCode.isNotBlank() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a6c3c066..d1966e8ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -948,12 +948,12 @@ Sign in to GOG Sign in with your GOG account: - Tap \'Open GOG Login\' and sign in. Once logged in, please take the code from the success URL and paste it below - Example: https://embed.gog.com/on_login_success?origin=client&code=COPY_THIS_CODE + Tap \'Open GOG Login\' and sign in. Once logged in, please take the code from the success URL OR copy the entire URL and paste it below + Example: https://embed.gog.com/on_login_success?origin=client&code=AUTH_CODE_HERE Open GOG Login Paste your code below - Authorization Code - Paste code here if needed… + Authorization Code or login success URL + Paste code or url here Login Cancel Could not open browser From ba33480f0dad815dbb39f1b0153f4f93bfec6af7 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 19 Dec 2025 19:31:24 +0000 Subject: [PATCH 053/122] Updated the strings to ensure that we can instruct user to paste in the URL. --- .../app/gamenative/ui/component/dialog/GOGLoginDialog.kt | 5 ----- app/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) 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 index d2b5e40e6..e8e16a9ee 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -20,11 +20,6 @@ import android.content.Intent import android.net.Uri import android.widget.Toast -/** - * Extract authorization code from various input formats: - * - Full URL: https://embed.gog.com/on_login_success?origin=client&code=ABC123... - * - Just code: ABC123... - */ private fun extractCodeFromInput(input: String): String { val trimmed = input.trim() // Check if it's a URL with code parameter diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1966e8ac..b7363adce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -934,10 +934,10 @@ These containers will no longer work if you proceed: - GOG Integration + GOG Integration (Alpha) GOG Login Sign in to your GOG account - Sync GOG Library + Manually Sync GOG Library Syncing… Error: %1$s ✓ Synced %1$d games From d99f2ea83064af2a656b5a79cd59411b1afb668c Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 19 Dec 2025 19:42:12 +0000 Subject: [PATCH 054/122] Refactored to run a background sync if the gog service start function is triggered, even if it's running. --- .../app/gamenative/service/gog/GOGService.kt | 57 +++++++++++++------ .../screen/settings/SettingsGroupInterface.kt | 21 ++----- 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 7bd49ea47..73e0da7a2 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -31,6 +31,8 @@ import javax.inject.Inject class GOGService : Service() { companion object { + private const val ACTION_SYNC_LIBRARY = "app.gamenative.GOG_SYNC_LIBRARY" + private var instance: GOGService? = null // Sync tracking variables @@ -41,8 +43,13 @@ class GOGService : Service() { get() = instance != null fun start(context: Context) { + val intent = Intent(context, GOGService::class.java) if (!isRunning) { - val intent = Intent(context, GOGService::class.java) + Timber.d("[GOGService] Starting service for first time") + context.startForegroundService(intent) + } else { + Timber.d("[GOGService] Service already running, triggering sync") + intent.action = ACTION_SYNC_LIBRARY context.startForegroundService(intent) } } @@ -100,6 +107,16 @@ class GOGService : Service() { fun isSyncInProgress(): Boolean = syncInProgress + /** + * Trigger a background library sync + * Can be called even if service is already running + */ + fun triggerLibrarySync(context: Context) { + val intent = Intent(context, GOGService::class.java) + intent.action = ACTION_SYNC_LIBRARY + context.startForegroundService(intent) + } + fun getInstance(): GOGService? = instance // ========================================================================== @@ -290,29 +307,35 @@ class GOGService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Timber.d("GOGService.onStartCommand()") + Timber.d("[GOGService] onStartCommand() - action: ${intent?.action}") // Start as foreground service val notification = notificationHelper.createForegroundNotification("GOG Service running...") startForeground(2, notification) // Use different ID than SteamService (which uses 1) - // Start background library sync automatically when service starts - 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 started successfully") + // Start background library sync if service is starting or sync action requested + if (intent?.action == ACTION_SYNC_LIBRARY || backgroundSyncJob == null || !backgroundSyncJob!!.isActive) { + Timber.i("[GOGService] Triggering 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 started successfully") + } + } catch (e: Exception) { + Timber.e(e, "[GOGService]: Exception starting background sync") + } finally { + setSyncInProgress(false) } - } catch (e: Exception) { - Timber.e(e, "[GOGService]: Exception starting background sync") - } finally { - setSyncInProgress(false) } + } else { + Timber.d("[GOGService] Background sync already in progress, skipping") } return START_STICKY 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 9b0a32b97..dae296d3d 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 @@ -103,25 +103,12 @@ private suspend fun handleGogAuthentication( if (result.isSuccess) { Timber.i("[SettingsGOG]: ✓ Authentication successful!") - // Start GOGService before syncing so service is running for operations - Timber.i("[SettingsGOG]: Starting GOGService") + // Start GOGService which will automatically trigger background library sync + Timber.i("[SettingsGOG]: Starting GOGService (will sync library in background)") GOGService.start(context) - // Sync the library using refreshLibrary which handles database updates - Timber.i("[SettingsGOG]: Syncing GOG library...") - val syncResult = GOGService.refreshLibrary(context) - - if (syncResult.isSuccess) { - val count = syncResult.getOrNull() ?: 0 - Timber.i("[SettingsGOG]: ✓ Synced $count games from GOG library") - onSuccess(count) - } else { - val error = syncResult.exceptionOrNull()?.message ?: "Failed to sync library" - Timber.w("[SettingsGOG]: Failed to sync library: $error") - // Don't fail authentication if library sync fails - onSuccess(0) - } - + // Authentication succeeded - service will handle library sync in background + onSuccess(0) onLoadingChange(false) onDialogClose() } else { From d7c0a050d68ee2810e823e7bad21d5bd4921b004 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 19 Dec 2025 20:00:12 +0000 Subject: [PATCH 055/122] Added message that we will sync the library for GOG. --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b7363adce..0facca1c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -943,7 +943,7 @@ ✓ Synced %1$d games Fetch your GOG games library Login Successful - You are now signed in to GOG. + You are now signed in to GOG.\nWe will now sync your library in the background. Sign in to GOG From 01699fc8d243553c4cf8fcf3c9c8e42ec7aa5871 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 19 Dec 2025 20:02:47 +0000 Subject: [PATCH 056/122] Fix issue where UI didn't update if the download got broken. --- app/src/main/java/app/gamenative/service/gog/GOGService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 73e0da7a2..a57aeb735 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -255,14 +255,15 @@ class GOGService : Service() { Timber.i("[Download] Completed successfully for game $gameId") downloadInfo.setProgress(1.0f) downloadInfo.setActive(false) - // Remove from activeDownloads so UI knows download is complete - instance.activeDownloads.remove(gameId) } } catch (e: Exception) { Timber.e(e, "[Download] Exception for game $gameId") downloadInfo.setProgress(-1.0f) downloadInfo.setActive(false) } finally { + // Remove from activeDownloads for both success and failure + // so UI knows download is complete and to prevent stale entries + instance.activeDownloads.remove(gameId) Timber.d("[Download] Finished for game $gameId, progress: ${downloadInfo.getProgress()}, active: ${downloadInfo.isActive()}") } } From c87d0acf980788bcca52a4c1b540f9e8601dd0f7 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sat, 20 Dec 2025 08:51:11 +0000 Subject: [PATCH 057/122] Fixed prefixing and reduced timeout on sending progress bar updates to the UI --- .../java/app/gamenative/data/LibraryItem.kt | 8 +-- .../app/gamenative/service/gog/GOGManager.kt | 2 + .../gamenative/ui/model/LibraryViewModel.kt | 2 +- .../screen/library/appscreen/BaseAppScreen.kt | 2 +- .../screen/library/appscreen/GOGAppScreen.kt | 57 ++++++++++--------- .../library/components/LibraryAppItem.kt | 4 +- .../app/gamenative/utils/ContainerUtils.kt | 6 +- app/src/main/python/gogdl/dl/progressbar.py | 4 +- 8 files changed, 44 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/LibraryItem.kt b/app/src/main/java/app/gamenative/data/LibraryItem.kt index c6619959d..dbcd61632 100644 --- a/app/src/main/java/app/gamenative/data/LibraryItem.kt +++ b/app/src/main/java/app/gamenative/data/LibraryItem.kt @@ -59,12 +59,8 @@ data class LibraryItem( /** * Helper property to get the game ID as an integer - * For GOG games, appId is already the numeric ID without prefix - * For Steam/Custom games, extract the numeric part after the prefix + * For all game sources, extract the numeric part after the prefix */ val gameId: Int - get() = when (gameSource) { - GameSource.GOG -> appId.toIntOrNull() ?: 0 - else -> appId.removePrefix("${gameSource.name}_").toIntOrNull() ?: 0 - } + get() = appId.removePrefix("${gameSource.name}_").toIntOrNull() ?: 0 } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index d7d49d6a7..a2c92ebb3 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -174,6 +174,8 @@ class GOGManager @Inject constructor( } } + // TODO: Optimisation: Rather than grab ALL game details at once, we should batch process X amount at a time + // This will allow us to update the UI more often and be more dynamic. /** * Fetch the user's GOG library (list of owned games) * Returns a list of GOGGame objects with basic metadata 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 ab607e6a8..5f24e7f3d 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -345,7 +345,7 @@ class LibraryViewModel @Inject constructor( LibraryEntry( item = LibraryItem( index = 0, - appId = game.id, // Use plain game ID without GOG_ prefix + appId = "${GameSource.GOG.name}_${game.id}", // Use GOG_ prefix for consistency name = game.title, iconHash = game.imageUrl.ifEmpty { game.iconUrl }, // Use imageUrl (banner) with iconUrl as fallback isShared = false, 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 6cfdf6f72..3ff9128e4 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 @@ -588,7 +588,7 @@ abstract class BaseAppScreen { // 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.appId) + 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 } 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 index 218378694..9e1d1d8f6 100644 --- 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 @@ -111,8 +111,8 @@ class GOGAppScreen : BaseAppScreen() { libraryItem: LibraryItem ): GameDisplayInfo { Timber.tag(TAG).d("getGameDisplayInfo: appId=${libraryItem.appId}, name=${libraryItem.name}") - // For GOG games, appId is already the numeric game ID (no prefix) - val gameId = libraryItem.appId + // 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) } @@ -210,8 +210,8 @@ class GOGAppScreen : BaseAppScreen() { override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean { Timber.tag(TAG).d("isInstalled: checking appId=${libraryItem.appId}") return try { - // For GOG games, appId is already the numeric game ID - val installed = GOGService.isGameInstalled(libraryItem.appId) + // 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) { @@ -233,8 +233,8 @@ class GOGAppScreen : BaseAppScreen() { 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 - // For GOG games, appId is already the numeric game ID - val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + // 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 @@ -243,8 +243,8 @@ class GOGAppScreen : BaseAppScreen() { } override fun getDownloadProgress(context: Context, libraryItem: LibraryItem): Float { - // For GOG games, appId is already the numeric game ID - val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + // 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 @@ -252,8 +252,9 @@ class GOGAppScreen : BaseAppScreen() { override fun onDownloadInstallClick(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { Timber.tag(TAG).i("onDownloadInstallClick: appId=${libraryItem.appId}, name=${libraryItem.name}") - // For GOG games, appId is already the numeric game ID - val downloadInfo = GOGService.getDownloadInfo(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 installed = isInstalled(context, libraryItem) @@ -263,7 +264,7 @@ class GOGAppScreen : BaseAppScreen() { // Cancel ongoing download Timber.tag(TAG).i("Cancelling GOG download for: ${libraryItem.appId}") downloadInfo.cancel() - GOGService.cleanupDownload(libraryItem.appId) + GOGService.cleanupDownload(gameId) } else if (installed) { // Already installed: launch game Timber.tag(TAG).i("GOG game already installed, launching: ${libraryItem.appId}") @@ -280,7 +281,7 @@ class GOGAppScreen : BaseAppScreen() { * Delegates to GOGService/GOGManager for proper service layer separation */ private fun performDownload(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { - val gameId = libraryItem.appId + val gameId = libraryItem.gameId.toString() Timber.i("Starting GOG game download: ${libraryItem.appId}") CoroutineScope(Dispatchers.IO).launch { try { @@ -329,15 +330,16 @@ class GOGAppScreen : BaseAppScreen() { override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) { Timber.tag(TAG).i("onPauseResumeClick: appId=${libraryItem.appId}") - // For GOG games, appId is already the numeric game ID - val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + // GOGService expects numeric gameId + val gameId = libraryItem.gameId.toString() + val downloadInfo = GOGService.getDownloadInfo(gameId) val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f Timber.tag(TAG).d("onPauseResumeClick: appId=${libraryItem.appId}, isDownloading=$isDownloading") if (isDownloading) { // Cancel/pause download Timber.tag(TAG).i("Pausing GOG download: ${libraryItem.appId}") - GOGService.cleanupDownload(libraryItem.appId) + GOGService.cleanupDownload(gameId) downloadInfo.cancel() } else { // Resume download (restart from beginning for now) @@ -348,8 +350,9 @@ class GOGAppScreen : BaseAppScreen() { override fun onDeleteDownloadClick(context: Context, libraryItem: LibraryItem) { Timber.tag(TAG).i("onDeleteDownloadClick: appId=${libraryItem.appId}") - // For GOG games, appId is already the numeric game ID - val downloadInfo = GOGService.getDownloadInfo(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") @@ -357,7 +360,7 @@ class GOGAppScreen : BaseAppScreen() { if (isDownloading) { // Cancel download immediately if currently downloading Timber.tag(TAG).i("Cancelling active download for GOG game: ${libraryItem.appId}") - GOGService.cleanupDownload(libraryItem.appId) + GOGService.cleanupDownload(gameId) downloadInfo.cancel() android.widget.Toast.makeText( context, @@ -431,8 +434,8 @@ class GOGAppScreen : BaseAppScreen() { override fun getInstallPath(context: Context, libraryItem: LibraryItem): String? { Timber.tag(TAG).d("getInstallPath: appId=${libraryItem.appId}") return try { - // For GOG games, appId is already the numeric game ID - val path = GOGService.getInstallPath(libraryItem.appId) + // 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) { @@ -535,8 +538,8 @@ class GOGAppScreen : BaseAppScreen() { Timber.tag(TAG).d("[OBSERVE] Download status changed for ${libraryItem.appId}, isDownloading=${event.isDownloading}") if (event.isDownloading) { // Download started - attach progress listener - // For GOG games, appId is already the numeric game ID - val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + // GOGService expects numeric gameId + val downloadInfo = GOGService.getDownloadInfo(libraryItem.gameId.toString()) if (downloadInfo != null) { // Remove previous listener if exists currentProgressListener?.let { listener -> @@ -560,7 +563,7 @@ class GOGAppScreen : BaseAppScreen() { } else { // Download stopped/completed - clean up listener currentProgressListener?.let { listener -> - val downloadInfo = GOGService.getDownloadInfo(libraryItem.appId) + val downloadInfo = GOGService.getDownloadInfo(libraryItem.gameId.toString()) downloadInfo?.removeProgressListener(listener) currentProgressListener = null } @@ -623,8 +626,8 @@ class GOGAppScreen : BaseAppScreen() { } // Show install confirmation dialog if (showInstallDialog) { - // For GOG games, appId is already the numeric game ID - val gameId = libraryItem.appId + // GOGService expects numeric gameId + val gameId = libraryItem.gameId.toString() val gogGame = remember(gameId) { GOGService.getGOGGameOf(gameId) } @@ -674,8 +677,8 @@ class GOGAppScreen : BaseAppScreen() { // Show uninstall confirmation dialog if (showUninstallDialog) { - // For GOG games, appId is already the numeric game ID - val gameId = libraryItem.appId + // GOGService expects numeric gameId + val gameId = libraryItem.gameId.toString() val gogGame = remember(gameId) { GOGService.getGOGGameOf(gameId) } 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 b66185bf0..2b3eb04d8 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 @@ -314,7 +314,7 @@ internal fun AppItem( var isInstalled by remember(appInfo.appId, appInfo.gameSource) { when (appInfo.gameSource) { GameSource.STEAM -> mutableStateOf(SteamService.isAppInstalled(appInfo.gameId)) - GameSource.GOG -> mutableStateOf(GOGService.isGameInstalled(appInfo.appId)) + GameSource.GOG -> mutableStateOf(GOGService.isGameInstalled(appInfo.gameId.toString())) GameSource.CUSTOM_GAME -> mutableStateOf(true) // Custom Games are always considered installed else -> mutableStateOf(false) } @@ -325,7 +325,7 @@ internal fun AppItem( // Refresh just completed, check installation status isInstalled = when (appInfo.gameSource) { GameSource.STEAM -> SteamService.isAppInstalled(appInfo.gameId) - GameSource.GOG -> GOGService.isGameInstalled(appInfo.appId) + GameSource.GOG -> GOGService.isGameInstalled(appInfo.gameId.toString()) GameSource.CUSTOM_GAME -> true else -> false } diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index c76dff67f..14c9a7bea 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -926,7 +926,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 @@ -950,13 +952,13 @@ object ContainerUtils { /** * Extracts the game source from a container ID string - * Note: GOG games use plain numeric IDs without prefix */ fun extractGameSourceFromContainerId(containerId: String): GameSource { return when { containerId.startsWith("STEAM_") -> GameSource.STEAM containerId.startsWith("CUSTOM_GAME_") -> GameSource.CUSTOM_GAME - // GOG games use plain numeric IDs - check if it's just a number + containerId.startsWith("GOG_") -> GameSource.GOG + // Legacy fallback for old GOG containers without prefix (numeric only) containerId.toIntOrNull() != null -> GameSource.GOG // Add other platforms here.. else -> GameSource.STEAM // default fallback diff --git a/app/src/main/python/gogdl/dl/progressbar.py b/app/src/main/python/gogdl/dl/progressbar.py index 5d759610e..47271afb1 100644 --- a/app/src/main/python/gogdl/dl/progressbar.py +++ b/app/src/main/python/gogdl/dl/progressbar.py @@ -48,13 +48,13 @@ def loop(self): timestamp = time() while not self.completed and (time() - timestamp) < 1: try: - dl, dec = self.speed_queue.get(timeout=0.5) + dl, dec = self.speed_queue.get(timeout=0.3) self.downloaded_since_last_update += dl self.decompressed_since_last_update += dec except queue.Empty: pass try: - wr, r = self.write_queue.get(timeout=0.5) + wr, r = self.write_queue.get(timeout=0.3) self.written_since_last_update += wr self.read_since_last_update += r except queue.Empty: From 63dafe735ca2d1135b66d1235c3e8a1c72775583 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 21 Dec 2025 18:53:59 +0000 Subject: [PATCH 058/122] Updated fix regarding the Steam Service that broke the depot downlader (Temporary fix until someone fixes it in the upstream). --- app/src/main/java/app/gamenative/service/SteamService.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index cdf7c9819..388351f0e 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -1084,10 +1084,7 @@ class SteamService : Service(), IChallengeUrlChanged { licenses, debug = false, androidEmulation = true, - maxDownloads = maxDownloads, - maxDecompress = maxDecompress, - maxFileWrites = maxFileWrites, - parentJob = coroutineContext[Job] + maxDownloads = maxDownloads ) // Create listener From 9c5dd46086c85bef4f34b941975337c09523eea0 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 21 Dec 2025 19:26:49 +0000 Subject: [PATCH 059/122] Updated the MainViewModel to not show the feedback UI if it's not a Steam game. --- .../app/gamenative/ui/model/MainViewModel.kt | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) 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 b02a00dbc..a8930be28 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -8,6 +8,7 @@ import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.data.GameProcessInfo import app.gamenative.data.LibraryItem +import app.gamenative.data.GameSource import app.gamenative.di.IAppTheme import app.gamenative.enums.AppTheme import app.gamenative.enums.LoginResult @@ -268,6 +269,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) @@ -275,7 +277,7 @@ 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) @@ -289,22 +291,29 @@ class MainViewModel @Inject constructor( // After app closes, check if we need to show the feedback dialog try { - val container = ContainerUtils.getContainer(context, appId) - val shown = container.getExtra("discord_support_prompt_shown", "false") == "true" - val configChanged = container.getExtra("config_changed", "false") == "true" - if (!shown) { - container.putExtra("discord_support_prompt_shown", "true") - container.saveData() - _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) - } + // Do not show the Feedback form for non-steam games until we can support. + val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) + if(gameSource == GameSource.STEAM) { + val container = ContainerUtils.getContainer(context, appId) + + val shown = container.getExtra("discord_support_prompt_shown", "false") == "true" + val configChanged = container.getExtra("config_changed", "false") == "true" + if (!shown) { + container.putExtra("discord_support_prompt_shown", "true") + container.saveData() + _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) + } - // Only show feedback if container config was changed before this game run - if (configChanged) { - // Clear the flag - container.putExtra("config_changed", "false") - container.saveData() - // Show the feedback dialog - _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) + // Only show feedback if container config was changed before this game run + if (configChanged) { + // Clear the flag + container.putExtra("config_changed", "false") + container.saveData() + // Show the feedback dialog + _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) + } + } else { + Timber.d("Non-Steam Game Detected, not showing feedback") } } catch (_: Exception) { // ignore container errors From 764cd0f17396d3424f611f64f6c885b571b77d94 Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Sun, 21 Dec 2025 20:48:22 +0000 Subject: [PATCH 060/122] Fetching improvements by only getting a batch of IDs. Maybe we don't need this and instead give it a callback. --- .../app/gamenative/service/gog/GOGManager.kt | 91 ++++++++++++------- app/src/main/python/gogdl/cli.py | 39 ++++++++ 2 files changed, 98 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index a2c92ebb3..399249b09 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import org.json.JSONArray import org.json.JSONObject import timber.log.Timber @@ -139,39 +140,46 @@ class GOGManager @Inject constructor( Timber.tag("GOG").i("Refreshing GOG library from GOG API...") // Fetch games from GOG via GOGDL Python backend - val listResult = listGames(context) - if (listResult.isFailure) { - val error = listResult.exceptionOrNull() - Timber.e(error, "Failed to fetch games from GOG: ${error?.message}") - return@withContext Result.failure(error ?: Exception("Failed to fetch GOG library")) - } - - val games = listResult.getOrNull() ?: emptyList() - Timber.tag("GOG").i("Successfully fetched ${games.size} games from GOG") - - if (games.isEmpty()) { - Timber.w("No games found in GOG library") - return@withContext Result.success(0) - } - - // Update database using upsert to preserve install status - Timber.d("Upserting ${games.size} games to database...") - gogGameDao.upsertPreservingInstallStatus(games) - - // Scan for existing installations on filesystem - Timber.d("Scanning for existing installations...") - val detectedCount = detectAndUpdateExistingInstallations() - if (detectedCount > 0) { - Timber.i("Detected and updated $detectedCount existing installations") - } - - Timber.tag("GOG").i("Successfully refreshed GOG library with ${games.size} games") - Result.success(games.size) - } catch (e: Exception) { - Timber.e(e, "Failed to refresh GOG library") - Result.failure(e) - } + // TODO: Optimise this to grab a list of IDs from GOG, then start to pick through them and save to DB in batches. + // Step 1: Get all the Ids from teh Python bridge. + // Step 2, iterate over each one and after X amount, save to the DB (Let's have a CONST for that batch size). Start with 40 or so. + // Step 3: Send event that allows the library to update the screen. + var gameIdList = getGameIdList() + return@withContext Result.failure("TESTING....") + // val listResult = listGames(context) + + // if (listResult.isFailure) { + // val error = listResult.exceptionOrNull() + // Timber.e(error, "Failed to fetch games from GOG: ${error?.message}") + // return@withContext Result.failure(error ?: Exception("Failed to fetch GOG library")) + // } + + // val games = listResult.getOrNull() ?: emptyList() + // Timber.tag("GOG").i("Successfully fetched ${games.size} games from GOG") + + // if (games.isEmpty()) { + // Timber.w("No games found in GOG library") + // return@withContext Result.success(0) + // } + + // // Update database using upsert to preserve install status + // Timber.d("Upserting ${games.size} games to database...") + // gogGameDao.upsertPreservingInstallStatus(games) + + // // Scan for existing installations on filesystem + // Timber.d("Scanning for existing installations...") + // val detectedCount = detectAndUpdateExistingInstallations() + // if (detectedCount > 0) { + // Timber.i("Detected and updated $detectedCount existing installations") + // } + + // Timber.tag("GOG").i("Successfully refreshed GOG library with ${games.size} games") + // Result.success(games.size) + // } catch (e: Exception) { + // Timber.e(e, "Failed to refresh GOG library") + // Result.failure(e) + // } } // TODO: Optimisation: Rather than grab ALL game details at once, we should batch process X amount at a time @@ -206,6 +214,25 @@ class GOGManager @Inject constructor( } } + private suspend fun listGameIds(context: Context): Result> { + + Timber.tag("GOG").i("Fetching GOG Game Ids via GOGDL...") + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.e("Cannot list games: not authenticated") + return Result.failure(Exception("Not authenticated. Please log in first.")) + } + + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "game-ids") + val data = result.getOrNull() + + val gameIds = JSONArray(data); + + Timber.tag("GOG").i("Result::: $result") + return Result.success(listOf(gameIds)) + } + + private fun parseGamesFromJson(output: String): Result> { return try { val gamesArray = org.json.JSONArray(output.trim()) diff --git a/app/src/main/python/gogdl/cli.py b/app/src/main/python/gogdl/cli.py index 74dd476dd..e659c7b6f 100644 --- a/app/src/main/python/gogdl/cli.py +++ b/app/src/main/python/gogdl/cli.py @@ -18,6 +18,42 @@ def display_version(): print(f"{gogdl_version}") +def get_game_ids(arguments, api_handler): + """List user's GOG games with full details""" + logger = logging.getLogger("GOGDL-GAME-IDS") + try: + # Check if we have valid credentials first + credentials = api_handler.auth_manager.get_credentials() + if not credentials: + logger.error("No valid credentials found. Please authenticate first.") + print(json.dumps([])) # Return empty array instead of error object + return + + logger.info("Fetching user's game library...") + logger.debug(f"Using access token: {credentials.get('access_token', '')[:20]}...") + + # Use the same endpoint as does_user_own - it just returns owned game IDs + response = api_handler.session.get(f'{constants.GOG_EMBED}/user/data/games') + + if not response.ok: + logger.error(f"Failed to fetch user data - HTTP {response.status_code}") + print(json.dumps([])) # Return empty array instead of error object + return + + user_data = response.json() + owned_games = user_data.get('owned', []) + if arguments.pretty: + print(json.dumps(owned_games, indent=2)) + else: + print(json.dumps(owned_games)) + except Exception as e: + logger.error(f"List command failed: {e}") + import traceback + logger.error(traceback.format_exc()) + # Return empty array on error so Kotlin can parse it + print(json.dumps([])) + + def handle_list(arguments, api_handler): """List user's GOG games with full details""" logger = logging.getLogger("GOGDL-LIST") @@ -290,6 +326,9 @@ def main(): if arguments.command == "list": switcher["list"] = lambda: handle_list(arguments, api_handler) + if arguments.command == "game-ids": + switcher["game-ids"] = lambda: get_game_ids(arguments, api_handler) + # Handle download/info commands if arguments.command in ["download", "repair", "update", "info"]: download_manager = manager.AndroidManager(arguments, unknown_args, api_handler) From 56fd623e0d8bec939832ed4c6ddba4deba3ab8cd Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Sun, 21 Dec 2025 21:28:30 +0000 Subject: [PATCH 061/122] update fetching functionality for gog --- .../app/gamenative/service/gog/GOGManager.kt | 77 ++++++++++--------- app/src/main/python/gogdl/cli.py | 72 ++++++++++++++++- 2 files changed, 113 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 399249b09..628ff6e83 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -145,41 +145,48 @@ class GOGManager @Inject constructor( // Step 1: Get all the Ids from teh Python bridge. // Step 2, iterate over each one and after X amount, save to the DB (Let's have a CONST for that batch size). Start with 40 or so. // Step 3: Send event that allows the library to update the screen. - var gameIdList = getGameIdList() - return@withContext Result.failure("TESTING....") - // val listResult = listGames(context) - - // if (listResult.isFailure) { - // val error = listResult.exceptionOrNull() - // Timber.e(error, "Failed to fetch games from GOG: ${error?.message}") - // return@withContext Result.failure(error ?: Exception("Failed to fetch GOG library")) - // } - - // val games = listResult.getOrNull() ?: emptyList() - // Timber.tag("GOG").i("Successfully fetched ${games.size} games from GOG") - - // if (games.isEmpty()) { - // Timber.w("No games found in GOG library") - // return@withContext Result.success(0) - // } - - // // Update database using upsert to preserve install status - // Timber.d("Upserting ${games.size} games to database...") - // gogGameDao.upsertPreservingInstallStatus(games) - - // // Scan for existing installations on filesystem - // Timber.d("Scanning for existing installations...") - // val detectedCount = detectAndUpdateExistingInstallations() - // if (detectedCount > 0) { - // Timber.i("Detected and updated $detectedCount existing installations") - // } - - // Timber.tag("GOG").i("Successfully refreshed GOG library with ${games.size} games") - // Result.success(games.size) - // } catch (e: Exception) { - // Timber.e(e, "Failed to refresh GOG library") - // Result.failure(e) - // } + var gameIdList = listGameIds(context) + + if(!gameIdList.isSuccess){ + val error = gameIdList.exceptionOrNull() + Timber.e(error, "Failed to fetch GOG game IDs: ${error?.message}") + return@withContext Result.failure(error ?: Exception("Failed to fetch GOG game IDs")) + } + + val gameIds = gameIdList.getOrNull() ?: emptyList() + Timber.tag("GOG").i("Successfully fetched ${gameIds.size} game IDs from GOG") + + if (gameIds.isEmpty()) { + Timber.w("No games found in GOG library") + return@withContext Result.success(0) + } + + var totalProcessed = 0 + + for(id in gameIds) { + val singleGameResult = refreshSingleGame(id, context) + if(singleGameResult.isSuccess){ + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "game-details", "--pretty") + if(result != null){ + Timer.tag("GOG").i("Got Game Details for ID: $id") + var game = parseGameObject(result) + insertGame(game) + Timber.tag("GOG").i("Refreshed GOG game ID $id: ${game.title}") + totalProcessed++ + } else { + Timber.w("GOG game ID $id not found in library after refresh") + } + } else { + val error = singleGameResult.exceptionOrNull() + Timber.e(error, "Failed to refresh single GOG game ID $id: ${error?.message}") + } + } + 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") + } } // TODO: Optimisation: Rather than grab ALL game details at once, we should batch process X amount at a time diff --git a/app/src/main/python/gogdl/cli.py b/app/src/main/python/gogdl/cli.py index e659c7b6f..250f03fec 100644 --- a/app/src/main/python/gogdl/cli.py +++ b/app/src/main/python/gogdl/cli.py @@ -53,6 +53,73 @@ def get_game_ids(arguments, api_handler): # Return empty array on error so Kotlin can parse it print(json.dumps([])) +def get_game_details(arguments, api_handler): + """Fetch full details for a single game by ID""" + logger = logging.getLogger("GOGDL-GAME-DETAILS") + try: + game_id = arguments.game_id + if(not game_id): + logger.error("No game ID provided!") + print(json.dumps({})) + return + # Check if we have valid credentials first + logger.info(f"Fetching details for game ID: {game_id}") + + # Get full game info with expanded data + game_info = api_handler.get_item_data(game_id, expanded=['downloads', 'description', 'screenshots']) + + if game_info: + logger.info(f"Game {game_id} API response keys: {list(game_info.keys())}") + # Extract image URLs and ensure they have protocol + logo2x = game_info.get('images', {}).get('logo2x', '') + logo = game_info.get('images', {}).get('logo', '') + icon = game_info.get('images', {}).get('icon', '') + + # Add https: protocol if missing + if logo2x and logo2x.startswith('//'): + logo2x = 'https:' + logo2x + if logo and logo.startswith('//'): + logo = 'https:' + logo + if icon and icon.startswith('//'): + icon = 'https:' + icon + + # Extract download size from first installer + download_size = 0 + downloads = game_info.get('downloads', {}) + installers = downloads.get('installers', []) + if installers and len(installers) > 0: + download_size = installers[0].get('total_size', 0) + + # Extract relevant fields + game_entry = { + "id": game_id, + "title": game_info.get('title', 'Unknown'), + "slug": game_info.get('slug', ''), + "imageUrl": logo2x or logo, + "iconUrl": icon, + "developer": game_info.get('developers', [{}])[0].get('name', '') if game_info.get('developers') else '', + "publisher": game_info.get('publisher', {}).get('name', '') if isinstance(game_info.get('publisher'), dict) else game_info.get('publisher', ''), + "genres": [g.get('name', '') if isinstance(g, dict) else str(g) for g in game_info.get('genres', [])], + "languages": list(game_info.get('languages', {}).keys()), + "description": game_info.get('description', {}).get('lead', '') if isinstance(game_info.get('description'), dict) else '', + "releaseDate": game_info.get('release_date', ''), + "downloadSize": download_size + } + # Output as JSON + if arguments.pretty: + print(json.dumps(game_entry, indent=2)) + else: + print(json.dumps(game_entry)) + else: + logger.warning(f"Failed to get details for game {game_id} - API returned None") + print(json.dumps({})) + + except Exception as e: + logger.error(f"Get game details command failed: {e}") + import traceback + logger.error(traceback.format_exc()) + # Return empty object on error so Kotlin can parse it + print(json.dumps({})) def handle_list(arguments, api_handler): """List user's GOG games with full details""" @@ -326,9 +393,12 @@ def main(): if arguments.command == "list": switcher["list"] = lambda: handle_list(arguments, api_handler) + # Handle game-ids command if arguments.command == "game-ids": switcher["game-ids"] = lambda: get_game_ids(arguments, api_handler) - + # Handle game-details command + if arguments.command == "game-details": + switcher["game-details"] = lambda: get_game_details(arguments, api_handler) # Handle download/info commands if arguments.command in ["download", "repair", "update", "info"]: download_manager = manager.AndroidManager(arguments, unknown_args, api_handler) From 360f21bf5651d4784c4b422df1eb8cd69038a4cb Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 21 Dec 2025 22:34:22 +0000 Subject: [PATCH 062/122] Updating the fetching of games. Next up is to batch save to DB instead of as singular ones, to reduce UI refreshes. --- .../java/app/gamenative/db/dao/GOGGameDao.kt | 26 +++++++- .../app/gamenative/service/gog/GOGManager.kt | 59 ++++++++++++------- app/src/main/python/gogdl/args.py | 9 +++ 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index aa93f77cf..d278f9dcb 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -63,7 +63,7 @@ interface GOGGameDao { * This is useful when refreshing the library from GOG API */ @Transaction - suspend fun upsertPreservingInstallStatus(games: List) { + suspend fun upsertMultipleGamesPreservingInstallStatus(games: List) { games.forEach { newGame -> val existingGame = getById(newGame.id) if (existingGame != null) { @@ -82,4 +82,26 @@ interface GOGGameDao { } } } -} + + /** + * PolymoprhForSingularTransactions to upsert and preserveInstallStatus + */ + @Transaction + suspend fun upsertSingleGamePreservingInstallStatus(newGame: GOGGame) { + 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/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 628ff6e83..2786fe937 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -162,30 +162,32 @@ class GOGManager @Inject constructor( } var totalProcessed = 0 - - for(id in gameIds) { - val singleGameResult = refreshSingleGame(id, context) - if(singleGameResult.isSuccess){ - val authConfigPath = GOGAuthManager.getAuthConfigPath(context) - val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "game-details", "--pretty") - if(result != null){ - Timer.tag("GOG").i("Got Game Details for ID: $id") - var game = parseGameObject(result) - insertGame(game) - Timber.tag("GOG").i("Refreshed GOG game ID $id: ${game.title}") - totalProcessed++ - } else { - Timber.w("GOG game ID $id not found in library after refresh") - } + + Timber.tag("GOG").i("Getting Game Details for GOG Games...") + for(id in gameIds) { + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "game-details", "--game_id", id, "--pretty") + val output = result.getOrNull() ?: "" + if(result != null){ + Timber.tag("GOG").i("Got Game Details for ID: $id") + val gameDetails = org.json.JSONObject(output.trim()) + var game = parseGameObject(gameDetails) + gogGameDao.upsertSingleGamePreservingInstallStatus(game) + Timber.tag("GOG").i("Refreshed GOG game ID $id: ${game.title}") + totalProcessed++ } else { - val error = singleGameResult.exceptionOrNull() - Timber.e(error, "Failed to refresh single GOG game ID $id: ${error?.message}") + Timber.w("GOG game ID $id not found in library after refresh") } } + val detectedCount = detectAndUpdateExistingInstallations() + if (detectedCount > 0) { + Timber.i("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) } } @@ -231,12 +233,25 @@ class GOGManager @Inject constructor( } val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "game-ids") - val data = result.getOrNull() - val gameIds = JSONArray(data); - - Timber.tag("GOG").i("Result::: $result") - return Result.success(listOf(gameIds)) + if (result.isFailure) { + val error = result.exceptionOrNull() + Timber.e(error, "Failed to fetch GOG game IDs") + return Result.failure(error ?: Exception("Failed to fetch GOG game IDs")) + } + + val output = result.getOrNull() ?: "" + + if (output.isBlank()) { + Timber.w("Empty response when fetching GOG game IDs") + return Result.failure(Exception("Empty response from GOGDL")) + } + + val gamesArray = org.json.JSONArray(output.trim()) + val gameIds = List(gamesArray.length()) { gamesArray.getString(it) } + Timber.tag("GOG").i("Successfully fetched ${gameIds.size} game IDs") + + return Result.success(gameIds) } diff --git a/app/src/main/python/gogdl/args.py b/app/src/main/python/gogdl/args.py index b87932a41..47e557538 100644 --- a/app/src/main/python/gogdl/args.py +++ b/app/src/main/python/gogdl/args.py @@ -86,4 +86,13 @@ def init_parser(): list_parser = subparsers.add_parser('list', help='List user\'s GOG games') list_parser.add_argument('--pretty', action='store_true', help='Pretty print JSON output') + # Game IDs command + game_ids_parser = subparsers.add_parser('game-ids', help='List user\'s GOG game IDs only') + game_ids_parser.add_argument('--pretty', action='store_true', help='Pretty print JSON output') + + # Game details command + game_details_parser = subparsers.add_parser('game-details', help='Get full details for a single game') + game_details_parser.add_argument('game_id', type=str, help='Game ID to fetch details for') + game_details_parser.add_argument('--pretty', action='store_true', help='Pretty print JSON output') + return parser.parse_known_args() From 51e2773ffb73c80456866b49ebb647afd59be348 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 21 Dec 2025 23:21:49 +0000 Subject: [PATCH 063/122] Fixed refreshSingleGame just to only grab details for the specific game it needs --- .../app/gamenative/service/gog/GOGManager.kt | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 2786fe937..18dca2985 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -475,23 +475,19 @@ class GOGManager @Inject constructor( return Result.failure(Exception("Not authenticated")) } - val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "list", "--pretty") + val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "game-details", "--game_id", gameId, "--pretty") if (result.isFailure) { return Result.failure(result.exceptionOrNull() ?: Exception("Failed to fetch game data")) } val output = result.getOrNull() ?: "" - val gamesArray = org.json.JSONArray(output.trim()) - // Find the game with matching ID - for (i in 0 until gamesArray.length()) { - val gameObj = gamesArray.getJSONObject(i) - if (gameObj.optString("id", "") == gameId) { - val game = parseGameObject(gameObj) - insertGame(game) - return Result.success(game) - } + if(result != null){ + val gameDetails = org.json.JSONObject(output.trim()) + var game = parseGameObject(gameDetails) + insertGame(game) + return Result.success(game) } Timber.w("Game $gameId not found in GOG library") From f364f365bf648b4dda0f08e2cf9717cddbc25345 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 21 Dec 2025 23:28:40 +0000 Subject: [PATCH 064/122] removing comments and adjusting the logs. --- .../app/gamenative/service/gog/GOGManager.kt | 44 ++----------------- 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 18dca2985..752529429 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -141,10 +141,6 @@ class GOGManager @Inject constructor( // Fetch games from GOG via GOGDL Python backend - // TODO: Optimise this to grab a list of IDs from GOG, then start to pick through them and save to DB in batches. - // Step 1: Get all the Ids from teh Python bridge. - // Step 2, iterate over each one and after X amount, save to the DB (Let's have a CONST for that batch size). Start with 40 or so. - // Step 3: Send event that allows the library to update the screen. var gameIdList = listGameIds(context) if(!gameIdList.isSuccess){ @@ -163,17 +159,17 @@ class GOGManager @Inject constructor( var totalProcessed = 0 - Timber.tag("GOG").i("Getting Game Details for GOG Games...") + Timber.tag("GOG").d("Getting Game Details for GOG Games...") for(id in gameIds) { val authConfigPath = GOGAuthManager.getAuthConfigPath(context) val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "game-details", "--game_id", id, "--pretty") val output = result.getOrNull() ?: "" if(result != null){ - Timber.tag("GOG").i("Got Game Details for ID: $id") + Timber.tag("GOG").d("Got Game Details for ID: $id") val gameDetails = org.json.JSONObject(output.trim()) var game = parseGameObject(gameDetails) gogGameDao.upsertSingleGamePreservingInstallStatus(game) - Timber.tag("GOG").i("Refreshed GOG game ID $id: ${game.title}") + Timber.tag("GOG").d("Refreshed GOG game ID $id: ${game.title}") totalProcessed++ } else { Timber.w("GOG game ID $id not found in library after refresh") @@ -181,7 +177,7 @@ class GOGManager @Inject constructor( } val detectedCount = detectAndUpdateExistingInstallations() if (detectedCount > 0) { - Timber.i("Detected and updated $detectedCount existing installations") + Timber.d("Detected and updated $detectedCount existing installations") } Timber.tag("GOG").i("Successfully refreshed GOG library with $totalProcessed games") return@withContext Result.success(totalProcessed) @@ -191,38 +187,6 @@ class GOGManager @Inject constructor( } } - // TODO: Optimisation: Rather than grab ALL game details at once, we should batch process X amount at a time - // This will allow us to update the UI more often and be more dynamic. - /** - * Fetch the user's GOG library (list of owned games) - * Returns a list of GOGGame objects with basic metadata - */ - private suspend fun listGames(context: Context): Result> { - return try { - Timber.d("Fetching GOG library via GOGDL...") - val authConfigPath = GOGAuthManager.getAuthConfigPath(context) - - if (!GOGAuthManager.hasStoredCredentials(context)) { - Timber.e("Cannot list games: not authenticated") - return Result.failure(Exception("Not authenticated. Please log in first.")) - } - - val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "list", "--pretty") - - if (result.isFailure) { - val error = result.exceptionOrNull() - Timber.e(error, "Failed to fetch GOG library: ${error?.message}") - return Result.failure(error ?: Exception("Failed to fetch GOG library")) - } - - val output = result.getOrNull() ?: "" - parseGamesFromJson(output) - } catch (e: Exception) { - Timber.e(e, "Unexpected error while fetching GOG library") - Result.failure(e) - } - } - private suspend fun listGameIds(context: Context): Result> { Timber.tag("GOG").i("Fetching GOG Game Ids via GOGDL...") From 8b0489ba8ac7a0180a8a24250380c40cacb6f1ab Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 21 Dec 2025 23:40:07 +0000 Subject: [PATCH 065/122] Batch procesing test. --- .../app/gamenative/service/gog/GOGManager.kt | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 752529429..7ff1adbfe 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -160,19 +160,41 @@ class GOGManager @Inject constructor( var totalProcessed = 0 Timber.tag("GOG").d("Getting Game Details for GOG Games...") - for(id in gameIds) { - val authConfigPath = GOGAuthManager.getAuthConfigPath(context) - val result = GOGPythonBridge.executeCommand("--auth-config-path", authConfigPath, "game-details", "--game_id", id, "--pretty") - val output = result.getOrNull() ?: "" - if(result != null){ - Timber.tag("GOG").d("Got Game Details for ID: $id") - val gameDetails = org.json.JSONObject(output.trim()) - var game = parseGameObject(gameDetails) - gogGameDao.upsertSingleGamePreservingInstallStatus(game) - Timber.tag("GOG").d("Refreshed GOG game ID $id: ${game.title}") - totalProcessed++ - } else { - Timber.w("GOG game ID $id not found in library after refresh") + + val games = mutableListOf() + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + + for ((index, id) in gameIds.withIndex()) { + try { + val result = GOGPythonBridge.executeCommand( + "--auth-config-path", authConfigPath, + "game-details", + "--game_id", id, + "--pretty" + ) + + if (result.isSuccess) { + val output = result.getOrNull() ?: "" + Timber.tag("GOG").d("Got Game Details for ID: $id") + val gameDetails = JSONObject(output.trim()) + val game = parseGameObject(gameDetails) + games.add(game) + Timber.tag("GOG").d("Refreshed GOG game ID $id: ${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") + } + + // Batch upsert every 20 games or at the end + if ((index + 1) % 20 == 0 || index == gameIds.size - 1) { + if (games.isNotEmpty()) { + gogGameDao.upsertMultipleGamesPreservingInstallStatus(games) + Timber.tag("GOG").d("Batch inserted ${games.size} games (processed ${index + 1}/${gameIds.size})") + games.clear() + } } } val detectedCount = detectAndUpdateExistingInstallations() From a04395271706e65eaf01d598afc023a2217d3fcf Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 21 Dec 2025 23:41:16 +0000 Subject: [PATCH 066/122] Added batch size --- app/src/main/java/app/gamenative/service/gog/GOGManager.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 7ff1adbfe..98ecec566 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -71,7 +71,7 @@ class GOGManager @Inject constructor( // Thread-safe cache for download sizes private val downloadSizeCache = ConcurrentHashMap() - + private val REFRESH_BATCH_SIZE = 20 suspend fun getGameById(gameId: String): GOGGame? { return withContext(Dispatchers.IO) { try { @@ -188,8 +188,7 @@ class GOGManager @Inject constructor( Timber.e(e, "Failed to parse game details for ID: $id") } - // Batch upsert every 20 games or at the end - if ((index + 1) % 20 == 0 || index == gameIds.size - 1) { + if ((index + 1) % REFRESH_BATCH_SIZE == 0 || index == gameIds.size - 1) { if (games.isNotEmpty()) { gogGameDao.upsertMultipleGamesPreservingInstallStatus(games) Timber.tag("GOG").d("Batch inserted ${games.size} games (processed ${index + 1}/${gameIds.size})") From e44830d013312cef8c66e4092db3f6edafaaad19 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 21 Dec 2025 23:44:15 +0000 Subject: [PATCH 067/122] Removed singular as not needed. --- .../java/app/gamenative/db/dao/GOGGameDao.kt | 26 ++----------------- .../app/gamenative/service/gog/GOGManager.kt | 2 +- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index d278f9dcb..0459b2599 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -63,7 +63,7 @@ interface GOGGameDao { * This is useful when refreshing the library from GOG API */ @Transaction - suspend fun upsertMultipleGamesPreservingInstallStatus(games: List) { + suspend fun upsertGamesPreservingInstallStatus(games: List) { games.forEach { newGame -> val existingGame = getById(newGame.id) if (existingGame != null) { @@ -82,26 +82,4 @@ interface GOGGameDao { } } } - - /** - * PolymoprhForSingularTransactions to upsert and preserveInstallStatus - */ - @Transaction - suspend fun upsertSingleGamePreservingInstallStatus(newGame: GOGGame) { - 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/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 98ecec566..45a5f61fc 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -190,7 +190,7 @@ class GOGManager @Inject constructor( if ((index + 1) % REFRESH_BATCH_SIZE == 0 || index == gameIds.size - 1) { if (games.isNotEmpty()) { - gogGameDao.upsertMultipleGamesPreservingInstallStatus(games) + gogGameDao.upsertGamesPreservingInstallStatus(games) Timber.tag("GOG").d("Batch inserted ${games.size} games (processed ${index + 1}/${gameIds.size})") games.clear() } From e42ebfb8726144c3917baaae4c0f03e05aed0599 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 21 Dec 2025 23:45:18 +0000 Subject: [PATCH 068/122] reverted name for clean PR --- app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt | 2 +- app/src/main/java/app/gamenative/service/gog/GOGManager.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index 0459b2599..aa93f77cf 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -63,7 +63,7 @@ interface GOGGameDao { * This is useful when refreshing the library from GOG API */ @Transaction - suspend fun upsertGamesPreservingInstallStatus(games: List) { + suspend fun upsertPreservingInstallStatus(games: List) { games.forEach { newGame -> val existingGame = getById(newGame.id) if (existingGame != null) { diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 45a5f61fc..d48eda261 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -190,7 +190,7 @@ class GOGManager @Inject constructor( if ((index + 1) % REFRESH_BATCH_SIZE == 0 || index == gameIds.size - 1) { if (games.isNotEmpty()) { - gogGameDao.upsertGamesPreservingInstallStatus(games) + gogGameDao.upsertPreservingInstallStatus(games) Timber.tag("GOG").d("Batch inserted ${games.size} games (processed ${index + 1}/${gameIds.size})") games.clear() } From 3f56814271fa60442373d9cf36653d451fde1e52 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 08:45:36 +0000 Subject: [PATCH 069/122] Reverting Steam hotfix now that upstream is fixed. --- app/src/main/java/app/gamenative/service/SteamService.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 388351f0e..cdf7c9819 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -1084,7 +1084,10 @@ class SteamService : Service(), IChallengeUrlChanged { licenses, debug = false, androidEmulation = true, - maxDownloads = maxDownloads + maxDownloads = maxDownloads, + maxDecompress = maxDecompress, + maxFileWrites = maxFileWrites, + parentJob = coroutineContext[Job] ) // Create listener From 9f2d4b25cc7af94328ed7ba43f2cb0d0d9e3f0fe Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 08:50:53 +0000 Subject: [PATCH 070/122] Reduced batch size to 10 and removed parseGamesfromJson as we no longer use it. --- .../app/gamenative/service/gog/GOGManager.kt | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index d48eda261..368d6f352 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -71,7 +71,8 @@ class GOGManager @Inject constructor( // Thread-safe cache for download sizes private val downloadSizeCache = ConcurrentHashMap() - private val REFRESH_BATCH_SIZE = 20 + private val REFRESH_BATCH_SIZE = 10 + suspend fun getGameById(gameId: String): GOGGame? { return withContext(Dispatchers.IO) { try { @@ -239,29 +240,6 @@ class GOGManager @Inject constructor( return Result.success(gameIds) } - - private fun parseGamesFromJson(output: String): Result> { - return try { - val gamesArray = org.json.JSONArray(output.trim()) - val games = mutableListOf() - - for (i in 0 until gamesArray.length()) { - try { - val gameObj = gamesArray.getJSONObject(i) - games.add(parseGameObject(gameObj)) - } catch (e: Exception) { - Timber.w(e, "Failed to parse game at index $i, skipping") - } - } - - Timber.i("Successfully parsed ${games.size} games from GOG library") - Result.success(games) - } catch (e: Exception) { - Timber.e(e, "Failed to parse GOG library JSON") - Result.failure(Exception("Failed to parse GOG library: ${e.message}", e)) - } - } - private fun parseGameObject(gameObj: JSONObject): GOGGame { val genresList = parseJsonArray(gameObj.optJSONArray("genres")) val languagesList = parseJsonArray(gameObj.optJSONArray("languages")) From f33168c89b880197859d46873a15cd2da4ee00dc Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 09:16:45 +0000 Subject: [PATCH 071/122] Updated the check to be more simplistic to reduce CPU overhead. --- app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5f24e7f3d..bbfd74459 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -112,10 +112,10 @@ class LibraryViewModel @Inject constructor( gogGameDao.getAll().collect { games -> Timber.tag("LibraryViewModel").d("Collecting ${games.size} GOG games") - val hasChanges = gogGameList.size != games.size || gogGameList != games - gogGameList = games + val hasChanges = gogGameList.size != games.size if (hasChanges) { + gogGameList = games onFilterApps(paginationCurrentPage) } } From aff6cef28a9b587bd76f63293d094f8293b4da9e Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 09:31:07 +0000 Subject: [PATCH 072/122] Updated Login UI to point out success url pasting --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0facca1c5..2d31c2346 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -951,7 +951,7 @@ Tap \'Open GOG Login\' and sign in. Once logged in, please take the code from the success URL OR copy the entire URL and paste it below Example: https://embed.gog.com/on_login_success?origin=client&code=AUTH_CODE_HERE Open GOG Login - Paste your code below + Paste your code or success URL below Authorization Code or login success URL Paste code or url here Login From d990f27ca42e0a31ba84e52aa4310c39d1b1bee7 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 09:56:53 +0000 Subject: [PATCH 073/122] Removed manual sync and reduced CPU overhead for gog lib filtering. --- .../app/gamenative/service/gog/GOGManager.kt | 4 -- .../gamenative/ui/model/LibraryViewModel.kt | 5 +- .../screen/settings/SettingsGroupInterface.kt | 48 ------------------- app/src/main/res/values/strings.xml | 1 - 4 files changed, 1 insertion(+), 57 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 368d6f352..761ee585f 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -84,10 +84,6 @@ class GOGManager @Inject constructor( } } - /** - * Insert or update a GOG game in database - * Uses REPLACE strategy, so will update if exists - */ suspend fun insertGame(game: GOGGame) { withContext(Dispatchers.IO) { gogGameDao.insert(game) 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 bbfd74459..92afc7ce5 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -111,10 +111,7 @@ class LibraryViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { gogGameDao.getAll().collect { games -> Timber.tag("LibraryViewModel").d("Collecting ${games.size} GOG games") - - val hasChanges = gogGameList.size != games.size - - if (hasChanges) { + if(gogGameList.size != games.size) { gogGameList = games onFilterApps(paginationCurrentPage) } 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 dae296d3d..9152ea5ef 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 @@ -301,54 +301,6 @@ fun SettingsGroupInterface( gogLoginSuccess = false } ) - - SettingsMenuLink( - colors = settingsTileColorsAlt(), - title = { Text(text = stringResource(R.string.gog_settings_sync_title)) }, - subtitle = { - Text( - text = when { - gogLibrarySyncing -> stringResource(R.string.gog_settings_sync_subtitle_syncing) - gogLibrarySyncError != null -> stringResource(R.string.gog_settings_sync_subtitle_error, gogLibrarySyncError!!) - gogLibrarySyncSuccess -> stringResource(R.string.gog_settings_sync_subtitle_success, gogLibraryGameCount) - else -> stringResource(R.string.gog_settings_sync_subtitle_default) - } - ) - }, - enabled = !gogLibrarySyncing, - onClick = { - gogLibrarySyncing = true - gogLibrarySyncError = null - gogLibrarySyncSuccess = false - - coroutineScope.launch { - try { - Timber.i("[SettingsGOG]: Syncing GOG library...") - - // Use GOGService.refreshLibrary() which handles everything - val result = GOGService.refreshLibrary(context) - - if (result.isSuccess) { - val count = result.getOrNull() ?: 0 - gogLibraryGameCount = count - Timber.i("[SettingsGOG]: ✓ Successfully synced $count games from GOG") - - gogLibrarySyncing = false - gogLibrarySyncSuccess = true - } else { - val error = result.exceptionOrNull()?.message ?: "Failed to sync library" - Timber.e("[SettingsGOG]: Library sync failed: $error") - gogLibrarySyncing = false - gogLibrarySyncError = error - } - } catch (e: Exception) { - Timber.e(e, "[SettingsGOG]: Library sync exception: ${e.message}") - gogLibrarySyncing = false - gogLibrarySyncError = e.message ?: "Sync failed" - } - } - } - ) } // Downloads settings diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2d31c2346..d663e9091 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -937,7 +937,6 @@ GOG Integration (Alpha) GOG Login Sign in to your GOG account - Manually Sync GOG Library Syncing… Error: %1$s ✓ Synced %1$d games From f61015442e0343e19e3f8d9e15bcfd078ae13ff4 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 10:30:32 +0000 Subject: [PATCH 074/122] Fixed issue where the libViewmodel was siezing up. This is the final commit for this branch unless more Pr feedback comes in. It's already a chunk PR, so I'll keep the scope as-is. --- app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 92afc7ce5..f9d27edae 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -111,7 +111,7 @@ class LibraryViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { gogGameDao.getAll().collect { games -> Timber.tag("LibraryViewModel").d("Collecting ${games.size} GOG games") - if(gogGameList.size != games.size) { + if(gogGameList.size != games.size || gogGameList != games) { gogGameList = games onFilterApps(paginationCurrentPage) } From 14d74a72c28cfb26a5feb31c670b055209282b5c Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 18:04:30 +0000 Subject: [PATCH 075/122] Now ignores invalid games from GOG. Should eliminate the weird issue regarding unknown games and duplicates with broken names. --- .../app/gamenative/service/gog/GOGManager.kt | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 761ee585f..957132040 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -175,9 +175,11 @@ class GOGManager @Inject constructor( Timber.tag("GOG").d("Got Game Details for ID: $id") val gameDetails = JSONObject(output.trim()) val game = parseGameObject(gameDetails) - games.add(game) - Timber.tag("GOG").d("Refreshed GOG game ID $id: ${game.title}") - totalProcessed++ + 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") } @@ -236,13 +238,23 @@ class GOGManager @Inject constructor( return Result.success(gameIds) } - private fun parseGameObject(gameObj: JSONObject): GOGGame { + private fun parseGameObject(gameObj: JSONObject): GOGGame? { val genresList = parseJsonArray(gameObj.optJSONArray("genres")) val languagesList = parseJsonArray(gameObj.optJSONArray("languages")) + val title = gameObj.optString("title", "Unknown Game") + val id = gameObj.optString("id", "") + + val isInvalidGame = title == "Unknown Game" || title.startsWith("product_title_") + + if(isInvalidGame){ + Timber.tag("GOG").w("Found incorrectly formatted game: $title, $id") + return null + } + return GOGGame( - id = gameObj.optString("id", ""), - title = gameObj.optString("title", "Unknown Game"), + id, + title, slug = gameObj.optString("slug", ""), imageUrl = gameObj.optString("imageUrl", ""), iconUrl = gameObj.optString("iconUrl", ""), @@ -442,15 +454,19 @@ class GOGManager @Inject constructor( val output = result.getOrNull() ?: "" - if(result != null){ - val gameDetails = org.json.JSONObject(output.trim()) - var game = parseGameObject(gameDetails) - insertGame(game) - return Result.success(game) + if(result == null) { + Timber.w("Game $gameId not found in GOG library") + return Result.success(null) } - Timber.w("Game $gameId not found in GOG library") - Result.success(null) + val gameDetails = org.json.JSONObject(output.trim()) + var game = parseGameObject(gameDetails) + if(game == null){ + Timber.tag("GOG").w("Skipping Invalid GOG App with id: $gameId") + return Result.success(null) + } + insertGame(game) + return Result.success(game) } catch (e: Exception) { Timber.e(e, "Error fetching single game data for $gameId") Result.failure(e) From f93f265544e7d48aa463d7be323a62f750047eea Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 21:30:12 +0000 Subject: [PATCH 076/122] Reverted the downloadClick default as we don't need to change this. --- .../gamenative/ui/screen/library/appscreen/BaseAppScreen.kt | 4 ---- 1 file changed, 4 deletions(-) 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 3ff9128e4..84b884307 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 @@ -637,10 +637,6 @@ abstract class BaseAppScreen { }, onDeleteDownloadClick = { onDeleteDownloadClick(context, libraryItem) - uiScope.launch { - delay(100) - performStateRefresh(true) - } }, onUpdateClick = { onUpdateClick(context, libraryItem) From 1650508f9494b9d5f9a319aedae6f8793cb14247 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 21:52:05 +0000 Subject: [PATCH 077/122] Reverting some changes that aren't required. --- .../main/java/app/gamenative/ui/model/LibraryViewModel.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 f9d27edae..a3acb714c 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -102,7 +102,6 @@ class LibraryViewModel @Inject constructor( if (appList.size != apps.size) { // Don't filter if it's no change appList = apps - onFilterApps(paginationCurrentPage) } } } @@ -342,18 +341,17 @@ class LibraryViewModel @Inject constructor( LibraryEntry( item = LibraryItem( index = 0, - appId = "${GameSource.GOG.name}_${game.id}", // Use GOG_ prefix for consistency + appId = "${GameSource.GOG.name}_${game.id}", name = game.title, - iconHash = game.imageUrl.ifEmpty { game.iconUrl }, // Use imageUrl (banner) with iconUrl as fallback + iconHash = game.imageUrl.ifEmpty { game.iconUrl }, isShared = false, gameSource = GameSource.GOG, ), isInstalled = game.isInstalled, ) } - // Calculate GOG installed count - val gogInstalledCount = filteredGOGGames.count { it.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()) { From 70ef1be44b8616e051fa6d8abf4e23cd07be6c05 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 22:20:48 +0000 Subject: [PATCH 078/122] Fixed whitespacing --- .../java/app/gamenative/ui/screen/library/LibraryAppScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 c0bfdfe1d..9015ae3e7 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 @@ -920,7 +920,9 @@ private fun Preview_AppScreen() { isDownloading = isDownloading, downloadProgress = .50f, hasPartialDownload = false, - isUpdatePending = false, downloadInfo = null, onDownloadInstallClick = { isDownloading = !isDownloading }, + isUpdatePending = false, + downloadInfo = null, + onDownloadInstallClick = { isDownloading = !isDownloading }, onPauseResumeClick = { }, onDeleteDownloadClick = { }, onUpdateClick = { }, From 89fdaa4504b4ad64f0a54c6776dadf46972385d2 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 22:22:03 +0000 Subject: [PATCH 079/122] revert whitespace --- app/src/main/java/app/gamenative/MainActivity.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index e68dbc5dd..c3da8bac2 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -196,7 +196,6 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) handleLaunchIntent(intent) } - private fun handleLaunchIntent(intent: Intent) { Timber.d("[IntentLaunch]: handleLaunchIntent called with action=${intent.action}") try { From 8cdaec2b0048a97a6257fe3a0215911a01b1d19d Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 22:24:25 +0000 Subject: [PATCH 080/122] Update to avoid using runBlocking for GOG function on ContainerUtils --- app/src/main/java/app/gamenative/service/gog/GOGService.kt | 4 ++-- .../gamenative/ui/screen/library/appscreen/GOGAppScreen.kt | 6 ++++-- app/src/main/java/app/gamenative/utils/ContainerUtils.kt | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index a57aeb735..f30956916 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -163,8 +163,8 @@ class GOGService : Service() { // GAME & LIBRARY OPERATIONS - Delegate to instance GOGManager // ========================================================================== - fun getGOGGameOf(gameId: String): GOGGame? { - return runBlocking(Dispatchers.IO) { + suspend fun getGOGGameOf(gameId: String): GOGGame? { + return withContext(Dispatchers.IO) { getInstance()?.gogManager?.getGameById(gameId) } } 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 index 9e1d1d8f6..75b9de295 100644 --- 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 @@ -128,8 +128,10 @@ class GOGAppScreen : BaseAppScreen() { app.gamenative.PluviaApp.events.on(installListener) } - val gogGame = remember(gameId, refreshTrigger) { - val game = GOGService.getGOGGameOf(gameId) + var gogGame by remember(gameId, refreshTrigger) { mutableStateOf(null) } + LaunchedEffect(gameId, refreshTrigger) { + gogGame = GOGService.getGOGGameOf(gameId) + val game = gogGame if (game != null) { Timber.tag(TAG).d(""" |=== GOG Game Object === diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index f313acbd1..bbd015d46 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -532,7 +532,7 @@ object ContainerUtils { GameSource.GOG -> { // For GOG games, map the specific game directory to A: drive val gameId = extractGameIdFromContainerId(appId) - val game = runBlocking { GOGService.getGOGGameOf(gameId.toString()) } + val game = GOGService.getGOGGameOf(gameId.toString()) if (game != null) { val gameInstallPath = GOGConstants.getGameInstallPath(game.title) val drive: Char = if (defaultDrives.contains("A:")) { From b6c5c6b4b79225f34094f7010a956bae30d22aed Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 22:37:42 +0000 Subject: [PATCH 081/122] Removed unneeded GOG bit --- app/src/main/java/app/gamenative/utils/ContainerUtils.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index bbd015d46..8d2838664 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -959,8 +959,6 @@ object ContainerUtils { containerId.startsWith("STEAM_") -> GameSource.STEAM containerId.startsWith("CUSTOM_GAME_") -> GameSource.CUSTOM_GAME containerId.startsWith("GOG_") -> GameSource.GOG - // Legacy fallback for old GOG containers without prefix (numeric only) - containerId.toIntOrNull() != null -> GameSource.GOG // Add other platforms here.. else -> GameSource.STEAM // default fallback } From 4a090c0ffe06bde1562235b66f13749924679d65 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 22:43:05 +0000 Subject: [PATCH 082/122] Updated translations --- app/src/main/res/values-da/strings.xml | 4 ++++ app/src/main/res/values-fr/strings.xml | 4 ++++ app/src/main/res/values-pt-rBR/strings.xml | 4 ++++ app/src/main/res/values-uk/strings.xml | 4 ++++ app/src/main/res/values-zh-rCN/strings.xml | 4 ++++ app/src/main/res/values-zh-rTW/strings.xml | 4 ++++ 6 files changed, 24 insertions(+) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 867383db9..9d4c824b6 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 + Download %1$s (%2$s)? Sørg for at have tilstrækkelig lagerplads. Installér app Slet app Annullér download diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 07439a5b5..b7f893446 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -40,6 +40,10 @@ Êtes-vous sûr de vouloir désinstaller %1$s ? Cette action ne peut pas être annulée. %1$s a été désinstallé Échec de la désinstallation du jeu + Désinstaller le jeu + Êtes-vous sûr de vouloir désinstaller %1$s ? Cette action ne peut pas être annulée. + Télécharger le jeu + Télécharger %1$s (%2$s) ? Assurez-vous d\'avoir suffisamment d\'espace de stockage. Jamais Continuer Image d\'en-tête de l\'application diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6264a98ef..692e53f82 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 + Baixar %1$s (%2$s)? Certifique-se de ter espaço de armazenamento suficiente. Instalar App Deletar App Cancelar Download diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index eb587b880..6e8c684d5 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -19,6 +19,10 @@ Видалити Деінсталювати Грати + Деінсталювати гру + Ви впевнені, що хочете деінсталювати %1$s? Цю дію не можна скасувати. + Завантажити гру + Завантажити %1$s (%2$s)? Переконайтеся, що у вас достатньо місця для зберігання. Інсталювати застосунок Деінсталювати застосунок Скасувати завантаження diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5b5eed116..10f8f661d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -39,6 +39,10 @@ 您确定要卸载 %1$s 吗? 此操作无法撤回 %1$s 已卸载 卸载游戏失败 + 卸载游戏 + 您确定要卸载 %1$s 吗? 此操作无法撤回 + 下载游戏 + 下载 %1$s (%2$s)? 请确保有足够的存储空间 从不 继续 App header image diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8207e6ccd..147106c4e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -39,6 +39,10 @@ 您確定要卸載 %1$s 嗎? 此操作無法撤回 %1$s 已卸載 卸載遊戲失敗 + 卸載遊戲 + 您確定要卸載 %1$s 嗎? 此操作無法撤回 + 下載遊戲 + 下載 %1$s (%2$s)? 請確保有足夠的儲存空間 從不 繼續 App header image From cc7b7c20a2c5b53001ab436e35057ef3f500ee3b Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 22:46:00 +0000 Subject: [PATCH 083/122] reverted the runBlocking. Will chat with other devs about where we can optimise. --- app/src/main/java/app/gamenative/service/gog/GOGService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index f30956916..a57aeb735 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -163,8 +163,8 @@ class GOGService : Service() { // GAME & LIBRARY OPERATIONS - Delegate to instance GOGManager // ========================================================================== - suspend fun getGOGGameOf(gameId: String): GOGGame? { - return withContext(Dispatchers.IO) { + fun getGOGGameOf(gameId: String): GOGGame? { + return runBlocking(Dispatchers.IO) { getInstance()?.gogManager?.getGameById(gameId) } } From bcca5ac9dfc753404a7dfe6c748183f0c6e0be49 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 23:12:27 +0000 Subject: [PATCH 084/122] Updated the GOG images to just use the iconHash. The other is not worth using. --- .../library/components/LibraryAppItem.kt | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 2b3eb04d8..8bffafc8d 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt @@ -233,12 +233,7 @@ internal fun AppItem( } } GameSource.GOG -> { - // For GOG games, use the iconHash which contains the full image URL - // GOG stores images directly in iconHash (populated from GOGGame.imageUrl or iconUrl) - // The imageUrl is typically a larger banner/hero image, iconUrl is smaller icon - val gogUrl = appInfo.iconHash.ifEmpty { appInfo.clientIconUrl } - timber.log.Timber.d("GOG image URL for ${appInfo.name}: iconHash='${appInfo.iconHash}', clientIconUrl='${appInfo.clientIconUrl}', final='$gogUrl'") - gogUrl + appInfo.iconHash } GameSource.STEAM -> { // For Steam games, use standard Steam URLs @@ -312,13 +307,19 @@ internal fun AppItem( ) } else { var isInstalled by remember(appInfo.appId, appInfo.gameSource) { - when (appInfo.gameSource) { - GameSource.STEAM -> mutableStateOf(SteamService.isAppInstalled(appInfo.gameId)) - GameSource.GOG -> mutableStateOf(GOGService.isGameInstalled(appInfo.gameId.toString())) - GameSource.CUSTOM_GAME -> mutableStateOf(true) // Custom Games are always considered installed - else -> mutableStateOf(false) + mutableStateOf(false) + } + + // Initialize installation status + LaunchedEffect(appInfo.appId, appInfo.gameSource) { + isInstalled = when (appInfo.gameSource) { + GameSource.STEAM -> SteamService.isAppInstalled(appInfo.gameId) + GameSource.GOG -> GOGService.isGameInstalled(appInfo.gameId.toString()) + GameSource.CUSTOM_GAME -> true + else -> false } } + // Update installation status when refresh completes LaunchedEffect(isRefreshing) { if (!isRefreshing) { From afacff68e49b50c11c3a9d868bcb61520a6b0925 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 23:17:46 +0000 Subject: [PATCH 085/122] Removed unused image-related code from GOGGame. --- app/src/main/java/app/gamenative/data/GOGGame.kt | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/GOGGame.kt b/app/src/main/java/app/gamenative/data/GOGGame.kt index 9750b2e16..24fc958f4 100644 --- a/app/src/main/java/app/gamenative/data/GOGGame.kt +++ b/app/src/main/java/app/gamenative/data/GOGGame.kt @@ -69,22 +69,6 @@ data class GOGGame( companion object { const val GOG_IMAGE_BASE_URL = "https://images.gog.com/images" } - - /** - * Get the GOG CDN image URL for this game - * GOG uses a specific URL pattern for game images - */ - val gogImageUrl: String - get() = if (imageUrl.isNotEmpty()) { - imageUrl - } else if (slug.isNotEmpty()) { - "$GOG_IMAGE_BASE_URL/$slug.jpg" - } else { - "" - } - - val gogIconUrl: String - get() = iconUrl.ifEmpty { gogImageUrl } } data class GOGCredentials( From 146c54aa3e96808560e0adecca48590266d1f544 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 22 Dec 2025 23:20:08 +0000 Subject: [PATCH 086/122] Fixed issue where Steam games weren't populating on load. --- app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 a3acb714c..597249bb3 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -102,6 +102,7 @@ class LibraryViewModel @Inject constructor( if (appList.size != apps.size) { // Don't filter if it's no change appList = apps + onFilterApps(paginationCurrentPage) } } } From 7435974bf9abfc0d8e8f8fd6fb5a027c51b25d86 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Tue, 23 Dec 2025 08:39:32 +0000 Subject: [PATCH 087/122] Performance fix for the LibraryViewModel to reduce blocking --- .../gamenative/ui/model/LibraryViewModel.kt | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) 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 597249bb3..50c9872b4 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -99,8 +99,8 @@ 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) } @@ -111,7 +111,8 @@ class LibraryViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { gogGameDao.getAll().collect { games -> Timber.tag("LibraryViewModel").d("Collecting ${games.size} GOG games") - if(gogGameList.size != games.size || gogGameList != games) { + // Check if the list has actually changed before triggering a re-filter + if (gogGameList != games) { gogGameList = games onFilterApps(paginationCurrentPage) } @@ -232,18 +233,19 @@ class LibraryViewModel @Inject constructor( } private fun onFilterApps(paginationPage: Int = 0): Job { - // May be filtering 1000+ apps - in future should paginate at the point of DAO request Timber.tag("LibraryViewModel").d("onFilterApps - appList.size: ${appList.size}, isFirstLoad: $isFirstLoad") - return viewModelScope.launch { + return viewModelScope.launch(Dispatchers.IO) { _state.update { it.copy(isLoading = true) } val currentState = _state.value val currentFilter = AppFilter.getAppType(currentState.appInfoSortType) + // Fetch download directory apps once on IO thread and cache as a HashSet for O(1) lookups val downloadDirectoryApps = DownloadService.getDownloadDirectoryApps() + val downloadDirectorySet = downloadDirectoryApps.toHashSet() // Filter Steam apps first (no pagination yet) - val downloadDirectorySet = downloadDirectoryApps.toHashSet() + // Note: Don't sort individual lists - we'll sort the combined list for consistent ordering val filteredSteamApps: List = appList .asSequence() .filter { item -> @@ -335,7 +337,6 @@ class LibraryViewModel @Inject constructor( true } } - .sortedBy { it.title.lowercase() } .toList() val gogEntries = filteredGOGGames.map { game -> @@ -368,17 +369,24 @@ class LibraryViewModel @Inject constructor( val includeOpen = _state.value.showCustomGamesInLibrary val includeGOG = _state.value.showGOGInLibrary - // Combine all lists + // Combine all lists and sort: installed games first, then alphabetically val combined = buildList { if (includeSteam) addAll(steamEntries) if (includeOpen) addAll(customEntries) if (includeGOG) addAll(gogEntries) }.sortedWith( - // Always sort by installed status first (installed games at top), then alphabetically within each group + // Primary sort: installed status (0 = installed at top, 1 = not installed at bottom) + // Secondary sort: alphabetically by name (case-insensitive) compareBy { entry -> if (entry.isInstalled) 0 else 1 - }.thenBy { it.item.name.lowercase() } // Alphabetical sorting within installed and uninstalled groups - ).mapIndexed { idx, entry -> entry.item.copy(index = idx) } + }.thenBy { it.item.name.lowercase() } + ).also { sortedList -> + // Log first few items to verify sorting + if (sortedList.isNotEmpty()) { + val installedCount = sortedList.count { it.isInstalled } + val first10 = sortedList.take(10) + } + }.mapIndexed { idx, entry -> entry.item.copy(index = idx) } // Total count for the current filter val totalFound = combined.size From 04dd102ab1224db8e8032b034de708f0891f6855 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Tue, 23 Dec 2025 10:54:08 +0000 Subject: [PATCH 088/122] Added fix for gradle duplication --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 617b8d0ce..6d67f99ec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -158,6 +158,7 @@ android { 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 From 844dd5ae2eecb6949433f2fa8f30bf35628bb68c Mon Sep 17 00:00:00 2001 From: phobos665 Date: Tue, 23 Dec 2025 10:58:09 +0000 Subject: [PATCH 089/122] Fixed issue where I was forcing the GOG .exe instead of using the selected .exe file. --- .../screen/library/appscreen/GOGAppScreen.kt | 131 +++++------------- 1 file changed, 38 insertions(+), 93 deletions(-) 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 index 75b9de295..9c68d47d7 100644 --- 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 @@ -10,7 +10,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalContext @@ -23,17 +22,13 @@ 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 app.gamenative.utils.ContainerUtils import com.winlator.container.ContainerData -import com.winlator.container.ContainerManager +import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -import java.io.File -import java.util.Locale /** * GOG-specific implementation of BaseAppScreen @@ -108,7 +103,7 @@ class GOGAppScreen : BaseAppScreen() { @Composable override fun getGameDisplayInfo( context: Context, - libraryItem: LibraryItem + libraryItem: LibraryItem, ): GameDisplayInfo { Timber.tag(TAG).d("getGameDisplayInfo: appId=${libraryItem.appId}, name=${libraryItem.name}") // Extract numeric gameId for GOGService calls @@ -132,35 +127,6 @@ class GOGAppScreen : BaseAppScreen() { LaunchedEffect(gameId, refreshTrigger) { gogGame = GOGService.getGOGGameOf(gameId) val game = gogGame - if (game != null) { - Timber.tag(TAG).d(""" - |=== GOG Game Object === - |Game ID: $gameId - |Title: ${game.title} - |Developer: ${game.developer} - |Publisher: ${game.publisher} - |Release Date: ${game.releaseDate} - |Description: ${game.description.take(100)}... - |Icon URL: ${game.iconUrl} - |Image URL: ${game.imageUrl} - |Install Path: ${game.installPath} - |Is Installed: ${game.isInstalled} - |Download Size: ${game.downloadSize} bytes (${game.downloadSize / 1_000_000_000.0} GB) - |Install Size: ${game.installSize} bytes (${game.installSize / 1_000_000_000.0} GB) - |Genres: ${game.genres.joinToString(", ")} - |Languages: ${game.languages.joinToString(", ")} - |Play Time: ${game.playTime} seconds - |Last Played: ${game.lastPlayed} - |Type: ${game.type} - |====================== - """.trimMargin()) - } else { - Timber.tag(TAG).w(""" - |GOG game not found in database for gameId=$gameId - |This usually means the game was added as a container but GOG library hasn't synced yet. - |The game will use fallback data from the LibraryItem until GOG library is refreshed. - """.trimMargin()) - } game } @@ -169,11 +135,15 @@ class GOGAppScreen : BaseAppScreen() { // Format sizes for display val sizeOnDisk = if (game != null && game.isInstalled && game.installSize > 0) { formatBytes(game.installSize) - } else null + } else { + null + } val sizeFromStore = if (game != null && game.downloadSize > 0) { formatBytes(game.downloadSize) - } else null + } 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) @@ -197,13 +167,13 @@ class GOGAppScreen : BaseAppScreen() { 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 + 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 + developer = game?.developer?.takeIf { it.isNotEmpty() } ?: "", // GOG API doesn't provide this installLocation = game?.installPath?.takeIf { it.isNotEmpty() }, sizeOnDisk = sizeOnDisk, - sizeFromStore = sizeFromStore + 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 @@ -278,10 +248,6 @@ class GOGAppScreen : BaseAppScreen() { } } - /** - * Perform the actual download after confirmation - * Delegates to GOGService/GOGManager for proper service layer separation - */ private fun performDownload(context: Context, libraryItem: LibraryItem, onClickPlay: (Boolean) -> Unit) { val gameId = libraryItem.gameId.toString() Timber.i("Starting GOG game download: ${libraryItem.appId}") @@ -296,7 +262,7 @@ class GOGAppScreen : BaseAppScreen() { android.widget.Toast.makeText( context, "Starting download for ${libraryItem.name}...", - android.widget.Toast.LENGTH_SHORT + android.widget.Toast.LENGTH_SHORT, ).show() } @@ -313,7 +279,7 @@ class GOGAppScreen : BaseAppScreen() { android.widget.Toast.makeText( context, "Failed to start download: ${error?.message}", - android.widget.Toast.LENGTH_LONG + android.widget.Toast.LENGTH_LONG, ).show() } } @@ -323,7 +289,7 @@ class GOGAppScreen : BaseAppScreen() { android.widget.Toast.makeText( context, "Download error: ${e.message}", - android.widget.Toast.LENGTH_LONG + android.widget.Toast.LENGTH_LONG, ).show() } } @@ -367,7 +333,7 @@ class GOGAppScreen : BaseAppScreen() { android.widget.Toast.makeText( context, "Download cancelled", - android.widget.Toast.LENGTH_SHORT + android.widget.Toast.LENGTH_SHORT, ).show() } else if (isInstalled) { // Show uninstall confirmation dialog @@ -376,10 +342,6 @@ class GOGAppScreen : BaseAppScreen() { } } - /** - * Perform the actual uninstall of a GOG game - * Delegates to GOGService/GOGManager for proper service layer separation - */ private fun performUninstall(context: Context, libraryItem: LibraryItem) { Timber.i("Uninstalling GOG game: ${libraryItem.appId}") CoroutineScope(Dispatchers.IO).launch { @@ -393,7 +355,7 @@ class GOGAppScreen : BaseAppScreen() { android.widget.Toast.makeText( context, "Game uninstalled successfully", - android.widget.Toast.LENGTH_SHORT + android.widget.Toast.LENGTH_SHORT, ).show() } } else { @@ -403,7 +365,7 @@ class GOGAppScreen : BaseAppScreen() { android.widget.Toast.makeText( context, "Failed to uninstall game: ${error?.message}", - android.widget.Toast.LENGTH_LONG + android.widget.Toast.LENGTH_LONG, ).show() } } @@ -413,7 +375,7 @@ class GOGAppScreen : BaseAppScreen() { android.widget.Toast.makeText( context, "Failed to uninstall game: ${e.message}", - android.widget.Toast.LENGTH_LONG + android.widget.Toast.LENGTH_LONG, ).show() } } @@ -478,7 +440,7 @@ class GOGAppScreen : BaseAppScreen() { onEditContainer: () -> Unit, onBack: () -> Unit, onClickPlay: (Boolean) -> Unit, - isInstalled: Boolean + isInstalled: Boolean, ): List { val options = mutableListOf() return options @@ -490,36 +452,17 @@ class GOGAppScreen : BaseAppScreen() { @Composable override fun getResetContainerOption( context: Context, - libraryItem: LibraryItem + libraryItem: LibraryItem, ): AppMenuOption { return AppMenuOption( optionType = AppOptionMenuType.ResetToDefaults, onClick = { resetContainerToDefaults(context, libraryItem) - } + }, ) } - - /** - * Override to launch GOG games properly (not as boot-to-container) - */ - override fun onRunContainerClick( - context: Context, - libraryItem: LibraryItem, - onClickPlay: (Boolean) -> Unit - ) { - // GOG games should launch with bootToContainer=false so getWineStartCommand - // can construct the proper launch command via GOGGameManager - Timber.tag(TAG).i("Launching GOG game: ${libraryItem.appId}") - onClickPlay(false) - } - - /** - * GOG games don't need special image fetching logic like Custom Games - * Images come from GOG CDN - */ override fun getGameFolderPathForImageFetch(context: Context, libraryItem: LibraryItem): String? { - return null // GOG uses CDN images, not local files + return null // GOG Stores full URLs in their database entry. } override fun observeGameState( @@ -527,7 +470,7 @@ class GOGAppScreen : BaseAppScreen() { libraryItem: LibraryItem, onStateChanged: () -> Unit, onProgressChanged: (Float) -> Unit, - onHasPartialDownloadChanged: ((Boolean) -> Unit)? + onHasPartialDownloadChanged: ((Boolean) -> Unit)?, ): (() -> Unit)? { Timber.tag(TAG).d("[OBSERVE] Setting up observeGameState for appId=${libraryItem.appId}, gameId=${libraryItem.gameId}") val disposables = mutableListOf<() -> Unit>() @@ -575,7 +518,8 @@ class GOGAppScreen : BaseAppScreen() { } } app.gamenative.PluviaApp.events.on(downloadStatusListener) - disposables += { app.gamenative.PluviaApp.events.off(downloadStatusListener) } + disposables += + { app.gamenative.PluviaApp.events.off(downloadStatusListener) } // Listen for install status changes val installListener: (app.gamenative.events.AndroidEvent.LibraryInstallStatusChanged) -> Unit = { event -> @@ -586,7 +530,8 @@ class GOGAppScreen : BaseAppScreen() { } } app.gamenative.PluviaApp.events.on(installListener) - disposables += { app.gamenative.PluviaApp.events.off(installListener) } + disposables += + { app.gamenative.PluviaApp.events.off(installListener) } // Return cleanup function return { @@ -602,7 +547,7 @@ class GOGAppScreen : BaseAppScreen() { libraryItem: LibraryItem, onDismiss: () -> Unit, onEditContainer: () -> Unit, - onBack: () -> Unit + onBack: () -> Unit, ) { Timber.tag(TAG).d("AdditionalDialogs: composing for appId=${libraryItem.appId}") val context = LocalContext.current @@ -651,8 +596,8 @@ class GOGAppScreen : BaseAppScreen() { text = stringResource( R.string.gog_install_confirmation_message, gogGame?.title ?: libraryItem.name, - sizeText - ) + sizeText, + ), ) }, confirmButton = { @@ -660,7 +605,7 @@ class GOGAppScreen : BaseAppScreen() { onClick = { hideInstallDialog(libraryItem.appId) performDownload(context, libraryItem) {} - } + }, ) { Text(stringResource(R.string.download)) } @@ -669,11 +614,11 @@ class GOGAppScreen : BaseAppScreen() { TextButton( onClick = { hideInstallDialog(libraryItem.appId) - } + }, ) { Text(stringResource(R.string.cancel)) } - } + }, ) } @@ -694,8 +639,8 @@ class GOGAppScreen : BaseAppScreen() { Text( text = stringResource( R.string.gog_uninstall_confirmation_message, - gogGame?.title ?: libraryItem.name - ) + gogGame?.title ?: libraryItem.name, + ), ) }, confirmButton = { @@ -703,7 +648,7 @@ class GOGAppScreen : BaseAppScreen() { onClick = { hideUninstallDialog(libraryItem.appId) performUninstall(context, libraryItem) - } + }, ) { Text(stringResource(R.string.uninstall)) } @@ -712,11 +657,11 @@ class GOGAppScreen : BaseAppScreen() { TextButton( onClick = { hideUninstallDialog(libraryItem.appId) - } + }, ) { Text(stringResource(R.string.cancel)) } - } + }, ) } } From 65bae51768e35076861b14df586cf8dc49215ef9 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Tue, 23 Dec 2025 11:05:01 +0000 Subject: [PATCH 090/122] GOGManager now automatically downloads all the DLC for the game. Complexity is handled by GOGDL and filtering of what they own. Later, we can ask the user WHAT DLC they want etc. --- app/src/main/java/app/gamenative/service/gog/GOGManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 957132040..3594df55d 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -530,7 +530,7 @@ class GOGManager @Inject constructor( "--platform", "windows", "--path", installPath, "--support", supportDir.absolutePath, - "--skip-dlcs", + "--with-dlcs", "--lang", "en-US", "--max-workers", "1", ) From 5efe9305b0c54c14f2323e2e1c037edcd8200b82 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 25 Dec 2025 20:48:35 +0000 Subject: [PATCH 091/122] Fixed issue where the path wasn't configured correctly for GOG games. --- app/src/main/java/app/gamenative/utils/ContainerUtils.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 8d2838664..6705aee15 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -533,8 +533,8 @@ object ContainerUtils { // For GOG games, map the specific game directory to A: drive val gameId = extractGameIdFromContainerId(appId) val game = GOGService.getGOGGameOf(gameId.toString()) - if (game != null) { - val gameInstallPath = GOGConstants.getGameInstallPath(game.title) + if (game != null && game.installPath.isNotEmpty()) { + val gameInstallPath = game.installPath val drive: Char = if (defaultDrives.contains("A:")) { Container.getNextAvailableDriveLetter(defaultDrives) } else { @@ -823,8 +823,8 @@ object ContainerUtils { // Ensure GOG games have the specific game directory mapped val gameId = extractGameIdFromContainerId(appId) val game = runBlocking { GOGService.getGOGGameOf(gameId.toString()) } - if (game != null) { - val gameInstallPath = GOGConstants.getGameInstallPath(game.title) + if (game != null && game.installPath.isNotEmpty()) { + val gameInstallPath = game.installPath var hasCorrectDriveMapping = false // Check if the specific game directory is already mapped From d9a8f4b925a1af2f666f1cb41d2857dab383013b Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Tue, 30 Dec 2025 20:49:59 -0800 Subject: [PATCH 092/122] added cloud saves to GOG (not tested) --- .../gamenative/data/GOGCloudSavesLocation.kt | 46 +++ .../java/app/gamenative/enums/PathType.kt | 110 +++++++ .../app/gamenative/service/gog/GOGManager.kt | 268 +++++++++++++++++- .../app/gamenative/service/gog/GOGService.kt | 92 ++++++ .../main/java/app/gamenative/ui/PluviaMain.kt | 19 +- .../app/gamenative/ui/model/MainViewModel.kt | 32 ++- 6 files changed, 561 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt 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 000000000..adac6cd89 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt @@ -0,0 +1,46 @@ +package app.gamenative.data + +/** + * Represents a GOG cloud save location + * @param name The name/identifier of the save location (e.g., "__default", "saves", "configs") + * @param location The absolute path to the save directory on the device + */ +data class GOGCloudSavesLocation( + val name: String, + val location: String +) + +/** + * Response from GOG's remote config API + * Structure: content.Windows.cloudStorage.locations[] + * (Android runs games through Wine, so always uses Windows platform) + */ +data class GOGRemoteConfigResponse( + val content: Map +) + +/** + * Platform-specific content from remote config + */ +data class GOGPlatformContent( + val cloudStorage: GOGCloudStorageInfo? +) + +/** + * Cloud storage configuration + */ +data class GOGCloudStorageInfo( + val enabled: Boolean, + val locations: List +) + +/** + * Save location template from API (before path resolution) + * @param name The name/identifier of the location + * @param location The path template with GOG variables (e.g., "/saves") + */ +data class GOGCloudSavesLocationTemplate( + val name: String, + val location: String +) + diff --git a/app/src/main/java/app/gamenative/enums/PathType.kt b/app/src/main/java/app/gamenative/enums/PathType.kt index a019a27bf..9594a3f42 100644 --- a/app/src/main/java/app/gamenative/enums/PathType.kt +++ b/app/src/main/java/app/gamenative/enums/PathType.kt @@ -102,6 +102,116 @@ 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): String { + val imageFs = ImageFs.find(context) + val winePrefix = imageFs.rootDir.absolutePath + val user = ImageFs.USER + + var mappedPath = gogWindowsPath + + // Map Windows environment variables to their Wine prefix equivalents + // Handle %USERPROFILE% first to avoid partial replacements + if (mappedPath.contains("%USERPROFILE%/Saved Games") || mappedPath.contains("%USERPROFILE%\\Saved Games")) { + val savedGamesPath = Paths.get( + winePrefix, ImageFs.WINEPREFIX, + "/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, ImageFs.WINEPREFIX, + "/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, ImageFs.WINEPREFIX, "/drive_c/users/", user, "AppData/Local/").toString()) + mappedPath = mappedPath.replace("%APPDATA%", + Paths.get(winePrefix, ImageFs.WINEPREFIX, "/drive_c/users/", user, "AppData/Roaming/").toString()) + mappedPath = mappedPath.replace("%USERPROFILE%", + Paths.get(winePrefix, ImageFs.WINEPREFIX, "/drive_c/users/", user, "").toString()) + + // Normalize path separators + mappedPath = mappedPath.replace("\\", "/") + + // Build absolute path - if it doesn't start with drive_c, assume it's relative to drive_c + val absolutePath = when { + mappedPath.startsWith("drive_c/") || mappedPath.startsWith("/drive_c/") -> { + val cleanPath = mappedPath.removePrefix("/") + Paths.get(winePrefix, ImageFs.WINEPREFIX, cleanPath).toString() + } + mappedPath.startsWith(winePrefix) -> { + // Already absolute + mappedPath + } + else -> { + // Relative path, assume it's in drive_c + Paths.get(winePrefix, ImageFs.WINEPREFIX, "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/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 3594df55d..686981a80 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -4,12 +4,17 @@ import android.content.Context import android.net.Uri import androidx.core.net.toUri import app.gamenative.data.DownloadInfo +import app.gamenative.data.GOGCloudSavesLocation +import app.gamenative.data.GOGCloudSavesLocationTemplate import app.gamenative.data.GOGGame import app.gamenative.data.LaunchInfo import app.gamenative.data.LibraryItem import app.gamenative.data.PostSyncInfo import app.gamenative.data.SteamApp import app.gamenative.data.GameSource +import app.gamenative.enums.PathType +import okhttp3.Request +import app.gamenative.utils.Net import app.gamenative.db.dao.GOGGameDao import app.gamenative.enums.AppType import app.gamenative.enums.ControllerSupport @@ -73,6 +78,13 @@ class GOGManager @Inject constructor( 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) + private val syncTimestamps = ConcurrentHashMap() + suspend fun getGameById(gameId: String): GOGGame? { return withContext(Dispatchers.IO) { try { @@ -879,7 +891,261 @@ class GOGManager @Inject constructor( return "\"$windowsPath\"" } - // TODO: Implement Cloud Saves here + // ========================================================================== + // CLOUD SAVES + // ========================================================================== + + /** + * Read GOG game info file and extract clientId + * @param appId Game ID + * @param installPath Optional install path, if null will try to get from game database + * @return JSONObject with game info, or null if not found + */ + suspend fun readInfoFile(appId: String, installPath: String?): JSONObject? = withContext(Dispatchers.IO) { + try { + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + var path = installPath + + // If no install path provided, try to get from database + if (path == null) { + val game = getGameById(gameId.toString()) + path = game?.installPath + } + + if (path == null || path.isEmpty()) { + Timber.w("No install path found for game $gameId") + return@withContext null + } + + val installDir = File(path) + if (!installDir.exists()) { + Timber.w("Install directory does not exist: $path") + return@withContext null + } + + // Look for goggame-{gameId}.info file + val infoFileName = "goggame-$gameId.info" + val infoFile = File(installDir, infoFileName) + + if (!infoFile.exists()) { + Timber.w("Info file not found: ${infoFile.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 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 + ): List? = withContext(Dispatchers.IO) { + try { + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val infoJson = readInfoFile(appId, installPath) + + if (infoJson == null) { + Timber.w("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.w("No clientId found in info file for game $gameId") + return@withContext null + } + + // Check cache first + remoteConfigCache[clientId]?.let { cachedLocations -> + Timber.d("Using cached save locations for clientId $clientId") + return@withContext 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.d("Fetching save sync location from: $url") + + val request = Request.Builder() + .url(url) + .build() + + val response = Net.http.newCall(request).execute() + + if (!response.isSuccessful) { + Timber.w("Failed to fetch remote config: HTTP ${response.code}") + return@withContext null + } + + val responseBody = response.body?.string() ?: return@withContext null + val configJson = JSONObject(responseBody) + + // Parse response: content.Windows.cloudStorage.locations + val content = configJson.optJSONObject("content") + if (content == null) { + Timber.w("No 'content' field in remote config response") + return@withContext null + } + + val platformContent = content.optJSONObject(syncPlatform) + if (platformContent == null) { + Timber.d("No cloud storage config for platform $syncPlatform") + return@withContext null + } + + val cloudStorage = platformContent.optJSONObject("cloudStorage") + if (cloudStorage == null) { + Timber.d("No cloudStorage field for platform $syncPlatform") + return@withContext null + } + + val enabled = cloudStorage.optBoolean("enabled", false) + if (!enabled) { + Timber.d("Cloud saves not enabled for game $gameId") + return@withContext null + } + + val locationsArray = cloudStorage.optJSONArray("locations") + if (locationsArray == null || locationsArray.length() == 0) { + Timber.d("No save locations configured for game $gameId") + return@withContext null + } + + 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()) { + locations.add(GOGCloudSavesLocationTemplate(name, location)) + } + } + + // Cache the result + if (locations.isNotEmpty()) { + remoteConfigCache[clientId] = locations + Timber.d("Cached save locations for clientId $clientId") + } + + Timber.i("Found ${locations.size} save location(s) for game $gameId") + return@withContext locations + } catch (e: Exception) { + Timber.e(e, "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 { + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val game = getGameById(gameId.toString()) + + if (game == null) { + Timber.w("Game not found for appId $appId") + return@withContext null + } + + val installPath = game.installPath + if (installPath.isEmpty()) { + Timber.w("Game not installed: $appId") + return@withContext null + } + + // Fetch save locations from API (Android runs games through Wine, so always Windows) + var locations = getSaveSyncLocation(context, appId, installPath) + + // If no locations from API, use default Windows path + if (locations == null || locations.isEmpty()) { + Timber.d("No save locations from API, using default for game $gameId") + val infoJson = readInfoFile(appId, installPath) + val clientId = infoJson?.optString("clientId", "") ?: "" + + if (clientId.isNotEmpty()) { + val defaultLocation = "%LocalAppData%/GOG.com/Galaxy/Applications/$clientId/Storage/Shared/Files" + locations = listOf(GOGCloudSavesLocationTemplate("__default", defaultLocation)) + } else { + Timber.w("Cannot create default save location: no clientId") + return@withContext null + } + } + + // Resolve each location + val resolvedLocations = mutableListOf() + for (locationTemplate in locations) { + // Resolve GOG variables (, etc.) to Windows env vars + var resolvedPath = PathType.resolveGOGPathVariables(locationTemplate.location, installPath) + + // Map GOG Windows path to device path using PathType + resolvedPath = PathType.toAbsPathForGOG(context, resolvedPath) + + resolvedLocations.add( + GOGCloudSavesLocation( + name = locationTemplate.name, + location = resolvedPath + ) + ) + } + + Timber.i("Resolved ${resolvedLocations.size} save location(s) for game $gameId") + return@withContext resolvedLocations + } catch (e: Exception) { + Timber.e(e, "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 getSyncTimestamp(appId: String, locationName: String): String { + val key = "${appId}_$locationName" + return syncTimestamps.getOrDefault(key, "0") + } + + /** + * Store sync timestamp for a game+location + * @param appId Game app ID + * @param locationName Location name + * @param timestamp Timestamp string + */ + fun setSyncTimestamp(appId: String, locationName: String, timestamp: String) { + val key = "${appId}_$locationName" + syncTimestamps[key] = timestamp + Timber.d("Stored sync timestamp for $key: $timestamp") + } // ========================================================================== // FILE SYSTEM & PATHS diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index a57aeb735..18c7a647e 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -10,6 +10,7 @@ import app.gamenative.data.GOGGame import app.gamenative.data.LaunchInfo import app.gamenative.data.LibraryItem import app.gamenative.service.NotificationHelper +import app.gamenative.utils.ContainerUtils import dagger.hilt.android.AndroidEntryPoint import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.* @@ -285,6 +286,97 @@ class GOGService : Service() { 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 { + val instance = getInstance() ?: return@withContext false + + if (!GOGAuthManager.hasStoredCredentials(context)) { + Timber.e("Cannot sync saves: not authenticated") + return@withContext false + } + + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + + // Get game info + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val game = instance.gogManager.getGameById(gameId.toString()) + + if (game == null) { + Timber.e("Game not found for appId: $appId") + return@withContext false + } + + // Get save directory paths (Android runs games through Wine, so always Windows) + val saveLocations = instance.gogManager.getSaveDirectoryPath(context, appId, game.title) + + if (saveLocations == null || saveLocations.isEmpty()) { + Timber.w("No save locations found for game $appId (cloud saves may not be enabled)") + return@withContext false + } + + var allSucceeded = true + + // Sync each save location + for (location in saveLocations) { + try { + // Get stored timestamp for this location + val timestamp = instance.gogManager.getSyncTimestamp(appId, location.name) + + Timber.i("Syncing save location '${location.name}' for game $gameId (timestamp: $timestamp)") + + // Build command arguments (matching HeroicGamesLauncher format) + val commandArgs = mutableListOf( + "--auth-config-path", authConfigPath, + "save-sync", + location.location, + gameId.toString(), + "--os", "windows", // Android runs games through Wine + "--ts", timestamp, + "--name", location.name, + "--prefered-action", preferredAction + ) + + // Execute sync command + val result = GOGPythonBridge.executeCommand(*commandArgs.toTypedArray()) + + if (result.isSuccess) { + val output = result.getOrNull() ?: "" + // Python save-sync returns timestamp on success, store it + val newTimestamp = output.trim() + if (newTimestamp.isNotEmpty() && newTimestamp != "0") { + instance.gogManager.setSyncTimestamp(appId, location.name, newTimestamp) + } + Timber.i("Successfully synced save location '${location.name}' for game $gameId") + } else { + Timber.e(result.exceptionOrNull(), "Failed to sync save location '${location.name}' for game $gameId") + allSucceeded = false + } + } catch (e: Exception) { + Timber.e(e, "Exception syncing save location '${location.name}' for game $gameId") + allSucceeded = false + } + } + + if (allSucceeded) { + Timber.i("Cloud saves synced successfully for $appId") + return@withContext true + } else { + Timber.w("Some save locations failed to sync for $appId") + return@withContext false + } + } catch (e: Exception) { + Timber.e(e, "Failed to sync cloud saves for App ID: $appId") + return@withContext false + } + } } private lateinit var notificationHelper: NotificationHelper diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 7553e16c8..02d297e72 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1134,10 +1134,25 @@ fun preLaunchApp( return@launch } - // For GOG Games, bypass Steam Cloud operations entirely and proceed to launch + // For GOG Games, sync cloud saves before launch val isGOGGame = ContainerUtils.extractGameSourceFromContainerId(appId) == GameSource.GOG if (isGOGGame) { - Timber.tag("preLaunchApp").i("GOG Game detected for $appId — skipping Steam Cloud sync and launching container") + Timber.tag("preLaunchApp").i("GOG Game detected for $appId — syncing cloud saves before launch") + + // Sync cloud saves (download latest saves before playing) + val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves( + context = context, + appId = appId, + preferredAction = "download" + ) + + if (!syncSuccess) { + Timber.w("GOG cloud save sync failed for $appId, proceeding with launch anyway") + // Don't block launch on sync failure - log warning and continue + } else { + Timber.i("GOG cloud save sync completed successfully for $appId") + } + setLoadingDialogVisible(false) onSuccess(context, appId) return@launch 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 a8930be28..c7d41e96a 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -279,9 +279,35 @@ class MainViewModel @Inject constructor( 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("Exit").i("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 { + val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves( + context = context, + appId = appId, + preferredAction = "upload" + ) + if (syncSuccess) { + Timber.i("GOG cloud save sync completed successfully for $appId") + } else { + Timber.w("GOG cloud save sync failed for $appId") + } + } catch (e: Exception) { + Timber.e(e, "Exception syncing GOG cloud saves 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) { From 02a0911fea6ae6420439dc1bb7f6ccf74dad014e Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Tue, 30 Dec 2025 21:29:16 -0800 Subject: [PATCH 093/122] Updated GOG manager to correctly find save file location --- .../gamenative/data/GOGCloudSavesLocation.kt | 40 ++++--------------- .../app/gamenative/service/gog/GOGManager.kt | 28 ++++++++----- 2 files changed, 26 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt b/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt index adac6cd89..6095ad2c1 100644 --- a/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt +++ b/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt @@ -1,45 +1,21 @@ package app.gamenative.data /** - * Represents a GOG cloud save location - * @param name The name/identifier of the save location (e.g., "__default", "saves", "configs") - * @param location The absolute path to the save directory on the device + * 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 GOGCloudSavesLocation( +data class GOGCloudSavesLocationTemplate( val name: String, val location: String ) /** - * Response from GOG's remote config API - * Structure: content.Windows.cloudStorage.locations[] - * (Android runs games through Wine, so always uses Windows platform) - */ -data class GOGRemoteConfigResponse( - val content: Map -) - -/** - * Platform-specific content from remote config - */ -data class GOGPlatformContent( - val cloudStorage: GOGCloudStorageInfo? -) - -/** - * Cloud storage configuration - */ -data class GOGCloudStorageInfo( - val enabled: Boolean, - val locations: List -) - -/** - * Save location template from API (before path resolution) - * @param name The name/identifier of the location - * @param location The path template with GOG variables (e.g., "/saves") + * 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 */ -data class GOGCloudSavesLocationTemplate( +data class GOGCloudSavesLocation( val name: String, val location: String ) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 686981a80..0def7a927 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -787,11 +787,20 @@ class GOGManager @Inject constructor( return "" } + private fun findGOGInfoFile(directory: File, gameId: String? = null): File? { + return directory.listFiles()?.find { + it.isFile && if (gameId != null) { + it.name == "goggame-$gameId.info" + } else { + it.name.startsWith("goggame-") && it.name.endsWith(".info") + } + } + } + private fun getMainExecutableFromGOGInfo(gameDir: File, installPath: String): Result { return try { - val infoFile = gameDir.listFiles()?.find { - it.isFile && it.name.startsWith("goggame-") && it.name.endsWith(".info") - } ?: return Result.failure(Exception("GOG info file not found in ${gameDir.absolutePath}")) + val infoFile = findGOGInfoFile(gameDir) + ?: return Result.failure(Exception("GOG info file not found in ${gameDir.absolutePath}")) val content = infoFile.readText() val jsonObject = JSONObject(content) @@ -923,12 +932,11 @@ class GOGManager @Inject constructor( return@withContext null } - // Look for goggame-{gameId}.info file - val infoFileName = "goggame-$gameId.info" - val infoFile = File(installDir, infoFileName) + // Look for goggame-{gameId}.info file - check root first, then common subdirectories + var infoFile = findGOGInfoFile(installDir, gameId.toString()) - if (!infoFile.exists()) { - Timber.w("Info file not found: ${infoFile.absolutePath}") + if (!infoFile!!.exists()) { + Timber.w("Info file not found for game $gameId in ${installDir.absolutePath}") return@withContext null } @@ -957,7 +965,7 @@ class GOGManager @Inject constructor( try { val gameId = ContainerUtils.extractGameIdFromContainerId(appId) val infoJson = readInfoFile(appId, installPath) - + if (infoJson == null) { Timber.w("Cannot get save sync location: info file not found") return@withContext null @@ -1089,7 +1097,7 @@ class GOGManager @Inject constructor( Timber.d("No save locations from API, using default for game $gameId") val infoJson = readInfoFile(appId, installPath) val clientId = infoJson?.optString("clientId", "") ?: "" - + if (clientId.isNotEmpty()) { val defaultLocation = "%LocalAppData%/GOG.com/Galaxy/Applications/$clientId/Storage/Shared/Files" locations = listOf(GOGCloudSavesLocationTemplate("__default", defaultLocation)) From b09618dff62a83531634bfe53bb505033b8f8c8f Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Tue, 30 Dec 2025 22:57:01 -0800 Subject: [PATCH 094/122] Cleaned up cloud save location fetching --- .../app/gamenative/service/gog/GOGManager.kt | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 0def7a927..702a64155 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -787,14 +787,39 @@ class GOGManager @Inject constructor( return "" } - private fun findGOGInfoFile(directory: File, gameId: String? = null): File? { - return directory.listFiles()?.find { + 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 { From 60b2c41bd39aa5b72c2e333eebb92fef8cb11a5d Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Wed, 31 Dec 2025 17:07:46 -0800 Subject: [PATCH 095/122] Small change for GOGManager, not assuming infoFile exists --- app/src/main/java/app/gamenative/service/gog/GOGManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 702a64155..7719c69c0 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -958,9 +958,9 @@ class GOGManager @Inject constructor( } // Look for goggame-{gameId}.info file - check root first, then common subdirectories - var infoFile = findGOGInfoFile(installDir, gameId.toString()) + val infoFile = findGOGInfoFile(installDir, gameId.toString()) - if (!infoFile!!.exists()) { + if (infoFile == null || !infoFile.exists()) { Timber.w("Info file not found for game $gameId in ${installDir.absolutePath}") return@withContext null } From 78c8506c5bce0fa1c7e87dc41c7f523af3c541b8 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 10:39:20 +0000 Subject: [PATCH 096/122] Added functionality to stop service when the app is destroyed. --- app/src/main/java/app/gamenative/MainActivity.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index c3da8bac2..3ee24e4ce 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -33,6 +33,7 @@ import coil.memory.MemoryCache import coil.request.CachePolicy import app.gamenative.events.AndroidEvent import app.gamenative.service.SteamService +import app.gamenative.service.gog.GOGService import app.gamenative.ui.PluviaMain import app.gamenative.ui.enums.Orientation import app.gamenative.utils.AnimatedPngDecoder @@ -250,6 +251,11 @@ class MainActivity : ComponentActivity() { Timber.i("Stopping Steam Service") SteamService.stop() } + + if(GOGService.isRunning) { + Timber.i("Stopping GOG Service") + GOGService.stop() + } } override fun onResume() { From c6b6cb48287221c5d3bd2a367762abc50115e288 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 10:45:49 +0000 Subject: [PATCH 097/122] Small tweaks --- .../gamenative/service/gog/GOGConstants.kt | 2 +- .../app/gamenative/service/gog/GOGManager.kt | 25 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt index bd8d2560c..6401e22e7 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt @@ -25,7 +25,7 @@ object GOGConstants { const val GOG_AUTH_LOGIN_URL = "https://auth.gog.com/auth?client_id=$GOG_CLIENT_ID&redirect_uri=$GOG_REDIRECT_URI&response_type=code&layout=client2" // GOG paths - following Steam's structure pattern - private const val INTERNAL_BASE_PATH = "/data/data/app.gamenative/files" + private const val INTERNAL_BASE_PATH = "/data/data/app.gamenative" /** * Internal GOG games installation path (similar to Steam's internal path) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 702a64155..354fc1b6c 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -791,7 +791,7 @@ class GOGManager @Inject constructor( if (!directory.exists() || !directory.isDirectory) { return null } - + // Check current directory first val infoFile = directory.listFiles()?.find { it.isFile && if (gameId != null) { @@ -800,16 +800,16 @@ class GOGManager @Inject constructor( 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) { @@ -818,7 +818,7 @@ class GOGManager @Inject constructor( return found } } - + return null } @@ -839,12 +839,15 @@ class GOGManager @Inject constructor( val task = playTasks.getJSONObject(i) if (task.has("isPrimary") && task.getBoolean("isPrimary")) { val executablePath = task.getString("path") - val actualExeFile = gameDir.listFiles()?.find { - it.name.equals(executablePath, ignoreCase = true) - } - if (actualExeFile != null && actualExeFile.exists()) { - return Result.success("${gameDir.name}/${actualExeFile.name}") + + // Construct full path - executablePath may include subdirectories + val exeFile = File(gameDir, executablePath) + + if (exeFile.exists()) { + val relativePath = exeFile.relativeTo(gameDir.parentFile).path + return Result.success(relativePath) } + return Result.failure(Exception("Primary executable '$executablePath' not found in ${gameDir.absolutePath}")) } } @@ -1124,7 +1127,7 @@ class GOGManager @Inject constructor( val clientId = infoJson?.optString("clientId", "") ?: "" if (clientId.isNotEmpty()) { - val defaultLocation = "%LocalAppData%/GOG.com/Galaxy/Applications/$clientId/Storage/Shared/Files" + val defaultLocation = "%LOCALAPPDATA%/GOG.com/Galaxy/Applications/$clientId/Storage/Shared/Files" locations = listOf(GOGCloudSavesLocationTemplate("__default", defaultLocation)) } else { Timber.w("Cannot create default save location: no clientId") From 644cd1fd342c67fd90d418869d873acca8c48d21 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 11:08:40 +0000 Subject: [PATCH 098/122] Various fixes including login view. --- .../app/gamenative/service/gog/GOGManager.kt | 7 +- .../ui/component/dialog/GOGLoginDialog.kt | 37 ++++--- .../gamenative/ui/model/LibraryViewModel.kt | 1 - .../screen/library/appscreen/BaseAppScreen.kt | 17 ++++ .../screen/library/appscreen/GOGAppScreen.kt | 99 +++++++------------ app/src/main/res/values/strings.xml | 6 +- 6 files changed, 80 insertions(+), 87 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 571cd1cb5..e06e5a75c 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -123,11 +123,11 @@ class GOGManager @Inject constructor( if (result.isSuccess) { val count = result.getOrNull() ?: 0 Timber.tag("GOG").i("Background sync completed: $count games synced") - Result.success(Unit) + return@withContext Result.success(Unit) } else { val error = result.exceptionOrNull() Timber.e(error, "Background sync failed: ${error?.message}") - Result.failure(error ?: Exception("Background sync failed")) + return@withContext Result.failure(error ?: Exception("Background sync failed")) } } catch (e: Exception) { Timber.e(e, "Failed to sync GOG library in background") @@ -844,7 +844,8 @@ class GOGManager @Inject constructor( val exeFile = File(gameDir, executablePath) if (exeFile.exists()) { - val relativePath = exeFile.relativeTo(gameDir.parentFile).path + val parentDir = gameDir.parentFile ?: gameDir ++ val relativePath = exeFile.relativeTo(parentDir).path return Result.success(relativePath) } 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 index e8e16a9ee..95fa1c9c7 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GOGLoginDialog.kt @@ -2,6 +2,8 @@ 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 @@ -9,6 +11,7 @@ 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 @@ -50,6 +53,9 @@ fun GOGLoginDialog( ) { 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( @@ -58,21 +64,24 @@ fun GOGLoginDialog( title = { Text(stringResource(R.string.gog_login_title)) }, text = { Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) + 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) ) { - // Instructions - Text( - text = stringResource(R.string.gog_login_instruction), - style = MaterialTheme.typography.bodyMedium - ) - Text( text = stringResource(R.string.gog_login_auto_auth_info), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - // Open browser button Button( onClick = { @@ -88,7 +97,8 @@ fun GOGLoginDialog( } }, enabled = !isLoading, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + contentPadding = if (isLandscape) PaddingValues(8.dp) else ButtonDefaults.ContentPadding ) { Icon( imageVector = Icons.Default.OpenInBrowser, @@ -99,7 +109,7 @@ fun GOGLoginDialog( Text(stringResource(R.string.gog_login_open_button)) } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = if (isLandscape) 4.dp else 8.dp)) // Manual code entry fallback Text( @@ -107,11 +117,6 @@ fun GOGLoginDialog( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Text( - text = stringResource(R.string.gog_login_manual_entry), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) // Authorization code input OutlinedTextField( 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 50c9872b4..ec50dae91 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -381,7 +381,6 @@ class LibraryViewModel @Inject constructor( if (entry.isInstalled) 0 else 1 }.thenBy { it.item.name.lowercase() } ).also { sortedList -> - // Log first few items to verify sorting if (sortedList.isNotEmpty()) { val installedCount = sortedList.count { it.isInstalled } val first10 = sortedList.take(10) 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 84b884307..7b9458d5a 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. 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 index 9c68d47d7..f6594944c 100644 --- 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 @@ -35,7 +35,6 @@ import timber.log.Timber * Handles GOG games with integration to the Python gogdl backend */ class GOGAppScreen : BaseAppScreen() { - companion object { private const val TAG = "GOGAppScreen" @@ -124,10 +123,9 @@ class GOGAppScreen : BaseAppScreen() { } var gogGame by remember(gameId, refreshTrigger) { mutableStateOf(null) } + LaunchedEffect(gameId, refreshTrigger) { gogGame = GOGService.getGOGGameOf(gameId) - val game = gogGame - game } val game = gogGame @@ -552,9 +550,9 @@ class GOGAppScreen : BaseAppScreen() { 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 -> @@ -562,74 +560,49 @@ class GOGAppScreen : BaseAppScreen() { } } - // Monitor install dialog state - var showInstallDialog by remember { mutableStateOf(shouldShowInstallDialog(libraryItem.appId)) } - - LaunchedEffect(libraryItem.appId) { - snapshotFlow { shouldShowInstallDialog(libraryItem.appId) } - .collect { shouldShow -> - showInstallDialog = shouldShow + // Shared install dialog state (from BaseAppScreen) + val appId = libraryItem.appId + var installDialogState by remember(appId) { + mutableStateOf(getInstallDialogState(appId) ?: app.gamenative.ui.component.dialog.state.MessageDialogState(false)) + } + LaunchedEffect(appId) { + snapshotFlow { getInstallDialogState(appId) } + .collect { state -> + installDialogState = state ?: app.gamenative.ui.component.dialog.state.MessageDialogState(false) } } - // Show install confirmation dialog - if (showInstallDialog) { - // GOGService expects numeric gameId - val gameId = libraryItem.gameId.toString() - val gogGame = remember(gameId) { - GOGService.getGOGGameOf(gameId) - } - val downloadSizeGB = (gogGame?.downloadSize ?: 0L) / 1_000_000_000.0 - val sizeText = if (downloadSizeGB > 0) { - String.format(Locale.US, "%.2f GB", downloadSizeGB) - } else { - "Unknown size" + // Show install dialog if visible + if (installDialogState.visible) { + val onDismissRequest: (() -> Unit)? = { + hideInstallDialog(appId) } - - AlertDialog( - onDismissRequest = { - hideInstallDialog(libraryItem.appId) - }, - title = { Text(stringResource(R.string.gog_install_game_title)) }, - text = { - Text( - text = stringResource( - R.string.gog_install_confirmation_message, - gogGame?.title ?: libraryItem.name, - sizeText, - ), - ) - }, - confirmButton = { - TextButton( - onClick = { - hideInstallDialog(libraryItem.appId) - performDownload(context, libraryItem) {} - }, - ) { - Text(stringResource(R.string.download)) - } - }, - dismissButton = { - TextButton( - onClick = { - hideInstallDialog(libraryItem.appId) - }, - ) { - Text(stringResource(R.string.cancel)) + val onDismissClick: (() -> Unit)? = { + hideInstallDialog(appId) + } + val onConfirmClick: (() -> Unit)? = when (installDialogState.type) { + app.gamenative.ui.enums.DialogType.INSTALL_APP -> { + { + 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) { - // GOGService expects numeric gameId - val gameId = libraryItem.gameId.toString() - val gogGame = remember(gameId) { - GOGService.getGOGGameOf(gameId) - } - AlertDialog( onDismissRequest = { hideUninstallDialog(libraryItem.appId) @@ -639,7 +612,7 @@ class GOGAppScreen : BaseAppScreen() { Text( text = stringResource( R.string.gog_uninstall_confirmation_message, - gogGame?.title ?: libraryItem.name, + libraryItem.name, ), ) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d663e9091..523f04fd1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -946,11 +946,9 @@ Sign in to GOG - Sign in with your GOG account: - Tap \'Open GOG Login\' and sign in. Once logged in, please take the code from the success URL OR copy the entire URL and paste it below - Example: https://embed.gog.com/on_login_success?origin=client&code=AUTH_CODE_HERE + 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 - Paste your code or success URL below Authorization Code or login success URL Paste code or url here Login From e274284bc00e4a3e57148118a223fb7a88478956 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 11:19:04 +0000 Subject: [PATCH 099/122] Update to re-use the install dialog and remove install size for gog as we can't get it prior to installing it --- .../app/gamenative/service/gog/GOGManager.kt | 2 +- .../screen/library/appscreen/GOGAppScreen.kt | 61 ++++++++++--------- app/src/main/res/values-da/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 9 files changed, 41 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index e06e5a75c..b612e3354 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -845,7 +845,7 @@ class GOGManager @Inject constructor( if (exeFile.exists()) { val parentDir = gameDir.parentFile ?: gameDir -+ val relativePath = exeFile.relativeTo(parentDir).path + val relativePath = exeFile.relativeTo(parentDir).path return Result.success(relativePath) } 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 index f6594944c..f53923fc9 100644 --- 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 @@ -60,28 +60,6 @@ class GOGAppScreen : BaseAppScreen() { return result } - // Shared state for install dialog - list of appIds that should show the dialog - private val installDialogAppIds = mutableStateListOf() - - fun showInstallDialog(appId: String) { - Timber.tag(TAG).d("showInstallDialog: appId=$appId") - if (!installDialogAppIds.contains(appId)) { - installDialogAppIds.add(appId) - Timber.tag(TAG).d("Added to install dialog list: $appId") - } - } - - fun hideInstallDialog(appId: String) { - Timber.tag(TAG).d("hideInstallDialog: appId=$appId") - installDialogAppIds.remove(appId) - } - - fun shouldShowInstallDialog(appId: String): Boolean { - val result = installDialogAppIds.contains(appId) - Timber.tag(TAG).d("shouldShowInstallDialog: appId=$appId, result=$result") - return result - } - /** * Formats bytes into a human-readable string (KB, MB, GB). * Uses binary units (1024 base). @@ -242,7 +220,34 @@ class GOGAppScreen : BaseAppScreen() { } else { // Show install confirmation dialog Timber.tag(TAG).i("Showing install confirmation dialog for: ${libraryItem.appId}") - showInstallDialog(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}") + } + } } } @@ -563,10 +568,10 @@ class GOGAppScreen : BaseAppScreen() { // Shared install dialog state (from BaseAppScreen) val appId = libraryItem.appId var installDialogState by remember(appId) { - mutableStateOf(getInstallDialogState(appId) ?: app.gamenative.ui.component.dialog.state.MessageDialogState(false)) + mutableStateOf(BaseAppScreen.getInstallDialogState(appId) ?: app.gamenative.ui.component.dialog.state.MessageDialogState(false)) } LaunchedEffect(appId) { - snapshotFlow { getInstallDialogState(appId) } + snapshotFlow { BaseAppScreen.getInstallDialogState(appId) } .collect { state -> installDialogState = state ?: app.gamenative.ui.component.dialog.state.MessageDialogState(false) } @@ -575,15 +580,15 @@ class GOGAppScreen : BaseAppScreen() { // Show install dialog if visible if (installDialogState.visible) { val onDismissRequest: (() -> Unit)? = { - hideInstallDialog(appId) + BaseAppScreen.hideInstallDialog(appId) } val onDismissClick: (() -> Unit)? = { - hideInstallDialog(appId) + BaseAppScreen.hideInstallDialog(appId) } val onConfirmClick: (() -> Unit)? = when (installDialogState.type) { app.gamenative.ui.enums.DialogType.INSTALL_APP -> { { - hideInstallDialog(appId) + BaseAppScreen.hideInstallDialog(appId) performDownload(context, libraryItem) {} } } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 9d4c824b6..c384b3b32 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -20,7 +20,7 @@ Afinstallér spil Er du sikker på, at du vil afinstallere %1$s? Denne handling kan ikke fortrydes. Download spil - Download %1$s (%2$s)? Sørg for at have tilstrækkelig lagerplads. + 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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b7f893446..aea9cc7c9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -43,7 +43,7 @@ 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 - Télécharger %1$s (%2$s) ? Assurez-vous d\'avoir suffisamment d\'espace de stockage. + 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 692e53f82..c84982b6b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -20,7 +20,7 @@ Desinstalar Jogo Tem certeza que deseja desinstalar %1$s? Esta ação não pode ser desfeita. Baixar Jogo - Baixar %1$s (%2$s)? Certifique-se de ter espaço de armazenamento suficiente. + 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 diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6e8c684d5..d91b8925e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -22,7 +22,7 @@ Деінсталювати гру Ви впевнені, що хочете деінсталювати %1$s? Цю дію не можна скасувати. Завантажити гру - Завантажити %1$s (%2$s)? Переконайтеся, що у вас достатньо місця для зберігання. + Гра має наступні вимоги до простору. Бажаєте продовжити?\n\n\tРозмір завантаження: %1$s\n\tДоступний простір: %2$s Інсталювати застосунок Деінсталювати застосунок Скасувати завантаження diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 10f8f661d..065759129 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -42,7 +42,7 @@ 卸载游戏 您确定要卸载 %1$s 吗? 此操作无法撤回 下载游戏 - 下载 %1$s (%2$s)? 请确保有足够的存储空间 + 正在安装的应用程式有以下空间需求:\n\n\t下载大小: %1$s\n\t可用空间: %2$s 从不 继续 App header image diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 147106c4e..0a3481927 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -42,7 +42,7 @@ 卸載遊戲 您確定要卸載 %1$s 嗎? 此操作無法撤回 下載遊戲 - 下載 %1$s (%2$s)? 請確保有足夠的儲存空間 + 正在安裝的應用程式有以下空間需求:\n\n\t下載大小: %1$s\n\t可用空間: %2$s 從不 繼續 App header image diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 523f04fd1..062f452ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,7 +43,7 @@ Uninstall Game Are you sure you want to uninstall %1$s? This action cannot be undone. Download Game - Download %1$s (%2$s)? Make sure you have enough storage space. + 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 From b348e8cd9d58b20510d33f311112671cdcf99b73 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 11:22:57 +0000 Subject: [PATCH 100/122] Update non-english strings --- app/src/main/res/values-da/strings.xml | 22 ++++++++++++++++++++++ app/src/main/res/values-fr/strings.xml | 22 ++++++++++++++++++++++ app/src/main/res/values-pt-rBR/strings.xml | 22 ++++++++++++++++++++++ app/src/main/res/values-uk/strings.xml | 22 ++++++++++++++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 22 ++++++++++++++++++++++ app/src/main/res/values-zh-rTW/strings.xml | 22 ++++++++++++++++++++++ 6 files changed, 132 insertions(+) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index c384b3b32..8ba9247d6 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -799,4 +799,26 @@ 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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aea9cc7c9..dd8ed9872 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -932,6 +932,28 @@ 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c84982b6b..468b3d94a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -799,4 +799,26 @@ 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 diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index d91b8925e..92e82a213 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -578,6 +578,28 @@ Вміст успішно інстальовано Не вдалося інсталювати вміст Помилка інсталяції: %1$s + + + Інтеграція GOG (Альфа) + Вхід у GOG + Увійдіть у свій обліковий запис GOG + Синхронізація… + Помилка: %1$s + ✓ Синхронізовано %1$d ігор + Отримати вашу бібліотеку ігор GOG + Вхід успішний + Ви увійшли в GOG.\nМи тепер синхронізуємо вашу бібліотеку у фоновому режимі. + + + Увійти в GOG + Натисніть \'Відкрити вхід GOG\' і увійдіть. Після входу скопіюйте URL-адресу та вставте нижче + Приклад: https://embed.gog.com/on_login_success?origin=client&code=aaa + Відкрити вхід GOG + Код авторизації або URL успішного входу + Вставте код або url сюди + Увійти + Скасувати + Не вдалося відкрити браузер diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 065759129..56f5e1505 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -929,6 +929,28 @@ 使用此版本的容器: 目前没有容器使用此版本 若您继续操作, 这些容器将无法继续运作: + + + 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 + 登录 + 取消 + 无法打开浏览器 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0a3481927..e7047b88f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -929,6 +929,28 @@ 使用此版本的容器: 目前沒有容器使用此版本 若您繼續操作, 這些容器將無法繼續運作: + + + 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 + 登入 + 取消 + 無法開啟瀏覽器 From b295637b031f252c242ac440b10ac368246edc9a Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 11:26:45 +0000 Subject: [PATCH 101/122] Small fix for verifying installation --- app/src/main/java/app/gamenative/service/gog/GOGManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index b612e3354..f27662436 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -724,7 +724,7 @@ class GOGManager @Inject constructor( val game = runBlocking { getGameById(gameId) } val installPath = game?.installPath - if (installPath == null || !game.isInstalled) { + if (game == null || installPath == null || !game.isInstalled) { return Pair(false, "Game not marked as installed in database") } From 8b485c66ab239e34a1484c057b379a454687a163 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 11:37:07 +0000 Subject: [PATCH 102/122] initial logout WIP --- .../app/gamenative/service/gog/GOGManager.kt | 5 ++ .../app/gamenative/service/gog/GOGService.kt | 41 +++++++++- .../screen/settings/SettingsGroupInterface.kt | 76 +++++++++++++++++++ app/src/main/res/values-da/strings.xml | 10 +++ app/src/main/res/values-fr/strings.xml | 10 +++ app/src/main/res/values-pt-rBR/strings.xml | 10 +++ app/src/main/res/values-uk/strings.xml | 10 +++ app/src/main/res/values-zh-rCN/strings.xml | 10 +++ app/src/main/res/values-zh-rTW/strings.xml | 10 +++ app/src/main/res/values/strings.xml | 10 +++ 10 files changed, 190 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index f27662436..cbb54789b 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -108,6 +108,11 @@ class GOGManager @Inject constructor( } } + suspend fun deleteAllGames() { + withContext(Dispatchers.IO) { + gogGameDao.deleteAll() + } + } suspend fun startBackgroundSync(context: Context): Result = withContext(Dispatchers.IO) { try { diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 18c7a647e..168382afa 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -94,6 +94,43 @@ class GOGService : Service() { 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 // ========================================================================== @@ -308,7 +345,7 @@ class GOGService : Service() { // Get game info val gameId = ContainerUtils.extractGameIdFromContainerId(appId) val game = instance.gogManager.getGameById(gameId.toString()) - + if (game == null) { Timber.e("Game not found for appId: $appId") return@withContext false @@ -316,7 +353,7 @@ class GOGService : Service() { // Get save directory paths (Android runs games through Wine, so always Windows) val saveLocations = instance.gogManager.getSaveDirectoryPath(context, appId, game.title) - + if (saveLocations == null || saveLocations.isEmpty()) { Timber.w("No save locations found for game $appId (cloud saves may not be enabled)") return@withContext false 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 9152ea5ef..bf2ca07b0 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt @@ -289,6 +289,10 @@ 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( @@ -301,6 +305,18 @@ fun SettingsGroupInterface( 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 @@ -614,6 +630,66 @@ fun SettingsGroupInterface( 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) + ) } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 8ba9247d6..bd4e82901 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -821,4 +821,14 @@ Log ind Annuller Kunne ikke åbne browser + + + Log ud + Log ud fra din GOG-konto + Log ud fra GOG? + Dette vil fjerne dine GOG-legitimationsoplysninger og rydde dit GOG-bibliotek fra denne enhed. Du kan logge ind igen når som helst. + Log ud + Logget ud fra GOG + Kunne ikke logge ud: %s + Logger ud fra GOG… diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index dd8ed9872..aebb899b9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -954,6 +954,16 @@ Se connecter Annuler Impossible d\'ouvrir le navigateur + + + Déconnexion + Se déconnecter de votre compte GOG + Se déconnecter de GOG ? + Cela supprimera vos identifiants GOG et effacera votre bibliothèque GOG de cet appareil. Vous pouvez vous reconnecter à tout moment. + Déconnexion + Déconnecté de GOG avec succès + Échec de la déconnexion : %s + Déconnexion de GOG… diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 468b3d94a..eef60bce1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -821,4 +821,14 @@ Entrar Cancelar Não foi possível abrir o navegador + + + Sair + Desconectar da sua conta GOG + Sair do GOG? + Isso removerá suas credenciais GOG e limpará sua biblioteca GOG deste dispositivo. Você pode entrar novamente a qualquer momento. + Sair + Desconectado do GOG com sucesso + Falha ao sair: %s + Saindo do GOG… diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 92e82a213..8fafa6ce2 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -600,6 +600,16 @@ Увійти Скасувати Не вдалося відкрити браузер + + + Вийти + Вийти з облікового запису 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 ed7066e81..0be8a9c69 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -947,4 +947,14 @@ 登录 取消 无法打开浏览器 + + + 注销 + 从您的 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 e7047b88f..d0a4aa0a4 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -951,6 +951,16 @@ 登入 取消 無法開啟瀏覽器 + + + 登出 + 從您的 GOG 帳戶登出 + 從 GOG 登出? + 這將刪除您的 GOG 憑證並清除此裝置上的 GOG 遊戲庫。您可以隨時重新登入。 + 登出 + 成功從 GOG 登出 + 登出失敗: %s + 正在從 GOG 登出… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c37e0bdfe..4b8c42622 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -958,5 +958,15 @@ 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… From 3512cdcebb74787fdcaa00b3d0e8aaaef552a12b Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 21:45:41 +0000 Subject: [PATCH 103/122] Adjusting the sync library work so that we don't pull game details for games we already have stored. Also adjusted so we are not constantly retrieving game details. There's now a 15 minute minimum space between syncs. --- .../java/app/gamenative/db/dao/GOGGameDao.kt | 3 + .../app/gamenative/service/gog/GOGManager.kt | 88 ++++++++---- .../app/gamenative/service/gog/GOGService.kt | 127 +++++++++++++----- .../main/java/app/gamenative/ui/PluviaMain.kt | 13 +- .../gamenative/ui/model/LibraryViewModel.kt | 5 + .../app/gamenative/ui/model/MainViewModel.kt | 11 +- 6 files changed, 179 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt index aa93f77cf..fb896be00 100644 --- a/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt @@ -52,6 +52,9 @@ interface GOGGameDao { @Query("SELECT COUNT(*) FROM gog_games") fun getCount(): Flow + @Query("SELECT id FROM gog_games") + suspend fun getAllGameIds(): List + @Transaction suspend fun replaceAll(games: List) { deleteAll() diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index cbb54789b..9b65d7741 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -171,14 +171,27 @@ class GOGManager @Inject constructor( return@withContext Result.success(0) } + // Get existing game IDs from database to avoid re-fetching + val existingGameIds = gogGameDao.getAllGameIds().toSet() + Timber.tag("GOG").d("Found ${existingGameIds.size} games already in database") + + // Filter to only new games that need details fetched + val newGameIds = gameIds.filter { it !in existingGameIds } + Timber.tag("GOG").d("${newGameIds.size} new games need details fetched") + + if (newGameIds.isEmpty()) { + Timber.tag("GOG").d("No new games to fetch, library is up to date") + return@withContext Result.success(0) + } + var totalProcessed = 0 - Timber.tag("GOG").d("Getting Game Details for GOG Games...") + Timber.tag("GOG").d("Getting Game Details for ${newGameIds.size} new GOG Games...") val games = mutableListOf() val authConfigPath = GOGAuthManager.getAuthConfigPath(context) - for ((index, id) in gameIds.withIndex()) { + for ((index, id) in newGameIds.withIndex()) { try { val result = GOGPythonBridge.executeCommand( "--auth-config-path", authConfigPath, @@ -204,10 +217,10 @@ class GOGManager @Inject constructor( Timber.e(e, "Failed to parse game details for ID: $id") } - if ((index + 1) % REFRESH_BATCH_SIZE == 0 || index == gameIds.size - 1) { + 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}/${gameIds.size})") + Timber.tag("GOG").d("Batch inserted ${games.size} games (processed ${index + 1}/${newGameIds.size})") games.clear() } } @@ -997,24 +1010,26 @@ class GOGManager @Inject constructor( installPath: String ): List? = 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.w("Cannot get save sync location: info file not found") + 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.w("No clientId found in info file for game $gameId") + 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") // Check cache first remoteConfigCache[clientId]?.let { cachedLocations -> - Timber.d("Using cached save locations for clientId $clientId") + Timber.tag("GOG").d("[Cloud Saves] Using cached save locations for clientId $clientId (${cachedLocations.size} locations)") return@withContext cachedLocations } @@ -1023,7 +1038,7 @@ class GOGManager @Inject constructor( // Fetch remote config val url = "https://remote-config.gog.com/components/galaxy_client/clients/$clientId?component_version=2.0.45" - Timber.d("Fetching save sync location from: $url") + Timber.tag("GOG").d("[Cloud Saves] Fetching remote config from: $url") val request = Request.Builder() .url(url) @@ -1032,43 +1047,50 @@ class GOGManager @Inject constructor( val response = Net.http.newCall(request).execute() if (!response.isSuccessful) { - Timber.w("Failed to fetch remote config: HTTP ${response.code}") + 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() ?: return@withContext null + 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.w("No 'content' field in remote config response") + 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.d("No cloud storage config for platform $syncPlatform") + 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.d("No cloudStorage field for platform $syncPlatform") + Timber.tag("GOG").d("[Cloud Saves] No cloudStorage field for platform $syncPlatform") return@withContext null } val enabled = cloudStorage.optBoolean("enabled", false) if (!enabled) { - Timber.d("Cloud saves not enabled for game $gameId") + 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.d("No save locations configured for game $gameId") + 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()) { @@ -1076,20 +1098,23 @@ class GOGManager @Inject constructor( 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.d("Cached save locations for clientId $clientId") + Timber.tag("GOG").d("[Cloud Saves] Cached ${locations.size} save locations for clientId $clientId") } - Timber.i("Found ${locations.size} save location(s) for game $gameId") + Timber.tag("GOG").i("[Cloud Saves] Found ${locations.size} save location(s) for game $gameId") return@withContext locations } catch (e: Exception) { - Timber.e(e, "Failed to get save sync location for appId $appId") + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to get save sync location for appId $appId") return@withContext null } } @@ -1109,46 +1134,56 @@ class GOGManager @Inject constructor( gameTitle: String ): List? = withContext(Dispatchers.IO) { try { + Timber.tag("GOG").d("[Cloud Saves] Getting save directory path for $appId ($gameTitle)") val gameId = ContainerUtils.extractGameIdFromContainerId(appId) val game = getGameById(gameId.toString()) if (game == null) { - Timber.w("Game not found for appId $appId") + Timber.tag("GOG").w("[Cloud Saves] Game not found for appId $appId") return@withContext null } val installPath = game.installPath if (installPath.isEmpty()) { - Timber.w("Game not installed: $appId") + Timber.tag("GOG").w("[Cloud Saves] Game not installed: $appId") return@withContext null } + Timber.tag("GOG").d("[Cloud Saves] Game install path: $installPath") // Fetch save locations from API (Android runs games through Wine, so always Windows) + Timber.tag("GOG").d("[Cloud Saves] Fetching save locations from API") var locations = getSaveSyncLocation(context, appId, installPath) // If no locations from API, use default Windows path if (locations == null || locations.isEmpty()) { - Timber.d("No save locations from API, using default for game $gameId") + Timber.tag("GOG").d("[Cloud Saves] No save locations from API, using default for game $gameId") val infoJson = readInfoFile(appId, installPath) val clientId = infoJson?.optString("clientId", "") ?: "" + Timber.tag("GOG").d("[Cloud Saves] Client ID from info file: $clientId") if (clientId.isNotEmpty()) { 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 { - Timber.w("Cannot create default save location: no clientId") + Timber.tag("GOG").w("[Cloud Saves] Cannot create default save location: no clientId") return@withContext null } + } else { + Timber.tag("GOG").i("[Cloud Saves] Retrieved ${locations.size} save location(s) from API") } // Resolve each location val resolvedLocations = mutableListOf() - for (locationTemplate in locations) { + 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 resolvedPath = PathType.toAbsPathForGOG(context, resolvedPath) + Timber.tag("GOG").d("[Cloud Saves] After path mapping to Wine prefix: $resolvedPath") resolvedLocations.add( GOGCloudSavesLocation( @@ -1158,10 +1193,13 @@ class GOGManager @Inject constructor( ) } - Timber.i("Resolved ${resolvedLocations.size} save location(s) for game $gameId") + 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.e(e, "Failed to get save directory path for appId $appId") + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to get save directory path for appId $appId") return@withContext null } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 168382afa..e02227cb9 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -33,28 +33,63 @@ class GOGService : Service() { companion object { private const val ACTION_SYNC_LIBRARY = "app.gamenative.GOG_SYNC_LIBRARY" + private const val ACTION_MANUAL_SYNC = "app.gamenative.GOG_MANUAL_SYNC" + private const val SYNC_THROTTLE_MILLIS = 15 * 60 * 1000L // 15 minutes private var instance: GOGService? = null // Sync tracking variables private var syncInProgress: Boolean = false private var backgroundSyncJob: Job? = null + private var lastSyncTimestamp: Long = 0L + private var hasPerformedInitialSync: Boolean = false val isRunning: Boolean get() = instance != null + /** + * Start the GOG service. Handles both first-time start and subsequent automatic syncs. + * - First-time start: Always syncs (no throttle) + * - Subsequent starts: Throttled to once per 15 minutes + */ fun start(context: Context) { - val intent = Intent(context, GOGService::class.java) - if (!isRunning) { - Timber.d("[GOGService] Starting service for first time") + // 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) - } else { - Timber.d("[GOGService] Service already running, triggering sync") + return + } + + // Subsequent starts: check throttle + val now = System.currentTimeMillis() + val timeSinceLastSync = now - lastSyncTimestamp + + if (timeSinceLastSync >= SYNC_THROTTLE_MILLIS) { + Timber.i("[GOGService] Starting service with automatic sync (throttle passed)") + val intent = Intent(context, GOGService::class.java) intent.action = ACTION_SYNC_LIBRARY context.startForegroundService(intent) + } else { + val remainingMinutes = (SYNC_THROTTLE_MILLIS - timeSinceLastSync) / 1000 / 60 + Timber.d("[GOGService] Skipping start - throttled (${remainingMinutes}min remaining)") } } + fun triggerLibrarySync(context: Context) { + Timber.i("[GOGService] Triggering manual library sync (bypasses throttle)") + val intent = Intent(context, GOGService::class.java) + intent.action = ACTION_MANUAL_SYNC + context.startForegroundService(intent) + } + fun stop() { instance?.let { service -> service.stopSelf() @@ -145,16 +180,6 @@ class GOGService : Service() { fun isSyncInProgress(): Boolean = syncInProgress - /** - * Trigger a background library sync - * Can be called even if service is already running - */ - fun triggerLibrarySync(context: Context) { - val intent = Intent(context, GOGService::class.java) - intent.action = ACTION_SYNC_LIBRARY - context.startForegroundService(intent) - } - fun getInstance(): GOGService? = instance // ========================================================================== @@ -333,41 +358,52 @@ class GOGService : Service() { */ suspend fun syncCloudSaves(context: Context, appId: String, preferredAction: String = "none"): Boolean = withContext(Dispatchers.IO) { try { - val instance = getInstance() ?: return@withContext false + Timber.tag("GOG").d("[Cloud Saves] syncCloudSaves called for $appId with action: $preferredAction") + val instance = getInstance() + if (instance == null) { + Timber.tag("GOG").e("[Cloud Saves] Service instance not available") + return@withContext false + } if (!GOGAuthManager.hasStoredCredentials(context)) { - Timber.e("Cannot sync saves: not authenticated") + Timber.tag("GOG").e("[Cloud Saves] Cannot sync saves: not authenticated") return@withContext false } val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + Timber.tag("GOG").d("[Cloud Saves] Using auth config path: $authConfigPath") // Get game info val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + Timber.tag("GOG").d("[Cloud Saves] Extracted game ID: $gameId from appId: $appId") val game = instance.gogManager.getGameById(gameId.toString()) if (game == null) { - Timber.e("Game not found for appId: $appId") + 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.w("No save locations found for game $appId (cloud saves may not be enabled)") + 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 (location in saveLocations) { + for ((index, location) in saveLocations.withIndex()) { try { + Timber.tag("GOG").d("[Cloud Saves] Processing location ${index + 1}/${saveLocations.size}: '${location.name}'") // Get stored timestamp for this location val timestamp = instance.gogManager.getSyncTimestamp(appId, location.name) - Timber.i("Syncing save location '${location.name}' for game $gameId (timestamp: $timestamp)") + Timber.tag("GOG").i("[Cloud Saves] Syncing '${location.name}' for game $gameId (path: ${location.location}, timestamp: $timestamp, action: $preferredAction)") // Build command arguments (matching HeroicGamesLauncher format) val commandArgs = mutableListOf( @@ -380,37 +416,43 @@ class GOGService : Service() { "--name", location.name, "--prefered-action", preferredAction ) + Timber.tag("GOG").d("[Cloud Saves] Executing Python command with args: ${commandArgs.joinToString(" ")}") // Execute sync command val result = GOGPythonBridge.executeCommand(*commandArgs.toTypedArray()) if (result.isSuccess) { val output = result.getOrNull() ?: "" + Timber.tag("GOG").d("[Cloud Saves] Python command output: $output") // Python save-sync returns timestamp on success, store it val newTimestamp = output.trim() if (newTimestamp.isNotEmpty() && newTimestamp != "0") { instance.gogManager.setSyncTimestamp(appId, location.name, newTimestamp) + Timber.tag("GOG").d("[Cloud Saves] Updated timestamp for '${location.name}': $newTimestamp") + } else { + Timber.tag("GOG").w("[Cloud Saves] No valid timestamp returned (output: '$newTimestamp')") } - Timber.i("Successfully synced save location '${location.name}' for game $gameId") + Timber.tag("GOG").i("[Cloud Saves] Successfully synced save location '${location.name}' for game $gameId") } else { - Timber.e(result.exceptionOrNull(), "Failed to sync save location '${location.name}' for game $gameId") + val error = result.exceptionOrNull() + Timber.tag("GOG").e(error, "[Cloud Saves] Failed to sync save location '${location.name}' for game $gameId") allSucceeded = false } } catch (e: Exception) { - Timber.e(e, "Exception syncing save location '${location.name}' for game $gameId") + Timber.tag("GOG").e(e, "[Cloud Saves] Exception syncing save location '${location.name}' for game $gameId") allSucceeded = false } } if (allSucceeded) { - Timber.i("Cloud saves synced successfully for $appId") + Timber.tag("GOG").i("[Cloud Saves] All save locations synced successfully for $appId") return@withContext true } else { - Timber.w("Some save locations failed to sync for $appId") + Timber.tag("GOG").w("[Cloud Saves] Some save locations failed to sync for $appId") return@withContext false } } catch (e: Exception) { - Timber.e(e, "Failed to sync cloud saves for App ID: $appId") + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to sync cloud saves for App ID: $appId") return@withContext false } } @@ -443,9 +485,26 @@ class GOGService : Service() { val notification = notificationHelper.createForegroundNotification("GOG Service running...") startForeground(2, notification) // Use different ID than SteamService (which uses 1) - // Start background library sync if service is starting or sync action requested - if (intent?.action == ACTION_SYNC_LIBRARY || backgroundSyncJob == null || !backgroundSyncJob!!.isActive) { - Timber.i("[GOGService] Triggering background library sync") + // Determine if we should sync based on the action + val shouldSync = when (intent?.action) { + ACTION_MANUAL_SYNC -> { + Timber.i("[GOGService] Manual sync requested - bypassing throttle") + true + } + ACTION_SYNC_LIBRARY -> { + Timber.i("[GOGService] Automatic sync requested") + true + } + else -> { + // Service started without sync action (e.g., just to keep it alive) + Timber.d("[GOGService] Service started without sync action") + false + } + } + + // Start background library sync if requested + if (shouldSync && (backgroundSyncJob == null || !backgroundSyncJob!!.isActive)) { + Timber.i("[GOGService] Starting background library sync") backgroundSyncJob?.cancel() // Cancel any existing job backgroundSyncJob = scope.launch { try { @@ -456,7 +515,11 @@ class GOGService : Service() { if (syncResult.isFailure) { Timber.w("[GOGService]: Failed to start background sync: ${syncResult.exceptionOrNull()?.message}") } else { - Timber.i("[GOGService]: Background library sync started successfully") + 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") @@ -464,7 +527,7 @@ class GOGService : Service() { setSyncInProgress(false) } } - } else { + } else if (shouldSync) { Timber.d("[GOGService] Background sync already in progress, skipping") } diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 02d297e72..6706dbc9a 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1137,22 +1137,23 @@ fun preLaunchApp( // For GOG Games, sync cloud saves before launch val isGOGGame = ContainerUtils.extractGameSourceFromContainerId(appId) == GameSource.GOG if (isGOGGame) { - Timber.tag("preLaunchApp").i("GOG Game detected for $appId — syncing cloud saves before launch") - + 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, preferredAction = "download" ) - + if (!syncSuccess) { - Timber.w("GOG cloud save sync failed for $appId, proceeding with launch anyway") + 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.i("GOG cloud save sync completed successfully for $appId") + Timber.tag("GOG").i("[Cloud Saves] Download sync completed successfully for $appId") } - + setLoadingDialogVisible(false) onSuccess(context, appId) return@launch 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 ec50dae91..f3070bede 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -204,6 +204,10 @@ class LibraryViewModel @Inject constructor( } else { Timber.tag("LibraryViewModel").d("No newly owned games discovered during refresh") } + if (app.gamenative.service.gog.GOGService.hasStoredCredentials(context)) { + Timber.tag("LibraryViewModel").i("Triggering GOG library refresh") + app.gamenative.service.gog.GOGService.triggerLibrarySync(context) + } } catch (e: Exception) { Timber.tag("LibraryViewModel").e(e, "Failed to refresh owned games from server") } finally { @@ -212,6 +216,7 @@ class LibraryViewModel @Inject constructor( } } } + fun addCustomGameFolder(path: String) { viewModelScope.launch(Dispatchers.IO) { val normalizedPath = File(path).absolutePath 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 c7d41e96a..771df2e07 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -279,27 +279,28 @@ class MainViewModel @Inject constructor( val gameId = ContainerUtils.extractGameIdFromContainerId(appId) Timber.tag("Exit").i("Got game id: $gameId") SteamService.notifyRunningProcesses() - + // Check if this is a GOG game and sync cloud saves val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) if (gameSource == GameSource.GOG) { - Timber.tag("Exit").i("GOG Game detected for $appId — syncing cloud saves after close") + 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.i("GOG cloud save sync completed successfully for $appId") + Timber.tag("GOG").i("[Cloud Saves] Upload sync completed successfully for $appId") } else { - Timber.w("GOG cloud save sync failed for $appId") + Timber.tag("GOG").w("[Cloud Saves] Upload sync failed for $appId") } } catch (e: Exception) { - Timber.e(e, "Exception syncing GOG cloud saves for $appId") + Timber.tag("GOG").e(e, "[Cloud Saves] Exception during upload sync for $appId") } } } else { From dafd9eb933156e44840323361964b721728a0c5b Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 22:29:45 +0000 Subject: [PATCH 104/122] Updating issues regarding paths. --- .../java/app/gamenative/enums/PathType.kt | 46 +++++++++++++++---- .../app/gamenative/service/gog/GOGManager.kt | 10 ++++ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/gamenative/enums/PathType.kt b/app/src/main/java/app/gamenative/enums/PathType.kt index 9594a3f42..9424ab43e 100644 --- a/app/src/main/java/app/gamenative/enums/PathType.kt +++ b/app/src/main/java/app/gamenative/enums/PathType.kt @@ -102,7 +102,7 @@ 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 @@ -165,7 +165,7 @@ enum class PathType { 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, ImageFs.WINEPREFIX, @@ -176,18 +176,44 @@ enum class PathType { } // Map standard Windows environment variables - mappedPath = mappedPath.replace("%LOCALAPPDATA%", - Paths.get(winePrefix, ImageFs.WINEPREFIX, "/drive_c/users/", user, "AppData/Local/").toString()) - mappedPath = mappedPath.replace("%APPDATA%", - Paths.get(winePrefix, ImageFs.WINEPREFIX, "/drive_c/users/", user, "AppData/Roaming/").toString()) - mappedPath = mappedPath.replace("%USERPROFILE%", - Paths.get(winePrefix, ImageFs.WINEPREFIX, "/drive_c/users/", user, "").toString()) + mappedPath = mappedPath.replace("%LOCALAPPDATA%", + Paths.get(winePrefix, ImageFs.WINEPREFIX, "drive_c/users/", user, "AppData/Local/").toString()) + mappedPath = mappedPath.replace("%APPDATA%", + Paths.get(winePrefix, ImageFs.WINEPREFIX, "drive_c/users/", user, "AppData/Roaming/").toString()) + mappedPath = mappedPath.replace("%USERPROFILE%", + Paths.get(winePrefix, ImageFs.WINEPREFIX, "drive_c/users/", user, "").toString()) // Normalize path separators mappedPath = mappedPath.replace("\\", "/") - // Build absolute path - if it doesn't start with drive_c, assume it's relative to drive_c + // 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, ImageFs.WINEPREFIX, cleanPath).toString() @@ -211,7 +237,7 @@ enum class PathType { return finalPath } - + fun from(keyValue: String?): PathType { return when (keyValue?.lowercase()) { "%${GameInstall.name.lowercase()}%", diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 9b65d7741..8eb1a36f6 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -1185,6 +1185,16 @@ class GOGManager @Inject constructor( resolvedPath = PathType.toAbsPathForGOG(context, resolvedPath) 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, From c037170bd3a28d952ae5b337780e9307275e85cc Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 22:38:17 +0000 Subject: [PATCH 105/122] Will now properly cancel downloads instead of thinking it can pause it. Also WIP at resolving cloud saves. --- .../java/app/gamenative/enums/PathType.kt | 12 +++++++++-- .../app/gamenative/service/gog/GOGManager.kt | 3 ++- .../screen/library/appscreen/GOGAppScreen.kt | 21 ++++++++++--------- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/app/gamenative/enums/PathType.kt b/app/src/main/java/app/gamenative/enums/PathType.kt index 9424ab43e..af014a1da 100644 --- a/app/src/main/java/app/gamenative/enums/PathType.kt +++ b/app/src/main/java/app/gamenative/enums/PathType.kt @@ -148,9 +148,17 @@ enum class PathType { * @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): String { + fun toAbsPathForGOG(context: Context, gogWindowsPath: String, appId: String? = null): String { val imageFs = ImageFs.find(context) - val winePrefix = imageFs.rootDir.absolutePath + // For GOG games, use the container-specific wine prefix if appId is provided + val winePrefix = if (appId != null) { + val container = app.gamenative.utils.ContainerUtils.getOrCreateContainer(context, appId) + val containerWinePrefix = container.rootDir.absolutePath + Timber.d("[PathType] Using container-specific wine prefix for $appId: $containerWinePrefix") + containerWinePrefix + } else { + imageFs.rootDir.absolutePath + } val user = ImageFs.USER var mappedPath = gogWindowsPath diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 8eb1a36f6..030dd4a00 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -1182,7 +1182,8 @@ class GOGManager @Inject constructor( Timber.tag("GOG").d("[Cloud Saves] After GOG variable resolution: $resolvedPath") // Map GOG Windows path to device path using PathType - resolvedPath = PathType.toAbsPathForGOG(context, resolvedPath) + // 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 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 index f53923fc9..245021c9a 100644 --- 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 @@ -198,6 +198,12 @@ class GOGAppScreen : BaseAppScreen() { 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 @@ -301,21 +307,16 @@ class GOGAppScreen : BaseAppScreen() { override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) { Timber.tag(TAG).i("onPauseResumeClick: appId=${libraryItem.appId}") - // GOGService expects numeric gameId + // 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) - val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f - Timber.tag(TAG).d("onPauseResumeClick: appId=${libraryItem.appId}, isDownloading=$isDownloading") - if (isDownloading) { - // Cancel/pause download - Timber.tag(TAG).i("Pausing GOG download: ${libraryItem.appId}") + if (downloadInfo != null) { + Timber.tag(TAG).i("Cancelling GOG download: ${libraryItem.appId}") GOGService.cleanupDownload(gameId) downloadInfo.cancel() - } else { - // Resume download (restart from beginning for now) - Timber.tag(TAG).i("Resuming GOG download: ${libraryItem.appId}") - onDownloadInstallClick(context, libraryItem) {} } } From 691281de1c53db43b4eb7255473786392793c012 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Thu, 1 Jan 2026 23:18:02 +0000 Subject: [PATCH 106/122] WIP cloudsave fixes. --- .../java/app/gamenative/enums/PathType.kt | 38 ++-- .../app/gamenative/service/gog/GOGManager.kt | 20 ++ .../app/gamenative/service/gog/GOGService.kt | 175 ++++++++++-------- 3 files changed, 142 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/app/gamenative/enums/PathType.kt b/app/src/main/java/app/gamenative/enums/PathType.kt index af014a1da..c79749e99 100644 --- a/app/src/main/java/app/gamenative/enums/PathType.kt +++ b/app/src/main/java/app/gamenative/enums/PathType.kt @@ -151,24 +151,34 @@ enum class PathType { 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 = if (appId != null) { + val (winePrefix, useContainerRoot) = if (appId != null) { val container = app.gamenative.utils.ContainerUtils.getOrCreateContainer(context, appId) - val containerWinePrefix = container.rootDir.absolutePath - Timber.d("[PathType] Using container-specific wine prefix for $appId: $containerWinePrefix") - containerWinePrefix + val containerRoot = container.rootDir.absolutePath + Timber.d("[PathType] Using container-specific root for $appId: $containerRoot") + Pair(containerRoot, true) } else { - imageFs.rootDir.absolutePath + 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, ImageFs.WINEPREFIX, - "/drive_c/users/", user, "Saved Games/" + winePrefix, winePrefixPath, + "drive_c/users/", user, "Saved Games/" ).toString() mappedPath = mappedPath.replace("%USERPROFILE%/Saved Games", savedGamesPath) .replace("%USERPROFILE%\\Saved Games", savedGamesPath) @@ -176,8 +186,8 @@ enum class PathType { if (mappedPath.contains("%USERPROFILE%/Documents") || mappedPath.contains("%USERPROFILE%\\Documents")) { val documentsPath = Paths.get( - winePrefix, ImageFs.WINEPREFIX, - "/drive_c/users/", user, "Documents/" + winePrefix, winePrefixPath, + "drive_c/users/", user, "Documents/" ).toString() mappedPath = mappedPath.replace("%USERPROFILE%/Documents", documentsPath) .replace("%USERPROFILE%\\Documents", documentsPath) @@ -185,11 +195,11 @@ enum class PathType { // Map standard Windows environment variables mappedPath = mappedPath.replace("%LOCALAPPDATA%", - Paths.get(winePrefix, ImageFs.WINEPREFIX, "drive_c/users/", user, "AppData/Local/").toString()) + Paths.get(winePrefix, winePrefixPath, "drive_c/users/", user, "AppData/Local/").toString()) mappedPath = mappedPath.replace("%APPDATA%", - Paths.get(winePrefix, ImageFs.WINEPREFIX, "drive_c/users/", user, "AppData/Roaming/").toString()) + Paths.get(winePrefix, winePrefixPath, "drive_c/users/", user, "AppData/Roaming/").toString()) mappedPath = mappedPath.replace("%USERPROFILE%", - Paths.get(winePrefix, ImageFs.WINEPREFIX, "drive_c/users/", user, "").toString()) + Paths.get(winePrefix, winePrefixPath, "drive_c/users/", user, "").toString()) // Normalize path separators mappedPath = mappedPath.replace("\\", "/") @@ -224,7 +234,7 @@ enum class PathType { } mappedPath.startsWith("drive_c/") || mappedPath.startsWith("/drive_c/") -> { val cleanPath = mappedPath.removePrefix("/") - Paths.get(winePrefix, ImageFs.WINEPREFIX, cleanPath).toString() + Paths.get(winePrefix, winePrefixPath, cleanPath).toString() } mappedPath.startsWith(winePrefix) -> { // Already absolute @@ -232,7 +242,7 @@ enum class PathType { } else -> { // Relative path, assume it's in drive_c - Paths.get(winePrefix, ImageFs.WINEPREFIX, "drive_c", mappedPath).toString() + Paths.get(winePrefix, winePrefixPath, "drive_c", mappedPath).toString() } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 030dd4a00..8da379b83 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -85,6 +85,9 @@ class GOGManager @Inject constructor( // Timestamp storage for sync state (gameId_locationName -> timestamp) private val syncTimestamps = ConcurrentHashMap() + // Track active sync operations to prevent concurrent syncs + private val activeSyncs = ConcurrentHashMap.newKeySet() + suspend fun getGameById(gameId: String): GOGGame? { return withContext(Dispatchers.IO) { try { @@ -1238,6 +1241,23 @@ class GOGManager @Inject constructor( Timber.d("Stored sync timestamp for $key: $timestamp") } + /** + * 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) + } + // ========================================================================== // FILE SYSTEM & PATHS // ========================================================================== diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index e02227cb9..d89b8eba3 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -359,97 +359,118 @@ class GOGService : Service() { 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") - val instance = getInstance() - if (instance == null) { - Timber.tag("GOG").e("[Cloud Saves] Service instance not available") + + // Check if there's already a sync in progress for this appId + if (!instance!!.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 - } + 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") + val authConfigPath = GOGAuthManager.getAuthConfigPath(context) + Timber.tag("GOG").d("[Cloud Saves] Using auth config path: $authConfigPath") - // Get game info - val gameId = ContainerUtils.extractGameIdFromContainerId(appId) - Timber.tag("GOG").d("[Cloud Saves] Extracted game ID: $gameId from appId: $appId") - val game = instance.gogManager.getGameById(gameId.toString()) + // Get game info + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + Timber.tag("GOG").d("[Cloud Saves] Extracted game ID: $gameId from appId: $appId") + val game = instance.gogManager.getGameById(gameId.toString()) - if (game == null) { - Timber.tag("GOG").e("[Cloud Saves] Game not found for appId: $appId") - return@withContext false - } - Timber.tag("GOG").d("[Cloud Saves] Found game: ${game.title}") + 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) + // 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}'") - // Get stored timestamp for this location - val timestamp = instance.gogManager.getSyncTimestamp(appId, location.name) - - Timber.tag("GOG").i("[Cloud Saves] Syncing '${location.name}' for game $gameId (path: ${location.location}, timestamp: $timestamp, action: $preferredAction)") - - // Build command arguments (matching HeroicGamesLauncher format) - val commandArgs = mutableListOf( - "--auth-config-path", authConfigPath, - "save-sync", - location.location, - gameId.toString(), - "--os", "windows", // Android runs games through Wine - "--ts", timestamp, - "--name", location.name, - "--prefered-action", preferredAction - ) - Timber.tag("GOG").d("[Cloud Saves] Executing Python command with args: ${commandArgs.joinToString(" ")}") - - // Execute sync command - val result = GOGPythonBridge.executeCommand(*commandArgs.toTypedArray()) - - if (result.isSuccess) { - val output = result.getOrNull() ?: "" - Timber.tag("GOG").d("[Cloud Saves] Python command output: $output") - // Python save-sync returns timestamp on success, store it - val newTimestamp = output.trim() - if (newTimestamp.isNotEmpty() && newTimestamp != "0") { - instance.gogManager.setSyncTimestamp(appId, location.name, newTimestamp) - Timber.tag("GOG").d("[Cloud Saves] Updated timestamp for '${location.name}': $newTimestamp") + 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}'") + // Get stored timestamp for this location + val timestamp = instance.gogManager.getSyncTimestamp(appId, location.name) + + Timber.tag("GOG").i("[Cloud Saves] Syncing '${location.name}' for game $gameId (path: ${location.location}, timestamp: $timestamp, action: $preferredAction)") + + // Build command arguments (matching HeroicGamesLauncher format) + val commandArgs = mutableListOf( + "--auth-config-path", authConfigPath, + "save-sync", + location.location, + gameId.toString(), + "--os", "windows", // Android runs games through Wine + "--ts", timestamp, + "--name", location.name, + "--prefered-action", preferredAction + ) + Timber.tag("GOG").d("[Cloud Saves] Executing Python command with args: ${commandArgs.joinToString(" ")}") + + // Execute sync command + val result = GOGPythonBridge.executeCommand(*commandArgs.toTypedArray()) + + if (result.isSuccess) { + val output = result.getOrNull() ?: "" + Timber.tag("GOG").d("[Cloud Saves] Python command output: $output") + + // Python save-sync returns timestamp on success, store it + // CRITICAL: Validate that the output is a valid numeric timestamp + val newTimestamp = output.trim() + if (newTimestamp.isNotEmpty() && newTimestamp != "0") { + // Check if it's a valid timestamp (number with optional decimal point) + if (newTimestamp.matches(Regex("^\\d+(\\.\\d+)?$"))) { + instance.gogManager.setSyncTimestamp(appId, location.name, newTimestamp) + Timber.tag("GOG").d("[Cloud Saves] Updated timestamp for '${location.name}': $newTimestamp") + } else { + Timber.tag("GOG").e("[Cloud Saves] Invalid timestamp format returned: '$newTimestamp' - expected numeric value") + allSucceeded = false + } + } else { + Timber.tag("GOG").w("[Cloud Saves] No valid timestamp returned (output: '$newTimestamp')") + } + Timber.tag("GOG").i("[Cloud Saves] Successfully synced save location '${location.name}' for game $gameId") } else { - Timber.tag("GOG").w("[Cloud Saves] No valid timestamp returned (output: '$newTimestamp')") + val error = result.exceptionOrNull() + Timber.tag("GOG").e(error, "[Cloud Saves] Failed to sync save location '${location.name}' for game $gameId") + allSucceeded = false } - Timber.tag("GOG").i("[Cloud Saves] Successfully synced save location '${location.name}' for game $gameId") - } else { - val error = result.exceptionOrNull() - Timber.tag("GOG").e(error, "[Cloud Saves] Failed to sync save location '${location.name}' for game $gameId") + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Exception syncing save location '${location.name}' for game $gameId") 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 + 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 + instance!!.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") From 1dd8a484cac16c89cc4f01554dc31a013c9bba0b Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 09:15:28 +0000 Subject: [PATCH 107/122] Fixed issue where we were using hard-coded paths which broke some devices from installing files. --- app/src/main/java/app/gamenative/PluviaApp.kt | 3 ++ .../java/app/gamenative/enums/PathType.kt | 2 +- .../gamenative/service/gog/GOGConstants.kt | 29 ++++++++--- .../app/gamenative/service/gog/GOGManager.kt | 47 +++++++++++++++++ .../app/gamenative/service/gog/GOGService.kt | 51 +++++++++++++++++-- .../java/app/gamenative/utils/StorageUtils.kt | 4 ++ 6 files changed, 126 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/gamenative/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt index 23a563bff..c6a518d0b 100644 --- a/app/src/main/java/app/gamenative/PluviaApp.kt +++ b/app/src/main/java/app/gamenative/PluviaApp.kt @@ -64,6 +64,9 @@ class PluviaApp : SplitCompatApplication() { // Init our datastore preferences. PrefManager.init(this) + // Initialize GOGConstants + app.gamenative.service.gog.GOGConstants.init(this) + DownloadService.populateDownloadService(this) appScope.launch { diff --git a/app/src/main/java/app/gamenative/enums/PathType.kt b/app/src/main/java/app/gamenative/enums/PathType.kt index c79749e99..9b7314573 100644 --- a/app/src/main/java/app/gamenative/enums/PathType.kt +++ b/app/src/main/java/app/gamenative/enums/PathType.kt @@ -173,7 +173,7 @@ enum class PathType { // 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( diff --git a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt index 6401e22e7..92def55b3 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGConstants.kt @@ -1,5 +1,6 @@ package app.gamenative.service.gog +import android.content.Context import app.gamenative.PrefManager import java.io.File import java.nio.file.Paths @@ -9,6 +10,14 @@ 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" @@ -24,22 +33,30 @@ object GOGConstants { // GOG OAuth authorization URL with redirect const val GOG_AUTH_LOGIN_URL = "https://auth.gog.com/auth?client_id=$GOG_CLIENT_ID&redirect_uri=$GOG_REDIRECT_URI&response_type=code&layout=client2" - // GOG paths - following Steam's structure pattern - private const val INTERNAL_BASE_PATH = "/data/data/app.gamenative" - /** * Internal GOG games installation path (similar to Steam's internal path) - * /data/data/app.gamenative/files/GOG/games/common/ + * Uses application's internal files directory */ val internalGOGGamesPath: String - get() = Paths.get(INTERNAL_BASE_PATH, "GOG", "games", "common").toString() + 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() = Paths.get(PrefManager.externalStoragePath, "GOG", "games", "common").toString() + 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() { diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 8da379b83..5f4a2879d 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -28,6 +28,7 @@ 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 @@ -72,6 +73,7 @@ data class GameSizeInfo( @Singleton class GOGManager @Inject constructor( private val gogGameDao: GOGGameDao, + @ApplicationContext private val context: Context, ) { // Thread-safe cache for download sizes @@ -83,11 +85,18 @@ class GOGManager @Inject constructor( private val remoteConfigCache = ConcurrentHashMap>() // Timestamp storage for sync state (gameId_locationName -> timestamp) + // Persisted to disk to survive app restarts private val syncTimestamps = ConcurrentHashMap() + private val timestampFile = File(context.filesDir, "gog_sync_timestamps.json") // Track active sync operations to prevent concurrent syncs private val activeSyncs = ConcurrentHashMap.newKeySet() + init { + // Load persisted timestamps on initialization + loadTimestampsFromDisk() + } + suspend fun getGameById(gameId: String): GOGGame? { return withContext(Dispatchers.IO) { try { @@ -1239,6 +1248,8 @@ class GOGManager @Inject constructor( val key = "${appId}_$locationName" syncTimestamps[key] = timestamp Timber.d("Stored sync timestamp for $key: $timestamp") + // Persist to disk + saveTimestampsToDisk() } /** @@ -1258,6 +1269,42 @@ class GOGManager @Inject constructor( activeSyncs.remove(appId) } + /** + * Load timestamps from disk + */ + private fun loadTimestampsFromDisk() { + try { + if (timestampFile.exists()) { + val json = timestampFile.readText() + val map = org.json.JSONObject(json) + map.keys().forEach { key -> + syncTimestamps[key] = map.getString(key) + } + Timber.tag("GOG").i("[Cloud Saves] Loaded ${syncTimestamps.size} sync timestamps from disk") + } else { + Timber.tag("GOG").d("[Cloud Saves] No persisted timestamps found (first run)") + } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to load timestamps from disk") + } + } + + /** + * Save timestamps to disk + */ + private fun saveTimestampsToDisk() { + try { + val json = org.json.JSONObject() + syncTimestamps.forEach { (key, value) -> + json.put(key, value) + } + timestampFile.writeText(json.toString()) + Timber.tag("GOG").d("[Cloud Saves] Saved ${syncTimestamps.size} timestamps to disk") + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to save timestamps to disk") + } + } + // ========================================================================== // FILE SYSTEM & PATHS // ========================================================================== diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index d89b8eba3..4395f6516 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -359,13 +359,13 @@ class GOGService : Service() { 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 if (!instance!!.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) { @@ -408,6 +408,26 @@ class GOGService : Service() { 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 timestamp = instance.gogManager.getSyncTimestamp(appId, location.name) @@ -432,7 +452,7 @@ class GOGService : Service() { if (result.isSuccess) { val output = result.getOrNull() ?: "" Timber.tag("GOG").d("[Cloud Saves] Python command output: $output") - + // Python save-sync returns timestamp on success, store it // CRITICAL: Validate that the output is a valid numeric timestamp val newTimestamp = output.trim() @@ -448,6 +468,31 @@ class GOGService : Service() { } else { Timber.tag("GOG").w("[Cloud Saves] No valid timestamp returned (output: '$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 { val error = result.exceptionOrNull() diff --git a/app/src/main/java/app/gamenative/utils/StorageUtils.kt b/app/src/main/java/app/gamenative/utils/StorageUtils.kt index 31b20a9b0..9127bcc1c 100644 --- a/app/src/main/java/app/gamenative/utils/StorageUtils.kt +++ b/app/src/main/java/app/gamenative/utils/StorageUtils.kt @@ -21,6 +21,10 @@ import java.nio.file.Path object StorageUtils { fun getAvailableSpace(path: String): Long { + val file = File(path) + if (!file.exists()) { + throw IllegalArgumentException("Invalid path: $path") + } val stat = StatFs(path) return stat.blockSizeLong * stat.availableBlocksLong } From 3f3de1a29b76c411a0fc17c1c1df1b47b99df6e3 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 13:19:18 +0000 Subject: [PATCH 108/122] WIP cloud saves own Kotlin implementation. --- .../gamenative/data/GOGCloudSavesLocation.kt | 6 +- .../gamenative/service/gog/GOGAuthManager.kt | 98 ++++ .../service/gog/GOGCloudSavesManager.kt | 510 ++++++++++++++++++ .../app/gamenative/service/gog/GOGManager.kt | 201 ++++++- .../app/gamenative/service/gog/GOGService.kt | 72 ++- .../screen/library/appscreen/GOGAppScreen.kt | 31 ++ 6 files changed, 855 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt diff --git a/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt b/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt index 6095ad2c1..ec8aac633 100644 --- a/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt +++ b/app/src/main/java/app/gamenative/data/GOGCloudSavesLocation.kt @@ -14,9 +14,13 @@ data class GOGCloudSavesLocationTemplate( * 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 location: String, + val clientId: String, + val clientSecret: String = "" // Default empty for backward compatibility ) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt index cb859e599..55f5f957b 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -108,6 +108,104 @@ object GOGAuthManager { } } + /** + * Get game-specific credentials using the game's clientId and clientSecret. + * This exchanges the Galaxy app's refresh token for a game-specific access token. + * + * @param context Application context + * @param clientId Game's client ID (from .info file) + * @param clientSecret Game's client secret (from build metadata) + * @return Game-specific credentials or error + */ + suspend fun getGameCredentials( + context: Context, + clientId: String, + clientSecret: String + ): Result { + return try { + val authFile = File(getAuthConfigPath(context)) + if (!authFile.exists()) { + return Result.failure(Exception("No stored credentials found")) + } + + // Read auth file + val authContent = authFile.readText() + val authJson = JSONObject(authContent) + + // Check if we already have credentials for this game + if (authJson.has(clientId)) { + val gameCredentials = authJson.getJSONObject(clientId) + + // Check if expired + val loginTime = gameCredentials.optDouble("loginTime", 0.0) + val expiresIn = gameCredentials.optInt("expires_in", 0) + val isExpired = System.currentTimeMillis() / 1000.0 >= loginTime + expiresIn + + if (!isExpired) { + // Return existing valid credentials + return Result.success(GOGCredentials( + accessToken = gameCredentials.getString("access_token"), + refreshToken = gameCredentials.optString("refresh_token", ""), + userId = gameCredentials.getString("user_id"), + username = gameCredentials.optString("username", "GOG User") + )) + } + } + + // Need to get/refresh game-specific token + // Get Galaxy app's refresh token + val galaxyCredentials = if (authJson.has(GOGConstants.GOG_CLIENT_ID)) { + authJson.getJSONObject(GOGConstants.GOG_CLIENT_ID) + } else { + return Result.failure(Exception("No Galaxy credentials found")) + } + + val refreshToken = galaxyCredentials.optString("refresh_token", "") + if (refreshToken.isEmpty()) { + return Result.failure(Exception("No refresh token available")) + } + + // Request game-specific token using Galaxy's refresh token + Timber.d("Requesting game-specific token for clientId: $clientId") + val tokenUrl = "https://auth.gog.com/token?client_id=$clientId&client_secret=$clientSecret&grant_type=refresh_token&refresh_token=$refreshToken" + + val request = okhttp3.Request.Builder() + .url(tokenUrl) + .get() + .build() + + val response = okhttp3.OkHttpClient().newCall(request).execute() + + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Timber.e("Failed to get game token: HTTP ${response.code} - $errorBody") + return Result.failure(Exception("Failed to get game-specific token: HTTP ${response.code}")) + } + + val responseBody = response.body?.string() ?: return Result.failure(Exception("Empty response")) + val tokenJson = JSONObject(responseBody) + + // Store the new game-specific credentials + tokenJson.put("loginTime", System.currentTimeMillis() / 1000.0) + authJson.put(clientId, tokenJson) + + // Write updated auth file + authFile.writeText(authJson.toString(2)) + + Timber.i("Successfully obtained game-specific token for clientId: $clientId") + + return Result.success(GOGCredentials( + accessToken = tokenJson.getString("access_token"), + refreshToken = tokenJson.optString("refresh_token", refreshToken), + userId = tokenJson.getString("user_id"), + username = tokenJson.optString("username", "GOG User") + )) + } catch (e: Exception) { + Timber.e(e, "Failed to get game-specific credentials") + Result.failure(e) + } + } + /** * Validate credentials by calling GOGDL auth command (without --code) * This will automatically refresh tokens if they're expired diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt new file mode 100644 index 000000000..3bc919f03 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -0,0 +1,510 @@ +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 + +/** + * Manages GOG cloud save synchronization in pure Kotlin + * Replaces Python-based implementation to avoid stdout contamination issues + */ +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 + val cloudFiles = getCloudFiles(credentials.userId, clientId, dirname, credentials.accessToken) + val downloadableCloud = cloudFiles.filter { !it.isDeleted } + Timber.tag("GOG-CloudSaves").i("Found ${downloadableCloud.size} cloud file(s)") + + // 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 - manual intervention required") + } + + 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-CloudSaves").d("Fetching cloud files from: $url") + + 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() + + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "No response body" + Timber.tag("GOG-CloudSaves").e("Failed to fetch cloud files: HTTP ${response.code}") + Timber.tag("GOG-CloudSaves").e("Response body: $errorBody") + Timber.tag("GOG-CloudSaves").e("Request URL: $url") + Timber.tag("GOG-CloudSaves").e("Request headers: ${request.headers}") + return@withContext emptyList() + } + + val responseBody = response.body?.string() ?: return@withContext emptyList() + val json = JSONObject(responseBody) + val items = json.optJSONArray("items") ?: return@withContext emptyList() + + 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") + + // 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-CloudSaves").d("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() + + 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() + + 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) + } + if (file.updateTimestamp != null && file.updateTimestamp!! > timestamp) { + updatedLocal.add(file) + } + } + + // Check cloud files + cloudFiles.forEach { file -> + if (file.isDeleted) return@forEach + + if (file.relativePath !in localPaths) { + notExistingLocally.add(file) + } + if (file.updateTimestamp != null && file.updateTimestamp!! > 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/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 5f4a2879d..0a04ea9c4 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -14,6 +14,7 @@ import app.gamenative.data.SteamApp import app.gamenative.data.GameSource import app.gamenative.enums.PathType import okhttp3.Request +import okhttp3.OkHttpClient import app.gamenative.utils.Net import app.gamenative.db.dao.GOGGameDao import app.gamenative.enums.AppType @@ -25,6 +26,7 @@ import app.gamenative.enums.SyncResult import app.gamenative.utils.ContainerUtils import app.gamenative.utils.MarkerUtils import app.gamenative.utils.StorageUtils +import java.util.concurrent.TimeUnit import com.winlator.container.Container import com.winlator.core.envvars.EnvVars import com.winlator.xenvironment.components.GuestProgramLauncherComponent @@ -76,6 +78,11 @@ class GOGManager @Inject constructor( @ApplicationContext private val context: Context, ) { + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + // Thread-safe cache for download sizes private val downloadSizeCache = ConcurrentHashMap() private val REFRESH_BATCH_SIZE = 10 @@ -1014,13 +1021,13 @@ class GOGManager @Inject constructor( * @param context Android context * @param appId Game app ID * @param installPath Game install path - * @return List of save location templates, or null if cloud saves not enabled or API call fails + * @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 - ): List? = withContext(Dispatchers.IO) { + ): Pair>? = withContext(Dispatchers.IO) { try { Timber.tag("GOG").d("[Cloud Saves] Getting save sync location for $appId") val gameId = ContainerUtils.extractGameIdFromContainerId(appId) @@ -1039,10 +1046,19 @@ class GOGManager @Inject constructor( } Timber.tag("GOG").d("[Cloud Saves] Client ID: $clientId") + // Get clientSecret from build metadata + val clientSecret = getClientSecret(gameId.toString(), installPath) ?: "" + if (clientSecret.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No clientSecret available for game $gameId") + } else { + Timber.tag("GOG").d("[Cloud Saves] Got client secret for game") + } + // Check cache first remoteConfigCache[clientId]?.let { cachedLocations -> Timber.tag("GOG").d("[Cloud Saves] Using cached save locations for clientId $clientId (${cachedLocations.size} locations)") - return@withContext cachedLocations + // 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 @@ -1124,7 +1140,7 @@ class GOGManager @Inject constructor( } Timber.tag("GOG").i("[Cloud Saves] Found ${locations.size} save location(s) for game $gameId") - return@withContext locations + 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 @@ -1162,26 +1178,32 @@ class GOGManager @Inject constructor( } 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") - var locations = getSaveSyncLocation(context, appId, installPath) - + val result = getSaveSyncLocation(context, appId, installPath) + + val clientSecret: String + val locations: List + // If no locations from API, use default Windows path - if (locations == null || locations.isEmpty()) { + if (result == null || result.second.isEmpty()) { + clientSecret = "" Timber.tag("GOG").d("[Cloud Saves] No save locations from API, using default for game $gameId") - val infoJson = readInfoFile(appId, installPath) - val clientId = infoJson?.optString("clientId", "") ?: "" - Timber.tag("GOG").d("[Cloud Saves] Client ID from info file: $clientId") - - if (clientId.isNotEmpty()) { - 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 { - Timber.tag("GOG").w("[Cloud Saves] Cannot create default save location: no clientId") - return@withContext null - } + 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") } @@ -1208,10 +1230,13 @@ class GOGManager @Inject constructor( Timber.tag("GOG").w(e, "[Cloud Saves] Failed to normalize path, using as-is: $resolvedPath") } + resolvedLocations.add( GOGCloudSavesLocation( name = locationTemplate.name, - location = resolvedPath + location = resolvedPath, + clientId = clientId, + clientSecret = clientSecret ) ) } @@ -1227,6 +1252,142 @@ class GOGManager @Inject constructor( } } + /** + * Fetch client secret from GOG build metadata API + * @param gameId GOG game ID + * @param installPath Game install path (for platform detection, defaults to "windows") + * @return Client secret string, or null if not found + */ + private suspend fun getClientSecret(gameId: String, installPath: String?): String? = withContext(Dispatchers.IO) { + try { + val platform = "windows" // For now, assume Windows (proton) + val buildsUrl = "https://content-system.gog.com/products/$gameId/os/$platform/builds?generation=2" + + Timber.tag("GOG").d("[Cloud Saves] Fetching build metadata from: $buildsUrl") + + // Get credentials for API authentication + val credentials = GOGAuthManager.getStoredCredentials(context).getOrNull() + if (credentials == null) { + Timber.tag("GOG").w("[Cloud Saves] No credentials available for build metadata fetch") + return@withContext null + } + + val request = Request.Builder() + .url(buildsUrl) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + val response = httpClient.newCall(request).execute() + 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 manifestLink = firstBuild.optString("link", "") + if (manifestLink.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: $manifestLink") + + // Fetch the build manifest + val manifestRequest = Request.Builder() + .url(manifestLink) + .header("Authorization", "Bearer ${credentials.accessToken}") + .build() + + val manifestResponse = httpClient.newCall(manifestRequest).execute() + if (!manifestResponse.isSuccessful) { + Timber.tag("GOG").w("[Cloud Saves] Manifest fetch failed: ${manifestResponse.code}") + return@withContext null + } + + // Log response headers to debug compression + val contentEncoding = manifestResponse.header("Content-Encoding") + val contentType = manifestResponse.header("Content-Type") + Timber.tag("GOG").d("[Cloud Saves] Response headers - Content-Encoding: $contentEncoding, Content-Type: $contentType") + + // Read the response bytes (can only read body once) + val manifestBytes = manifestResponse.body?.bytes() ?: return@withContext null + + // Check compression type by magic bytes + val isGzipped = manifestBytes.size >= 2 && + manifestBytes[0] == 0x1f.toByte() && + manifestBytes[1] == 0x8b.toByte() + + val isZlib = manifestBytes.size >= 2 && + manifestBytes[0] == 0x78.toByte() && + (manifestBytes[1] == 0x9c.toByte() || + manifestBytes[1] == 0xda.toByte() || + manifestBytes[1] == 0x01.toByte()) + + Timber.tag("GOG").d("[Cloud Saves] Manifest bytes: ${manifestBytes.size}, isGzipped: $isGzipped, isZlib: $isZlib") + + // Decompress based on detected format + val manifestStr = when { + isGzipped -> { + try { + Timber.tag("GOG").d("[Cloud Saves] Decompressing gzip manifest") + val gzipStream = java.util.zip.GZIPInputStream(java.io.ByteArrayInputStream(manifestBytes)) + gzipStream.bufferedReader().use { it.readText() } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Gzip decompression failed") + return@withContext null + } + } + isZlib -> { + try { + Timber.tag("GOG").d("[Cloud Saves] Decompressing zlib manifest") + val inflaterStream = java.util.zip.InflaterInputStream(java.io.ByteArrayInputStream(manifestBytes)) + inflaterStream.bufferedReader().use { it.readText() } + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Zlib decompression failed") + return@withContext null + } + } + else -> { + // Not compressed, read as plain text + Timber.tag("GOG").d("[Cloud Saves] Not compressed, reading as UTF-8") + String(manifestBytes, Charsets.UTF_8) + } + } + + if (manifestStr.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] Empty manifest response") + return@withContext null + } + + Timber.tag("GOG").d("[Cloud Saves] Parsing manifest JSON (${manifestStr.take(100)}...)") + val manifestJson = JSONObject(manifestStr) + + // Extract clientSecret from manifest + val clientSecret = manifestJson.optString("clientSecret", "") + if (clientSecret.isEmpty()) { + Timber.tag("GOG").w("[Cloud Saves] No clientSecret in manifest for game $gameId") + return@withContext null + } + + Timber.tag("GOG").d("[Cloud Saves] Successfully retrieved clientSecret for game $gameId") + return@withContext clientSecret + + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to get clientSecret for game $gameId") + return@withContext null + } + } + /** * Get stored sync timestamp for a game+location * @param appId Game app ID diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 4395f6516..5bcfdd709 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -408,7 +408,7 @@ class GOGService : Service() { 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) @@ -427,47 +427,36 @@ class GOGService : Service() { } catch (e: Exception) { Timber.tag("GOG").e(e, "[Cloud Saves] [BEFORE] Failed to check directory") } - + // Get stored timestamp for this location - val timestamp = instance.gogManager.getSyncTimestamp(appId, location.name) - - Timber.tag("GOG").i("[Cloud Saves] Syncing '${location.name}' for game $gameId (path: ${location.location}, timestamp: $timestamp, action: $preferredAction)") - - // Build command arguments (matching HeroicGamesLauncher format) - val commandArgs = mutableListOf( - "--auth-config-path", authConfigPath, - "save-sync", - location.location, - gameId.toString(), - "--os", "windows", // Android runs games through Wine - "--ts", timestamp, - "--name", location.name, - "--prefered-action", preferredAction + val timestampStr = instance.gogManager.getSyncTimestamp(appId, location.name) + val timestamp = timestampStr.toLongOrNull() ?: 0L + + Timber.tag("GOG").i("[Cloud Saves] Syncing '${location.name}' for game $gameId (clientId: ${location.clientId}, path: ${location.location}, timestamp: $timestamp, action: $preferredAction)") + + // Validate clientSecret is available + if (location.clientSecret.isEmpty()) { + Timber.tag("GOG").e("[Cloud Saves] Missing clientSecret for '${location.name}', skipping sync") + continue + } + + // Use Kotlin cloud saves manager instead of Python + val cloudSavesManager = GOGCloudSavesManager(context) + val newTimestamp = cloudSavesManager.syncSaves( + clientId = location.clientId, + clientSecret = location.clientSecret, + localPath = location.location, + dirname = location.name, + lastSyncTimestamp = timestamp, + preferredAction = preferredAction ) - Timber.tag("GOG").d("[Cloud Saves] Executing Python command with args: ${commandArgs.joinToString(" ")}") - - // Execute sync command - val result = GOGPythonBridge.executeCommand(*commandArgs.toTypedArray()) - - if (result.isSuccess) { - val output = result.getOrNull() ?: "" - Timber.tag("GOG").d("[Cloud Saves] Python command output: $output") - - // Python save-sync returns timestamp on success, store it - // CRITICAL: Validate that the output is a valid numeric timestamp - val newTimestamp = output.trim() - if (newTimestamp.isNotEmpty() && newTimestamp != "0") { - // Check if it's a valid timestamp (number with optional decimal point) - if (newTimestamp.matches(Regex("^\\d+(\\.\\d+)?$"))) { - instance.gogManager.setSyncTimestamp(appId, location.name, newTimestamp) - Timber.tag("GOG").d("[Cloud Saves] Updated timestamp for '${location.name}': $newTimestamp") - } else { - Timber.tag("GOG").e("[Cloud Saves] Invalid timestamp format returned: '$newTimestamp' - expected numeric value") - allSucceeded = false - } - } else { - Timber.tag("GOG").w("[Cloud Saves] No valid timestamp returned (output: '$newTimestamp')") - } + + if (newTimestamp > 0) { + // Success - store new timestamp + instance.gogManager.setSyncTimestamp(appId, location.name, newTimestamp.toString()) + Timber.tag("GOG").d("[Cloud Saves] Updated timestamp for '${location.name}': $newTimestamp") + + Timber.tag("GOG").d("[Cloud Saves] Updated timestamp for '${location.name}': $newTimestamp") // Log the save files in the directory after sync try { @@ -495,8 +484,7 @@ class GOGService : Service() { Timber.tag("GOG").i("[Cloud Saves] Successfully synced save location '${location.name}' for game $gameId") } else { - val error = result.exceptionOrNull() - Timber.tag("GOG").e(error, "[Cloud Saves] Failed to sync save location '${location.name}' for game $gameId") + Timber.tag("GOG").e("[Cloud Saves] Failed to sync save location '${location.name}' for game $gameId (timestamp: $newTimestamp)") allSucceeded = false } } catch (e: Exception) { 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 index 245021c9a..9570f71c4 100644 --- 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 @@ -447,6 +447,37 @@ class GOGAppScreen : BaseAppScreen() { isInstalled: Boolean, ): List { val options = mutableListOf() + + // Add cloud save sync option for installed games + if (isInstalled) { + options.add( + AppMenuOption( + optionType = AppOptionMenuType.ForceDownloadRemote, + onClick = { + Timber.tag(TAG).d("Manual cloud save sync requested for ${libraryItem.appId}") + CoroutineScope(Dispatchers.IO).launch { + try { + val success = GOGService.syncCloudSaves( + context = context, + appId = libraryItem.appId, + preferredAction = "download" + ) + withContext(Dispatchers.Main) { + if (success) { + Timber.tag(TAG).i("Cloud save sync completed successfully") + } else { + Timber.tag(TAG).e("Cloud save sync failed") + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to sync cloud saves") + } + } + } + ) + ) + } + return options } From 0eda56979ee0006bdab5b56f4c61aa747598a86a Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 13:25:11 +0000 Subject: [PATCH 109/122] Slowly working, but still no saves found. --- .../service/gog/GOGCloudSavesManager.kt | 41 +++++++++++++++---- .../app/gamenative/service/gog/GOGManager.kt | 4 +- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt index 3bc919f03..22407c0ed 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -175,9 +175,16 @@ class GOGCloudSavesManager( 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-CloudSaves").i("Found ${downloadableCloud.size} cloud file(s)") + 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 { @@ -302,7 +309,7 @@ class GOGCloudSavesManager( 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-CloudSaves").d("Fetching cloud files from: $url") + Timber.tag("GOG").d("[Cloud Saves] API Request: GET $url (dirname filter: $dirname)") val request = Request.Builder() .url(url) @@ -316,16 +323,27 @@ class GOGCloudSavesManager( if (!response.isSuccessful) { val errorBody = response.body?.string() ?: "No response body" - Timber.tag("GOG-CloudSaves").e("Failed to fetch cloud files: HTTP ${response.code}") - Timber.tag("GOG-CloudSaves").e("Response body: $errorBody") - Timber.tag("GOG-CloudSaves").e("Request URL: $url") - Timber.tag("GOG-CloudSaves").e("Request headers: ${request.headers}") + 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() ?: 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() + } + + Timber.tag("GOG").d("[Cloud Saves] Response body length: ${responseBody.length} bytes") val json = JSONObject(responseBody) - val items = json.optJSONArray("items") ?: return@withContext emptyList() + val items = json.optJSONArray("items") + + if (items == null) { + Timber.tag("GOG").d("[Cloud Saves] No 'items' array in response") + 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()) { @@ -334,6 +352,8 @@ class GOGCloudSavesManager( 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 { @@ -345,10 +365,13 @@ class GOGCloudSavesManager( // 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-CloudSaves").d("Retrieved ${files.size} cloud files for dirname '$dirname'") + Timber.tag("GOG").i("[Cloud Saves] Retrieved ${files.size} cloud files for dirname '$dirname'") files } catch (e: Exception) { diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 0a04ea9c4..ce41df25f 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -1190,10 +1190,10 @@ class GOGManager @Inject constructor( // 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 = "" From 3998e1575a21726df3df783b1a5e5ce85edd997c Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 13:28:55 +0000 Subject: [PATCH 110/122] Parsing the cloud saves properly. --- .../gamenative/service/gog/GOGCloudSavesManager.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt index 22407c0ed..c49f7cc06 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -333,13 +333,15 @@ class GOGCloudSavesManager( Timber.tag("GOG").d("[Cloud Saves] Empty response body from cloud storage API") return@withContext emptyList() } - + Timber.tag("GOG").d("[Cloud Saves] Response body length: ${responseBody.length} bytes") - val json = JSONObject(responseBody) - val items = json.optJSONArray("items") + Timber.tag("GOG").d("[Cloud Saves] Response body preview: ${responseBody.take(200)}") - if (items == null) { - Timber.tag("GOG").d("[Cloud Saves] No 'items' array in response") + 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() } From f2033be0fcd4725189a387d3496e263b20a08b0d Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 13:43:42 +0000 Subject: [PATCH 111/122] Removing logs. --- .../java/app/gamenative/service/gog/GOGCloudSavesManager.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt index c49f7cc06..95bd09943 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -334,9 +334,6 @@ class GOGCloudSavesManager( return@withContext emptyList() } - Timber.tag("GOG").d("[Cloud Saves] Response body length: ${responseBody.length} bytes") - Timber.tag("GOG").d("[Cloud Saves] Response body preview: ${responseBody.take(200)}") - val items = try { JSONArray(responseBody) } catch (e: Exception) { @@ -344,7 +341,7 @@ class GOGCloudSavesManager( 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() From fae6c4241f9deefa0a922d166649e1ea65c02a02 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 13:49:52 +0000 Subject: [PATCH 112/122] Removed comment --- .../java/app/gamenative/service/gog/GOGCloudSavesManager.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt index 95bd09943..3a68bad32 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -21,10 +21,7 @@ import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream import java.util.concurrent.TimeUnit -/** - * Manages GOG cloud save synchronization in pure Kotlin - * Replaces Python-based implementation to avoid stdout contamination issues - */ + class GOGCloudSavesManager( private val context: Context ) { From 00ab906ae45b83d6d4ec1f06bc23b67cb89ec10f Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 13:51:46 +0000 Subject: [PATCH 113/122] Removed debug option for cloud saves. --- .../screen/library/appscreen/GOGAppScreen.kt | 47 ------------------- 1 file changed, 47 deletions(-) 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 index 9570f71c4..549e39588 100644 --- 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 @@ -434,53 +434,6 @@ class GOGAppScreen : BaseAppScreen() { return true } - /** - * GOG-specific menu options - */ - @Composable - override fun getSourceSpecificMenuOptions( - context: Context, - libraryItem: LibraryItem, - onEditContainer: () -> Unit, - onBack: () -> Unit, - onClickPlay: (Boolean) -> Unit, - isInstalled: Boolean, - ): List { - val options = mutableListOf() - - // Add cloud save sync option for installed games - if (isInstalled) { - options.add( - AppMenuOption( - optionType = AppOptionMenuType.ForceDownloadRemote, - onClick = { - Timber.tag(TAG).d("Manual cloud save sync requested for ${libraryItem.appId}") - CoroutineScope(Dispatchers.IO).launch { - try { - val success = GOGService.syncCloudSaves( - context = context, - appId = libraryItem.appId, - preferredAction = "download" - ) - withContext(Dispatchers.Main) { - if (success) { - Timber.tag(TAG).i("Cloud save sync completed successfully") - } else { - Timber.tag(TAG).e("Cloud save sync failed") - } - } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Failed to sync cloud saves") - } - } - } - ) - ) - } - - return options - } - /** * GOG games support standard container reset */ From 3cde782902d4783ab864a6e31968d09c12f113ab Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 14:50:34 +0000 Subject: [PATCH 114/122] WIP coderabbitAI fixes. --- .../gamenative/service/gog/GOGAuthManager.kt | 31 +- .../service/gog/GOGCloudSavesManager.kt | 145 ++++----- .../app/gamenative/service/gog/GOGManager.kt | 288 +++++++++--------- .../gamenative/service/gog/GOGPythonBridge.kt | 20 +- .../app/gamenative/service/gog/GOGService.kt | 14 +- .../screen/library/appscreen/GOGAppScreen.kt | 4 +- 6 files changed, 253 insertions(+), 249 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt index 55f5f957b..43c5c1ba1 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGAuthManager.kt @@ -174,25 +174,26 @@ object GOGAuthManager { .get() .build() - val response = okhttp3.OkHttpClient().newCall(request).execute() - - if (!response.isSuccessful) { - val errorBody = response.body?.string() ?: "Unknown error" - Timber.e("Failed to get game token: HTTP ${response.code} - $errorBody") - return Result.failure(Exception("Failed to get game-specific token: HTTP ${response.code}")) - } + val tokenJson = okhttp3.OkHttpClient().newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Timber.e("Failed to get game token: HTTP ${response.code} - $errorBody") + return Result.failure(Exception("Failed to get game-specific token: HTTP ${response.code}")) + } - val responseBody = response.body?.string() ?: return Result.failure(Exception("Empty response")) - val tokenJson = JSONObject(responseBody) + val responseBody = response.body?.string() ?: return Result.failure(Exception("Empty response")) + val json = JSONObject(responseBody) - // Store the new game-specific credentials - tokenJson.put("loginTime", System.currentTimeMillis() / 1000.0) - authJson.put(clientId, tokenJson) + // Store the new game-specific credentials + json.put("loginTime", System.currentTimeMillis() / 1000.0) + authJson.put(clientId, json) - // Write updated auth file - authFile.writeText(authJson.toString(2)) + // Write updated auth file + authFile.writeText(authJson.toString(2)) - Timber.i("Successfully obtained game-specific token for clientId: $clientId") + Timber.i("Successfully obtained game-specific token for clientId: $clientId") + json + } return Result.success(GOGCredentials( accessToken = tokenJson.getString("access_token"), diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt index 3a68bad32..b77ead6ca 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -317,58 +317,59 @@ class GOGCloudSavesManager( .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() + } - 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 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() + } - 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") - 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") - 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'") - 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 + } - // 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)") } - - // 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 + 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") @@ -410,13 +411,14 @@ class GOGCloudSavesManager( } val response = httpClient.newCall(requestBuilder.build()).execute() - - 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") + 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) { @@ -448,32 +450,33 @@ class GOGCloudSavesManager( .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 + } - 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}") - 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() - // Save to local file - val localFile = File(syncDir, file.relativePath) - localFile.parentFile?.mkdirs() + FileOutputStream(localFile).use { fos -> + fos.write(bytes) + } - FileOutputStream(localFile).use { fos -> - fos.write(bytes) - } + // Preserve timestamp if available + file.updateTimestamp?.let { timestamp -> + localFile.setLastModified(timestamp * 1000) + } - // Preserve timestamp if available - file.updateTimestamp?.let { timestamp -> - localFile.setLastModified(timestamp * 1000) + Timber.tag("GOG-CloudSaves").i("Successfully downloaded: ${file.relativePath}") } - Timber.tag("GOG-CloudSaves").i("Successfully downloaded: ${file.relativePath}") - } catch (e: Exception) { Timber.tag("GOG-CloudSaves").e(e, "Failed to download ${file.relativePath}") } @@ -500,7 +503,8 @@ class GOGCloudSavesManager( if (file.relativePath !in cloudPaths) { notExistingRemotely.add(file) } - if (file.updateTimestamp != null && file.updateTimestamp!! > timestamp) { + val fileTimestamp = file.updateTimestamp + if (fileTimestamp != null && fileTimestamp > timestamp) { updatedLocal.add(file) } } @@ -512,7 +516,8 @@ class GOGCloudSavesManager( if (file.relativePath !in localPaths) { notExistingLocally.add(file) } - if (file.updateTimestamp != null && file.updateTimestamp!! > timestamp) { + val fileTimestamp = file.updateTimestamp + if (fileTimestamp != null && fileTimestamp > timestamp) { updatedCloud.add(file) } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index ce41df25f..724f1d5c7 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -950,7 +950,14 @@ class GOGManager @Inject constructor( val gameInstallDir = File(gameInstallPath) val execFile = File(gameInstallPath, executablePath) - val relativePath = execFile.relativeTo(gameInstallDir).path.replace('/', '\\') + // 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 @@ -1073,74 +1080,75 @@ class GOGManager @Inject constructor( .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") - 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) - 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 + } - // 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 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 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 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") + } + } - 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") } - } - // 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) } - - 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 @@ -1230,7 +1238,6 @@ class GOGManager @Inject constructor( Timber.tag("GOG").w(e, "[Cloud Saves] Failed to normalize path, using as-is: $resolvedPath") } - resolvedLocations.add( GOGCloudSavesLocation( name = locationTemplate.name, @@ -1277,30 +1284,33 @@ class GOGManager @Inject constructor( .header("Authorization", "Bearer ${credentials.accessToken}") .build() - val response = httpClient.newCall(request).execute() - if (!response.isSuccessful) { - Timber.tag("GOG").w("[Cloud Saves] Build metadata fetch failed: ${response.code}") - return@withContext null - } + // 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) + 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 - } + // 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 manifestLink = firstBuild.optString("link", "") - if (manifestLink.isEmpty()) { - Timber.tag("GOG").w("[Cloud Saves] No manifest link in first build") - 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: $manifestLink") + Timber.tag("GOG").d("[Cloud Saves] Fetching build manifest from: $link") + link + } // Fetch the build manifest val manifestRequest = Request.Builder() @@ -1309,79 +1319,81 @@ class GOGManager @Inject constructor( .build() val manifestResponse = httpClient.newCall(manifestRequest).execute() - if (!manifestResponse.isSuccessful) { - Timber.tag("GOG").w("[Cloud Saves] Manifest fetch failed: ${manifestResponse.code}") - return@withContext null - } + manifestResponse.use { + if (!manifestResponse.isSuccessful) { + Timber.tag("GOG").w("[Cloud Saves] Manifest fetch failed: ${manifestResponse.code}") + return@withContext null + } - // Log response headers to debug compression - val contentEncoding = manifestResponse.header("Content-Encoding") - val contentType = manifestResponse.header("Content-Type") - Timber.tag("GOG").d("[Cloud Saves] Response headers - Content-Encoding: $contentEncoding, Content-Type: $contentType") + // 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 + // 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() + // 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()) + 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") + 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 + // 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 + 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) } } - 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 } - } - 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) - 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 + } - // 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 } - 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 diff --git a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt index 275307b0f..793d38f26 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt @@ -97,52 +97,39 @@ object GOGPythonBridge { suspend fun executeCommand(vararg args: String): Result { return withContext(Dispatchers.IO) { try { - Timber.d("executeCommand called with args: ${args.joinToString(" ")}") - if (!Python.isStarted()) { Timber.e("Python is not started! Cannot execute GOGDL command") return@withContext Result.failure(Exception("Python environment not initialized")) } val python = Python.getInstance() - Timber.d("Python instance obtained successfully") - val sys = python.getModule("sys") val io = python.getModule("io") val originalArgv = sys.get("argv") try { - Timber.d("Importing gogdl.cli module...") val gogdlCli = python.getModule("gogdl.cli") - Timber.d("gogdl.cli module imported successfully") // Set up arguments for argparse val argsList = listOf("gogdl") + args.toList() - Timber.d("Setting GOGDL arguments for argparse: ${args.joinToString(" ")}") val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) sys.put("argv", pythonList) - Timber.d("sys.argv set to: $argsList") // Capture stdout val stdoutCapture = io.callAttr("StringIO") val originalStdout = sys.get("stdout") sys.put("stdout", stdoutCapture) - Timber.d("stdout capture configured") // Execute the main function - Timber.d("Calling gogdl.cli.main()...") gogdlCli.callAttr("main") - Timber.d("gogdl.cli.main() completed") // Get the captured output val output = stdoutCapture.callAttr("getvalue").toString() - Timber.d("GOGDL raw output (length: ${output.length}): $output") // Restore original stdout sys.put("stdout", originalStdout) if (output.isNotEmpty()) { - Timber.d("Returning success with output") Result.success(output) } else { Timber.w("GOGDL execution completed but output is empty") @@ -150,17 +137,14 @@ object GOGPythonBridge { } } catch (e: Exception) { - Timber.e(e, "GOGDL execution exception: ${e.javaClass.simpleName} - ${e.message}") - Timber.e("Exception stack trace: ${e.stackTraceToString()}") + Timber.e(e, "GOGDL execution failed: ${e.message}") Result.failure(Exception("GOGDL execution failed: ${e.message}", e)) } finally { // Restore original sys.argv sys.put("argv", originalArgv) - Timber.d("sys.argv restored") } } catch (e: Exception) { Timber.e(e, "Failed to execute GOGDL command: ${args.joinToString(" ")}") - Timber.e("Outer exception stack trace: ${e.stackTraceToString()}") Result.failure(Exception("GOGDL execution failed: ${e.message}", e)) } } @@ -192,7 +176,6 @@ object GOGPythonBridge { // Try to set progress callback if gogdl supports it try { gogdlModule.put("_progress_callback", progressCallback) - Timber.d("Progress callback registered with GOGDL") } catch (e: Exception) { Timber.w(e, "Could not register progress callback, will use estimation") } @@ -201,7 +184,6 @@ object GOGPythonBridge { // Set up arguments for argparse val argsList = listOf("gogdl") + args.toList() - Timber.d("Setting GOGDL arguments: ${args.joinToString(" ")}") val pythonList = python.builtins.callAttr("list", argsList.toTypedArray()) sys.put("argv", pythonList) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 5bcfdd709..06f041517 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -361,7 +361,13 @@ class GOGService : Service() { 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 - if (!instance!!.gogManager.startSync(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 } @@ -456,8 +462,6 @@ class GOGService : Service() { instance.gogManager.setSyncTimestamp(appId, location.name, newTimestamp.toString()) Timber.tag("GOG").d("[Cloud Saves] Updated timestamp for '${location.name}': $newTimestamp") - 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) @@ -502,7 +506,7 @@ class GOGService : Service() { } } finally { // Always end the sync, even if an exception occurred - instance!!.gogManager.endSync(appId) + getInstance()?.gogManager?.endSync(appId) Timber.tag("GOG").d("[Cloud Saves] Sync completed and lock released for $appId") } } catch (e: Exception) { @@ -557,7 +561,7 @@ class GOGService : Service() { } // Start background library sync if requested - if (shouldSync && (backgroundSyncJob == null || !backgroundSyncJob!!.isActive)) { + if (shouldSync && (backgroundSyncJob == null || backgroundSyncJob?.isActive != true)) { Timber.i("[GOGService] Starting background library sync") backgroundSyncJob?.cancel() // Cancel any existing job backgroundSyncJob = scope.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 index 549e39588..040bede0a 100644 --- 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 @@ -315,8 +315,8 @@ class GOGAppScreen : BaseAppScreen() { if (downloadInfo != null) { Timber.tag(TAG).i("Cancelling GOG download: ${libraryItem.appId}") - GOGService.cleanupDownload(gameId) downloadInfo.cancel() + GOGService.cleanupDownload(gameId) } } @@ -332,8 +332,8 @@ class GOGAppScreen : BaseAppScreen() { if (isDownloading) { // Cancel download immediately if currently downloading Timber.tag(TAG).i("Cancelling active download for GOG game: ${libraryItem.appId}") - GOGService.cleanupDownload(gameId) downloadInfo.cancel() + GOGService.cleanupDownload(gameId) android.widget.Toast.makeText( context, "Download cancelled", From bcffecb6d33488ccc329c8eb0f5da81e7ee7bed5 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 19:22:31 +0000 Subject: [PATCH 115/122] Fixed a small bug with the downloading bar not updating correctly. --- .../main/java/app/gamenative/service/gog/GOGPythonBridge.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt index 793d38f26..f46b5e27d 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt @@ -37,9 +37,13 @@ class ProgressCallback(private val downloadInfo: DownloadInfo) { // Also set percentage-based progress for compatibility downloadInfo.setProgress(progress) - // Update status message with ETA + // Update status message with ETA or progress info if (eta.isNotEmpty() && eta != "00:00:00") { downloadInfo.updateStatusMessage("ETA: $eta") + } else if (percent > 0f) { + downloadInfo.updateStatusMessage(String.format("%.1f%%", percent)) + } else { + downloadInfo.updateStatusMessage("Starting...") } if (percent > 0f) { From 91b576c03cf5dc11552a1f6bdc9bb0d2822a2ddf Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 19:33:34 +0000 Subject: [PATCH 116/122] Fixed issue where the ownership check was not working correctly in gogdl. --- app/src/main/python/gogdl/api.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/main/python/gogdl/api.py b/app/src/main/python/gogdl/api.py index 317824054..50a48a45d 100644 --- a/app/src/main/python/gogdl/api.py +++ b/app/src/main/python/gogdl/api.py @@ -97,6 +97,30 @@ def get_authenticated_request(self, url): """Make an authenticated request with proper headers""" return self.session.get(url) + def does_user_own(self, game_id): + """Check if the user owns a specific game + + Args: + game_id: The GOG game ID to check + + Returns: + bool: True if the user owns the game, False otherwise + """ + # If owned games list is populated, check it + if self.owned: + return str(game_id) in [str(g) for g in self.owned] + + # Otherwise, try to fetch user data and check + try: + user_data = self.get_user_data() + if user_data and 'owned' in user_data: + self.owned = [str(g) for g in user_data['owned']] + return str(game_id) in self.owned + except Exception as e: + self.logger.warning(f"Failed to check game ownership for {game_id}: {e}") + + # If we can't determine, assume they own it (they're trying to download it) + return True def get_dependencies_repo(self, depot_version=2): self.logger.info("Getting Dependencies repository") From bc129bb6c29594663606e90116b66dc4ddd1034a Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 2 Jan 2026 19:38:53 +0000 Subject: [PATCH 117/122] Fixed issue with race condition when downloading. --- .../gamenative/service/gog/GOGPythonBridge.kt | 60 +++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt index f46b5e27d..122fc5389 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGPythonBridge.kt @@ -230,30 +230,54 @@ object GOGPythonBridge { private suspend fun estimateProgress(downloadInfo: DownloadInfo) { try { var lastProgress = 0.0f + var lastBytesDownloaded = 0L val startTime = System.currentTimeMillis() + var callbackDetected = false + val CHECK_INTERVAL = 3000L while (downloadInfo.getProgress() < 1.0f && downloadInfo.getProgress() >= 0.0f) { - delay(3000L) // Update every 3 seconds - - val elapsed = System.currentTimeMillis() - startTime - val estimatedProgress = when { - elapsed < 5000 -> 0.05f - elapsed < 15000 -> 0.15f - elapsed < 30000 -> 0.30f - elapsed < 60000 -> 0.50f - elapsed < 120000 -> 0.70f - elapsed < 180000 -> 0.85f - else -> 0.95f - }.coerceAtLeast(lastProgress) - - // Only update if progress hasn't been set by callback - if (downloadInfo.getProgress() <= lastProgress + 0.01f) { + delay(CHECK_INTERVAL) + + val currentBytes = downloadInfo.getBytesDownloaded() + val currentProgress = downloadInfo.getProgress() + + // Check if the callback is actively updating (bytes are increasing) + if (currentBytes > lastBytesDownloaded) { + if (!callbackDetected) { + Timber.d("Progress callback detected, disabling estimator") + callbackDetected = true + } + lastBytesDownloaded = currentBytes + lastProgress = currentProgress + continue // Don't override real progress + } + + // Also check if progress increased significantly without estimator intervention + if (currentProgress > lastProgress + 0.02f) { + if (!callbackDetected) { + Timber.d("Progress callback detected (progress jump), disabling estimator") + callbackDetected = true + } + lastProgress = currentProgress + continue // Don't override real progress + } + + // Only estimate if callback hasn't been detected + if (!callbackDetected) { + val elapsed = System.currentTimeMillis() - startTime + val estimatedProgress = when { + elapsed < 5000 -> 0.05f + elapsed < 15000 -> 0.15f + elapsed < 30000 -> 0.30f + elapsed < 60000 -> 0.50f + elapsed < 120000 -> 0.70f + elapsed < 180000 -> 0.85f + else -> 0.95f + }.coerceAtLeast(lastProgress) + downloadInfo.setProgress(estimatedProgress) lastProgress = estimatedProgress Timber.d("Estimated progress: %.1f%%", estimatedProgress * 100) - } else { - // Callback is working, update our tracking - lastProgress = downloadInfo.getProgress() } } } catch (e: CancellationException) { From 72f10dfeb494e70c9c1459218d617835a724b0b8 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sat, 3 Jan 2026 09:43:21 +0000 Subject: [PATCH 118/122] Now the GOGService will restart if it was not started onResume. Also fixed an issue with the .exe not being applied correctly in the GOGManager for the getWineStartCommand. --- app/src/main/java/app/gamenative/MainActivity.kt | 7 +++++++ .../java/app/gamenative/service/gog/GOGManager.kt | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 3ee24e4ce..38c82e8c2 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -268,6 +268,13 @@ class MainActivity : ComponentActivity() { PluviaApp.xEnvironment?.onResume() Timber.d("Game resumed") } + + // Restart GOG service if it went down + if (GOGService.hasStoredCredentials(this) && !GOGService.isRunning) { + Timber.i("GOG service was down on resume - restarting") + GOGService.start(this) + } + PostHog.capture(event = "app_foregrounded") } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 724f1d5c7..ce91acea0 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -927,7 +927,16 @@ class GOGManager @Inject constructor( return "\"explorer.exe\"" } - val executablePath = runBlocking { getInstalledExe(context, libraryItem) } + // Use container's configured executable path if available, otherwise auto-detect + val executablePath = if (container.executablePath.isNotEmpty()) { + Timber.d("Using configured executable path from container: ${container.executablePath}") + container.executablePath + } else { + val detectedPath = runBlocking { getInstalledExe(context, libraryItem) } + Timber.d("Auto-detected executable path: $detectedPath") + detectedPath + } + if (executablePath.isEmpty()) { Timber.w("No executable found, opening file manager") return "\"explorer.exe\"" From a64448cd6d19820582464d071834179eb1265a7f Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 5 Jan 2026 10:28:19 +0000 Subject: [PATCH 119/122] Fixed issue where context was missing from winStartCommand. --- .../main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 73bfd9a81..3ae2ac75e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -1514,7 +1514,7 @@ private fun setupXEnvironment( guestProgramLauncherComponent.setContainer(container); guestProgramLauncherComponent.setWineInfo(xServerState.value.wineInfo); val guestExecutable = "wine explorer /desktop=shell," + xServer.screenInfo + " " + - getWineStartCommand(appId, container, bootToContainer, testGraphics, appLaunchInfo, envVars, guestProgramLauncherComponent) + + getWineStartCommand(context, appId, container, bootToContainer, testGraphics, appLaunchInfo, envVars, guestProgramLauncherComponent) + (if (container.execArgs.isNotEmpty()) " " + container.execArgs else "") guestProgramLauncherComponent.isWoW64Mode = wow64Mode guestProgramLauncherComponent.guestExecutable = guestExecutable From 48752c58a2ff979cee52ab41caa82f64ef41202f Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 5 Jan 2026 10:54:16 +0000 Subject: [PATCH 120/122] Added fix for the conflict syncAction. Will need to test more thoroughly with users to understand how it should work. In a follow-up we should give them a notice on the front-end to ask them for their decision on what should happen. --- .../service/gog/GOGCloudSavesManager.kt | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt index b77ead6ca..eeac19fe5 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -250,9 +250,69 @@ class GOGCloudSavesManager( } SyncAction.CONFLICT -> { - Timber.tag("GOG-CloudSaves").w("Sync conflict detected - manual intervention required") - } + 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") } From 5b8b68289b6ed792f4a4b611f562e088c7942abc Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 5 Jan 2026 10:56:37 +0000 Subject: [PATCH 121/122] Adjusted preferredAction for pluviamain so it doesn't ALWAYS download. --- app/src/main/java/app/gamenative/ui/PluviaMain.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 10092499c..5fafe1c6f 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1162,7 +1162,6 @@ fun preLaunchApp( val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves( context = context, appId = appId, - preferredAction = "download" ) if (!syncSuccess) { From 9ab946de21ca3db6b3c7641db4e5e6abb4874615 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 5 Jan 2026 11:07:52 +0000 Subject: [PATCH 122/122] Confirmed that we download/upload only the latest files. --- app/src/main/java/app/gamenative/service/gog/GOGService.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 273320df3..0cfe753cb 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -446,7 +446,6 @@ class GOGService : Service() { continue } - // Use Kotlin cloud saves manager instead of Python val cloudSavesManager = GOGCloudSavesManager(context) val newTimestamp = cloudSavesManager.syncSaves( clientId = location.clientId,