From bd7e2d50f39126c24963c67a96d26685eb40998b Mon Sep 17 00:00:00 2001 From: UnbelievableFlavour Date: Mon, 13 Apr 2026 14:07:01 +0200 Subject: [PATCH 01/10] Add GOG force cloud save and related string updates. (#812) --- .../screen/library/appscreen/EpicAppScreen.kt | 15 +++- .../screen/library/appscreen/GOGAppScreen.kt | 60 +++++++++++++- .../library/appscreen/SteamAppScreen.kt | 8 +- app/src/main/res/values-da/strings.xml | 11 +-- app/src/main/res/values-de/strings.xml | 11 +-- app/src/main/res/values-es/strings.xml | 11 +-- app/src/main/res/values-fr/strings.xml | 11 +-- app/src/main/res/values-it/strings.xml | 11 +-- app/src/main/res/values-ko/strings.xml | 11 +-- app/src/main/res/values-pl/strings.xml | 11 +-- app/src/main/res/values-pt-rBR/strings.xml | 11 +-- app/src/main/res/values-ro/strings.xml | 11 +-- app/src/main/res/values-ru/strings.xml | 11 +-- app/src/main/res/values-uk/strings.xml | 7 +- app/src/main/res/values-zh-rCN/strings.xml | 15 ++-- app/src/main/res/values-zh-rTW/strings.xml | 11 +-- app/src/main/res/values/strings.xml | 11 +-- .../library/appscreen/GOGAppScreenTest.kt | 82 +++++++++++++++++++ 18 files changed, 202 insertions(+), 117 deletions(-) create mode 100644 app/src/test/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreenTest.kt diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt index bdb5ebb6e8..f725b7e3d3 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt @@ -546,7 +546,7 @@ class EpicAppScreen : BaseAppScreen() { val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) scope.launch { try { - SnackbarManager.show(context.getString(R.string.epic_cloud_sync_starting)) + SnackbarManager.show(context.getString(R.string.library_cloud_sync_starting)) val result = withContext(Dispatchers.IO) { EpicCloudSavesManager.syncCloudSaves( @@ -557,11 +557,20 @@ class EpicAppScreen : BaseAppScreen() { } SnackbarManager.show( - if (result) context.getString(R.string.epic_cloud_sync_success) else context.getString(R.string.epic_cloud_sync_failed), + if (result) { + context.getString(R.string.library_cloud_sync_success) + } else { + context.getString(R.string.library_cloud_sync_failed) + }, ) } catch (e: Exception) { Timber.tag(TAG).e(e, "[Cloud Saves] Sync failed") - SnackbarManager.show(context.getString(R.string.epic_cloud_sync_error, e.message ?: "")) + SnackbarManager.show( + context.getString( + R.string.library_cloud_sync_error, + e.message ?: "", + ), + ) } } }, 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 122771a6bf..0b44973e5b 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 @@ -29,6 +29,7 @@ import com.winlator.container.ContainerData import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import app.gamenative.ui.util.SnackbarManager @@ -79,6 +80,44 @@ class GOGAppScreen : BaseAppScreen() { else -> "$bytes B" } } + + internal suspend fun forceCloudSync( + context: Context, + appId: String, + syncCloudSaves: suspend (Context, String, String) -> Boolean = { syncContext, syncAppId, preferredAction -> + GOGService.syncCloudSaves( + context = syncContext, + appId = syncAppId, + preferredAction = preferredAction, + ) + }, + showSnackbar: (String) -> Unit = SnackbarManager::show, + logError: (Throwable) -> Unit = { error -> + Timber.tag(TAG).e(error, "[Cloud Saves] Sync failed") + }, + ) { + try { + showSnackbar(context.getString(R.string.library_cloud_sync_starting)) + + val result = withContext(Dispatchers.IO) { + syncCloudSaves(context, appId, "auto") + } + + if (result) { + showSnackbar(context.getString(R.string.library_cloud_sync_success)) + } else { + showSnackbar(context.getString(R.string.library_cloud_sync_failed)) + } + } catch (e: Exception) { + logError(e) + showSnackbar( + context.getString( + R.string.library_cloud_sync_error, + e.message ?: "", + ), + ) + } + } } @Composable @@ -446,7 +485,9 @@ class GOGAppScreen : BaseAppScreen() { return emptyList() } - return listOf( + val options = mutableListOf() + + options.add( AppMenuOption( optionType = AppOptionMenuType.VerifyFiles, onClick = { @@ -464,6 +505,23 @@ class GOGAppScreen : BaseAppScreen() { }, ), ) + + options.add( + AppMenuOption( + optionType = AppOptionMenuType.ForceCloudSync, + onClick = { + val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + scope.launch { + forceCloudSync( + context = context, + appId = libraryItem.appId, + ) + } + }, + ), + ) + + return options } /** diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index 2b4f8db97d..fe5c7448fe 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -756,6 +756,8 @@ class SteamAppScreen : BaseAppScreen() { properties = mapOf("game_name" to appInfo.name), ) CoroutineScope(Dispatchers.IO).launch { + SnackbarManager.show(context.getString(R.string.library_cloud_sync_starting)) + val steamId = SteamService.userSteamId if (steamId == null) { SnackbarManager.show(context.getString(R.string.steam_not_logged_in)) @@ -776,17 +778,17 @@ class SteamAppScreen : BaseAppScreen() { when (syncResult.syncResult) { SyncResult.Success -> { - SnackbarManager.show(context.getString(R.string.steam_cloud_sync_success)) + SnackbarManager.show(context.getString(R.string.library_cloud_sync_success)) } SyncResult.UpToDate -> { - SnackbarManager.show(context.getString(R.string.steam_cloud_sync_up_to_date)) + SnackbarManager.show(context.getString(R.string.library_cloud_sync_up_to_date)) } else -> { SnackbarManager.show( context.getString( - R.string.steam_cloud_sync_failed, + R.string.library_cloud_sync_error, syncResult.syncResult, ), ) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index eace596152..b24c040091 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -30,10 +30,6 @@ Appen der installeres har følgende pladskrav. Vil du fortsætte?\n\n\tDownload-størrelse: %1$s\n\tTilgængelig plads: %2$s Er du sikker på, at du vil annullere download af appen? Slet alle downloadede data for dette spil? - Starter synkronisering af cloud-gemmer… - Cloud-gemmer synkroniseret med succes - Synkronisering af cloud-gemmer mislykkedes - Fejl ved synkronisering af cloud-gemmer: %1$s Afinstallation mislykkedes: %1$s Afinstallationsfejl: %1$s Installér app @@ -798,9 +794,11 @@ Eksport annulleret Genvej oprettet Kunne ikke oprette genvej: %s + Starter synkronisering af cloud-gemmer… Sky-synkronisering fuldført Gemfiler er allerede opdaterede - Sky-synkronisering fejlede: %s + Sky-synkronisering fejlede + Fejl ved synkronisering af cloud-gemmer: %1$s Internet påkrævet for installation @@ -1082,9 +1080,6 @@ Sørg venligst for, at dine gemfiler er uploadet til skyen eller sikkerhedskopieret før verificering, da de ellers kan blive overskrevet. Opdatering Sørg venligst for, at dine gemfiler er uploadet til skyen eller sikkerhedskopieret før opdatering, da de ellers kan blive overskrevet. - Sky-synkronisering fuldført - Gemfiler er allerede opdaterede - Sky-synkronisering fejlede: %s Lagertilladelse påkrævet Container nulstillet til standard ImageFS installeret. Prøv at redigere container igen. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8ab9c81668..1ab7551b60 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -28,9 +28,6 @@ Stelle bitte sicher, dass deine Spielstände vorher in die Cloud hochgeladen oder gesichert wurden, da sie sonst überschrieben werden können. Aktualisieren Stelle sicher, dass deine Speicherstände vor dem Aktualisieren in der Cloud gesichert oder gebackupt sind, um ein Überschreiben zu verhindern. - Cloud-Synchronisierung erfolgreich abgeschlossen - Spielstände sind bereits aktuell - Cloud-Synchronisierung fehlgeschlagen: %s Du musst bei Steam angemeldet sein, um diese Funktion zu nutzen Speicherberechtigung erforderlich Container auf Werkseinstellungen zurückgesetzt @@ -54,10 +51,6 @@ Nie Möchten Sie den Download der App wirklich abbrechen? Alle heruntergeladenen Daten für dieses Spiel löschen? - Starte Cloud-Speicherstand-Synchronisation… - Cloud-Spielstände erfolgreich synchronisiert - Cloud-Spielstand-Synchronisation fehlgeschlagen - Fehler bei Cloud-Spielstand-Synchronisation: %1$s Deinstallation fehlgeschlagen: %1$s Deinstallationsfehler: %1$s Weiter @@ -931,9 +924,11 @@ Export abgebrochen Verknüpfung erstellt Verknüpfung konnte nicht erstellt werden: %s + Cloud-Synchronisierung wird gestartet… Cloud-Synchronisierung erfolgreich Spielstände sind aktuell - Cloud-Synchronisierung fehlgeschlagen: %s + Cloud-Synchronisierung fehlgeschlagen + Cloud-Synchronisierungsfehler: %1$s Internetverbindung erforderlich Installieren nur über WLAN/LAN aktiviert diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index be46bac632..471eac3f49 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -29,9 +29,6 @@ Asegúrate de que tus archivos de guardado están en la nube o respaldados antes de verificar, ya que podrían sobrescribirse. Actualizar Asegúrate de que tus archivos de guardado están en la nube o respaldados antes de actualizar, ya que podrían sobrescribirse. - Sincronización con la nube completada con éxito. - Los archivos de guardado ya están actualizados. - Fallo en la sincronización con la nube: %s. Debes iniciar sesión en Steam para usar esta función. Se requiere permiso de almacenamiento. Contenedor restablecido a los valores por defecto. @@ -67,10 +64,6 @@ Error de descarga: %1$s. ¿Deseas cancelar la descarga de la aplicación? ¿Deseas eliminar todos los datos descargados de este juego? - Iniciando sincronización en la nube… - Partidas en la nube sincronizadas correctamente. - Error al sincronizar con la nube. - Error de sincronización en la nube: %1$s. Error al desinstalar: %1$s. Error de desinstalación: %1$s. Nunca @@ -988,9 +981,11 @@ Exportación cancelada. Acceso directo creado. Error al crear el acceso directo: %s. + Iniciando sincronización en la nube… Sincronización con la nube completada con éxito. Los archivos de guardado ya están actualizados. - Fallo en la sincronización con la nube: %s. + Fallo en la sincronización con la nube. + Error de sincronización en la nube: %1$s. Necesitas conexión a internet para instalar. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index eeed738420..7f0140f92e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -29,9 +29,6 @@ Veuillez vous assurer que vos sauvegardes sont téléchargées dans le cloud ou sauvegardées avant de vérifier, car elles peuvent être écrasées. Mettre à jour Veuillez vous assurer que vos sauvegardes sont téléchargées dans le cloud ou sauvegardées avant de mettre à jour, car elles peuvent être écrasées. - Synchronisation cloud terminée avec succès - Les fichiers de sauvegarde sont déjà à jour - Échec de la synchronisation cloud : %s Vous devez être connecté à Steam pour utiliser cette fonctionnalité Autorisation de stockage requise Conteneur réinitialisé aux paramètres par défaut @@ -59,10 +56,6 @@ Jamais Êtes-vous sûr de vouloir annuler le téléchargement de l\'application? Supprimer toutes les données téléchargées pour ce jeu? - Démarrage de la synchronisation des sauvegardes cloud… - Sauvegardes cloud synchronisées avec succès - Échec de la synchronisation des sauvegardes cloud - Erreur de synchronisation des sauvegardes cloud: %1$s Échec de la désinstallation: %1$s Erreur de désinstallation: %1$s Continuer @@ -974,9 +967,11 @@ Exportation annulée Raccourci créé Échec de la création du raccourci : %s + Démarrage de la synchronisation des sauvegardes cloud… Synchronisation cloud terminée avec succès Les fichiers de sauvegarde sont déjà à jour - Échec de la synchronisation cloud : %s + Échec de la synchronisation cloud + Erreur de synchronisation des sauvegardes cloud: %1$s Connexion internet nécessaire pour installer diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e9634e9522..b0e50f47b2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -29,9 +29,6 @@ Assicurati che i tuoi salvataggi siano caricati sul cloud o sottoposti a backup prima della verifica, altrimenti potrebbero essere sovrascritti. Aggiorna Assicurati che i tuoi salvataggi siano caricati sul cloud o sottoposti a backup prima dell\'aggiornamento, altrimenti potrebbero essere sovrascritti. - Sincronizzazione cloud completata con successo - I file di salvataggio sono già aggiornati - Sincronizzazione cloud fallita: %s Devi aver effettuato l\'accesso a Steam per utilizzare questa funzione Permesso di archiviazione richiesto Container reimpostato ai valori predefiniti @@ -59,10 +56,6 @@ Mai Sei sicuro di voler annullare il download dell\'app? Eliminare tutti i dati scaricati per questo gioco? - Avvio sincronizzazione salvataggi cloud… - Salvataggi cloud sincronizzati con successo - Sincronizzazione salvataggi cloud non riuscita - Errore sincronizzazione salvataggi cloud: %1$s Disinstallazione non riuscita: %1$s Errore di disinstallazione: %1$s Continua @@ -970,9 +963,11 @@ Esportazione annullata Scorciatoia creata Impossibile creare scorciatoia: %s + Avvio sincronizzazione salvataggi cloud… Sincronizzazione cloud completata con successo I file di salvataggio sono già aggiornati - Sincronizzazione cloud fallita: %s + Sincronizzazione cloud fallita + Errore sincronizzazione salvataggi cloud: %1$s Serve internet per installare diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e0fe8a1ef7..082593597d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -29,9 +29,6 @@ 검증하기 전에 저장 파일이 클라우드에 업로드되었거나 백업되었는지 확인하세요. 그렇지 않으면 덮어쓰기될 수 있습니다. 업데이트 업데이트하기 전에 저장 파일이 클라우드에 업로드되었거나 백업되었는지 확인하세요. 그렇지 않으면 덮어쓰기될 수 있습니다. - 클라우드 동기화가 완료되었습니다 - 저장 파일이 이미 최신 상태입니다 - 클라우드 동기화 실패: %s 이 기능을 사용하려면 Steam에 로그인해야 합니다 저장소 권한이 필요합니다 컨테이너가 기본값으로 초기화되었습니다 @@ -67,10 +64,6 @@ 다운로드 오류: %1$s 앱 다운로드를 취소하시겠습니까? 이 게임의 다운로드된 모든 데이터를 삭제하시겠습니까? - 클라우드 저장 파일 동기화 시작… - 클라우드 저장 파일 동기화 완료 - 클라우드 저장 파일 동기화 실패 - 클라우드 저장 파일 동기화 오류: %1$s 제거 실패: %1$s 제거 오류: %1$s 안 함 @@ -988,9 +981,11 @@ 내보내기 취소됨 바로가기가 생성되었습니다 바로가기 생성 실패: %s + 클라우드 저장 파일 동기화 시작… 클라우드 동기화가 완료되었습니다 저장 파일이 이미 최신 상태입니다 - 클라우드 동기화 실패: %s + 클라우드 동기화 실패 + 클라우드 저장 파일 동기화 오류: %1$s 설치하려면 인터넷이 필요합니다 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index b2e9a09965..9cd5e90433 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -29,9 +29,6 @@ Upewnij się, że Twoje zapisy są przesłane do chmury lub utworzono ich kopię zapasową przed weryfikacją, w przeciwnym razie mogą zostać nadpisane. Aktualizuj Upewnij się, że Twoje zapisy są przesłane do chmury lub utworzono ich kopię zapasową przed aktualizacją, w przeciwnym razie mogą zostać nadpisane. - Synchronizacja z chmurą zakończona pomyślnie - Pliki zapisu są już aktualne - Synchronizacja z chmurą nie powiodła się: %s Musisz być zalogowany do Steam, aby użyć tej funkcji Wymagane uprawnienie do pamięci Kontener zresetowany do domyślnych @@ -67,10 +64,6 @@ Błąd pobierania: %1$s Czy na pewno chcesz anulować pobieranie aplikacji? Usunąć wszystkie pobrane dane dla tej gry? - Rozpoczynanie synchronizacji zapisów w chmurze… - Zapisy w chmurze zsynchronizowane pomyślnie - Synchronizacja zapisów w chmurze nie powiodła się - Błąd synchronizacji zapisów w chmurze: %1$s Odinstalowywanie nie powiodło się: %1$s Błąd odinstalowywania: %1$s Nigdy @@ -987,9 +980,11 @@ Eksport anulowany Utworzono skrót Nie udało się utworzyć skrótu: %s + Rozpoczynanie synchronizacji zapisów w chmurze… Synchronizacja z chmurą zakończona pomyślnie Pliki zapisu są już aktualne - Synchronizacja z chmurą nie powiodła się: %s + Synchronizacja z chmurą nie powiodła się + Błąd synchronizacji zapisów w chmurze: %1$s Wymagany internet do instalacji diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a7bfb8f251..989bc0aba7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -120,10 +120,6 @@ Ubuntu FS Tem certeza de que deseja cancelar o download do aplicativo? Excluir todos os dados baixados deste jogo? - Iniciando sincronização de salvamentos na nuvem… - Salvamentos na nuvem sincronizados com sucesso - Falha na sincronização de salvamentos na nuvem - Erro na sincronização de salvamentos na nuvem: %1$s Falha na desinstalação: %1$s Erro na desinstalação: %1$s @@ -798,9 +794,11 @@ Exportação cancelada Atalho criado Falha ao criar atalho: %s + Iniciando sincronização de salvamentos na nuvem… Sincronização na nuvem concluída com sucesso Os arquivos de save já estão atualizados - Sincronização na nuvem falhou: %s + Sincronização na nuvem falhou + Erro na sincronização de salvamentos na nuvem: %1$s Internet necessária para instalar @@ -1082,9 +1080,6 @@ Certifique-se de que seus saves foram enviados para a nuvem ou foram feitos backups antes de verificar, caso contrário, eles podem ser sobrescritos. Atualização Certifique-se de que seus saves foram enviados para a nuvem ou foram feitos backups antes de atualizar, caso contrário, eles podem ser sobrescritos. - Sincronização na nuvem concluída com sucesso - Os arquivos de save já estão atualizados - Sincronização na nuvem falhou: %s Permissão de armazenamento necessária Contêiner redefinido para padrões ImageFS instalado. Por favor, tente editar o contêiner novamente. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index d158693a96..694bb223b5 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -29,9 +29,6 @@ Asigură-te că salvările sunt încărcate în cloud sau copiate în altă parte înainte de verificare, deoarece pot fi suprascrise. Actualizare Asigură-te că salvările sunt încărcate în cloud sau copiate în altă parte înainte de actualizare, deoarece pot fi suprascrise. - Sincronizarea cloud s-a încheiat cu succes - Fișierele de salvare sunt deja actualizate - Sincronizare cloud eșuată: %s Trebuie să fii autentificat în Steam pentru a folosi această funcție Este necesară permisiunea de stocare Container resetat la setările implicite @@ -59,10 +56,6 @@ Niciodată Sigur doriți să anulați descărcarea aplicației? Ștergeți toate datele descărcate pentru acest joc? - Se pornește sincronizarea salvărilor din cloud… - Salvările din cloud au fost sincronizate cu succes - Sincronizarea salvărilor din cloud a eșuat - Eroare la sincronizarea salvărilor din cloud: %1$s Dezinstalarea a eșuat: %1$s Eroare la dezinstalare: %1$s Continuă @@ -978,9 +971,11 @@ Export anulat Shortcut creat Nu s-a putut crea shortcut-ul: %s + Se pornește sincronizarea salvărilor din cloud… Sincronizare cloud finalizată cu succes Fișierele de salvare sunt deja la zi - Sincronizare cloud eșuată: %s + Sincronizare cloud eșuată + Eroare la sincronizarea salvărilor din cloud: %1$s Este necesar internet pentru instalare diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5ceb539174..17bfa7eeb3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -342,10 +342,6 @@ Завершить процесс Переменные окружения Вы уверены, что хотите отменить загрузку приложения? - Ошибка синхронизации облачного сохранения: %1$s - Ошибка синхронизации облачного сохранения - Начало синхронизации облачного сохранения… - Облачные сохранения успешно синхронизированы Удалить все загруженные данные для этой игры? Ошибка загрузки: %1$s Ошибка при запуске загрузки: %1$s @@ -541,9 +537,11 @@ Ubuntu RootFs - releases.ubuntu.com/focal Статус приложения Тип приложения Вы уверены, что хотите отменить загрузку приложения? - Ошибка синхронизации облака: %s + Начало синхронизации облака… Синхронизация облака завершена успешно Файлы сохранения уже актуальны + Ошибка синхронизации облака + Ошибка синхронизации облачного сохранения: %1$s Неизвестно Совместимо Конфигурация %s @@ -1056,9 +1054,6 @@ https://gamenative.app Пожалуйста, введите код аутентификации, отправленный на %s Предыдущий код был неправильным, пожалуйста, попробуйте снова. Вы уверены, что хотите отменить загрузку приложения? - Ошибка синхронизации облака: %s - Синхронизация облака завершена успешно - Файлы сохранения уже актуальны Контейнер сброшен на значения по умолчанию Продолжить Удалить все загруженные данные для этой игры? diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6722400b7e..82db6b2f80 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -29,9 +29,6 @@ Будь ласка, переконайтеся, що ваші збереження завантажено у хмару або створено їхню резервну копію перед перевіркою, оскільки інакше вони можуть бути перезаписані. Оновити Будь ласка, переконайтеся, що ваші збереження завантажено у хмару або створено їхню резервну копію перед оновленням, оскільки інакше вони можуть бути перезаписані. - Успішна синхронізація з хмарою - Файли збереження вже актуальні - Помилка синхронізації з хмарою: %s Ви повинні увійти в Steam, щоб використовувати цю функцію Необхідний дозвіл на доступ до сховища Контейнер скинуто до стандартних налаштувань @@ -969,9 +966,11 @@ Вивантаження скасовано Створено ярлик Помилка створення ярлика: %s + Запуск синхронізації з хмарою… Успішна синхронізація з хмарою Файли збереження вже актуальні - Помилка синхронізації з хмарою: %s + Помилка синхронізації з хмарою + Помилка синхронізації з хмарою: %1$s Потрібне інтернет-з\'єднання для інсталяції diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ef85a85ff8..64141bb6e4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -29,9 +29,6 @@ 验证前请确保存档已上传至云端或已备份,否则存档可能会被覆盖 更新 更新前请确保存档已上传至云端或已备份,否则存档可能会被覆盖 - 云同步已成功完成 - 游戏存档已是最新版本 - 云同步失败:%s 需要存储权限 容器已重置为默认设置 ImageFS 已安装,请重试编辑容器 @@ -58,10 +55,6 @@ 从不 确定要取消下载应用程序吗? 删除此游戏的所有已下载数据? - 正在开始云存档同步… - 云存档同步成功 - 云存档同步失败 - 云存档同步错误:%1$s 卸载失败:%1$s 卸载错误:%1$s 继续 @@ -967,9 +960,11 @@ 导出已取消 快捷方式已创建 创建快捷方式失败:%s - 云同步完成 - 存档文件已更新至最新版本 - 云同步失败:%s + 正在同步云存档… + 云存档同步完成 + 云存档同步已是最新状态 + 云存档同步失败 + 云存档同步错误:%1$s 需联网安装 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7cb5e111d1..daef174782 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -29,9 +29,6 @@ 請確保您的存檔已上傳至雲端或已備份, 然後再進行驗證, 否則存檔可能會被覆蓋 更新 請確保您的存檔已上傳至雲端或已備份, 然後再進行驗證, 否則存檔可能會被覆蓋 - 雲端同步已成功完成 - 遊戲存檔已是最新版本 - 雲端同步失敗: %s 需要存儲權限 容器重設為預設設定 已安裝 ImageFS, 請嘗試再次編輯容器 @@ -58,10 +55,6 @@ 從不 確定要取消下載應用程式嗎? 刪除此遊戲的所有已下載資料? - 正在開始雲端存檔同步… - 雲端存檔同步成功 - 雲端存檔同步失敗 - 雲端存檔同步錯誤:%1$s 解除安裝失敗:%1$s 解除安裝錯誤:%1$s 繼續 @@ -970,9 +963,11 @@ 匯出已取消 捷徑已建立 建立捷徑失敗: %s + 正在開始雲端存檔同步… 雲端同步完成 儲存檔案已更新至最新版本 - 雲端同步失敗: %s + 雲端同步失敗 + 雲端存檔同步錯誤:%1$s 需連網安裝 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8a6ff6a332..ec63c8ce4c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,9 +29,6 @@ Please ensure your saves are uploaded to the cloud or backed up before verifying, as they may be overwritten otherwise. Update Please ensure your saves are uploaded to the cloud or backed up before updating, as they may be overwritten otherwise. - Cloud sync completed successfully - Save files are already up to date - Cloud sync failed: %s You must be logged into Steam to use this feature Storage permission required Container reset to defaults @@ -67,10 +64,6 @@ Download error: %1$s Are you sure you want to cancel the download of the app? Delete all downloaded data for this game? - Starting cloud save sync… - Cloud saves synced successfully - Cloud save sync failed - Cloud save sync error: %1$s Uninstall failed: %1$s Uninstall error: %1$s Never @@ -994,9 +987,11 @@ Export cancelled Shortcut created Failed to create shortcut: %s + Starting cloud save sync… Cloud sync completed successfully Save files are already up to date - Cloud sync failed: %s + Cloud sync failed + Cloud sync error: %1$s Need internet to install diff --git a/app/src/test/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreenTest.kt b/app/src/test/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreenTest.kt new file mode 100644 index 0000000000..dc64e253ab --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreenTest.kt @@ -0,0 +1,82 @@ +package app.gamenative.ui.screen.library.appscreen + +import android.content.Context +import app.gamenative.R +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class GOGAppScreenTest { + @Test + fun `forceCloudSync shows success messages when sync succeeds`() = runBlocking { + val context = mockContext() + val messages = mutableListOf() + var calledAppId: String? = null + var calledPreferredAction: String? = null + + GOGAppScreen.forceCloudSync( + context = context, + appId = "app-123", + syncCloudSaves = { _, appId, preferredAction -> + calledAppId = appId + calledPreferredAction = preferredAction + true + }, + showSnackbar = { messages += it }, + ) + + assertEquals("app-123", calledAppId) + assertEquals("auto", calledPreferredAction) + assertEquals(listOf("starting", "success"), messages) + } + + @Test + fun `forceCloudSync shows failure message when sync fails`() = runBlocking { + val context = mockContext() + val messages = mutableListOf() + + GOGAppScreen.forceCloudSync( + context = context, + appId = "app-123", + syncCloudSaves = { _, _, _ -> false }, + showSnackbar = { messages += it }, + ) + + assertEquals(listOf("starting", "failed"), messages) + } + + @Test + fun `forceCloudSync logs and shows error when sync throws`() = runBlocking { + val context = mockContext() + val messages = mutableListOf() + var loggedError: Throwable? = null + + GOGAppScreen.forceCloudSync( + context = context, + appId = "app-123", + syncCloudSaves = { _, _, _ -> throw IllegalStateException("boom") }, + showSnackbar = { messages += it }, + logError = { loggedError = it }, + ) + + assertTrue(loggedError is IllegalStateException) + assertEquals("boom", loggedError?.message) + assertEquals(listOf("starting", "error: boom"), messages) + } + + private fun mockContext(): Context { + val context = mock() + whenever(context.getString(R.string.library_cloud_sync_starting)).thenReturn("starting") + whenever(context.getString(R.string.library_cloud_sync_success)).thenReturn("success") + whenever(context.getString(R.string.library_cloud_sync_failed)).thenReturn("failed") + whenever(context.getString(eq(R.string.library_cloud_sync_error), any())).thenAnswer { + "error: ${it.arguments[1]}" + } + return context + } +} From dfebbefcf85daf7a823d3688ec3039eae532cc84 Mon Sep 17 00:00:00 2001 From: Jeremy Bernstein Date: Mon, 13 Apr 2026 14:20:41 +0200 Subject: [PATCH 02/10] fix: library view not updating after game uninstall (#956) DownloadService caches directory listings for 5s. After deleteApp, the cache still holds the deleted directory, so the subsequent LibraryInstallStatusChanged refresh sees stale data. Invalidate the cache after deletion so the next scan picks up the change. --- .../java/app/gamenative/service/DownloadService.kt | 10 ++++++++-- .../ui/screen/library/appscreen/AmazonAppScreen.kt | 3 +++ .../ui/screen/library/appscreen/EpicAppScreen.kt | 3 +++ .../ui/screen/library/appscreen/GOGAppScreen.kt | 4 ++++ .../ui/screen/library/appscreen/SteamAppScreen.kt | 2 ++ 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/DownloadService.kt b/app/src/main/java/app/gamenative/service/DownloadService.kt index 32938e6664..5b39356dce 100644 --- a/app/src/main/java/app/gamenative/service/DownloadService.kt +++ b/app/src/main/java/app/gamenative/service/DownloadService.kt @@ -9,8 +9,8 @@ import timber.log.Timber import java.io.File object DownloadService { - private var lastUpdateTime: Long = 0 - private var downloadDirectoryApps: MutableList? = null + @Volatile private var lastUpdateTime: Long = 0 + @Volatile private var downloadDirectoryApps: MutableList? = null var baseDataDirPath: String = "" private set(value) { field = value @@ -44,6 +44,12 @@ object DownloadService { .map { it.absolutePath } } + @Synchronized + fun invalidateCache() { + lastUpdateTime = 0 + } + + @Synchronized fun getDownloadDirectoryApps (): MutableList { // What apps have folders in the download area? // Isn't checking for "complete" marker - incomplete is accepted diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt index 4e46bb63d5..17ae4bbfd0 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt @@ -22,6 +22,7 @@ import app.gamenative.R import app.gamenative.data.AmazonGame import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent +import app.gamenative.service.DownloadService import app.gamenative.service.amazon.AmazonConstants import app.gamenative.service.amazon.AmazonService import app.gamenative.ui.component.dialog.AmazonInstallDialog @@ -345,6 +346,7 @@ override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean = Timber.tag(TAG).i("performUninstall: deleting game $productId") CoroutineScope(Dispatchers.IO).launch { val result = AmazonService.deleteGame(context, productId) + DownloadService.invalidateCache() if (result.isSuccess) { Timber.tag(TAG).i("Uninstall succeeded for $productId") } else { @@ -633,6 +635,7 @@ override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean = scope.launch { downloadInfo?.awaitCompletion() AmazonService.deleteGame(context, productId) + DownloadService.invalidateCache() withContext(Dispatchers.Main) { BaseAppScreen.hideInstallDialog(appId) val gameId = libraryItem.gameId diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt index f725b7e3d3..a0f39c4f47 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.res.stringResource import app.gamenative.R import app.gamenative.data.EpicGame import app.gamenative.data.LibraryItem +import app.gamenative.service.DownloadService import app.gamenative.service.epic.EpicCloudSavesManager import app.gamenative.service.epic.EpicConstants import app.gamenative.service.epic.EpicService @@ -465,6 +466,7 @@ class EpicAppScreen : BaseAppScreen() { CoroutineScope(Dispatchers.IO).launch { try { val result = EpicService.deleteGame(context, libraryItem.gameId) + DownloadService.invalidateCache() if (result.isSuccess) { Timber.tag(TAG).i("Epic game uninstalled successfully: ${libraryItem.appId}") @@ -769,6 +771,7 @@ class EpicAppScreen : BaseAppScreen() { downloadInfo?.awaitCompletion() EpicService.cleanupDownload(context, gameId) EpicService.deleteGame(context, gameId) + DownloadService.invalidateCache() withContext(Dispatchers.Main) { BaseAppScreen.hideInstallDialog(appId) app.gamenative.PluviaApp.events.emit(app.gamenative.events.AndroidEvent.DownloadStatusChanged(gameId, 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 0b44973e5b..f70ca01d2b 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 @@ -17,6 +17,8 @@ import androidx.compose.ui.res.stringResource import app.gamenative.R import app.gamenative.data.GOGGame import app.gamenative.data.LibraryItem +import app.gamenative.enums.Marker +import app.gamenative.service.DownloadService import app.gamenative.service.gog.GOGConstants import app.gamenative.service.gog.GOGService import app.gamenative.utils.MarkerUtils @@ -392,6 +394,7 @@ class GOGAppScreen : BaseAppScreen() { try { // Delegate to GOGService which calls GOGManager.deleteGame val result = GOGService.deleteGame(context, libraryItem) + DownloadService.invalidateCache() if (result.isSuccess) { Timber.i("Successfully uninstalled GOG game: ${libraryItem.appId}") @@ -684,6 +687,7 @@ class GOGAppScreen : BaseAppScreen() { } val result = GOGService.deleteGame(context, libraryItem) + DownloadService.invalidateCache() if (wasDownloading && !isInstalledAfterCancel) { SnackbarManager.show("Download cancelled") } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index fe5c7448fe..ad7e4f9757 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -1036,6 +1036,7 @@ class SteamAppScreen : BaseAppScreen() { SteamService.workshopPausedApps.remove(gameId) CoroutineScope(Dispatchers.IO).launch { SteamService.deleteApp(gameId) + DownloadService.invalidateCache() PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(gameId)) withContext(Dispatchers.Main) { hideInstallDialog(gameId) @@ -1159,6 +1160,7 @@ class SteamAppScreen : BaseAppScreen() { CoroutineScope(Dispatchers.IO).launch { val success = SteamService.deleteApp(gameId) + DownloadService.invalidateCache() withContext(Dispatchers.Main) { ContainerUtils.deleteContainer(context, libraryItem.appId) } From 10210d89fb7359de36b687def15b5ee78f7aac07 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:26:12 +0800 Subject: [PATCH 03/10] fix: correct steam game dlc licensing logic and enhance dlc display (#1191) * fix: correct steam game dlc licensing logic and enhance dlc display in content Cross-references resolved depots with owned DLC package information to ensure depots are attributed to the correct DLC app ID. This ensures accurate DLC identification for titles like Don't Starve, Halo MCC, and Cyberpunk 2077. * refactor getMainAppDepots to calculate the logic to be used in getDownloadableDepots --- .../app/gamenative/service/SteamService.kt | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 6849ba22c0..32ce1f860b 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -816,8 +816,33 @@ class SteamService : Service(), IChallengeUrlChanged { val appInfo = getAppInfoOf(appId) ?: return emptyMap() val ownedDlc = runBlocking { getOwnedAppDlc(appId) } val hasSteamUnlockedBranch = runBlocking { getSteamUnlockedBranches(appId).isNotEmpty() } - val licensedDepots = getLicensedDepotIds(appId) - return resolveDownloadableDepots(appInfo.depots, containerLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch) + val licensedDepots = getLicensedDepotIds(appId).orEmpty().toMutableSet() + + // Use the dlcAppID of the ownedDlc, to find the licensed depotIds from steam_license + val mapDlcDepotIds = mutableMapOf>() + ownedDlc.forEach { (dlcAppId, info) -> + val dlcDepotIds = getPkgInfoOf(dlcAppId)?.depotIds.orEmpty() + mapDlcDepotIds[dlcAppId] = dlcDepotIds + + // Make sure licensedDepots contains the dlc depots + licensedDepots.addAll(dlcDepotIds) + } + + val baseDepots = resolveDownloadableDepots(appInfo.depots, containerLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch) + + // Find in the depots of mainApp, that if any of the depotID is actually belongs to another steam_app entry + // override the dlcAppId to the corresponding app id + // It should fix Don't Starve DLC list, and keeping existing DLC logic correct + // For existing DLC logic, two games checked Halo MCC, Cyberpunk 2077 to have correct data + val map = mutableMapOf() + baseDepots.forEach { (depotId, info) -> + val foundDlcAppId = mapDlcDepotIds + .filter { it.value.contains(info.depotId) } + .keys.firstOrNull() + map[depotId] = info.copy(dlcAppId = foundDlcAppId ?: info.dlcAppId) + } + + return map } /** @@ -837,13 +862,13 @@ class SteamService : Service(), IChallengeUrlChanged { val appInfo = getAppInfoOf(appId) ?: return emptyMap() val ownedDlc = runBlocking { getOwnedAppDlc(appId) } val hasSteamUnlockedBranch = runBlocking { getSteamUnlockedBranches(appId).isNotEmpty() } - val licensedDepots = getLicensedDepotIds(appId) + val licensedDepots = getLicensedDepotIds(appId).orEmpty().toMutableSet() + + val map = getMainAppDepots(appId, preferredLanguage).toMutableMap() - val baseDepots = resolveDownloadableDepots(appInfo.depots, preferredLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch) // parent app's arch applies to DLC arch selection val has64Bit = eligibleDepots(appInfo.depots, preferredLanguage, ownedDlc, licensedDepots) .any { it.osArch == OSArch.Arch64 } - val map = baseDepots.toMutableMap() val indirectDlcApps = getDownloadableDlcAppsOf(appId).orEmpty() indirectDlcApps.forEach { dlcApp -> From a930fe9df93c6cd9bea547c12c67d71c7c32a8ba Mon Sep 17 00:00:00 2001 From: Jeremy Bernstein Date: Mon, 13 Apr 2026 14:29:53 +0200 Subject: [PATCH 04/10] fix: suppress connection banner during initial Steam connect (#918) Also use state.isSteamConnected (Compose-observable StateFlow) instead of SteamService.isConnected (static boolean invisible to recomposition) for banner visibility. --- app/src/main/java/app/gamenative/ui/PluviaMain.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 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 0035c72aa9..b1d92e5365 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -304,6 +304,9 @@ fun PluviaMain( // Track if connection banner was dismissed by user var connectionBannerDismissed by rememberSaveable { mutableStateOf(false) } + // suppress CONNECTING banner during first attempt; DISCONNECTED always shows + var initialConnectDone by rememberSaveable { mutableStateOf(SteamService.isConnected) } + // Track previous connection state to detect actual changes (not just recomposition) val previousConnectionState = remember { mutableStateOf(state.connectionState) } @@ -313,6 +316,10 @@ fun PluviaMain( connectionBannerDismissed = false previousConnectionState.value = state.connectionState } + // first attempt resolved (connected or failed) + if (state.connectionState != ConnectionState.CONNECTING) { + initialConnectDone = true + } } // Check for updates on app start @@ -1227,7 +1234,7 @@ fun PluviaMain( } // Connection status banner (overlay) - dismissible so users can access navigation - if (state.currentScreen != PluviaScreen.LoginUser && !connectionBannerDismissed && !SteamService.isConnected && + if (state.currentScreen != PluviaScreen.LoginUser && !connectionBannerDismissed && initialConnectDone && !state.isSteamConnected && PrefManager.refreshToken.isNotEmpty() && PrefManager.username.isNotEmpty()) { Box(modifier = Modifier.zIndex(5f)) { ConnectionStatusBanner( From 56369dff41094f85b61b42d23ddc2e116d322d4a Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Mon, 13 Apr 2026 18:22:37 +0530 Subject: [PATCH 05/10] Devil blade reboot utkarsh (#1198) * fix: case-insensitive .exe filter in getWindowsLaunchInfos * removed bug around appLaunchInfo null opening wfm.exe * fixed build * addressed coderabbit * more coderabbit --------- Co-authored-by: Dan Brooke Co-authored-by: Utkarsh Dalal --- app/src/main/java/app/gamenative/service/SteamService.kt | 2 +- .../java/app/gamenative/ui/screen/xserver/XServerScreen.kt | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 32ce1f860b..65771f0b4d 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2087,7 +2087,7 @@ class SteamService : Service(), IChallengeUrlChanged { return getAppInfoOf(appId)?.let { appInfo -> appInfo.config.launch.filter { launchInfo -> // since configOS was unreliable and configArch was even more unreliable - launchInfo.executable.endsWith(".exe") + launchInfo.executable.endsWith(".exe", ignoreCase = true) } }.orEmpty() } 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 d1b76c799a..200bf6b9f8 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 @@ -3288,7 +3288,7 @@ private fun getWineStartCommand( val normalizedPath = executablePath.replace('/', '\\') envVars.put("WINEPATH", "A:\\") "\"A:\\${normalizedPath}\"" - } else if (appLaunchInfo == null) { + } else if (container.executablePath.isEmpty()) { // For Steam games, we need appLaunchInfo Timber.tag("XServerScreen").w("appLaunchInfo is null for Steam game: $appId") "\"wfm.exe\"" @@ -3322,7 +3322,9 @@ private fun getWineStartCommand( Timber.e("Could not locate game drive") 'D' } - envVars.put("WINEPATH", "$drive:/${appLaunchInfo.workingDir}") + if (appLaunchInfo != null){ + envVars.put("WINEPATH", "$drive:/${appLaunchInfo.workingDir}") + } "\"$drive:/${executablePath}\"" } else { "\"C:\\\\Program Files (x86)\\\\Steam\\\\steamclient_loader_x64.exe\"" From 27f530f845fde2ef4696883081b875b77ed3a404 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:54:33 +0800 Subject: [PATCH 06/10] fix: Migrate GSE Saves to steam userdata, always upload userdata files to steamcloud (#1100) * migrate GSE Saves to steam userdata, always upload userdata files to steam cloud fix tests * move migrateGSESavesToSteamUserdata just before beginLaunchApp * also migrateGSESavesToSteamUserdata just before forceSyncUserFiles * also migrateGSESavesToSteamUserdata in SteamUtils ensureSteamSettings * use Files.move for migrating files * check dir empty to exit earlier, update logging * preserve file attributes like timestamp and permission during migration --- .../app/gamenative/service/SteamAutoCloud.kt | 98 ++++++++++++----- .../app/gamenative/service/SteamService.kt | 6 ++ .../java/app/gamenative/utils/SteamUtils.kt | 101 ++++++++++++++++-- .../gamenative/service/SteamAutoCloudTest.kt | 4 + .../utils/SteamUtilsFileSearchTest.kt | 8 +- 5 files changed, 179 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index 42cabce4a2..762a360592 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -259,10 +259,15 @@ object SteamAutoCloud { val getLocalUserFilesAsPrefixMap: () -> Map> = { val savePatterns = appInfo.ufs.saveFilePatterns.filter { userFile -> userFile.root.isWindows } - if (savePatterns.isNotEmpty()) { - val result = mutableMapOf>() + val result = mutableMapOf>() + if (savePatterns.isNotEmpty()) { savePatterns.forEach { userFile -> + if (userFile.root == PathType.SteamUserData) { + // skip handling, use the logic below to scan SteamUserData + return@forEach + } + val basePath = Paths.get(prefixToPath(userFile.root.toString()), userFile.substitutedPath) Timber.i("Looking for saves in $basePath with pattern ${userFile.pattern} (prefix ${userFile.prefix})") @@ -278,7 +283,15 @@ object SteamAutoCloud { val relativePath = basePath.relativize(it).pathString - UserFileInfo(userFile.root, userFile.substitutedPath, relativePath, Files.getLastModifiedTime(it).toMillis(), sha, cloudRoot = userFile.uploadRoot, cloudPath = userFile.uploadPath) + UserFileInfo( + root = userFile.root, + path = userFile.substitutedPath, + filename = relativePath, + timestamp = Files.getLastModifiedTime(it).toMillis(), + sha = sha, + cloudRoot = userFile.uploadRoot, + cloudPath = userFile.uploadPath + ) }.collect(Collectors.toList()) Timber.i("Found ${files.size} file(s) in $basePath for pattern ${userFile.pattern}") @@ -286,34 +299,47 @@ object SteamAutoCloud { val prefixKey = Paths.get(userFile.prefix).pathString result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) } + } - result - } else { - // Fallback: no UFS patterns; scan SteamUserData root recursively (depth 5) - val rootType = PathType.SteamUserData - val basePath = Paths.get(prefixToPath(rootType.toString())) + // Scan SteamUserData root recursively (depth 5) + val rootType = PathType.SteamUserData + val basePath = Paths.get(prefixToPath(rootType.toString())) + + Timber.i("Scanning $basePath recursively (depth 5) under ${rootType.name}") - Timber.i("No UFS patterns; scanning $basePath recursively (depth 5) under ${rootType.name}") + val files = FileUtils.findFilesRecursive( + rootPath = basePath, + pattern = "*", + maxDepth = 5, + ).map { + val sha = streamingShaHash(it) - val files = FileUtils.findFilesRecursive( - rootPath = basePath, - pattern = "*", - maxDepth = 5, - ).map { - val sha = streamingShaHash(it) + val relativePath = basePath.relativize(it).pathString - val relativePath = basePath.relativize(it).pathString + Timber.i("Found ${it.pathString}\n\tin %${rootType.name}%\n\twith sha [${sha.joinToString(", ")}]") - Timber.i("Found ${it.pathString}\n\tin %${rootType.name}%\n\twith sha [${sha.joinToString(", ")}]") + // Store relative path in filename; empty path component + UserFileInfo( + root = rootType, + path = "", + filename = relativePath, + timestamp = Files.getLastModifiedTime(it).toMillis(), + sha = sha, + cloudRoot = rootType, + cloudPath = "" + ) + }.collect(Collectors.toList()) - // Store relative path in filename; empty path component - UserFileInfo(rootType, "", relativePath, Files.getLastModifiedTime(it).toMillis(), sha) - }.collect(Collectors.toList()) + Timber.i("Found ${files.size} file(s) in $basePath") - Timber.i("Found ${files.size} file(s) in $basePath for fallback recursive scan") + mapOf(Paths.get("%${rootType.name}%").pathString to files) - mapOf(Paths.get("%${rootType.name}%").pathString to files) + if (files.isNotEmpty()) { + val prefixKey = "%${rootType.name}%" + result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) } + + result } val fileChangeListToUserFiles: (AppFileChangeList) -> List = { appFileListChange -> @@ -514,9 +540,19 @@ object SteamAutoCloud { val uploadInfo = steamCloud.beginFileUpload( appId = appInfo.id, filename = if (appInfo.ufs.saveFilePatterns.isEmpty()) { - file.path + file.filename + // For SteamUserData files, use just the filename without folder prefix + if (file.root == PathType.SteamUserData) { + file.filename + } else { + file.path + file.filename + } } else { - file.prefixPath + // For SteamUserData files, use just the filename to avoid folder prefix + if (file.root == PathType.SteamUserData) { + file.filename + } else { + file.prefixPath + } }, fileSize = fileSize, rawFileSize = fileSize, @@ -633,9 +669,19 @@ object SteamAutoCloud { appId = appInfo.id, fileSha = file.sha, filename = if (appInfo.ufs.saveFilePatterns.isEmpty()) { - file.path + file.filename + // For SteamUserData files, use just the filename without folder prefix + if (file.root == PathType.SteamUserData) { + file.filename + } else { + file.path + file.filename + } } else { - file.prefixPath + // For SteamUserData files, use just the filename to avoid folder prefix + if (file.root == PathType.SteamUserData) { + file.filename + } else { + file.prefixPath + } }, ).await() diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 65771f0b4d..d54e577746 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2168,6 +2168,9 @@ class SteamService : Service(), IChallengeUrlChanged { return@async PostSyncInfo(SyncResult.InProgress) } + // Migrate GSE Saves to Steam userdata + SteamUtils.migrateGSESavesToSteamUserdata(instance?.applicationContext!!, appId) + try { var syncResult = PostSyncInfo(SyncResult.UnknownFail) @@ -2256,6 +2259,9 @@ class SteamService : Service(), IChallengeUrlChanged { return@async PostSyncInfo(SyncResult.InProgress) } + // Migrate GSE Saves to Steam userdata + SteamUtils.migrateGSESavesToSteamUserdata(instance?.applicationContext!!, appId) + try { var syncResult = PostSyncInfo(SyncResult.UnknownFail) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index ad7f6d3be3..25f0dcafc2 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -860,6 +860,91 @@ object SteamUtils { Timber.i("Finished restoreOriginalExecutable for appId: $steamAppId. Restored $restoredCount executable(s)") } + /** + * Migrates save files from GSE Saves directory to Steam userdata directory. + * This function copies all files from the GSE saves location to the proper Steam userdata + * location and then removes the original GSE directory to complete the migration. + */ + fun migrateGSESavesToSteamUserdata(context: Context, appId: Int) { + val imageFs = ImageFs.find(context) + val accountId = SteamService.userSteamId?.accountID?.toInt() + ?: PrefManager.steamUserAccountId.takeIf { it != 0 } + + if (accountId == null) { + Timber.tag("migrateGSESavesToSteamUserdata").w("Cannot migrate GSE saves: no Steam account ID available") + return + } + + val gseDir = File( + imageFs.rootDir, + "${ImageFs.WINEPREFIX}/drive_c/users/xuser/AppData/Roaming/GSE Saves/$appId" + ) + + val steamUserdataDir = File( + imageFs.rootDir, + "${ImageFs.WINEPREFIX}/drive_c/Program Files (x86)/Steam/userdata/$accountId/$appId" + ) + + fun isDirectoryEmpty(file: File): Boolean { + return file.isDirectory && file.list()?.isEmpty() ?: true + } + + if ( + !gseDir.exists() || + !gseDir.isDirectory || + isDirectoryEmpty(gseDir) // No files inside gseDir + ) { + Timber.tag("migrateGSESavesToSteamUserdata").d("No GSE save directory found for appId=$appId") + return + } + + Timber.tag("migrateGSESavesToSteamUserdata").i("Starting GSE Saves Migration for appId=$appId") + + if (!steamUserdataDir.exists()) { + try { + Files.createDirectories(steamUserdataDir.toPath()) + Timber.tag("migrateGSESavesToSteamUserdata").i("Created Steam userdata directory: ${steamUserdataDir.absolutePath}") + } catch (e: IOException) { + Timber.tag("migrateGSESavesToSteamUserdata").e(e, "Failed to create Steam userdata directory") + return + } + } + + var migratedCount = 0 + var migrationFailed = false + + gseDir.walkTopDown() + .filter { it.isFile } + .forEach { file -> + val relativePath = gseDir.toPath().relativize(file.toPath()) + val targetFile = steamUserdataDir.toPath().resolve(relativePath) + try { + Files.createDirectories(targetFile.parent) + + // As Files.move use linux rename syscall (or simply mv command we know, no need to manually remove the target file) + Files.move( + file.toPath(), + targetFile, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES, // Preserve file attributes like timestamp and permission + StandardCopyOption.ATOMIC_MOVE // will throw if the FS can’t guarantee atomicity + ) + + Timber.tag("migrateGSESavesToSteamUserdata").i("Migrated ${file.name} from GSE saves to Steam userdata") + migratedCount++ + } catch (e: IOException) { + migrationFailed = true + Timber.tag("migrateGSESavesToSteamUserdata").w(e, "Failed to migrate ${file.name}") + } + } + + if (!migrationFailed) { + gseDir.deleteRecursively() + } + + Timber.tag("migrateGSESavesToSteamUserdata").i("Migration completed for appId=$appId. Migrated $migratedCount file(s)") + } + /** * Sibling folder "steam_settings" + empty "offline.txt" file, no-ops if they already exist. */ @@ -908,7 +993,6 @@ object SteamUtils { // Get appInfo to check if saveFilePatterns exist (used for both user and app configs) val appInfo = getAppInfoOf(steamAppId) - val hasSaveFilePatterns = appInfo?.ufs?.saveFilePatterns?.isNotEmpty() == true val iniContent = buildString { appendLine("[user::general]") @@ -919,13 +1003,14 @@ object SteamUtils { appendLine("ticket=$ticketBase64") } - // Only add [user::saves] section if no saveFilePatterns are defined - if (!hasSaveFilePatterns) { - val steamUserDataPath = "C:\\Program Files (x86)\\Steam\\userdata\\$accountId" - appendLine() - appendLine("[user::saves]") - appendLine("local_save_path=$steamUserDataPath") - } + // Migrate GSE Saves to Steam userdata + migrateGSESavesToSteamUserdata(context, steamAppId) + + // Add [user::saves] section + val steamUserDataPath = "C:\\Program Files (x86)\\Steam\\userdata\\$accountId" + appendLine() + appendLine("[user::saves]") + appendLine("local_save_path=$steamUserDataPath") } if (Files.notExists(configsIni)) Files.createFile(configsIni) diff --git a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt index 4690667fe8..e4f917600a 100644 --- a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt +++ b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt @@ -1219,6 +1219,7 @@ class SteamAutoCloudTest { )) val roamingRoot = File(tempDir, "roaming") + val userdataRoot = File(tempDir, "userdata") // Files live in the addPath subdirectory: /MyGame/saves/ val saveDir = File(roamingRoot, "MyGame/saves") saveDir.mkdirs() @@ -1282,6 +1283,7 @@ class SteamAutoCloudTest { val prefixToPath: (String) -> String = { prefix -> when (prefix) { "WinAppDataRoaming" -> roamingRoot.absolutePath + "SteamUserData" -> userdataRoot.absolutePath else -> tempDir.absolutePath } } @@ -1331,6 +1333,7 @@ class SteamAutoCloudTest { // Create a temp directory to act as the WinAppDataRoaming root val roamingRoot = File(tempDir, "roaming") + val userdataRoot = File(tempDir, "userdata") val saveSubdir = File(roamingRoot, "TheGame") saveSubdir.mkdirs() val saveFile = File(saveSubdir, "save.sav") @@ -1391,6 +1394,7 @@ class SteamAutoCloudTest { val prefixToPath: (String) -> String = { prefix -> when (prefix) { "WinAppDataRoaming" -> roamingRoot.absolutePath + "SteamUserData" -> userdataRoot.absolutePath else -> tempDir.absolutePath } } diff --git a/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt b/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt index 589263d943..02b685a08c 100644 --- a/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt +++ b/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt @@ -1531,12 +1531,12 @@ class SteamUtilsFileSearchTest { val userIniContent = userIni.readText() - // Verify [user::saves] section does NOT exist - assertFalse("configs.user.ini should not contain [user::saves] section", + // Verify [user::saves] section does exist + assertTrue("configs.user.ini should contain [user::saves] section", userIniContent.contains("[user::saves]")) - // Verify local_save_path does NOT exist - assertFalse("configs.user.ini should not contain local_save_path", + // Verify local_save_path does exist + assertTrue("configs.user.ini should contain local_save_path", userIniContent.contains("local_save_path=")) } From bebe704a67f58569bdcaa2583365e2523ed514d8 Mon Sep 17 00:00:00 2001 From: Misazam <60115666+Misazam@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:57:50 +0200 Subject: [PATCH 07/10] Add reusable INI game fix for Imperivm (#1009) * Add reusable ini game fix for Imperivm * Avoid rereading ini fixes after migration * Remove ini migration marker tracking --- .../gamenative/gamefixes/GameFixesRegistry.kt | 1 + .../app/gamenative/gamefixes/IniFileFix.kt | 66 ++++++++++ .../app/gamenative/gamefixes/STEAM_752580.kt | 14 +++ .../gamenative/gamefixes/IniFileFixTest.kt | 117 ++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 app/src/main/java/app/gamenative/gamefixes/IniFileFix.kt create mode 100644 app/src/main/java/app/gamenative/gamefixes/STEAM_752580.kt create mode 100644 app/src/test/java/app/gamenative/gamefixes/IniFileFixTest.kt diff --git a/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt b/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt index 21be17ccf6..4ff78d7730 100644 --- a/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt +++ b/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt @@ -31,6 +31,7 @@ object GameFixesRegistry { STEAM_Fix_22300, STEAM_Fix_22380, STEAM_Fix_22330, + STEAM_Fix_752580, STEAM_Fix_400, STEAM_Fix_413150, STEAM_Fix_3373660, diff --git a/app/src/main/java/app/gamenative/gamefixes/IniFileFix.kt b/app/src/main/java/app/gamenative/gamefixes/IniFileFix.kt new file mode 100644 index 0000000000..937f1c5ac3 --- /dev/null +++ b/app/src/main/java/app/gamenative/gamefixes/IniFileFix.kt @@ -0,0 +1,66 @@ +package app.gamenative.gamefixes + +import android.content.Context +import app.gamenative.data.GameSource +import com.winlator.container.Container +import java.io.File +import java.nio.charset.StandardCharsets +import timber.log.Timber + +private fun updateIniValue(content: String, key: String, value: String): String { + val regex = Regex("(?im)^(${Regex.escape(key)}\\s*=\\s*).*$") + return if (regex.containsMatchIn(content)) { + content.replace(regex, "$1$value") + } else { + val suffix = if (content.endsWith("\n") || content.isEmpty()) "" else System.lineSeparator() + content + suffix + "$key=$value" + System.lineSeparator() + } +} + +class IniFileFix( + private val relativePath: String, + private val defaultValues: Map, +) : GameFix { + override fun apply( + context: Context, + gameId: String, + installPath: String, + installPathWindows: String, + container: Container, + ): Boolean { + val iniFile = File(installPath, relativePath) + if (!iniFile.isFile) { + return false + } + + return runCatching { + val original = iniFile.readText(StandardCharsets.UTF_8) + var updated = original + for ((key, value) in defaultValues) { + updated = updateIniValue(updated, key, value) + } + + val fileChanged = updated != original + + if (fileChanged) { + iniFile.writeText(updated, StandardCharsets.UTF_8) + } + + if (fileChanged) { + Timber.tag("GameFixes").i("Updated $relativePath for game $gameId") + } + + fileChanged + }.getOrElse { error -> + Timber.tag("GameFixes").w(error, "Failed to update $relativePath for game $gameId") + false + } + } +} + +class KeyedIniFileFix( + override val gameSource: GameSource, + override val gameId: String, + relativePath: String, + defaultValues: Map, +) : KeyedGameFix, GameFix by IniFileFix(relativePath, defaultValues) diff --git a/app/src/main/java/app/gamenative/gamefixes/STEAM_752580.kt b/app/src/main/java/app/gamenative/gamefixes/STEAM_752580.kt new file mode 100644 index 0000000000..ed1ece8933 --- /dev/null +++ b/app/src/main/java/app/gamenative/gamefixes/STEAM_752580.kt @@ -0,0 +1,14 @@ +package app.gamenative.gamefixes + +import app.gamenative.data.GameSource + +val STEAM_Fix_752580: KeyedGameFix = KeyedIniFileFix( + gameSource = GameSource.STEAM, + gameId = "752580", + relativePath = "Settings.ini", + defaultValues = linkedMapOf( + "Music" to "0", + "SoundFX" to "1", + "Speech" to "1", + ), +) diff --git a/app/src/test/java/app/gamenative/gamefixes/IniFileFixTest.kt b/app/src/test/java/app/gamenative/gamefixes/IniFileFixTest.kt new file mode 100644 index 0000000000..e526b71f3a --- /dev/null +++ b/app/src/test/java/app/gamenative/gamefixes/IniFileFixTest.kt @@ -0,0 +1,117 @@ +package app.gamenative.gamefixes + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.winlator.container.Container +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class IniFileFixTest { + + private lateinit var context: Context + private lateinit var tempDir: File + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + tempDir = Files.createTempDirectory("ini_file_fix_test_").toFile() + } + + @After + fun tearDown() { + tempDir.deleteRecursively() + } + + @Test + fun apply_updatesExistingValuesAndAppendsMissingKeys() { + val iniFile = File(tempDir, "Settings.ini") + iniFile.writeText( + """ + Music=1 + SoundFX=0 + """.trimIndent() + "\n", + StandardCharsets.UTF_8, + ) + val container = RecordingContainer("STEAM_752580") + val fix = IniFileFix( + relativePath = "Settings.ini", + defaultValues = linkedMapOf( + "Music" to "0", + "SoundFX" to "1", + "Speech" to "1", + ), + ) + + val changed = fix.apply( + context = context, + gameId = "752580", + installPath = tempDir.absolutePath, + installPathWindows = "A:\\", + container = container, + ) + + assertTrue(changed) + assertEquals( + """ + Music=0 + SoundFX=1 + Speech=1 + """.trimIndent() + "\n", + normalizeLineEndings(iniFile.readText(StandardCharsets.UTF_8)), + ) + assertEquals(0, container.saveCalls) + } + + @Test + fun steamFix752580_ignoresLegacyMigrationMarkersAndStillReappliesIniValues() { + val iniFile = File(tempDir, "Settings.ini") + iniFile.writeText( + """ + Music=1 + SoundFX=0 + Speech=0 + """.trimIndent() + "\n", + StandardCharsets.UTF_8, + ) + val container = RecordingContainer("STEAM_752580") + container.putExtra("imperivm_audio_settings_v1", "1") + + val changed = STEAM_Fix_752580.apply( + context = context, + gameId = "752580", + installPath = tempDir.absolutePath, + installPathWindows = "A:\\", + container = container, + ) + + assertTrue(changed) + assertEquals( + """ + Music=0 + SoundFX=1 + Speech=1 + """.trimIndent() + "\n", + normalizeLineEndings(iniFile.readText(StandardCharsets.UTF_8)), + ) + assertEquals(0, container.saveCalls) + } + + private fun normalizeLineEndings(text: String): String = text.replace("\r\n", "\n") + + private class RecordingContainer(id: String) : Container(id) { + var saveCalls = 0 + + override fun saveData() { + saveCalls += 1 + } + } +} From 68bb2ac99e07dd849fb17f9446432c8aec65af69 Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Mon, 13 Apr 2026 16:47:13 +0200 Subject: [PATCH 08/10] Fix GOG cloud save fetch handling --- .../service/gog/GOGCloudSavesManager.kt | 27 +++++++----- .../app/gamenative/service/gog/GOGManager.kt | 44 +++++-------------- 2 files changed, 27 insertions(+), 44 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 aaba899bde..7f2945c935 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -9,16 +9,13 @@ 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 @@ -36,6 +33,7 @@ class GOGCloudSavesManager( 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 { @@ -174,7 +172,10 @@ class GOGCloudSavesManager( // 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) + val cloudFiles = getCloudFiles(credentials.userId, clientId, dirname, credentials.accessToken) ?: run { + Timber.tag("GOG-CloudSaves").e("Failed to fetch cloud files, aborting sync") + return@withContext 0L + } Timber.tag("GOG").d("[Cloud Saves] Retrieved ${cloudFiles.size} total cloud files") val downloadableCloud = cloudFiles.filter { !it.isDeleted } Timber.tag("GOG").i("[Cloud Saves] Found ${downloadableCloud.size} downloadable cloud file(s) (excluding deleted)") @@ -356,14 +357,16 @@ class GOGCloudSavesManager( } /** - * Get cloud files list from GOG API + * Returns the list of cloud files for this dirname, or null if the request failed + * (network error, HTTP error, parse error). A successful but empty response returns + * an empty list — callers must distinguish null (unknown) from empty (no cloud files). */ private suspend fun getCloudFiles( userId: String, clientId: String, dirname: String, authToken: String - ): List = withContext(Dispatchers.IO) { + ): 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" @@ -383,7 +386,7 @@ class GOGCloudSavesManager( 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() + return@withContext null } val responseBody = response.body?.string() ?: "" @@ -397,7 +400,7 @@ class GOGCloudSavesManager( } 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() + return@withContext null } Timber.tag("GOG").d("[Cloud Saves] Found ${items.length()} total items in cloud storage") @@ -413,9 +416,11 @@ class GOGCloudSavesManager( // Filter files that belong to this save location (name starts with dirname/) if (name.isNotEmpty() && hash.isNotEmpty() && name.startsWith("$dirname/")) { + // GOG cloud storage returns ISO 8601 timestamps with a UTC offset, e.g. + // "2026-04-02T20:34:00.123456+00:00". OffsetDateTime also accepts "Z". val timestamp = try { - Instant.parse(lastModified).epochSecond - } catch (e: Exception) { + java.time.OffsetDateTime.parse(lastModified).toInstant().epochSecond + } catch (e: java.time.format.DateTimeParseException) { null } @@ -434,7 +439,7 @@ class GOGCloudSavesManager( } catch (e: Exception) { Timber.tag("GOG-CloudSaves").e(e, "Failed to get cloud files") - emptyList() + 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 780a5e429d..64e3d81467 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -1,53 +1,33 @@ package app.gamenative.service.gog import android.content.Context -import android.net.Uri -import androidx.core.net.toUri import app.gamenative.PluviaApp -import app.gamenative.data.DownloadInfo import app.gamenative.data.GOGCloudSavesLocation import app.gamenative.data.GOGCloudSavesLocationTemplate import app.gamenative.data.GOGGame import app.gamenative.data.GameSource import app.gamenative.data.LaunchInfo import app.gamenative.data.LibraryItem -import app.gamenative.data.PostSyncInfo -import app.gamenative.data.SteamApp import app.gamenative.db.dao.GOGGameDao -import app.gamenative.enums.AppType -import app.gamenative.enums.ControllerSupport import app.gamenative.enums.Marker -import app.gamenative.enums.OS import app.gamenative.enums.PathType -import app.gamenative.enums.ReleaseState -import app.gamenative.enums.SyncResult import app.gamenative.utils.ContainerUtils import app.gamenative.utils.FileUtils import app.gamenative.utils.MarkerUtils import app.gamenative.utils.Net -import app.gamenative.utils.StorageUtils import com.winlator.container.Container import com.winlator.core.envvars.EnvVars import com.winlator.core.FileUtils as WinlatorFileUtils import com.winlator.xenvironment.components.GuestProgramLauncherComponent import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File -import java.util.EnumSet -import java.util.Locale import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.Request -import org.json.JSONArray import org.json.JSONObject import timber.log.Timber @@ -1099,22 +1079,20 @@ class GOGManager @Inject constructor( Timber.tag("GOG").d("[Cloud Saves] Fetching save locations from API") val result = getSaveSyncLocation(context, appId, installPath) - val clientSecret: String - val locations: List - - // If no locations from API, use default Windows path if (result == null || result.second.isEmpty()) { - clientSecret = "" - Timber.tag("GOG").d("[Cloud Saves] No save locations from API, using default for game $gameId") - val defaultLocation = "%LOCALAPPDATA%/GOG.com/Galaxy/Applications/$clientId/Storage/Shared/Files" - Timber.tag("GOG").d("[Cloud Saves] Using default location: $defaultLocation") - locations = listOf(GOGCloudSavesLocationTemplate("__default", defaultLocation)) - } else { - clientSecret = result.first - locations = result.second - Timber.tag("GOG").i("[Cloud Saves] Retrieved ${locations.size} save location(s) from API") + // The remote config API returned no locations, meaning this game either has cloud + // saves disabled or no save paths configured. We don't fall back to the default + // GOG Galaxy path (%LOCALAPPDATA%/GOG.com/Galaxy/Applications//Storage/…) + // because clientSecret also comes from the API — without it, the token exchange + // always fails and we'd just show a false "Offline" status. + Timber.tag("GOG").d("[Cloud Saves] No save locations from API for game $gameId, cloud saves not supported") + return@withContext null } + val clientSecret = result.first + val locations = result.second + Timber.tag("GOG").i("[Cloud Saves] Retrieved ${locations.size} save location(s) from API") + // Resolve each location val resolvedLocations = mutableListOf() for ((index, locationTemplate) in locations.withIndex()) { From 412733c65ff33623dcecef939ba1aa5e66bbfb76 Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Mon, 13 Apr 2026 16:51:17 +0200 Subject: [PATCH 09/10] Add GOG cloud save regression tests --- .../service/gog/GOGCloudSavesManager.kt | 83 +++++++++++-------- .../service/gog/GOGCloudSavesManagerTest.kt | 53 ++++++++++++ 2 files changed, 102 insertions(+), 34 deletions(-) create mode 100644 app/src/test/java/app/gamenative/service/gog/GOGCloudSavesManagerTest.kt 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 7f2945c935..7e9c4b0cc4 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -15,6 +15,8 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.security.MessageDigest import java.time.Instant +import java.time.OffsetDateTime +import java.time.format.DateTimeParseException import java.time.format.DateTimeFormatter import java.util.zip.GZIPOutputStream import java.util.concurrent.TimeUnit @@ -395,44 +397,11 @@ class GOGCloudSavesManager( return@withContext emptyList() } - val items = try { - JSONArray(responseBody) - } catch (e: Exception) { - Timber.tag("GOG").e(e, "[Cloud Saves] Failed to parse JSON array response") + val files = parseCloudFilesResponse(responseBody, dirname) ?: run { Timber.tag("GOG").e("[Cloud Saves] Response was: $responseBody") return@withContext null } - Timber.tag("GOG").d("[Cloud Saves] Found ${items.length()} total items in cloud storage") - - val files = mutableListOf() - for (i in 0 until items.length()) { - val fileObj = items.getJSONObject(i) - val name = fileObj.optString("name", "") - val hash = fileObj.optString("hash", "") - val lastModified = fileObj.optString("last_modified") - - Timber.tag("GOG").d("[Cloud Saves] Examining item $i: name='$name', dirname='$dirname'") - - // Filter files that belong to this save location (name starts with dirname/) - if (name.isNotEmpty() && hash.isNotEmpty() && name.startsWith("$dirname/")) { - // GOG cloud storage returns ISO 8601 timestamps with a UTC offset, e.g. - // "2026-04-02T20:34:00.123456+00:00". OffsetDateTime also accepts "Z". - val timestamp = try { - java.time.OffsetDateTime.parse(lastModified).toInstant().epochSecond - } catch (e: java.time.format.DateTimeParseException) { - null - } - - // Remove the dirname prefix to get relative path - val relativePath = name.removePrefix("$dirname/") - files.add(CloudFile(relativePath, hash, lastModified, timestamp)) - Timber.tag("GOG").d("[Cloud Saves] ✓ Matched: relativePath='$relativePath'") - } else { - Timber.tag("GOG").d("[Cloud Saves] ✗ Skipped (doesn't match dirname or missing data)") - } - } - Timber.tag("GOG").i("[Cloud Saves] Retrieved ${files.size} cloud files for dirname '$dirname'") files } @@ -443,6 +412,52 @@ class GOGCloudSavesManager( } } + internal fun parseCloudFilesResponse(responseBody: String, dirname: String): List? { + val items = try { + JSONArray(responseBody) + } catch (e: Exception) { + Timber.tag("GOG").e(e, "[Cloud Saves] Failed to parse JSON array response") + return null + } + + Timber.tag("GOG").d("[Cloud Saves] Found ${items.length()} total items in cloud storage") + + val files = mutableListOf() + for (i in 0 until items.length()) { + val fileObj = items.getJSONObject(i) + val name = fileObj.optString("name", "") + val hash = fileObj.optString("hash", "") + val lastModified = fileObj.optString("last_modified") + + Timber.tag("GOG").d("[Cloud Saves] Examining item $i: name='$name', dirname='$dirname'") + + if (name.isNotEmpty() && hash.isNotEmpty() && name.startsWith("$dirname/")) { + val relativePath = name.removePrefix("$dirname/") + files.add( + CloudFile( + relativePath = relativePath, + md5Hash = hash, + updateTime = lastModified, + updateTimestamp = parseCloudTimestamp(lastModified), + ), + ) + Timber.tag("GOG").d("[Cloud Saves] ✓ Matched: relativePath='$relativePath'") + } else { + Timber.tag("GOG").d("[Cloud Saves] ✗ Skipped (doesn't match dirname or missing data)") + } + } + + return files + } + + internal fun parseCloudTimestamp(lastModified: String): Long? = + try { + // GOG returns timestamps like "2026-04-02T20:34:00.123456+00:00". + OffsetDateTime.parse(lastModified).toInstant().epochSecond + } catch (_: DateTimeParseException) { + null + } + /** * Upload file to GOG cloud storage */ diff --git a/app/src/test/java/app/gamenative/service/gog/GOGCloudSavesManagerTest.kt b/app/src/test/java/app/gamenative/service/gog/GOGCloudSavesManagerTest.kt new file mode 100644 index 0000000000..da16a0f684 --- /dev/null +++ b/app/src/test/java/app/gamenative/service/gog/GOGCloudSavesManagerTest.kt @@ -0,0 +1,53 @@ +package app.gamenative.service.gog + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.kotlin.mock + +class GOGCloudSavesManagerTest { + private val context: Context = mock() + private val manager = GOGCloudSavesManager(context) + + @Test + fun parseCloudTimestamp_accepts_gog_offset_format() { + val timestamp = manager.parseCloudTimestamp("2026-04-02T20:34:00.123456+00:00") + + assertEquals(1_775_162_040L, timestamp) + } + + @Test + fun parseCloudFilesResponse_returns_null_for_invalid_json_instead_of_empty_list() { + val files = manager.parseCloudFilesResponse("not-json", "__default") + + assertNull(files) + } + + @Test + fun parseCloudFilesResponse_parses_matching_files_and_preserves_offset_timestamp() { + val files = manager.parseCloudFilesResponse( + """ + [ + { + "name": "__default/save-1.sav", + "hash": "abc123", + "last_modified": "2026-04-02T20:34:00.123456+00:00" + }, + { + "name": "other-dir/save-2.sav", + "hash": "ignored", + "last_modified": "2026-04-02T21:00:00+00:00" + } + ] + """.trimIndent(), + "__default", + ) + + assertNotNull(files) + assertEquals(1, files!!.size) + assertEquals("save-1.sav", files.single().relativePath) + assertEquals(1_775_162_040L, files.single().updateTimestamp) + } +} From 29d010f627baaa8e51068800e3e6a488528411b3 Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Mon, 13 Apr 2026 17:07:30 +0200 Subject: [PATCH 10/10] Clarify GOG cloud save fallback comment --- 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 64e3d81467..8ef7be5c37 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -1083,8 +1083,8 @@ class GOGManager @Inject constructor( // The remote config API returned no locations, meaning this game either has cloud // saves disabled or no save paths configured. We don't fall back to the default // GOG Galaxy path (%LOCALAPPDATA%/GOG.com/Galaxy/Applications//Storage/…) - // because clientSecret also comes from the API — without it, the token exchange - // always fails and we'd just show a false "Offline" status. + // because clientSecret also comes from the API — without it, cloud auth cannot + // succeed and the fallback path can never be used for a real sync. Timber.tag("GOG").d("[Cloud Saves] No save locations from API for game $gameId, cloud saves not supported") return@withContext null }