From cbc075ac4a64942fea0265a1017615dd63aad9e4 Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Sat, 11 Jul 2026 05:31:35 +0000 Subject: [PATCH 1/3] Fix library showing every Steam game as installed When app metadata was unavailable (service restarting or catalog not loaded yet), getAppDirPath resolved every app to the shared install root; a stray download-complete marker there made install recovery fabricate installed records for the whole library until a force close. Return an empty path when no folder name can be resolved and skip install recovery on blank paths. --- app/src/main/feature/stores/steam/service/SteamService.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 665e65695..a90792c07 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -1355,6 +1355,7 @@ class SteamService : Service() { private fun tryRecoverInstalledAppInfo(appId: Int): AppInfo? { val dirPath = getAppDirPath(appId) + if (dirPath.isBlank()) return null val hasCompleteMarker = MarkerUtils.hasMarker(dirPath, Marker.DOWNLOAD_COMPLETE_MARKER) val hasInProgressMarker = MarkerUtils.hasMarker(dirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) if (!hasCompleteMarker || hasInProgressMarker) return null @@ -2144,6 +2145,12 @@ class SteamService : Service() { if (oldName.isNotEmpty() && oldName != appName) add(oldName) } + // No resolvable folder name (metadata unavailable) — never fall back to a shared root. + if (candidateNames.isEmpty()) { + Timber.w("getAppDirPath: no metadata to resolve install dir for appId=%d", gameId) + return "" + } + // Respect user-selected default download folder val context = PluviaApp.instance.applicationContext if (context != null) { From 0ab529259ef403be376a21f9b1765663f596245a Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Sat, 11 Jul 2026 22:51:46 +0000 Subject: [PATCH 2/3] Fix empty library when the Steam service is not running Install-state metadata reads went through the live service instance and returned nothing while the service was down (e.g. killed during a large component install), so the library scan saw zero installed games and the cached result stuck until an app restart. Fall back to the database singleton for app metadata and install records. --- .../feature/stores/steam/service/SteamService.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index a90792c07..f65da1ef3 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -1258,7 +1258,11 @@ class SteamService : Service() { ) } - fun getAppInfoOf(appId: Int): SteamApp? = runBlocking(Dispatchers.IO) { instance?.appDao?.findApp(appId) } + fun getAppInfoOf(appId: Int): SteamApp? = + runBlocking(Dispatchers.IO) { + val dao = instance?.appDao ?: runCatching { PluviaDatabase.getInstance().steamAppDao() }.getOrNull() + dao?.findApp(appId) + } fun getDownloadingAppInfoOf(appId: Int): DownloadingAppInfo? = runBlocking(Dispatchers.IO) { @@ -1316,7 +1320,11 @@ class SteamService : Service() { fun getHiddenDlcAppsOf(appId: Int): List? = runBlocking(Dispatchers.IO) { instance?.appDao?.findHiddenDLCApps(appId) } - fun getInstalledApp(appId: Int): AppInfo? = runBlocking(Dispatchers.IO) { instance?.appInfoDao?.getInstalledApp(appId) } + fun getInstalledApp(appId: Int): AppInfo? = + runBlocking(Dispatchers.IO) { + val dao = instance?.appInfoDao ?: runCatching { PluviaDatabase.getInstance().appInfoDao() }.getOrNull() + dao?.getInstalledApp(appId) + } fun getInstalledDepotsOf(appId: Int): List? = getTrustedInstalledAppInfo(appId)?.downloadedDepots From a460a53ccf8bd0c190e5e40ad9076e7c51a31037 Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Sat, 11 Jul 2026 22:51:46 +0000 Subject: [PATCH 3/3] Add pull-to-refresh to the library Pulling down re-runs the shortcut and install-state scans and shows a refresh indicator, including on the empty state so a stale empty library can be recovered without restarting the app. --- app/src/main/app/shell/UnifiedActivity.kt | 274 ++++++++++++---------- 1 file changed, 149 insertions(+), 125 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index e94f5775d..bf7e79ccf 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -82,6 +82,7 @@ import androidx.compose.material.icons.automirrored.outlined.ExitToApp import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.saveable.rememberSaveable @@ -3449,6 +3450,7 @@ class UnifiedActivity : var customApps by remember { mutableStateOf>(emptyList()) } var localLibraryRefreshKey by remember { mutableIntStateOf(0) } var shortcutsLoaded by remember { mutableStateOf(false) } + var pullRefreshing by remember { mutableStateOf(false) } LaunchedEffect(shortcutRefreshKey, localLibraryRefreshKey) { shortcutsLoaded = false @@ -3525,7 +3527,7 @@ class UnifiedActivity : var libraryLoaded by remember { mutableStateOf(false) } // Suppress transient empty states before background recomputation starts. val scanInputToken = - remember(steamApps, epicApps, gogApps, customApps, libraryRefreshKey) { Any() } + remember(steamApps, epicApps, gogApps, customApps, libraryRefreshKey, localLibraryRefreshKey) { Any() } var processedScanToken by remember { mutableStateOf(null) } LaunchedEffect(scanInputToken) { @@ -3584,6 +3586,7 @@ class UnifiedActivity : } libraryLoaded = true processedScanToken = scanInputToken + pullRefreshing = false } } } @@ -3877,8 +3880,20 @@ class UnifiedActivity : navigateToSettings(SettingsNavItem.STORES) } } else if (anyLoggedIn) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - EmptyStateMessage(stringResource(R.string.library_games_no_games_installed)) + PullToRefreshBox( + isRefreshing = pullRefreshing, + onRefresh = { + pullRefreshing = true + localLibraryRefreshKey++ + }, + modifier = Modifier.fillMaxSize(), + ) { + Box( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + contentAlignment = Alignment.Center, + ) { + EmptyStateMessage(stringResource(R.string.library_games_no_games_installed)) + } } } return @@ -4044,136 +4059,145 @@ class UnifiedActivity : } } - when (layoutMode) { - LibraryLayoutMode.GRID_4 -> { - FourByTwoGridView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(), - gridState = gridState, - contentPadding = TabGridContentPadding, - clipContent = false, - keyOf = { it.id }, - ) { app, index, rowHeight -> - GameCapsule( - app = app, - gogGame = visibleGogByPseudoId[app.id], - epicGame = visibleEpicByPseudoId[app.id], - iconRefreshKey = iconRefreshKey, - artworkCacheRefreshKey = artworkCacheRefreshKey, - isFocusedOverride = index == focusIndex, - isControllerActive = isControllerConnected, - customArtworkPath = visibleCustomGridArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], - customIconPath = visibleCustomIconPathByAppId[app.id], - onClick = { - detailGogGame = visibleGogByPseudoId[app.id] - detailApp = app - }, - onLongClick = { - openSettingsForApp(index, app) - }, - modifier = - Modifier - .height(rowHeight) - .then( - if (index in focusRequesters.indices) { - Modifier.focusRequester(focusRequesters[index]) - } else { - Modifier - }, - ), - ) + PullToRefreshBox( + isRefreshing = pullRefreshing, + onRefresh = { + pullRefreshing = true + localLibraryRefreshKey++ + }, + modifier = Modifier.fillMaxSize(), + ) { + when (layoutMode) { + LibraryLayoutMode.GRID_4 -> { + FourByTwoGridView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(), + gridState = gridState, + contentPadding = TabGridContentPadding, + clipContent = false, + keyOf = { it.id }, + ) { app, index, rowHeight -> + GameCapsule( + app = app, + gogGame = visibleGogByPseudoId[app.id], + epicGame = visibleEpicByPseudoId[app.id], + iconRefreshKey = iconRefreshKey, + artworkCacheRefreshKey = artworkCacheRefreshKey, + isFocusedOverride = index == focusIndex, + isControllerActive = isControllerConnected, + customArtworkPath = visibleCustomGridArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], + customIconPath = visibleCustomIconPathByAppId[app.id], + onClick = { + detailGogGame = visibleGogByPseudoId[app.id] + detailApp = app + }, + onLongClick = { + openSettingsForApp(index, app) + }, + modifier = + Modifier + .height(rowHeight) + .then( + if (index in focusRequesters.indices) { + Modifier.focusRequester(focusRequesters[index]) + } else { + Modifier + }, + ), + ) + } } - } - LibraryLayoutMode.CAROUSEL -> { - CarouselView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(top = TabCarouselTopPadding, bottom = TabCarouselBottomPadding), - listState = carouselState, - selectedIndex = focusIndex, - onCenteredIndexChanged = { centeredIndex -> - if (activity != null && activity.libraryFocusIndex.value != centeredIndex) { - activity.libraryFocusIndex.value = centeredIndex - } - }, - ) { app, index, isSelected, cardWidth, cardHeight -> - GameCapsule( - app = app, - gogGame = visibleGogByPseudoId[app.id], - epicGame = visibleEpicByPseudoId[app.id], - iconRefreshKey = iconRefreshKey, - artworkCacheRefreshKey = artworkCacheRefreshKey, - isFocusedOverride = isSelected, - isControllerActive = isControllerConnected, - customArtworkPath = visibleCustomCarouselArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], - customIconPath = visibleCustomIconPathByAppId[app.id], - onClick = { - detailGogGame = visibleGogByPseudoId[app.id] - detailApp = app + LibraryLayoutMode.CAROUSEL -> { + CarouselView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(top = TabCarouselTopPadding, bottom = TabCarouselBottomPadding), + listState = carouselState, + selectedIndex = focusIndex, + onCenteredIndexChanged = { centeredIndex -> + if (activity != null && activity.libraryFocusIndex.value != centeredIndex) { + activity.libraryFocusIndex.value = centeredIndex + } }, - onLongClick = { openSettingsForApp(index, app) }, - useLibraryCapsule = true, - modifier = - Modifier - .fillMaxSize() - .then( - if (index in focusRequesters.indices) { - Modifier.focusRequester(focusRequesters[index]) - } else { - Modifier - }, - ), - ) + ) { app, index, isSelected, cardWidth, cardHeight -> + GameCapsule( + app = app, + gogGame = visibleGogByPseudoId[app.id], + epicGame = visibleEpicByPseudoId[app.id], + iconRefreshKey = iconRefreshKey, + artworkCacheRefreshKey = artworkCacheRefreshKey, + isFocusedOverride = isSelected, + isControllerActive = isControllerConnected, + customArtworkPath = visibleCustomCarouselArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], + customIconPath = visibleCustomIconPathByAppId[app.id], + onClick = { + detailGogGame = visibleGogByPseudoId[app.id] + detailApp = app + }, + onLongClick = { openSettingsForApp(index, app) }, + useLibraryCapsule = true, + modifier = + Modifier + .fillMaxSize() + .then( + if (index in focusRequesters.indices) { + Modifier.focusRequester(focusRequesters[index]) + } else { + Modifier + }, + ), + ) + } } - } - LibraryLayoutMode.LIST -> { - val listViewState = rememberLazyListState() - ListView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(), - listState = listViewState, - contentPadding = TabListContentPadding, - selectedIndex = focusIndex, - onSelectedIndexChanged = { newIdx -> - activity?.libraryFocusIndex?.value = newIdx - }, - keyOf = { it.id }, - ) { app, index, isSelected -> - GameCapsule( - app = app, - gogGame = visibleGogByPseudoId[app.id], - epicGame = visibleEpicByPseudoId[app.id], - iconRefreshKey = iconRefreshKey, - artworkCacheRefreshKey = artworkCacheRefreshKey, - isFocusedOverride = isSelected, - isControllerActive = isControllerConnected, - customArtworkPath = visibleCustomListArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], - customIconPath = visibleCustomIconPathByAppId[app.id], - onClick = { - detailGogGame = visibleGogByPseudoId[app.id] - detailApp = app + LibraryLayoutMode.LIST -> { + val listViewState = rememberLazyListState() + ListView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(), + listState = listViewState, + contentPadding = TabListContentPadding, + selectedIndex = focusIndex, + onSelectedIndexChanged = { newIdx -> + activity?.libraryFocusIndex?.value = newIdx }, - onLongClick = { openSettingsForApp(index, app) }, - listMode = true, - modifier = - Modifier - .then( - if (index in focusRequesters.indices) { - Modifier.focusRequester(focusRequesters[index]) - } else { - Modifier - }, - ), + keyOf = { it.id }, + ) { app, index, isSelected -> + GameCapsule( + app = app, + gogGame = visibleGogByPseudoId[app.id], + epicGame = visibleEpicByPseudoId[app.id], + iconRefreshKey = iconRefreshKey, + artworkCacheRefreshKey = artworkCacheRefreshKey, + isFocusedOverride = isSelected, + isControllerActive = isControllerConnected, + customArtworkPath = visibleCustomListArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], + customIconPath = visibleCustomIconPathByAppId[app.id], + onClick = { + detailGogGame = visibleGogByPseudoId[app.id] + detailApp = app + }, + onLongClick = { openSettingsForApp(index, app) }, + listMode = true, + modifier = + Modifier + .then( + if (index in focusRequesters.indices) { + Modifier.focusRequester(focusRequesters[index]) + } else { + Modifier + }, + ), + ) + } + JoystickListScroll( + listState = listViewState, + stickFlow = activity?.rightStickScrollState, + minSpeed = 2.5f, + maxSpeed = 16f, + quadratic = true, ) } - JoystickListScroll( - listState = listViewState, - stickFlow = activity?.rightStickScrollState, - minSpeed = 2.5f, - maxSpeed = 16f, - quadratic = true, - ) } }