diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 2053814de..af0501173 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -51,6 +51,7 @@ import app.gamenative.enums.SaveLocation import app.gamenative.enums.SyncResult import app.gamenative.events.AndroidEvent import app.gamenative.service.SteamService +import app.gamenative.service.gog.GOGService import app.gamenative.ui.component.ConnectingServersScreen import app.gamenative.ui.component.dialog.GameFeedbackDialog import app.gamenative.ui.component.dialog.LoadingDialog @@ -81,15 +82,15 @@ import com.winlator.core.TarCompressorUtils import com.winlator.xenvironment.ImageFs import com.winlator.xenvironment.ImageFsInstaller import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientObjects.ECloudPendingRemoteOperation +import java.io.File +import java.util.Date +import java.util.EnumSet +import kotlin.reflect.KFunction2 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber -import java.io.File -import java.util.Date -import java.util.EnumSet -import kotlin.reflect.KFunction2 @Composable fun PluviaMain( @@ -180,7 +181,7 @@ fun PluviaMain( } MainViewModel.MainUiEvent.OnBackPressed -> { - if (SteamService.isGameRunning){ + if (SteamService.isGameRunning) { gameBackAction?.invoke() ?: run { navController.popBackStack() } } else if (hasBack) { // TODO: check if back leads to log out and present confidence modal @@ -208,19 +209,23 @@ fun PluviaMain( // Extract game ID from appId (format: "STEAM_" or "CUSTOM_GAME_") val gameId = ContainerUtils.extractGameIdFromContainerId(launchRequest.appId) + val gameSource = ContainerUtils.extractGameSourceFromContainerId(launchRequest.appId) + + val isInstalled = when (gameSource) { + GameSource.STEAM -> { + SteamService.isAppInstalled(gameId) + } - // First check if it's a Steam game and if it's installed - val isSteamInstalled = SteamService.isAppInstalled(gameId) + GameSource.GOG -> { + GOGService.isGameInstalled(gameId.toString()) + } - // If not installed as Steam game, check if it's a custom game - val customGamePath = if (!isSteamInstalled) { - CustomGameScanner.findCustomGameById(gameId) - } else { - null + GameSource.CUSTOM_GAME -> { + CustomGameScanner.isGameInstalled(gameId) + } } - // If neither Steam installed nor custom game found, show error - if (!isSteamInstalled && customGamePath == null) { + if (!isInstalled) { val appName = SteamService.getAppInfoOf(gameId)?.name ?: "App ${launchRequest.appId}" Timber.tag("IntentLaunch").w("Game not installed: $appName (${launchRequest.appId})") @@ -235,27 +240,13 @@ fun PluviaMain( return@let } - // If it's a custom game, update the appId to use CUSTOM_GAME format - val finalAppId = if (customGamePath != null && !isSteamInstalled) { - "${GameSource.CUSTOM_GAME.name}_$gameId" - } else { - launchRequest.appId - } - - // Update launchRequest with the correct appId if it was changed - val updatedLaunchRequest = if (finalAppId != launchRequest.appId) { - launchRequest.copy(appId = finalAppId) - } else { - launchRequest - } - - if (updatedLaunchRequest.containerConfig != null) { + if (launchRequest.containerConfig != null) { IntentLaunchManager.applyTemporaryConfigOverride( context, - updatedLaunchRequest.appId, - updatedLaunchRequest.containerConfig, + launchRequest.appId, + launchRequest.containerConfig, ) - Timber.tag("IntentLaunch").i("Applied container config override for app ${updatedLaunchRequest.appId}") + Timber.tag("IntentLaunch").i("Applied container config override for app ${launchRequest.appId}") } if (navController.currentDestination?.route != PluviaScreen.Home.route) { @@ -266,11 +257,11 @@ fun PluviaMain( } } - viewModel.setLaunchedAppId(updatedLaunchRequest.appId) + viewModel.setLaunchedAppId(launchRequest.appId) viewModel.setBootToContainer(false) preLaunchApp( context = context, - appId = updatedLaunchRequest.appId, + appId = launchRequest.appId, setLoadingDialogVisible = viewModel::setLoadingDialogVisible, setLoadingProgress = viewModel::setLoadingDialogProgress, setLoadingMessage = viewModel::setLoadingDialogMessage, @@ -278,8 +269,7 @@ fun PluviaMain( onSuccess = viewModel::launchApp, ) } - } - else if (PluviaApp.xEnvironment == null) { + } else if (PluviaApp.xEnvironment == null) { Timber.i("Navigating to library") navController.navigate(PluviaScreen.Home.route) @@ -294,7 +284,7 @@ fun PluviaMain( message = context.getString( R.string.main_update_available_message, currentUpdateInfo.versionName, - currentUpdateInfo.releaseNotes?.let { "\n\n$it" } ?: "" + currentUpdateInfo.releaseNotes?.let { "\n\n$it" } ?: "", ), confirmBtnText = context.getString(R.string.main_update_button), dismissBtnText = context.getString(R.string.main_later_button), @@ -405,7 +395,8 @@ fun PluviaMain( // Start GOGService if user has GOG if (app.gamenative.service.gog.GOGService.hasStoredCredentials(context) && - !app.gamenative.service.gog.GOGService.isRunning) { + !app.gamenative.service.gog.GOGService.isRunning + ) { Timber.tag("GOG").d("[PluviaMain]: Starting GOGService for logged-in user") app.gamenative.service.gog.GOGService.start(context) } else { @@ -512,6 +503,7 @@ fun PluviaMain( setMessageDialogState(MessageDialogState(false)) } } + DialogType.SUPPORT -> { onConfirmClick = { uriHandler.openUri(Constants.Misc.KO_FI_LINK) @@ -749,7 +741,7 @@ fun PluviaMain( versionName = updateInfo.versionName, onProgress = { progress -> viewModel.setLoadingDialogProgress(progress) - } + }, ) viewModel.setLoadingDialogVisible(false) @@ -967,7 +959,7 @@ fun PluviaMain( CoroutineScope(Dispatchers.Main).launch { val currentRoute = navController.currentBackStackEntry ?.destination - ?.route // ← this is the screen’s route string + ?.route // ← this is the screen’s route string if (currentRoute == PluviaScreen.XServer.route) { navController.popBackStack() @@ -1050,7 +1042,9 @@ fun preLaunchApp( context = context, ).await() } - if (container.containerVariant.equals(Container.GLIBC) && !SteamService.isFileInstallable(context, "imagefs_patches_gamenative.tzst")) { + if (container.containerVariant.equals(Container.GLIBC) && + !SteamService.isFileInstallable(context, "imagefs_patches_gamenative.tzst") + ) { setLoadingMessage("Downloading Wine") SteamService.downloadImageFsPatches( onDownloadProgress = { setLoadingProgress(it / 1.0f) }, @@ -1058,21 +1052,25 @@ fun preLaunchApp( context = context, ).await() } else { - if (container.wineVersion.contains("proton-9.0-arm64ec") && !SteamService.isFileInstallable(context, "proton-9.0-arm64ec.txz")) { + if (container.wineVersion.contains("proton-9.0-arm64ec") && + !SteamService.isFileInstallable(context, "proton-9.0-arm64ec.txz") + ) { setLoadingMessage("Downloading arm64ec Proton") SteamService.downloadFile( onDownloadProgress = { setLoadingProgress(it / 1.0f) }, this, context = context, - "proton-9.0-arm64ec.txz" + "proton-9.0-arm64ec.txz", ).await() - } else if (container.wineVersion.contains("proton-9.0-x86_64") && !SteamService.isFileInstallable(context, "proton-9.0-x86_64.txz")) { + } else if (container.wineVersion.contains("proton-9.0-x86_64") && + !SteamService.isFileInstallable(context, "proton-9.0-x86_64.txz") + ) { setLoadingMessage("Downloading x86_64 Proton") SteamService.downloadFile( onDownloadProgress = { setLoadingProgress(it / 1.0f) }, this, context = context, - "proton-9.0-x86_64.txz" + "proton-9.0-x86_64.txz", ).await() } if (container.wineVersion.contains("proton-9.0-x86_64") || container.wineVersion.contains("proton-9.0-arm64ec")) { @@ -1093,13 +1091,15 @@ fun preLaunchApp( } } } - if (!container.isUseLegacyDRM && !container.isLaunchRealSteam && !SteamService.isFileInstallable(context, "experimental-drm-20260101.tzst")) { + if (!container.isUseLegacyDRM && !container.isLaunchRealSteam && + !SteamService.isFileInstallable(context, "experimental-drm-20260101.tzst") + ) { setLoadingMessage("Downloading extras") SteamService.downloadFile( onDownloadProgress = { setLoadingProgress(it / 1.0f) }, this, context = context, - "experimental-drm-20260101.tzst" + "experimental-drm-20260101.tzst", ).await() } if (container.isLaunchRealSteam && !SteamService.isFileInstallable(context, "steam.tzst")) { @@ -1116,13 +1116,14 @@ fun preLaunchApp( onDownloadProgress = { setLoadingProgress(it / 1.0f) }, this, context = context, - "steam-token.tzst" + "steam-token.tzst", ).await() } - val loadingMessage = if (container.containerVariant.equals(Container.GLIBC)) + val loadingMessage = if (container.containerVariant.equals(Container.GLIBC)) { context.getString(R.string.main_installing_glibc) - else + } else { context.getString(R.string.main_installing_bionic) + } setLoadingMessage(loadingMessage) val imageFsInstallSuccess = ImageFsInstaller.installIfNeededFuture(context, context.assets, container) { progress -> @@ -1218,7 +1219,7 @@ fun preLaunchApp( message = context.getString( R.string.main_save_conflict_message, Date(postSyncInfo.localTimestamp).toString(), - Date(postSyncInfo.remoteTimestamp).toString() + Date(postSyncInfo.remoteTimestamp).toString(), ), dismissBtnText = context.getString(R.string.main_keep_local), confirmBtnText = context.getString(R.string.main_keep_remote), @@ -1261,6 +1262,7 @@ fun preLaunchApp( ) } } + SyncResult.UnknownFail, SyncResult.DownloadFail, SyncResult.UpdateFail, @@ -1303,7 +1305,7 @@ fun preLaunchApp( R.string.main_upload_in_progress_message, gameName, pro.machineName, - dateStr + dateStr, ), dismissBtnText = context.getString(R.string.ok), ), @@ -1320,7 +1322,7 @@ fun preLaunchApp( R.string.main_pending_upload_message, gameName, pro.machineName, - dateStr + dateStr, ), confirmBtnText = context.getString(R.string.main_play_anyway), dismissBtnText = context.getString(R.string.cancel), @@ -1338,7 +1340,7 @@ fun preLaunchApp( R.string.main_app_running_other_device, pro.machineName, gameName, - dateStr + dateStr, ), confirmBtnText = context.getString(R.string.main_play_anyway), dismissBtnText = context.getString(R.string.cancel), 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 6691560f6..384ec5480 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 @@ -4,35 +4,36 @@ import android.content.Context import android.content.Intent import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.AlertDialog import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri -import app.gamenative.utils.SteamGridDB -import app.gamenative.utils.GameMetadataManager +import app.gamenative.PluviaApp import app.gamenative.R +import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent -import app.gamenative.PluviaApp import app.gamenative.ui.component.dialog.ContainerConfigDialog import app.gamenative.ui.data.AppMenuOption import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.ui.enums.AppOptionMenuType import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.GameMetadataManager +import app.gamenative.utils.SteamGridDB import app.gamenative.utils.createPinnedShortcut import com.winlator.container.ContainerData import java.io.File @@ -47,22 +48,23 @@ 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() + // 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 showInstallDialog(appId: String, state: app.gamenative.ui.component.dialog.state.MessageDialogState) { + installDialogStates[appId] = state + } - fun hideInstallDialog(appId: String) { - installDialogStates.remove(appId) - } + fun hideInstallDialog(appId: String) { + installDialogStates.remove(appId) + } - fun getInstallDialogState(appId: String): app.gamenative.ui.component.dialog.state.MessageDialogState? { - return installDialogStates[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. @@ -70,7 +72,7 @@ abstract class BaseAppScreen { @Composable abstract fun getGameDisplayInfo( context: Context, - libraryItem: LibraryItem + libraryItem: LibraryItem, ): GameDisplayInfo /** @@ -148,6 +150,10 @@ abstract class BaseAppScreen { return getGameDisplayInfo(context, libraryItem).name } + protected fun getGameSource(libraryItem: LibraryItem): GameSource { + return libraryItem.gameSource + } + /** * Get the game ID for shortcuts depending on app type */ @@ -183,11 +189,11 @@ abstract class BaseAppScreen { protected open fun getEditContainerOption( context: Context, libraryItem: LibraryItem, - onEditContainer: () -> Unit + onEditContainer: () -> Unit, ): AppMenuOption { return AppMenuOption( optionType = AppOptionMenuType.EditContainer, - onClick = onEditContainer + onClick = onEditContainer, ) } @@ -195,13 +201,13 @@ abstract class BaseAppScreen { protected open fun getRunContainerOption( context: Context, libraryItem: LibraryItem, - onClickPlay: (Boolean) -> Unit + onClickPlay: (Boolean) -> Unit, ): AppMenuOption? { return AppMenuOption( AppOptionMenuType.RunContainer, onClick = { onRunContainerClick(context, libraryItem, onClickPlay) - } + }, ) } @@ -209,28 +215,27 @@ abstract class BaseAppScreen { protected open fun getTestGraphicsOption( context: Context, libraryItem: LibraryItem, - onTestGraphics: () -> Unit + onTestGraphics: () -> Unit, ): AppMenuOption? { return AppMenuOption( AppOptionMenuType.TestGraphics, onClick = { onTestGraphicsClick(context, libraryItem, onTestGraphics) - } + }, ) } @Composable protected abstract fun getResetContainerOption( context: Context, - libraryItem: LibraryItem + libraryItem: LibraryItem, ): AppMenuOption? - @Composable protected open fun getExportContainerOption( context: Context, libraryItem: LibraryItem, - exportFrontendLauncher: ActivityResultLauncher + exportFrontendLauncher: ActivityResultLauncher, ): AppMenuOption? { val gameId = getGameId(libraryItem) val gameName = getGameName(context, libraryItem) @@ -238,9 +243,9 @@ abstract class BaseAppScreen { return AppMenuOption( optionType = AppOptionMenuType.ExportFrontend, onClick = { - val suggested = "${gameName}${extension}" + val suggested = "${gameName}$extension" exportFrontendLauncher.launch(suggested) - } + }, ) } @@ -250,8 +255,9 @@ abstract class BaseAppScreen { @Composable protected open fun getCreateShortcutOption( context: Context, - libraryItem: LibraryItem + libraryItem: LibraryItem, ): AppMenuOption? { + val gameSource = getGameSource(libraryItem) val gameId = getGameId(libraryItem) val gameName = getGameName(context, libraryItem) val iconUrl = getIconUrl(context, libraryItem) @@ -265,13 +271,14 @@ abstract class BaseAppScreen { context = context, gameId = gameId, label = gameName, - iconUrl = iconUrl + gameSource = gameSource, + iconUrl = iconUrl, ) withContext(Dispatchers.Main) { Toast.makeText( context, context.getString(R.string.base_app_shortcut_created), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } catch (e: Exception) { @@ -280,14 +287,14 @@ abstract class BaseAppScreen { context, context.getString( R.string.base_app_shortcut_failed, - e.message ?: "" + e.message ?: "", ), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } } - } + }, ) } @@ -301,7 +308,7 @@ abstract class BaseAppScreen { onEditContainer: () -> Unit, onBack: () -> Unit, onClickPlay: (Boolean) -> Unit, - isInstalled: Boolean + isInstalled: Boolean, ): List { return emptyList() } @@ -312,7 +319,7 @@ abstract class BaseAppScreen { optionType = AppOptionMenuType.SubmitFeedback, onClick = { PluviaApp.events.emit(AndroidEvent.ShowGameFeedback(libraryItem.appId)) - } + }, ) } @@ -332,7 +339,7 @@ abstract class BaseAppScreen { GameMetadataManager.update( folder = folder, appId = appId, - steamgriddbFetched = false + steamgriddbFetched = false, ) SteamGridDB.fetchGameImages(gameName, gameFolderPath) @@ -343,7 +350,7 @@ abstract class BaseAppScreen { Toast.makeText( context, context.getString(R.string.base_app_images_fetched), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } else { @@ -351,7 +358,7 @@ abstract class BaseAppScreen { Toast.makeText( context, context.getString(R.string.base_app_game_folder_not_found), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } @@ -361,14 +368,14 @@ abstract class BaseAppScreen { context, context.getString( R.string.base_app_images_fetch_failed, - e.message ?: "" + e.message ?: "", ), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } } - } + }, ) } @@ -379,17 +386,17 @@ abstract class BaseAppScreen { onClick = { val browserIntent = Intent( Intent.ACTION_VIEW, - ("https://discord.gg/2hKv4VfZfE").toUri() + ("https://discord.gg/2hKv4VfZfE").toUri(), ) context.startActivity(browserIntent) - } + }, ) } protected open fun onRunContainerClick( context: Context, libraryItem: LibraryItem, - onClickPlay: (Boolean) -> Unit + onClickPlay: (Boolean) -> Unit, ) { onClickPlay(true) } @@ -397,7 +404,7 @@ abstract class BaseAppScreen { protected open fun onTestGraphicsClick( context: Context, libraryItem: LibraryItem, - onTestGraphics: () -> Unit + onTestGraphics: () -> Unit, ) { onTestGraphics() } @@ -452,7 +459,7 @@ abstract class BaseAppScreen { TextButton(onClick = onConfirm) { Text( text = context.getString(R.string.base_app_reset_container_confirm), - color = MaterialTheme.colorScheme.error + color = MaterialTheme.colorScheme.error, ) } }, @@ -460,7 +467,7 @@ abstract class BaseAppScreen { TextButton(onClick = onDismiss) { Text(context.getString(R.string.cancel)) } - } + }, ) } @@ -475,7 +482,7 @@ abstract class BaseAppScreen { onBack: () -> Unit, onClickPlay: (Boolean) -> Unit, onTestGraphics: () -> Unit, - exportFrontendLauncher: ActivityResultLauncher + exportFrontendLauncher: ActivityResultLauncher, ): List { val isInstalled = isInstalled(context, libraryItem) val menuOptions = mutableListOf() @@ -599,23 +606,23 @@ abstract class BaseAppScreen { Toast.makeText( context, context.getString(R.string.base_app_exported), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } catch (e: Exception) { Toast.makeText( context, context.getString( R.string.base_app_export_failed, - e.message ?: "" + e.message ?: "", ), - Toast.LENGTH_LONG + Toast.LENGTH_LONG, ).show() } } else { Toast.makeText( context, context.getString(R.string.base_app_export_cancelled), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } }, @@ -642,7 +649,7 @@ abstract class BaseAppScreen { }, onHasPartialDownloadChanged = { hasPartial -> hasPartialDownloadState = hasPartial - } + }, ) onDispose { dispose?.invoke() @@ -717,7 +724,7 @@ abstract class BaseAppScreen { libraryItem: LibraryItem, onStateChanged: () -> Unit, onProgressChanged: (Float) -> Unit, - onHasPartialDownloadChanged: ((Boolean) -> Unit)? = null + onHasPartialDownloadChanged: ((Boolean) -> Unit)? = null, ): (() -> Unit)? { return null } @@ -731,9 +738,8 @@ abstract class BaseAppScreen { libraryItem: LibraryItem, onDismiss: () -> Unit, onEditContainer: () -> Unit, - onBack: () -> Unit + onBack: () -> Unit, ) { // Default: no additional dialogs } } - diff --git a/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt b/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt index 54a90a9d9..bbf18752b 100644 --- a/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt +++ b/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt @@ -2,12 +2,12 @@ package app.gamenative.utils import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Environment import android.provider.Settings import androidx.core.content.ContextCompat -import android.content.pm.PackageManager import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.data.GameSource @@ -18,8 +18,8 @@ import com.winlator.container.ContainerManager import java.io.File import kotlin.math.abs import kotlinx.coroutines.launch -import timber.log.Timber import org.json.JSONObject +import timber.log.Timber object CustomGameScanner { @@ -42,6 +42,7 @@ object CustomGameScanner { externalDir.parentFile?.mkdirs() externalDir } + else -> { Timber.tag("CustomGameScanner").w("External storage not available, falling back to internal: ${internalDir.path}") internalDir @@ -107,9 +108,11 @@ object CustomGameScanner { val steamGridLogo = folder.listFiles { file -> file.isFile && file.name.startsWith("steamgriddb_logo", ignoreCase = true) && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) + ( + file.name.endsWith(".png", ignoreCase = true) || + file.name.endsWith(".jpg", ignoreCase = true) || + file.name.endsWith(".webp", ignoreCase = true) + ) }?.firstOrNull() if (steamGridLogo != null) { Timber.tag("CustomGameScanner").d("Found SteamGridDB logo: ${steamGridLogo.absolutePath}") @@ -147,9 +150,11 @@ object CustomGameScanner { val steamGridLogo = folder.listFiles { file -> file.isFile && file.name.startsWith("steamgriddb_logo", ignoreCase = true) && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) + ( + file.name.endsWith(".png", ignoreCase = true) || + file.name.endsWith(".jpg", ignoreCase = true) || + file.name.endsWith(".webp", ignoreCase = true) + ) }?.firstOrNull() if (steamGridLogo != null) { Timber.tag("CustomGameScanner").d("Found SteamGridDB logo: ${steamGridLogo.absolutePath}") @@ -287,13 +292,13 @@ object CustomGameScanner { if (!folder.exists() || !folder.isDirectory) return null fun File.isValidExe(): Boolean = this.isFile && this.name.endsWith(".exe", ignoreCase = true) && - !this.name.startsWith("unins", ignoreCase = true) + !this.name.startsWith("unins", ignoreCase = true) val candidates = mutableListOf() folder.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) && - !f.name.startsWith("unins", ignoreCase = true) + !f.name.startsWith("unins", ignoreCase = true) }?.forEach { f -> candidates.add(f.name) } @@ -302,7 +307,7 @@ object CustomGameScanner { for (sd in subDirs) { sd.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) && - !f.name.startsWith("unins", ignoreCase = true) + !f.name.startsWith("unins", ignoreCase = true) }?.forEach { f -> val rel = sd.name + "/" + f.name candidates.add(rel) @@ -327,13 +332,13 @@ object CustomGameScanner { if (!folder.exists() || !folder.isDirectory) return emptyList() fun File.isValidExe(): Boolean = this.isFile && this.name.endsWith(".exe", ignoreCase = true) && - !this.name.startsWith("unins", ignoreCase = true) + !this.name.startsWith("unins", ignoreCase = true) val candidates = mutableListOf() folder.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) && - !f.name.startsWith("unins", ignoreCase = true) + !f.name.startsWith("unins", ignoreCase = true) }?.forEach { f -> candidates.add(f.name) } @@ -342,7 +347,7 @@ object CustomGameScanner { for (sd in subDirs) { sd.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) && - !f.name.startsWith("unins", ignoreCase = true) + !f.name.startsWith("unins", ignoreCase = true) }?.forEach { f -> val rel = sd.name + "/" + f.name candidates.add(rel) @@ -360,7 +365,7 @@ object CustomGameScanner { fun hasStoragePermission(context: Context, path: String): Boolean { // Check if path is outside app sandbox val isOutsideSandbox = !path.contains("/Android/data/${context.packageName}") && - !path.contains(context.dataDir.path) + !path.contains(context.dataDir.path) if (!isOutsideSandbox) { // Path is in app sandbox, no special permission needed @@ -375,7 +380,7 @@ object CustomGameScanner { // Android 10 and below use standard storage permissions return ContextCompat.checkSelfPermission( context, - android.Manifest.permission.READ_EXTERNAL_STORAGE + android.Manifest.permission.READ_EXTERNAL_STORAGE, ) == PackageManager.PERMISSION_GRANTED } } @@ -413,7 +418,11 @@ object CustomGameScanner { * All manually added folders are included regardless of content. * Optionally filter by [query] contained in folder name (case-insensitive). */ - fun scanAsLibraryItems(query: String = "", indexOffsetStart: Int = 0, includeWhenInstalledFilterActive: Boolean = true): List { + fun scanAsLibraryItems( + query: String = "", + indexOffsetStart: Int = 0, + includeWhenInstalledFilterActive: Boolean = true, + ): List { val items = mutableListOf() var indexCounter = indexOffsetStart val q = query.trim() @@ -427,7 +436,7 @@ object CustomGameScanner { val folderName = File(manualPath).name if (!folderName.contains(q, ignoreCase = true)) continue } - + val manualItem = createLibraryItemFromFolder(manualPath) if (manualItem != null && existingAppIds.add(manualItem.appId)) { items.add(manualItem.copy(index = indexCounter++)) @@ -474,7 +483,6 @@ object CustomGameScanner { return null } - val idPart = getOrGenerateGameId(folder) val appId = "${GameSource.CUSTOM_GAME.name}_$idPart" @@ -490,7 +498,6 @@ object CustomGameScanner { ) } - /** * Reads the game ID from the .gamenative file in the given folder. * Returns null if the file doesn't exist or doesn't contain a valid ID. @@ -531,7 +538,7 @@ object CustomGameScanner { private fun getOrRebuildCache(): Map { return CustomGameCache.getOrRebuildCache( getManualFolders = { PrefManager.customGameManualFolders }, - readGameIdFromFile = { folder -> readGameIdFromFile(folder) } + readGameIdFromFile = { folder -> readGameIdFromFile(folder) }, ) } @@ -612,6 +619,13 @@ object CustomGameScanner { return null } + // Helper function to check if game is installed to match pattern of GOG & Steam Service + fun isGameInstalled(appId: Int): Boolean { + val isInstalled = findCustomGameById(appId) != null + + return isInstalled + } + /** * Gets the folder path for a Custom Game from its appId using the cache. * The appId format is "CUSTOM_GAME_" where id is stored in .gamenative file or derived from folder name. diff --git a/app/src/main/java/app/gamenative/utils/IntentLaunchManager.kt b/app/src/main/java/app/gamenative/utils/IntentLaunchManager.kt index 6828cdc27..562f51185 100644 --- a/app/src/main/java/app/gamenative/utils/IntentLaunchManager.kt +++ b/app/src/main/java/app/gamenative/utils/IntentLaunchManager.kt @@ -15,6 +15,8 @@ import timber.log.Timber object IntentLaunchManager { private const val EXTRA_APP_ID = "app_id" + + private const val EXTRA_GAME_SOURCE = "game_source" private const val EXTRA_CONTAINER_CONFIG = "container_config" private const val ACTION_LAUNCH_GAME = "app.gamenative.LAUNCH_GAME" private const val MAX_CONFIG_JSON_SIZE = 50000 // 50KB limit to prevent memory exhaustion @@ -40,7 +42,14 @@ object IntentLaunchManager { return null } - val appId = "${GameSource.STEAM.name}_$gameId" + // Get Game Source for launch intent + var gameSource = intent.getStringExtra(EXTRA_GAME_SOURCE)?.uppercase() + val isValidGameSource = GameSource.entries.any { it.name == gameSource } + if (!isValidGameSource) { + gameSource = GameSource.STEAM.name + } + + val appId = "${gameSource}_$gameId" Timber.d("[IntentLaunchManager]: Converted to appId: $appId") val containerConfigJson = intent.getStringExtra(EXTRA_CONTAINER_CONFIG) diff --git a/app/src/main/java/app/gamenative/utils/ShortcutUtils.kt b/app/src/main/java/app/gamenative/utils/ShortcutUtils.kt index 849baa724..3d42ee1ac 100644 --- a/app/src/main/java/app/gamenative/utils/ShortcutUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ShortcutUtils.kt @@ -14,12 +14,13 @@ import android.graphics.drawable.Icon import android.os.Build import app.gamenative.MainActivity import app.gamenative.R +import app.gamenative.data.GameSource import coil.ImageLoader import coil.request.ImageRequest import coil.request.SuccessResult +import java.util.Arrays import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.util.Arrays private fun createAdaptiveIconBitmap(context: Context, src: Bitmap): Bitmap { val density = context.resources.displayMetrics.density @@ -71,7 +72,7 @@ private fun createAdaptiveIconBitmap(context: Context, src: Bitmap): Bitmap { // Center-fit scale to keep entire icon visible inside the padded area val scale = minOf( availSize.toFloat() / src.width.coerceAtLeast(1), - availSize.toFloat() / src.height.coerceAtLeast(1) + availSize.toFloat() / src.height.coerceAtLeast(1), ) val drawW = src.width * scale val drawH = src.height * scale @@ -84,13 +85,14 @@ private fun createAdaptiveIconBitmap(context: Context, src: Bitmap): Bitmap { return outBmp } -internal suspend fun createPinnedShortcut(context: Context, gameId: Int, label: String, iconUrl: String?) { +internal suspend fun createPinnedShortcut(context: Context, gameId: Int, label: String, gameSource: GameSource, iconUrl: String?) { val appContext = context.applicationContext val shortcutManager = appContext.getSystemService(ShortcutManager::class.java) val intent = Intent("app.gamenative.LAUNCH_GAME").apply { setClass(appContext, MainActivity::class.java) putExtra("app_id", gameId) + putExtra("game_source", gameSource.name) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } @@ -107,22 +109,27 @@ internal suspend fun createPinnedShortcut(context: Context, gameId: Int, label: val drawable = (result as? SuccessResult)?.drawable val rawBitmap = when (drawable) { is BitmapDrawable -> drawable.bitmap + else -> { if (drawable != null) { val bmp = Bitmap.createBitmap( drawable.intrinsicWidth.coerceAtLeast(1), drawable.intrinsicHeight.coerceAtLeast(1), - Bitmap.Config.ARGB_8888 + Bitmap.Config.ARGB_8888, ) val canvas = Canvas(bmp) drawable.setBounds(0, 0, canvas.width, canvas.height) drawable.draw(canvas) bmp - } else null + } else { + null + } } } rawBitmap - } else null + } else { + null + } } catch (_: Throwable) { null }