From 6b74c5a59affae6b835a4be9382410b150ecf4ad Mon Sep 17 00:00:00 2001 From: Pastry Date: Sun, 12 Jul 2026 18:13:29 -0300 Subject: [PATCH 1/6] allow for choosing which steam family library copy to use (DLCs etc) --- .../main/java/app/gamenative/PrefManager.kt | 35 ++ .../gamenative/data/PreferredCopyOption.kt | 13 + .../app/gamenative/events/AndroidEvent.kt | 1 + .../app/gamenative/service/SteamService.kt | 331 +++++++++++++++++- .../app/gamenative/ui/data/GameDisplayInfo.kt | 3 + .../gamenative/ui/enums/AppOptionMenuType.kt | 1 + .../gamenative/ui/model/LibraryViewModel.kt | 12 + .../ui/screen/library/LibraryAppScreen.kt | 23 ++ .../screen/library/appscreen/BaseAppScreen.kt | 10 + .../library/appscreen/SteamAppScreen.kt | 188 ++++++++++ .../library/components/GameOptionsPanel.kt | 3 + app/src/main/res/values/strings.xml | 9 + 12 files changed, 619 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/app/gamenative/data/PreferredCopyOption.kt diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 057d405f48..e744cbcaec 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -104,6 +104,7 @@ object PrefManager { pref.remove(STEAM_USER_NAME) pref.remove(LAST_PICS_CHANGE_NUMBER) pref.remove(STEAM_GAMES_COUNT) + pref.remove(PREFERRED_FAMILY_LENDERS_JSON) } } } @@ -1406,4 +1407,38 @@ object PrefManager { setPref(NEXUS_LAST_PLACEMENT_JSON, value) } } + + /** + * Preferred Steam Families lender per appId (appId string → lender steamId64). + * Empty / missing entry means use the account's own copy when available. + */ + private val PREFERRED_FAMILY_LENDERS_JSON = stringPreferencesKey("preferred_family_lenders_json") + var preferredFamilyLenders: Map + get() { + val value = getPref(PREFERRED_FAMILY_LENDERS_JSON, "{}") + return runCatching { + Json.decodeFromString>(value) + .mapKeys { it.key.toInt() } + }.getOrDefault(emptyMap()) + } + set(value) { + if (value.isEmpty()) { + removePref(PREFERRED_FAMILY_LENDERS_JSON) + } else { + setPref( + PREFERRED_FAMILY_LENDERS_JSON, + Json.encodeToString(value.mapKeys { it.key.toString() }), + ) + } + } + + fun setPreferredFamilyLender(appId: Int, lenderSteamId: Long?) { + val updated = preferredFamilyLenders.toMutableMap() + if (lenderSteamId == null || lenderSteamId == 0L) { + updated.remove(appId) + } else { + updated[appId] = lenderSteamId + } + preferredFamilyLenders = updated + } } diff --git a/app/src/main/java/app/gamenative/data/PreferredCopyOption.kt b/app/src/main/java/app/gamenative/data/PreferredCopyOption.kt new file mode 100644 index 0000000000..8e3746006e --- /dev/null +++ b/app/src/main/java/app/gamenative/data/PreferredCopyOption.kt @@ -0,0 +1,13 @@ +package app.gamenative.data + +/** + * One selectable Steam Families library copy for an app. + */ +data class PreferredCopyOption( + val lenderSteamId: Long, + val accountId: Int, + val displayName: String, + val isSelf: Boolean, + val packageId: Int?, + val ownedDlcCount: Int? = null, +) diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt index a2ec39df10..5ff0d4bcfc 100644 --- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt +++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt @@ -24,6 +24,7 @@ interface AndroidEvent : Event { data class DownloadStatusChanged(val appId: Int, val isDownloading: Boolean) : AndroidEvent data class PostInstallSyncStatusChanged(val appId: Int, val isSyncing: Boolean) : AndroidEvent data class LibraryInstallStatusChanged(val appId: Int, val source: GameSource) : AndroidEvent + data class PreferredCopyChanged(val appId: Int) : AndroidEvent data class CustomGameImagesFetched(val appId: String) : AndroidEvent data object RecommendationToggleChanged : AndroidEvent data class GOGAuthCodeReceived(val authCode: String) : AndroidEvent diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 9d30ac58de..1af7fed3ee 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -27,6 +27,7 @@ import app.gamenative.data.GameSource import app.gamenative.data.LaunchInfo import app.gamenative.data.OwnedGames import app.gamenative.data.PostSyncInfo +import app.gamenative.data.PreferredCopyOption import app.gamenative.data.SteamApp import app.gamenative.data.SteamControllerConfigDetail import app.gamenative.data.SteamFriend @@ -67,11 +68,13 @@ import `in`.dragonbra.javasteam.depotdownloader.DepotDownloader import `in`.dragonbra.javasteam.depotdownloader.IDownloadListener import `in`.dragonbra.javasteam.depotdownloader.data.AppItem import `in`.dragonbra.javasteam.depotdownloader.data.DownloadItem +import `in`.dragonbra.javasteam.enums.EAccountType import `in`.dragonbra.javasteam.enums.EDepotFileFlag import `in`.dragonbra.javasteam.enums.ELicenseFlags import `in`.dragonbra.javasteam.enums.EOSType import `in`.dragonbra.javasteam.enums.EPersonaState import `in`.dragonbra.javasteam.enums.EResult +import `in`.dragonbra.javasteam.enums.EUniverse import `in`.dragonbra.javasteam.networking.steam3.ProtocolTypes import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientObjects.ECloudPendingRemoteOperation import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesFamilygroupsSteamclient @@ -279,6 +282,13 @@ class SteamService : Service(), IChallengeUrlChanged { // The current shared family group the logged in user is joined to. private var familyGroupMembers: ArrayList = arrayListOf() + private var familyGroupId: Long = 0L + /** appId → distinct owner steamId64s from GetSharedLibraryApps */ + private val familyAppOwnerSteamIds: ConcurrentHashMap> = ConcurrentHashMap() + /** appId → preferred lender steamId64 from GetPreferredLenders / user choice */ + private val preferredLenderByAppId: ConcurrentHashMap = ConcurrentHashMap() + /** steamId64 → display name for family members when known */ + private val familyMemberNames: ConcurrentHashMap = ConcurrentHashMap() private val appTokens: ConcurrentHashMap = ConcurrentHashMap() @@ -533,6 +543,172 @@ class SteamService : Service(), IChallengeUrlChanged { val familyMembers: List get() = instance?.familyGroupMembers ?: emptyList() + val familyGroupId: Long + get() = instance?.familyGroupId ?: 0L + + fun hasMultiplePreferredCopyOptions(appId: Int): Boolean = + getPreferredCopyOptions(appId).size >= 2 + + fun getPreferredCopyOptions(appId: Int): List { + val svc = instance ?: return emptyList() + val selfSteamId = userSteamId?.convertToUInt64() ?: return emptyList() + val selfAccountId = userSteamId!!.accountID.toInt() + + val ownerSteamIds = linkedSetOf() + svc.familyAppOwnerSteamIds[appId]?.let { ownerSteamIds.addAll(it) } + + // Also include owners from local licenses that grant this app. + val licenses = runBlocking(Dispatchers.IO) { + svc.licenseDao.getAllLicenses().filter { appId in it.appIds } + } + for (license in licenses) { + for (accountId in license.ownerAccountId) { + ownerSteamIds.add(SteamID(accountId.toLong(), EUniverse.Public, EAccountType.Individual).convertToUInt64()) + } + } + + if (ownerSteamIds.isEmpty()) { + // Fallback: active package owners + getAppInfoOf(appId)?.ownerAccountId?.forEach { accountId -> + ownerSteamIds.add(SteamID(accountId.toLong(), EUniverse.Public, EAccountType.Individual).convertToUInt64()) + } + } + + if (ownerSteamIds.isEmpty()) return emptyList() + + return ownerSteamIds.map { steamId64 -> + val steamId = SteamID(steamId64) + val accountId = steamId.accountID.toInt() + val isSelf = steamId64 == selfSteamId || accountId == selfAccountId + val packageId = runBlocking(Dispatchers.IO) { + findLicenseForLender(appId, accountId)?.packageId + } + val dlcCount = if (packageId != null) { + runBlocking(Dispatchers.IO) { + countDlcForLender(appId, accountId) + } + } else { + null + } + PreferredCopyOption( + lenderSteamId = steamId64, + accountId = accountId, + displayName = if (isSelf) { + PrefManager.steamUserName.ifBlank { "Your copy" } + } else { + svc.familyMemberNames[steamId64] + ?: "Family member" + }, + isSelf = isSelf, + packageId = packageId, + ownedDlcCount = dlcCount, + ) + }.sortedWith(compareByDescending { it.isSelf }.thenBy { it.displayName }) + } + + fun getActivePreferredCopy(appId: Int): PreferredCopyOption? { + val options = getPreferredCopyOptions(appId) + if (options.isEmpty()) return null + val preferredSteamId = instance?.preferredLenderByAppId?.get(appId) + ?: PrefManager.preferredFamilyLenders[appId] + if (preferredSteamId != null) { + options.firstOrNull { it.lenderSteamId == preferredSteamId }?.let { return it } + } + val self = options.firstOrNull { it.isSelf } + if (self != null) return self + return options.first() + } + + suspend fun setPreferredCopy(appId: Int, lenderSteamId: Long): Boolean = withContext(Dispatchers.IO) { + val svc = instance ?: return@withContext false + val groupId = svc.familyGroupId + if (groupId == 0L) { + Timber.w("setPreferredCopy: no family group") + return@withContext false + } + val familyGroups = svc._steamFamilyGroups ?: return@withContext false + + val request = SteammessagesFamilygroupsSteamclient.CFamilyGroups_SetPreferredLender_Request.newBuilder().apply { + familyGroupid = groupId + this.appid = appId + this.lenderSteamid = lenderSteamId + }.build() + + val result = try { + familyGroups.setPreferredLender(request).await() + } catch (e: Exception) { + Timber.e(e, "setPreferredLender failed for appId=$appId") + return@withContext false + } + + if (result.result != EResult.OK) { + Timber.w("setPreferredLender returned ${result.result} for appId=$appId") + return@withContext false + } + + svc.preferredLenderByAppId[appId] = lenderSteamId + PrefManager.setPreferredFamilyLender(appId, lenderSteamId) + applyPreferredLenderLocally(appId, lenderSteamId) + PluviaApp.events.emit(AndroidEvent.PreferredCopyChanged(appId)) + true + } + + private suspend fun findLicenseForLender(appId: Int, lenderAccountId: Int): SteamLicense? { + val svc = instance ?: return null + val licenses = svc.licenseDao.getAllLicenses().filter { appId in it.appIds && lenderAccountId in it.ownerAccountId } + if (licenses.isEmpty()) return null + return licenses.maxByOrNull { license -> + when { + ELicenseFlags.Expired in license.licenseFlags -> 0 + else -> 1 + } + } + } + + private suspend fun countDlcForLender(appId: Int, lenderAccountId: Int): Int { + val svc = instance ?: return 0 + val licenses = svc.licenseDao.getAllLicenses().filter { lenderAccountId in it.ownerAccountId } + val dlcApps = svc.appDao.findDownloadableDLCApps(appId).orEmpty() + + svc.appDao.findHiddenDLCApps(appId).orEmpty() + return dlcApps.count { dlc -> + licenses.any { dlc.id in it.appIds } + } + } + + private suspend fun applyPreferredLenderLocally(appId: Int, lenderSteamId: Long) { + val svc = instance ?: return + val lenderAccountId = SteamID(lenderSteamId).accountID.toInt() + val license = findLicenseForLender(appId, lenderAccountId) + val app = svc.appDao.findApp(appId) ?: return + if (license != null) { + svc.appDao.update( + app.copy( + packageId = license.packageId, + ownerAccountId = listOf(lenderAccountId), + licenseFlags = license.licenseFlags, + ), + ) + Timber.i( + "Applied preferred lender $lenderAccountId for app $appId → package ${license.packageId}", + ) + } else { + // Still flip owner for badge / play session even if package row is missing. + svc.appDao.update(app.copy(ownerAccountId = listOf(lenderAccountId))) + Timber.w( + "Preferred lender $lenderAccountId for app $appId has no local license; ownerAccountId updated only", + ) + } + } + + private suspend fun applyAllCachedPreferredLenders() { + val svc = instance ?: return + val preferred = PrefManager.preferredFamilyLenders.toMutableMap() + preferred.putAll(svc.preferredLenderByAppId) + for ((appId, lenderSteamId) in preferred) { + applyPreferredLenderLocally(appId, lenderSteamId) + } + } + val isLoginInProgress: Boolean get() = instance?._loginResult == LoginResult.InProgress @@ -2330,14 +2506,20 @@ class SteamService : Service(), IChallengeUrlChanged { ?: 0 val userAccountId = userSteamId!!.accountID.toInt() + val preferredLender = instance?.preferredLenderByAppId?.get(gameProcess.appId) + ?: PrefManager.preferredFamilyLenders[gameProcess.appId] + val preferredAccountId = preferredLender?.let { SteamID(it).accountID.toInt() } + val ownerId = when { + preferredAccountId != null && + pkgInfo.ownerAccountId.contains(preferredAccountId) -> preferredAccountId + pkgInfo.ownerAccountId.contains(userAccountId) -> userAccountId + pkgInfo.ownerAccountId.isNotEmpty() -> pkgInfo.ownerAccountId.first() + else -> userAccountId + } GamePlayedInfo( gameId = gameProcess.appId.toLong(), processId = processId, - ownerId = if (pkgInfo.ownerAccountId.contains(userAccountId)) { - userAccountId - } else { - pkgInfo.ownerAccountId.first() - }, + ownerId = ownerId, // TODO: figure out what this is and un-hardcode launchSource = 100, gameBuildId = branch.buildId.toInt(), @@ -3717,6 +3899,87 @@ class SteamService : Service(), IChallengeUrlChanged { } } + private suspend fun refreshFamilyPreferredCopyData() { + val familyGroups = _steamFamilyGroups ?: return + if (familyGroupId == 0L) return + + try { + val sharedRequest = SteammessagesFamilygroupsSteamclient.CFamilyGroups_GetSharedLibraryApps_Request.newBuilder().apply { + familyGroupid = familyGroupId + includeOwn = true + includeExcluded = false + includeNonGames = false + }.build() + + val sharedResult = familyGroups.getSharedLibraryApps(sharedRequest).await() + if (sharedResult.result == EResult.OK) { + familyAppOwnerSteamIds.clear() + sharedResult.body.appsList.forEach { sharedApp -> + if (sharedApp.ownerSteamidsCount >= 1) { + familyAppOwnerSteamIds[sharedApp.appid] = sharedApp.ownerSteamidsList.toList() + } + } + Timber.i("Cached shared library owners for ${familyAppOwnerSteamIds.size} apps") + } else { + Timber.w("GetSharedLibraryApps failed: ${sharedResult.result}") + } + } catch (e: Exception) { + Timber.e(e, "GetSharedLibraryApps failed") + } + + try { + val preferredRequest = SteammessagesFamilygroupsSteamclient.CFamilyGroups_GetPreferredLenders_Request.newBuilder().apply { + familyGroupid = familyGroupId + }.build() + + val preferredResult = familyGroups.getPreferredLenders(preferredRequest).await() + if (preferredResult.result == EResult.OK) { + preferredLenderByAppId.clear() + preferredResult.body.membersList.forEach { member -> + val lenderSteamId = member.steamid + member.preferredAppidsList.forEach { appId -> + preferredLenderByAppId[appId] = lenderSteamId + } + } + // Overlay locally persisted preferences (user may have set while offline to Steam's cache). + PrefManager.preferredFamilyLenders.forEach { (appId, lender) -> + preferredLenderByAppId[appId] = lender + } + Timber.i("Cached ${preferredLenderByAppId.size} preferred family lenders") + } else { + Timber.w("GetPreferredLenders failed: ${preferredResult.result}") + PrefManager.preferredFamilyLenders.forEach { (appId, lender) -> + preferredLenderByAppId[appId] = lender + } + } + } catch (e: Exception) { + Timber.e(e, "GetPreferredLenders failed") + PrefManager.preferredFamilyLenders.forEach { (appId, lender) -> + preferredLenderByAppId[appId] = lender + } + } + + // Best-effort persona names for family members who are also friends. + try { + val friendIds = familyGroupMembers.map { accountId -> + SteamID(accountId.toLong(), EUniverse.Public, EAccountType.Individual) + } + if (friendIds.isNotEmpty()) { + _steamFriends?.requestFriendInfo(friendIds) + } + friendIds.forEach { steamId -> + val persona = _steamFriends?.getFriendPersonaName(steamId) + if (!persona.isNullOrBlank() && persona != "[unknown]") { + familyMemberNames[steamId.convertToUInt64()] = persona + } + } + } catch (e: Exception) { + Timber.d(e, "Could not resolve family member persona names") + } + + applyAllCachedPreferredLenders() + } + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) private fun onLoggedOn(callback: LoggedOnCallback) { Timber.i("Logged onto Steam: ${callback.result}") @@ -3752,6 +4015,7 @@ class SteamService : Service(), IChallengeUrlChanged { // Request family share info if we have a familyGroupId. if (callback.familyGroupId != 0L) { scope.launch { + familyGroupId = callback.familyGroupId val request = SteammessagesFamilygroupsSteamclient.CFamilyGroups_GetFamilyGroup_Request.newBuilder().apply { familyGroupid = callback.familyGroupId }.build() @@ -3766,12 +4030,23 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.i("Found family share: ${response.name}, with ${response.membersCount} members.") + familyGroupMembers.clear() response.membersList.forEach { member -> - val accountID = SteamID(member.steamid).accountID.toInt() + val steamId = SteamID(member.steamid) + val accountID = steamId.accountID.toInt() familyGroupMembers.add(accountID) + familyMemberNames[member.steamid] = "Family member" } } + + refreshFamilyPreferredCopyData() } + } else { + familyGroupId = 0L + familyGroupMembers.clear() + familyAppOwnerSteamIds.clear() + preferredLenderByAppId.clear() + familyMemberNames.clear() } picsChangesCheckerJob = continuousPICSChangesChecker() @@ -4009,6 +4284,11 @@ class SteamService : Service(), IChallengeUrlChanged { return } + val friendSteamId64 = callback.friendId.convertToUInt64() + if (familyGroupMembers.contains(callback.friendId.accountID.toInt())) { + familyMemberNames[friendSteamId64] = callback.playerName + } + // Timber.d("Persona state received: ${callback.name}") scope.launch { @@ -4132,6 +4412,16 @@ class SteamService : Service(), IChallengeUrlChanged { packagePicsChannel.send(chunk) } } + + // After licenses land, re-apply any preferred family lenders. + if (familyGroupId != 0L && preferredLenderByAppId.isNotEmpty()) { + applyAllCachedPreferredLenders() + } else if (familyGroupId != 0L && PrefManager.preferredFamilyLenders.isNotEmpty()) { + PrefManager.preferredFamilyLenders.forEach { (appId, lender) -> + preferredLenderByAppId[appId] = lender + } + applyAllCachedPreferredLenders() + } } } @@ -4364,13 +4654,29 @@ class SteamService : Service(), IChallengeUrlChanged { } // Prefer non-expired user-owned packages so a live sub wins over an expired remnant. - fun pkgRank(pkgId: Int): Int { + // When a preferred family lender is set for an app, prefer that lender's packages higher. + fun preferredLenderAccountForApp(appId: Int): Int? { + val lenderSteamId = preferredLenderByAppId[appId] + ?: PrefManager.preferredFamilyLenders[appId] + ?: return null + return SteamID(lenderSteamId).accountID.toInt() + } + + fun pkgRank(pkgId: Int, forAppId: Int? = null): Int { + val preferredAccount = forAppId?.let { preferredLenderAccountForApp(it) } + val license = packageLicenses[pkgId] + if (preferredAccount != null && license?.ownerAccountId?.contains(preferredAccount) == true) { + return if (ELicenseFlags.Expired in license.licenseFlags) 3 else 4 + } if (pkgId !in userOwnedPackageIds) return 0 - val expired = packageLicenses[pkgId]?.licenseFlags?.contains(ELicenseFlags.Expired) == true + val expired = license?.licenseFlags?.contains(ELicenseFlags.Expired) == true return if (expired) 1 else 2 } - val orderedPackages = picsCallback.packages.values.sortedBy { pkgRank(it.id) } + val orderedPackages = picsCallback.packages.values.sortedBy { pkg -> + val appIds = pkg.keyValues["appids"].children.map { it.asInteger() } + appIds.maxOfOrNull { pkgRank(pkg.id, it) } ?: pkgRank(pkg.id) + } orderedPackages.forEach { pkg -> val appIds = pkg.keyValues["appids"].children.map { it.asInteger() } @@ -4392,13 +4698,18 @@ class SteamService : Service(), IChallengeUrlChanged { if (accountId != null && existing.packageId != INVALID_PKG_ID) { val existingLicense = packageLicenses[existing.packageId] ?: licenseDao.findLicense(existing.packageId) + val preferredAccount = preferredLenderAccountForApp(appid) val existingRank = when { + preferredAccount != null && + existingLicense?.ownerAccountId?.contains(preferredAccount) == true -> { + if (ELicenseFlags.Expired in existingLicense.licenseFlags) 3 else 4 + } existingLicense == null -> 0 !existingLicense.ownerAccountId.contains(accountId) -> 0 ELicenseFlags.Expired in existingLicense.licenseFlags -> 1 else -> 2 } - if (existingRank > pkgRank(pkg.id)) { + if (existingRank > pkgRank(pkg.id, appid)) { return@forEach } } diff --git a/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt b/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt index 52d5a77fc8..15ca89b414 100644 --- a/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt +++ b/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt @@ -23,5 +23,8 @@ data class GameDisplayInfo( val compatibilityMessage: String? = null, // Compatibility message text (e.g., "Works on your GPU") val compatibilityColor: ULong? = null, // Compatibility message color (ARGB) val hltbStats: app.gamenative.utils.HltbService.Stats? = null, // How Long To Beat stats + /** Status line under Play for Steam Families preferred copy, e.g. "Using your copy". */ + val preferredCopyStatusText: String? = null, + val showChangePreferredCopy: Boolean = false, ) diff --git a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt index 1f846481e9..35887fca11 100644 --- a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt +++ b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt @@ -35,4 +35,5 @@ enum class AppOptionMenuType(@StringRes val title: Int) { ManageWorkshop(R.string.option_manage_workshop), ManageMods(R.string.option_manage_mods), ChangeBranch(R.string.change_branch), + ChangePreferredCopy(R.string.change_preferred_copy), } diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index 96a769f21a..50daadc69d 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -91,6 +91,16 @@ class LibraryViewModel @Inject constructor( onFilterApps(paginationCurrentPage) } + private val onPreferredCopyChanged: (AndroidEvent.PreferredCopyChanged) -> Unit = { event -> + viewModelScope.launch(Dispatchers.IO) { + val updated = steamAppDao.findApp(event.appId) ?: return@launch + if (appList.any { it.id == updated.id }) { + appList = appList.map { if (it.id == updated.id) updated else it } + onFilterApps(paginationCurrentPage) + } + } + } + private val onCustomGameImagesFetched: (AndroidEvent.CustomGameImagesFetched) -> Unit = { // Increment refresh counter and refresh the library list to pick up newly fetched images _state.update { it.copy(imageRefreshCounter = it.imageRefreshCounter + 1) } @@ -233,6 +243,7 @@ class LibraryViewModel @Inject constructor( } PluviaApp.events.on(onInstallStatusChanged) + PluviaApp.events.on(onPreferredCopyChanged) PluviaApp.events.on(onCustomGameImagesFetched) PluviaApp.events.on(onRecommendationToggleChanged) @@ -247,6 +258,7 @@ class LibraryViewModel @Inject constructor( override fun onCleared() { searchDebounceJob?.cancel() PluviaApp.events.off(onInstallStatusChanged) + PluviaApp.events.off(onPreferredCopyChanged) PluviaApp.events.off(onCustomGameImagesFetched) PluviaApp.events.off(onRecommendationToggleChanged) super.onCleared() diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 0e74b9f4ca..c22244e189 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -570,6 +570,7 @@ internal fun AppScreenContent( onUpdateClick: () -> Unit, onBack: () -> Unit = {}, optionsMenu: List, + onChangePreferredCopy: (() -> Unit)? = null, ) { val context = LocalContext.current // reactive — recomposes when network state changes @@ -984,6 +985,28 @@ internal fun AppScreenContent( } } } + + if (displayInfo.showChangePreferredCopy && onChangePreferredCopy != null) { + Spacer(modifier = Modifier.height(10.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + displayInfo.preferredCopyStatusText?.let { status -> + Text( + text = status, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + } + Text( + text = stringResource(R.string.change_preferred_copy), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onChangePreferredCopy), + ) + } + } } // Compatibility status (if applicable) 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 b7e056882c..bdec587599 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 @@ -292,6 +292,15 @@ abstract class BaseAppScreen { return compatibilityMessage to compatibilityColor } + /** + * Optional handler for Steam Families "Change preferred copy" under Play. + * Return a click lambda when the game has multiple family copies. + */ + protected open fun onChangePreferredCopyClick( + context: Context, + libraryItem: LibraryItem, + ): (() -> Unit)? = null + /** * Get the game display information for rendering the UI. * This is called to get all the data needed for the common UI layout. @@ -1359,6 +1368,7 @@ abstract class BaseAppScreen { }, onBack = onBack, optionsMenu = optionsMenu, + onChangePreferredCopy = onChangePreferredCopyClick(context, libraryItem), ) if (showReadiness && launchActivity != null) { 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 32a8c50f13..bbe66df190 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 @@ -211,6 +211,18 @@ class SteamAppScreen : BaseAppScreen() { fun shouldShowBranchDialog(gameId: Int): Boolean = gameId in branchDialogVisibleIds + private val preferredCopyDialogVisibleIds = mutableStateListOf() + + fun showPreferredCopyDialog(gameId: Int) { + if (gameId !in preferredCopyDialogVisibleIds) preferredCopyDialogVisibleIds.add(gameId) + } + + fun hidePreferredCopyDialog(gameId: Int) { + preferredCopyDialogVisibleIds.remove(gameId) + } + + fun shouldShowPreferredCopyDialog(gameId: Int): Boolean = gameId in preferredCopyDialogVisibleIds + // Shared state for update/verify operation - map of gameId to AppOptionMenuType private val pendingUpdateVerifyOperations = mutableStateMapOf() @@ -348,6 +360,32 @@ class SteamAppScreen : BaseAppScreen() { gameName = appInfo.name, ) + var preferredCopyRefresh by remember(gameId) { mutableIntStateOf(0) } + DisposableEffect(gameId) { + val listener: (AndroidEvent.PreferredCopyChanged) -> Unit = { event -> + if (event.appId == gameId) { + preferredCopyRefresh++ + } + } + PluviaApp.events.on(listener) + onDispose { + PluviaApp.events.off(listener) + } + } + + val showChangePreferredCopy = remember(gameId, preferredCopyRefresh) { + SteamService.hasMultiplePreferredCopyOptions(gameId) + } + val preferredCopyStatusText = remember(gameId, preferredCopyRefresh, showChangePreferredCopy) { + if (!showChangePreferredCopy) return@remember null + val active = SteamService.getActivePreferredCopy(gameId) ?: return@remember null + if (active.isSelf) { + context.getString(R.string.using_your_copy) + } else { + context.getString(R.string.shared_from_name, active.displayName) + } + } + return GameDisplayInfo( name = appInfo.name, developer = appInfo.developer, @@ -363,9 +401,20 @@ class SteamAppScreen : BaseAppScreen() { playtimeText = playtimeText, compatibilityMessage = compatibilityMessage, compatibilityColor = compatibilityColor, + preferredCopyStatusText = preferredCopyStatusText, + showChangePreferredCopy = showChangePreferredCopy, ) } + override fun onChangePreferredCopyClick( + context: Context, + libraryItem: LibraryItem, + ): (() -> Unit)? { + val gameId = libraryItem.gameId + if (!SteamService.hasMultiplePreferredCopyOptions(gameId)) return null + return { showPreferredCopyDialog(gameId) } + } + override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean { return SteamService.isAppInstalled(libraryItem.gameId) } @@ -802,6 +851,16 @@ class SteamAppScreen : BaseAppScreen() { showBranchDialog(gameId) } ), + ) + + if (SteamService.hasMultiplePreferredCopyOptions(gameId)) { + options += AppMenuOption( + AppOptionMenuType.ChangePreferredCopy, + onClick = { showPreferredCopyDialog(gameId) }, + ) + } + + options += listOf( AppMenuOption( AppOptionMenuType.ForceCloudSync, onClick = { @@ -1516,6 +1575,37 @@ class SteamAppScreen : BaseAppScreen() { onDismissRequest = { hideBranchDialog(gameId) }, ) } + + // Preferred family copy dialog + var showPreferredCopyDialogState by remember(gameId) { + mutableStateOf(shouldShowPreferredCopyDialog(gameId)) + } + LaunchedEffect(gameId) { + snapshotFlow { shouldShowPreferredCopyDialog(gameId) } + .collect { showPreferredCopyDialogState = it } + } + + if (showPreferredCopyDialogState) { + val options = remember(gameId) { SteamService.getPreferredCopyOptions(gameId) } + val current = remember(gameId) { SteamService.getActivePreferredCopy(gameId) } + val scope = rememberCoroutineScope() + SteamPreferredCopyDialog( + options = options, + currentLenderSteamId = current?.lenderSteamId, + onConfirm = { lenderSteamId -> + hidePreferredCopyDialog(gameId) + scope.launch { + val ok = withContext(Dispatchers.IO) { + SteamService.setPreferredCopy(gameId, lenderSteamId) + } + if (!ok) { + SnackbarManager.show(context.getString(R.string.preferred_copy_failed)) + } + } + }, + onDismissRequest = { hidePreferredCopyDialog(gameId) }, + ) + } } } @@ -1640,6 +1730,104 @@ private fun SteamChangeBranchDialog( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SteamPreferredCopyDialog( + options: List, + currentLenderSteamId: Long?, + onConfirm: (lenderSteamId: Long) -> Unit, + onDismissRequest: () -> Unit, +) { + var selectedSteamId by remember { + mutableStateOf(currentLenderSteamId ?: options.firstOrNull()?.lenderSteamId) + } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.change_preferred_copy)) }, + text = { + var expanded by remember { mutableStateOf(false) } + val selectedOption = options.firstOrNull { it.lenderSteamId == selectedSteamId } + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.change_preferred_copy_message), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + val label = selectedOption?.let { option -> + val base = if (option.isSelf) { + stringResource(R.string.preferred_copy_your_label, option.displayName) + } else { + stringResource(R.string.preferred_copy_family_label, option.displayName) + } + if (option.ownedDlcCount != null) { + "$base · ${stringResource(R.string.preferred_copy_dlc_count, option.ownedDlcCount)}" + } else { + base + } + }.orEmpty() + NoExtractOutlinedTextField( + value = label, + onValueChange = {}, + readOnly = true, + singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + val itemLabel = if (option.isSelf) { + stringResource(R.string.preferred_copy_your_label, option.displayName) + } else { + stringResource(R.string.preferred_copy_family_label, option.displayName) + } + val withDlc = if (option.ownedDlcCount != null) { + "$itemLabel · ${stringResource(R.string.preferred_copy_dlc_count, option.ownedDlcCount)}" + } else { + itemLabel + } + DropdownMenuItem( + text = { Text(withDlc) }, + onClick = { + selectedSteamId = option.lenderSteamId + expanded = false + }, + ) + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = selectedSteamId != null && selectedSteamId != currentLenderSteamId, + onClick = { selectedSteamId?.let(onConfirm) }, + ) { + Text(stringResource(R.string.preferred_copy_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt index 461238a961..26dcf8ea12 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt @@ -44,6 +44,7 @@ import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Face4 import androidx.compose.material.icons.filled.Feedback import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Key @@ -351,6 +352,7 @@ private fun getIconForOption(type: AppOptionMenuType): ImageVector { AppOptionMenuType.ManageWorkshop -> Icons.Default.Build AppOptionMenuType.ManageMods -> Icons.Default.Extension AppOptionMenuType.ChangeBranch -> Icons.AutoMirrored.Filled.CallSplit + AppOptionMenuType.ChangePreferredCopy -> Icons.Filled.Face4 } } @@ -377,6 +379,7 @@ private fun groupOptions(options: List): Map gameManagement.add(option) // Container Settings diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1144c498d..0f3ad9785f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,6 +80,15 @@ Branch Change Branch Download alternate game versions from Steam using developer-provided branches. Enter access codes to unlock private branches. + Change preferred copy + Choose which Steam Family member\'s copy to play. Use a family member\'s copy to access their DLC. + Using your copy + Shared from %1$s + Your copy (%1$s) + %1$s + %1$d DLC + Could not change preferred copy. Try again while connected to Steam. + Use this copy Enter access code Check Code Branch unlocked! From 31e047b11f17b8a79779eeb50f83a28e86359c9c Mon Sep 17 00:00:00 2001 From: Pastry Date: Sun, 12 Jul 2026 18:53:49 -0300 Subject: [PATCH 2/6] PR fixes --- .../main/java/app/gamenative/PrefManager.kt | 40 ++++-- .../app/gamenative/service/SteamService.kt | 77 +++++----- .../ui/screen/library/LibraryAppScreen.kt | 6 +- .../library/appscreen/SteamAppScreen.kt | 134 +++++++++++------- app/src/main/res/values/strings.xml | 2 + 5 files changed, 158 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index e744cbcaec..f44a106d48 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1413,14 +1413,18 @@ object PrefManager { * Empty / missing entry means use the account's own copy when available. */ private val PREFERRED_FAMILY_LENDERS_JSON = stringPreferencesKey("preferred_family_lenders_json") + + private fun decodePreferredFamilyLenders(value: String): Map = + runCatching { + Json.decodeFromString>(value) + .mapNotNull { (key, lenderSteamId) -> + key.toIntOrNull()?.let { appId -> appId to lenderSteamId } + } + .toMap() + }.getOrDefault(emptyMap()) + var preferredFamilyLenders: Map - get() { - val value = getPref(PREFERRED_FAMILY_LENDERS_JSON, "{}") - return runCatching { - Json.decodeFromString>(value) - .mapKeys { it.key.toInt() } - }.getOrDefault(emptyMap()) - } + get() = decodePreferredFamilyLenders(getPref(PREFERRED_FAMILY_LENDERS_JSON, "{}")) set(value) { if (value.isEmpty()) { removePref(PREFERRED_FAMILY_LENDERS_JSON) @@ -1433,12 +1437,22 @@ object PrefManager { } fun setPreferredFamilyLender(appId: Int, lenderSteamId: Long?) { - val updated = preferredFamilyLenders.toMutableMap() - if (lenderSteamId == null || lenderSteamId == 0L) { - updated.remove(appId) - } else { - updated[appId] = lenderSteamId + scope.launch { + dataStore.edit { pref -> + val current = decodePreferredFamilyLenders(pref[PREFERRED_FAMILY_LENDERS_JSON] ?: "{}") + val updated = current.toMutableMap() + if (lenderSteamId == null || lenderSteamId == 0L) { + updated.remove(appId) + } else { + updated[appId] = lenderSteamId + } + if (updated.isEmpty()) { + pref.remove(PREFERRED_FAMILY_LENDERS_JSON) + } else { + pref[PREFERRED_FAMILY_LENDERS_JSON] = + Json.encodeToString(updated.mapKeys { it.key.toString() }) + } + } } - preferredFamilyLenders = updated } } diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 1af7fed3ee..2f9c9d67b4 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -546,22 +546,22 @@ class SteamService : Service(), IChallengeUrlChanged { val familyGroupId: Long get() = instance?.familyGroupId ?: 0L - fun hasMultiplePreferredCopyOptions(appId: Int): Boolean = + suspend fun hasMultiplePreferredCopyOptions(appId: Int): Boolean = getPreferredCopyOptions(appId).size >= 2 - fun getPreferredCopyOptions(appId: Int): List { - val svc = instance ?: return emptyList() - val selfSteamId = userSteamId?.convertToUInt64() ?: return emptyList() - val selfAccountId = userSteamId!!.accountID.toInt() + suspend fun getPreferredCopyOptions(appId: Int): List = withContext(Dispatchers.IO) { + val svc = instance ?: return@withContext emptyList() + val selfId = userSteamId ?: return@withContext emptyList() + val selfSteamId = selfId.convertToUInt64() + val selfAccountId = selfId.accountID.toInt() val ownerSteamIds = linkedSetOf() svc.familyAppOwnerSteamIds[appId]?.let { ownerSteamIds.addAll(it) } - // Also include owners from local licenses that grant this app. - val licenses = runBlocking(Dispatchers.IO) { - svc.licenseDao.getAllLicenses().filter { appId in it.appIds } - } - for (license in licenses) { + // Load licenses once; reuse for owner discovery, package lookup, and DLC counts. + val allLicenses = svc.licenseDao.getAllLicenses() + val licensesForApp = allLicenses.filter { appId in it.appIds } + for (license in licensesForApp) { for (accountId in license.ownerAccountId) { ownerSteamIds.add(SteamID(accountId.toLong(), EUniverse.Public, EAccountType.Individual).convertToUInt64()) } @@ -574,19 +574,18 @@ class SteamService : Service(), IChallengeUrlChanged { } } - if (ownerSteamIds.isEmpty()) return emptyList() + if (ownerSteamIds.isEmpty()) return@withContext emptyList() + + val dlcApps = svc.appDao.findDownloadableDLCApps(appId).orEmpty() + + svc.appDao.findHiddenDLCApps(appId).orEmpty() - return ownerSteamIds.map { steamId64 -> + ownerSteamIds.map { steamId64 -> val steamId = SteamID(steamId64) val accountId = steamId.accountID.toInt() val isSelf = steamId64 == selfSteamId || accountId == selfAccountId - val packageId = runBlocking(Dispatchers.IO) { - findLicenseForLender(appId, accountId)?.packageId - } + val packageId = findLicenseForLender(licensesForApp, accountId)?.packageId val dlcCount = if (packageId != null) { - runBlocking(Dispatchers.IO) { - countDlcForLender(appId, accountId) - } + countDlcForLender(allLicenses, accountId, dlcApps) } else { null } @@ -594,10 +593,9 @@ class SteamService : Service(), IChallengeUrlChanged { lenderSteamId = steamId64, accountId = accountId, displayName = if (isSelf) { - PrefManager.steamUserName.ifBlank { "Your copy" } + PrefManager.steamUserName } else { - svc.familyMemberNames[steamId64] - ?: "Family member" + svc.familyMemberNames[steamId64].orEmpty() }, isSelf = isSelf, packageId = packageId, @@ -606,8 +604,13 @@ class SteamService : Service(), IChallengeUrlChanged { }.sortedWith(compareByDescending { it.isSelf }.thenBy { it.displayName }) } - fun getActivePreferredCopy(appId: Int): PreferredCopyOption? { - val options = getPreferredCopyOptions(appId) + suspend fun getActivePreferredCopy(appId: Int): PreferredCopyOption? = + selectActivePreferredCopy(appId, getPreferredCopyOptions(appId)) + + fun selectActivePreferredCopy( + appId: Int, + options: List, + ): PreferredCopyOption? { if (options.isEmpty()) return null val preferredSteamId = instance?.preferredLenderByAppId?.get(appId) ?: PrefManager.preferredFamilyLenders[appId] @@ -653,9 +656,11 @@ class SteamService : Service(), IChallengeUrlChanged { true } - private suspend fun findLicenseForLender(appId: Int, lenderAccountId: Int): SteamLicense? { - val svc = instance ?: return null - val licenses = svc.licenseDao.getAllLicenses().filter { appId in it.appIds && lenderAccountId in it.ownerAccountId } + private fun findLicenseForLender( + licensesForApp: List, + lenderAccountId: Int, + ): SteamLicense? { + val licenses = licensesForApp.filter { lenderAccountId in it.ownerAccountId } if (licenses.isEmpty()) return null return licenses.maxByOrNull { license -> when { @@ -665,11 +670,12 @@ class SteamService : Service(), IChallengeUrlChanged { } } - private suspend fun countDlcForLender(appId: Int, lenderAccountId: Int): Int { - val svc = instance ?: return 0 - val licenses = svc.licenseDao.getAllLicenses().filter { lenderAccountId in it.ownerAccountId } - val dlcApps = svc.appDao.findDownloadableDLCApps(appId).orEmpty() + - svc.appDao.findHiddenDLCApps(appId).orEmpty() + private fun countDlcForLender( + allLicenses: List, + lenderAccountId: Int, + dlcApps: List, + ): Int { + val licenses = allLicenses.filter { lenderAccountId in it.ownerAccountId } return dlcApps.count { dlc -> licenses.any { dlc.id in it.appIds } } @@ -678,7 +684,8 @@ class SteamService : Service(), IChallengeUrlChanged { private suspend fun applyPreferredLenderLocally(appId: Int, lenderSteamId: Long) { val svc = instance ?: return val lenderAccountId = SteamID(lenderSteamId).accountID.toInt() - val license = findLicenseForLender(appId, lenderAccountId) + val licensesForApp = svc.licenseDao.getAllLicenses().filter { appId in it.appIds } + val license = findLicenseForLender(licensesForApp, lenderAccountId) val app = svc.appDao.findApp(appId) ?: return if (license != null) { svc.appDao.update( @@ -3941,10 +3948,8 @@ class SteamService : Service(), IChallengeUrlChanged { preferredLenderByAppId[appId] = lenderSteamId } } - // Overlay locally persisted preferences (user may have set while offline to Steam's cache). - PrefManager.preferredFamilyLenders.forEach { (appId, lender) -> - preferredLenderByAppId[appId] = lender - } + // Persist server state locally so offline reconnect can fall back to it. + PrefManager.preferredFamilyLenders = preferredLenderByAppId.toMap() Timber.i("Cached ${preferredLenderByAppId.size} preferred family lenders") } else { Timber.w("GetPreferredLenders failed: ${preferredResult.result}") diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index c22244e189..39a4dc9b9f 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.ui.semantics.Role import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -1003,7 +1004,10 @@ internal fun AppScreenContent( text = stringResource(R.string.change_preferred_copy), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.clickable(onClick = onChangePreferredCopy), + modifier = Modifier.clickable( + role = Role.Button, + onClick = onChangePreferredCopy, + ), ) } } 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 bbe66df190..daf5212493 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 @@ -43,6 +43,7 @@ import app.gamenative.PluviaApp import app.gamenative.R import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem +import app.gamenative.data.PreferredCopyOption import app.gamenative.enums.Marker import app.gamenative.enums.PathType import app.gamenative.enums.SyncResult @@ -373,16 +374,24 @@ class SteamAppScreen : BaseAppScreen() { } } - val showChangePreferredCopy = remember(gameId, preferredCopyRefresh) { - SteamService.hasMultiplePreferredCopyOptions(gameId) - } - val preferredCopyStatusText = remember(gameId, preferredCopyRefresh, showChangePreferredCopy) { - if (!showChangePreferredCopy) return@remember null - val active = SteamService.getActivePreferredCopy(gameId) ?: return@remember null - if (active.isSelf) { + var showChangePreferredCopy by remember(gameId) { mutableStateOf(false) } + var preferredCopyStatusText by remember(gameId) { mutableStateOf(null) } + LaunchedEffect(gameId, preferredCopyRefresh) { + val options = withContext(Dispatchers.IO) { + SteamService.getPreferredCopyOptions(gameId) + } + val hasMultiple = options.size >= 2 + showChangePreferredCopy = hasMultiple + val active = SteamService.selectActivePreferredCopy(gameId, options) + preferredCopyStatusText = if (!hasMultiple || active == null) { + null + } else if (active.isSelf) { context.getString(R.string.using_your_copy) } else { - context.getString(R.string.shared_from_name, active.displayName) + val name = active.displayName.ifBlank { + context.getString(R.string.preferred_copy_family_member) + } + context.getString(R.string.shared_from_name, name) } } @@ -410,9 +419,8 @@ class SteamAppScreen : BaseAppScreen() { context: Context, libraryItem: LibraryItem, ): (() -> Unit)? { - val gameId = libraryItem.gameId - if (!SteamService.hasMultiplePreferredCopyOptions(gameId)) return null - return { showPreferredCopyDialog(gameId) } + // Visibility is gated by GameDisplayInfo.showChangePreferredCopy (loaded async). + return { showPreferredCopyDialog(libraryItem.gameId) } } override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean { @@ -761,6 +769,12 @@ class SteamAppScreen : BaseAppScreen() { val appInfo = SteamService.getAppInfoOf(gameId) ?: return emptyList() val isDownloadInProgress = SteamService.getDownloadingAppInfoOf(gameId) != null val scope = rememberCoroutineScope() + var showPreferredCopyMenuOption by remember(gameId) { mutableStateOf(false) } + LaunchedEffect(gameId) { + showPreferredCopyMenuOption = withContext(Dispatchers.IO) { + SteamService.hasMultiplePreferredCopyOptions(gameId) + } + } val options = mutableListOf( AppMenuOption( @@ -853,7 +867,7 @@ class SteamAppScreen : BaseAppScreen() { ), ) - if (SteamService.hasMultiplePreferredCopyOptions(gameId)) { + if (showPreferredCopyMenuOption) { options += AppMenuOption( AppOptionMenuType.ChangePreferredCopy, onClick = { showPreferredCopyDialog(gameId) }, @@ -1586,25 +1600,38 @@ class SteamAppScreen : BaseAppScreen() { } if (showPreferredCopyDialogState) { - val options = remember(gameId) { SteamService.getPreferredCopyOptions(gameId) } - val current = remember(gameId) { SteamService.getActivePreferredCopy(gameId) } + var options by remember(gameId) { mutableStateOf>(emptyList()) } + var current by remember(gameId) { mutableStateOf(null) } val scope = rememberCoroutineScope() - SteamPreferredCopyDialog( - options = options, - currentLenderSteamId = current?.lenderSteamId, - onConfirm = { lenderSteamId -> + LaunchedEffect(gameId) { + val loaded = withContext(Dispatchers.IO) { + SteamService.getPreferredCopyOptions(gameId) + } + if (loaded.isEmpty()) { hidePreferredCopyDialog(gameId) - scope.launch { - val ok = withContext(Dispatchers.IO) { - SteamService.setPreferredCopy(gameId, lenderSteamId) - } - if (!ok) { - SnackbarManager.show(context.getString(R.string.preferred_copy_failed)) + return@LaunchedEffect + } + options = loaded + current = SteamService.selectActivePreferredCopy(gameId, loaded) + } + if (options.isNotEmpty()) { + SteamPreferredCopyDialog( + options = options, + currentLenderSteamId = current?.lenderSteamId, + onConfirm = { lenderSteamId -> + hidePreferredCopyDialog(gameId) + scope.launch { + val ok = withContext(Dispatchers.IO) { + SteamService.setPreferredCopy(gameId, lenderSteamId) + } + if (!ok) { + SnackbarManager.show(context.getString(R.string.preferred_copy_failed)) + } } - } - }, - onDismissRequest = { hidePreferredCopyDialog(gameId) }, - ) + }, + onDismissRequest = { hidePreferredCopyDialog(gameId) }, + ) + } } } } @@ -1733,7 +1760,7 @@ private fun SteamChangeBranchDialog( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SteamPreferredCopyDialog( - options: List, + options: List, currentLenderSteamId: Long?, onConfirm: (lenderSteamId: Long) -> Unit, onDismissRequest: () -> Unit, @@ -1763,18 +1790,7 @@ private fun SteamPreferredCopyDialog( expanded = expanded, onExpandedChange = { expanded = it }, ) { - val label = selectedOption?.let { option -> - val base = if (option.isSelf) { - stringResource(R.string.preferred_copy_your_label, option.displayName) - } else { - stringResource(R.string.preferred_copy_family_label, option.displayName) - } - if (option.ownedDlcCount != null) { - "$base · ${stringResource(R.string.preferred_copy_dlc_count, option.ownedDlcCount)}" - } else { - base - } - }.orEmpty() + val label = selectedOption?.let { preferredCopyOptionLabel(it) }.orEmpty() NoExtractOutlinedTextField( value = label, onValueChange = {}, @@ -1790,16 +1806,7 @@ private fun SteamPreferredCopyDialog( onDismissRequest = { expanded = false }, ) { options.forEach { option -> - val itemLabel = if (option.isSelf) { - stringResource(R.string.preferred_copy_your_label, option.displayName) - } else { - stringResource(R.string.preferred_copy_family_label, option.displayName) - } - val withDlc = if (option.ownedDlcCount != null) { - "$itemLabel · ${stringResource(R.string.preferred_copy_dlc_count, option.ownedDlcCount)}" - } else { - itemLabel - } + val withDlc = preferredCopyOptionLabel(option) DropdownMenuItem( text = { Text(withDlc) }, onClick = { @@ -1828,6 +1835,31 @@ private fun SteamPreferredCopyDialog( ) } +@Composable +private fun preferredCopyOptionLabel(option: PreferredCopyOption): String { + val displayName = option.displayName.ifBlank { + if (option.isSelf) { + stringResource(R.string.default_user_name) + } else { + stringResource(R.string.preferred_copy_family_member) + } + } + val base = if (option.isSelf) { + stringResource(R.string.preferred_copy_your_label, displayName) + } else { + stringResource(R.string.preferred_copy_family_label, displayName) + } + return if (option.ownedDlcCount != null) { + stringResource( + R.string.preferred_copy_label_with_dlc, + base, + stringResource(R.string.preferred_copy_dlc_count, option.ownedDlcCount), + ) + } else { + base + } +} + @OptIn(ExperimentalMaterial3Api::class) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f3ad9785f..065f3b5106 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -86,7 +86,9 @@ Shared from %1$s Your copy (%1$s) %1$s + Family member %1$d DLC + %1$s · %2$s Could not change preferred copy. Try again while connected to Steam. Use this copy Enter access code From 09a9e41c091b994a292c600e6b21b9c88be9437e Mon Sep 17 00:00:00 2001 From: Pastry Date: Sun, 12 Jul 2026 19:01:22 -0300 Subject: [PATCH 3/6] fix --- .../java/app/gamenative/service/SteamService.kt | 15 ++++++++++----- 1 file changed, 10 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 2f9c9d67b4..2b302a7832 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -556,14 +556,19 @@ class SteamService : Service(), IChallengeUrlChanged { val selfAccountId = selfId.accountID.toInt() val ownerSteamIds = linkedSetOf() - svc.familyAppOwnerSteamIds[appId]?.let { ownerSteamIds.addAll(it) } + val cachedOwners = svc.familyAppOwnerSteamIds[appId] + if (!cachedOwners.isNullOrEmpty()) { + ownerSteamIds.addAll(cachedOwners) + } - // Load licenses once; reuse for owner discovery, package lookup, and DLC counts. + // Load licenses once; reuse for owner discovery (fallback), package lookup, and DLC counts. val allLicenses = svc.licenseDao.getAllLicenses() val licensesForApp = allLicenses.filter { appId in it.appIds } - for (license in licensesForApp) { - for (accountId in license.ownerAccountId) { - ownerSteamIds.add(SteamID(accountId.toLong(), EUniverse.Public, EAccountType.Individual).convertToUInt64()) + if (ownerSteamIds.isEmpty()) { + for (license in licensesForApp) { + for (accountId in license.ownerAccountId) { + ownerSteamIds.add(SteamID(accountId.toLong(), EUniverse.Public, EAccountType.Individual).convertToUInt64()) + } } } From c6d7f094ecb0ee890299b9296fa39933d9997bfe Mon Sep 17 00:00:00 2001 From: Pastry Date: Thu, 16 Jul 2026 12:50:36 -0300 Subject: [PATCH 4/6] QoL --- .../java/app/gamenative/db/dao/SteamAppDao.kt | 4 + .../app/gamenative/service/SteamService.kt | 548 ++++++++++++++++-- .../app/gamenative/ui/data/GameDisplayInfo.kt | 2 + .../ui/screen/library/LibraryAppScreen.kt | 23 +- .../library/appscreen/SteamAppScreen.kt | 208 +++++-- .../app/gamenative/utils/KeyValueUtils.kt | 13 +- app/src/main/res/values/strings.xml | 1 + 7 files changed, 703 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt b/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt index ff9a1aff8a..ed9a63c62a 100644 --- a/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt @@ -171,6 +171,10 @@ interface SteamAppDao { ) suspend fun findHiddenDLCApps(appId: Int): List? + /** All local DLC rows for a parent app, regardless of license (catalog only). */ + @Query("SELECT * FROM steam_app WHERE dlc_for_app_id = :appId") + suspend fun findDlcAppsForParent(appId: Int): List + @Query("DELETE from steam_app") suspend fun deleteAll() diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 2b302a7832..99262fdd34 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -158,6 +158,8 @@ import kotlinx.coroutines.future.await import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import timber.log.Timber @@ -285,6 +287,22 @@ class SteamService : Service(), IChallengeUrlChanged { private var familyGroupId: Long = 0L /** appId → distinct owner steamId64s from GetSharedLibraryApps */ private val familyAppOwnerSteamIds: ConcurrentHashMap> = ConcurrentHashMap() + /** + * True only after a successful GetSharedLibraryApps with includeNonGames=true + * and includeExcluded=true. Games-only / excluded-filtered caches must not be + * used for preferred-copy DLC counts (Steam omits DLC unless both flags are set). + */ + @Volatile + private var familySharedLibraryReadyForDlcCounts: Boolean = false + /** Debug: SharedApp metadata from the last successful GetSharedLibraryApps. */ + private data class SharedLibraryAppMeta( + val appType: Int, + val name: String, + val excludeReason: Int, + ) + private val familySharedLibraryAppMeta: ConcurrentHashMap = ConcurrentHashMap() + /** Debug: appIds from shared library whose app_type is DLC (32). */ + private val familySharedLibraryDlcAppIds: MutableSet = ConcurrentHashMap.newKeySet() /** appId → preferred lender steamId64 from GetPreferredLenders / user choice */ private val preferredLenderByAppId: ConcurrentHashMap = ConcurrentHashMap() /** steamId64 → display name for family members when known */ @@ -334,6 +352,9 @@ class SteamService : Service(), IChallengeUrlChanged { internal var instance: SteamService? = null + /** Serializes GetSharedLibraryApps clear+refill so login and modal cannot interleave. */ + private val familySharedLibraryRefreshMutex = Mutex() + var cachedAchievements: List? = null private set var cachedAchievementsAppId: Int? = null @@ -551,74 +572,520 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun getPreferredCopyOptions(appId: Int): List = withContext(Dispatchers.IO) { val svc = instance ?: return@withContext emptyList() + // Preferred-copy UI only applies in a Steam Family; skip the expensive license scan otherwise. + if (svc.familyGroupId == 0L) return@withContext emptyList() val selfId = userSteamId ?: return@withContext emptyList() val selfSteamId = selfId.convertToUInt64() val selfAccountId = selfId.accountID.toInt() val ownerSteamIds = linkedSetOf() val cachedOwners = svc.familyAppOwnerSteamIds[appId] - if (!cachedOwners.isNullOrEmpty()) { - ownerSteamIds.addAll(cachedOwners) + val ownerSource = when { + !cachedOwners.isNullOrEmpty() -> { + ownerSteamIds.addAll(cachedOwners) + "sharedLibrary" + } + else -> null } - // Load licenses once; reuse for owner discovery (fallback), package lookup, and DLC counts. + // Load licenses once; reuse for owner discovery (fallback) and package lookup. val allLicenses = svc.licenseDao.getAllLicenses() val licensesForApp = allLicenses.filter { appId in it.appIds } - if (ownerSteamIds.isEmpty()) { + val resolvedOwnerSource = if (ownerSource == null && licensesForApp.isNotEmpty()) { for (license in licensesForApp) { for (accountId in license.ownerAccountId) { ownerSteamIds.add(SteamID(accountId.toLong(), EUniverse.Public, EAccountType.Individual).convertToUInt64()) } } + "licenses" + } else { + ownerSource } - if (ownerSteamIds.isEmpty()) { - // Fallback: active package owners - getAppInfoOf(appId)?.ownerAccountId?.forEach { accountId -> + val finalOwnerSource = if (ownerSteamIds.isEmpty()) { + // Fallback: active package owners (already on IO; avoid nested runBlocking via getAppInfoOf) + svc.appDao.findApp(appId)?.ownerAccountId?.forEach { accountId -> ownerSteamIds.add(SteamID(accountId.toLong(), EUniverse.Public, EAccountType.Individual).convertToUInt64()) } + if (ownerSteamIds.isNotEmpty()) "appOwnerAccountId" else "none" + } else { + resolvedOwnerSource ?: "unknown" } - if (ownerSteamIds.isEmpty()) return@withContext emptyList() + if (ownerSteamIds.isEmpty()) { + Timber.d( + "getPreferredCopyOptions appId=$appId owners=0 source=$finalOwnerSource " + + "sharedCached=${cachedOwners?.size ?: 0} licensesForApp=${licensesForApp.size}", + ) + return@withContext emptyList() + } - val dlcApps = svc.appDao.findDownloadableDLCApps(appId).orEmpty() + - svc.appDao.findHiddenDLCApps(appId).orEmpty() + // DLC counts stay null here; the preferred-copy modal always runs + // ensurePreferredCopyDlcCounts before treating counts as final. + val selfDisplayName = PrefManager.steamUserName - ownerSteamIds.map { steamId64 -> + val options = ownerSteamIds.map { steamId64 -> val steamId = SteamID(steamId64) val accountId = steamId.accountID.toInt() val isSelf = steamId64 == selfSteamId || accountId == selfAccountId val packageId = findLicenseForLender(licensesForApp, accountId)?.packageId - val dlcCount = if (packageId != null) { - countDlcForLender(allLicenses, accountId, dlcApps) - } else { - null - } PreferredCopyOption( lenderSteamId = steamId64, accountId = accountId, displayName = if (isSelf) { - PrefManager.steamUserName + selfDisplayName } else { svc.familyMemberNames[steamId64].orEmpty() }, isSelf = isSelf, packageId = packageId, - ownedDlcCount = dlcCount, + ownedDlcCount = null, ) }.sortedWith(compareByDescending { it.isSelf }.thenBy { it.displayName }) + + Timber.d( + "getPreferredCopyOptions appId=$appId source=$finalOwnerSource " + + "owners=${options.map { "${it.accountId}(self=${it.isSelf},pkg=${it.packageId},name=${it.displayName})" }} " + + "sharedCachedOwners=${cachedOwners.orEmpty()} licensesForApp=${licensesForApp.size}", + ) + options + } + + /** + * Resolves the parent game's DLC ID catalog (local + PICS), refreshes Family + * shared-library owners including non-games and excluded apps (DLC), ensures + * lender package license appIds are filled (viaLicense readiness), then fills + * per-lender counts from shared-library owners union local licenses. + * Always refreshes; callers should show a loading state until this returns. + * Leaves [PreferredCopyOption.ownedDlcCount] null only when the catalog is empty + * or neither ownership source is usable. + */ + suspend fun ensurePreferredCopyDlcCounts( + appId: Int, + options: List, + ): List = withContext(Dispatchers.IO) { + if (options.isEmpty()) return@withContext options + val svc = instance ?: return@withContext options.map { it.copy(ownedDlcCount = null) } + val dlcIds = resolveDlcIdsForApp(appId, allowNetwork = true) + if (dlcIds.isEmpty()) { + Timber.i("ensurePreferredCopyDlcCounts appId=$appId dlcIds=0 (catalog empty)") + return@withContext options.map { it.copy(ownedDlcCount = null) } + } + + // Steam omits DLC from GetSharedLibraryApps unless includeExcluded=true + // (e.g. AppExcluded_NonrefundableDLC). includeNonGames alone is not enough. + val refresh = refreshFamilySharedLibraryOwners( + includeNonGames = true, + includeExcluded = true, + ) + val familyOwners = refresh.owners + val sharedReady = refresh.freshSuccess || svc.familySharedLibraryReadyForDlcCounts + + // viaLicense depends on package PICS having filled SteamLicense.appIds. + // That queue races the preferred-copy modal; fill empty lender packages + // synchronously before treating counts as final (keeps the UI spinner up). + val lenderAccountIds = options.mapTo(HashSet()) { it.accountId } + val packagesFilled = ensureLenderPackageAppIdsReady(lenderAccountIds) + + val allLicenses = svc.licenseDao.getAllLicenses() + val anyLenderHasLicenses = options.any { option -> + allLicenses.any { option.accountId in it.ownerAccountId } + } + if (!sharedReady && !anyLenderHasLicenses) { + Timber.i( + "ensurePreferredCopyDlcCounts appId=$appId dlcIds=${dlcIds.size} " + + "freshSuccess=${refresh.freshSuccess} sharedReady=false noLicenses " + + "packagesFilled=$packagesFilled", + ) + return@withContext options.map { it.copy(ownedDlcCount = null) } + } + + val sharedOwnersForGame = familyOwners[appId].orEmpty() + val sharedDlcHits = dlcIds.count { familyOwners.containsKey(it) } + val licenseDlcHits = dlcIds.count { dlcId -> + allLicenses.any { dlcId in it.appIds } + } + val missingFromShared = dlcIds.filterNot { familyOwners.containsKey(it) } + val catalogInShared = dlcIds.filter { familyOwners.containsKey(it) } + + Timber.d( + "ensurePreferredCopyDlcCounts DETAIL appId=$appId " + + "baseGameSharedOwners=$sharedOwnersForGame " + + "catalogDlcIds=$dlcIds " + + "catalogInShared=$catalogInShared " + + "missingFromShared=$missingFromShared " + + "sharedLibrarySize=${familyOwners.size} " + + "sharedDlcTypeCount=${svc.familySharedLibraryDlcAppIds.size} " + + "freshSuccess=${refresh.freshSuccess} sharedReady=$sharedReady", + ) + dlcIds.forEach { dlcId -> + val sharedOwners = familyOwners[dlcId] + val licenseOwners = allLicenses + .filter { dlcId in it.appIds } + .flatMap { it.ownerAccountId } + .distinct() + val meta = svc.familySharedLibraryAppMeta[dlcId] + Timber.d( + "ensurePreferredCopyDlcCounts DLC appId=$appId dlcId=$dlcId " + + "inShared=${sharedOwners != null} sharedOwners=$sharedOwners " + + "licenseOwnerAccounts=$licenseOwners " + + "sharedMeta=${meta?.let { + "type=${it.appType} exclude=${it.excludeReason} name=${it.name}" + } ?: "n/a"}", + ) + } + + val withCounts = options.map { option -> + val lenderLicenses = allLicenses.filter { option.accountId in it.ownerAccountId } + val viaShared = if (sharedReady) { + dlcIds.filter { familyOwners[it]?.contains(option.lenderSteamId) == true } + } else { + emptyList() + } + val viaLicense = dlcIds.filter { dlcId -> lenderLicenses.any { dlcId in it.appIds } } + val count = (viaShared.toSet() + viaLicense.toSet()).size + Timber.d( + "ensurePreferredCopyDlcCounts LENDER appId=$appId " + + "accountId=${option.accountId} name=${option.displayName} self=${option.isSelf} " + + "viaShared=$viaShared viaLicense=$viaLicense count=$count " + + "lenderLicenseCount=${lenderLicenses.size}", + ) + option.copy(ownedDlcCount = count) + } + Timber.i( + "ensurePreferredCopyDlcCounts appId=$appId dlcIds=${dlcIds.size} " + + "sharedDlcHits=$sharedDlcHits licenseDlcHits=$licenseDlcHits " + + "packagesFilled=$packagesFilled " + + "freshSuccess=${refresh.freshSuccess} sharedReady=$sharedReady " + + "counts=${withCounts.map { "${it.accountId}:${it.ownedDlcCount}" }}", + ) + withCounts + } + + /** + * Ensures [SteamLicense.appIds] are populated for packages owned by [lenderAccountIds]. + * Preferred-copy DLC counts use license appIds; those are normally filled by the + * async package PICS queue, which can still be empty when the modal opens. + * Returns how many packages were updated in this call. + */ + private suspend fun ensureLenderPackageAppIdsReady(lenderAccountIds: Set): Int { + if (lenderAccountIds.isEmpty()) return 0 + val svc = instance ?: return 0 + val steamApps = svc._steamApps ?: return 0 + val pending = svc.licenseDao.getAllLicenses().filter { license -> + license.appIds.isEmpty() && + license.ownerAccountId.any { it in lenderAccountIds } + } + if (pending.isEmpty()) { + Timber.d( + "ensureLenderPackageAppIdsReady: no empty appIds for " + + "${lenderAccountIds.size} lenders", + ) + return 0 + } + Timber.i( + "ensureLenderPackageAppIdsReady: filling appIds for ${pending.size} packages " + + "(lenders=${lenderAccountIds.size})", + ) + var filled = 0 + pending.chunked(MAX_PICS_BUFFER).forEach { chunk -> + val requests = chunk.map { PICSRequest(it.packageId, it.accessToken) } + try { + val callback = steamApps.picsGetProductInfo( + apps = emptyList(), + packages = requests, + ).await() + callback.results.forEach { picsCallback -> + picsCallback.packages.values.forEach { pkg -> + val appIds = pkg.keyValues["appids"].children.map { it.asInteger() } + val depotIds = pkg.keyValues["depotids"].children.map { it.asInteger() } + svc.licenseDao.updateApps(pkg.id, appIds) + svc.licenseDao.updateDepots(pkg.id, depotIds) + // Stub rows so downloadable-DLC queries can see newly revealed apps. + appIds.forEach { appid -> + if (svc.appDao.findApp(appid) == null) { + svc.appDao.insert(SteamApp(id = appid, packageId = pkg.id)) + } + } + filled++ + } + } + } catch (e: Exception) { + Timber.w( + e, + "ensureLenderPackageAppIdsReady: PICS failed for chunk size=${chunk.size}", + ) + } + } + Timber.i("ensureLenderPackageAppIdsReady: updated $filled packages") + return filled + } + + /** + * Collects DLC app IDs for [appId] from local parent metadata and DLC rows. + * When [allowNetwork] is true, PICS-fetches the parent (with access token when + * available) and merges remote listofdlc / depot DLC ids. + */ + private suspend fun resolveDlcIdsForApp(appId: Int, allowNetwork: Boolean): Set { + val svc = instance ?: return emptySet() + val ids = linkedSetOf() + val fromListOfDlc = linkedSetOf() + val fromDepots = linkedSetOf() + val fromParentRows = linkedSetOf() + val fromLicensedRows = linkedSetOf() + val fromPics = linkedSetOf() + + fun collectFromApp(app: SteamApp?, intoListOfDlc: MutableSet, intoDepots: MutableSet) { + if (app == null) return + app.dlcAppIds.filter { it > 0 && it != INVALID_APP_ID }.forEach { + intoListOfDlc.add(it) + ids.add(it) + } + app.depots.values.forEach { depot -> + if (depot.dlcAppId != INVALID_APP_ID && depot.dlcAppId > 0) { + intoDepots.add(depot.dlcAppId) + ids.add(depot.dlcAppId) + } + } + } + + collectFromApp(svc.appDao.findApp(appId), fromListOfDlc, fromDepots) + svc.appDao.findDlcAppsForParent(appId).forEach { + fromParentRows.add(it.id) + ids.add(it.id) + } + svc.appDao.findDownloadableDLCApps(appId).orEmpty().forEach { + fromLicensedRows.add(it.id) + ids.add(it.id) + } + svc.appDao.findHiddenDLCApps(appId).orEmpty().forEach { + fromLicensedRows.add(it.id) + ids.add(it.id) + } + + if (!allowNetwork) { + Timber.d( + "resolveDlcIdsForApp appId=$appId allowNetwork=false " + + "listofdlc=$fromListOfDlc depots=$fromDepots parentRows=$fromParentRows " + + "licensedRows=$fromLicensedRows total=$ids", + ) + return ids + } + + val steamApps = svc._steamApps ?: return ids + try { + val accessToken = try { + steamApps.picsGetAccessTokens( + appIds = listOf(appId), + packageIds = emptyList(), + ).await().appTokens[appId] ?: 0L + } catch (e: Exception) { + Timber.w(e, "resolveDlcIdsForApp: access token failed for appId=$appId") + 0L + } + Timber.d("resolveDlcIdsForApp appId=$appId picsAccessToken=${accessToken != 0L}") + val pics = steamApps.picsGetProductInfo( + apps = listOf(PICSRequest(id = appId, accessToken = accessToken)), + packages = emptyList(), + ).await() + val remote = pics.results + .firstOrNull() + ?.apps + ?.values + ?.firstOrNull() + ?: run { + Timber.d("resolveDlcIdsForApp appId=$appId PICS returned no app; total=$ids") + return ids + } + val generated = remote.keyValues.generateSteamApp() + collectFromApp(generated, fromPics, fromDepots) + + // Persist so subsequent opens start with a fuller local catalog. + val existing = svc.appDao.findApp(appId) + if (existing != null) { + val mergedDlcAppIds = (existing.dlcAppIds + generated.dlcAppIds) + .filter { it > 0 && it != INVALID_APP_ID } + .distinct() + svc.appDao.insert( + existing.copy( + dlcAppIds = mergedDlcAppIds.ifEmpty { existing.dlcAppIds }, + depots = if (generated.depots.isNotEmpty()) generated.depots else existing.depots, + receivedPICS = true, + lastChangeNumber = remote.changeNumber, + ), + ) + } else { + svc.appDao.insert( + generated.copy( + receivedPICS = true, + lastChangeNumber = remote.changeNumber, + ), + ) + } + } catch (e: Exception) { + Timber.w(e, "resolveDlcIdsForApp: PICS failed for appId=$appId") + } + Timber.d( + "resolveDlcIdsForApp appId=$appId " + + "listofdlcLocal=$fromListOfDlc picsListofdlc=$fromPics depots=$fromDepots " + + "parentRows=$fromParentRows licensedRows=$fromLicensedRows total=${ids.size} ids=$ids", + ) + return ids + } + + private data class SharedLibraryRefreshResult( + val owners: Map>, + /** + * True only when this call received EResult.OK for the requested + * includeNonGames / includeExcluded flags. + */ + val freshSuccess: Boolean, + ) + + /** + * Fetches Steam Family shared-library ownership and replaces [familyAppOwnerSteamIds]. + * Pass [includeNonGames] = true and [includeExcluded] = true for preferred-copy DLC + * counts; Steam omits DLC rows unless both are set. + * On failure returns the current cache with [SharedLibraryRefreshResult.freshSuccess] false. + */ + private suspend fun refreshFamilySharedLibraryOwners( + includeNonGames: Boolean, + includeExcluded: Boolean = false, + ): SharedLibraryRefreshResult = familySharedLibraryRefreshMutex.withLock { + val svc = instance + ?: return SharedLibraryRefreshResult(emptyMap(), freshSuccess = false) + val familyGroups = svc._steamFamilyGroups + ?: return SharedLibraryRefreshResult( + svc.familyAppOwnerSteamIds.toMap(), + freshSuccess = false, + ) + if (svc.familyGroupId == 0L) { + return SharedLibraryRefreshResult(emptyMap(), freshSuccess = false) + } + + try { + val sharedRequest = SteammessagesFamilygroupsSteamclient.CFamilyGroups_GetSharedLibraryApps_Request.newBuilder().apply { + familyGroupid = svc.familyGroupId + includeOwn = true + this.includeExcluded = includeExcluded + this.includeNonGames = includeNonGames + // Omit maxApps so Steam uses its default (large Int.MAX_VALUE was speculative). + }.build() + + val sharedResult = familyGroups.getSharedLibraryApps(sharedRequest).await() + if (sharedResult.result != EResult.OK) { + Timber.w( + "GetSharedLibraryApps(includeNonGames=$includeNonGames " + + "includeExcluded=$includeExcluded) failed: ${sharedResult.result}", + ) + return SharedLibraryRefreshResult( + svc.familyAppOwnerSteamIds.toMap(), + freshSuccess = false, + ) + } + + // Full replace under the mutex so readers never see a half-cleared map from + // concurrent login + modal refreshes. + val next = ConcurrentHashMap>() + val nextMeta = ConcurrentHashMap() + val nextDlcIds = ConcurrentHashMap.newKeySet() + var appTypeGame = 0 + var appTypeDlc = 0 + var appTypeOther = 0 + val excludeReasonCounts = mutableMapOf() + sharedResult.body.appsList.forEach { sharedApp -> + if (sharedApp.ownerSteamidsCount >= 1) { + next[sharedApp.appid] = sharedApp.ownerSteamidsList.toList() + } + val appType = sharedApp.appType.number + val excludeReason = sharedApp.excludeReason.number + val name = sharedApp.name.orEmpty() + nextMeta[sharedApp.appid] = SharedLibraryAppMeta( + appType = appType, + name = name, + excludeReason = excludeReason, + ) + excludeReasonCounts[excludeReason] = (excludeReasonCounts[excludeReason] ?: 0) + 1 + when (appType) { + 1 -> appTypeGame++ + 32 -> { + appTypeDlc++ + nextDlcIds.add(sharedApp.appid) + } + else -> appTypeOther++ + } + } + svc.familyAppOwnerSteamIds.clear() + svc.familyAppOwnerSteamIds.putAll(next) + svc.familySharedLibraryAppMeta.clear() + svc.familySharedLibraryAppMeta.putAll(nextMeta) + svc.familySharedLibraryDlcAppIds.clear() + svc.familySharedLibraryDlcAppIds.addAll(nextDlcIds) + // Only mark DLC-ready when this successful response requested both flags. + svc.familySharedLibraryReadyForDlcCounts = includeNonGames && includeExcluded + val excludeHistogram = excludeReasonCounts.entries + .sortedByDescending { it.value } + .take(10) + .joinToString { "${it.key}=${it.value}" } + Timber.i( + "Cached shared library owners for ${svc.familyAppOwnerSteamIds.size} apps " + + "(includeNonGames=$includeNonGames includeExcluded=$includeExcluded " + + "game=$appTypeGame dlc=$appTypeDlc other=$appTypeOther " + + "excludeTop=[$excludeHistogram])", + ) + if (nextDlcIds.isNotEmpty()) { + Timber.d( + "GetSharedLibraryApps DLC sample (up to 30): " + + nextDlcIds.take(30).map { id -> + val meta = nextMeta[id] + "$id(type=${meta?.appType},exclude=${meta?.excludeReason}," + + "name=${meta?.name},owners=${next[id]})" + }, + ) + } else { + Timber.d( + "GetSharedLibraryApps returned 0 app_type=DLC entries " + + "(includeNonGames=$includeNonGames includeExcluded=$includeExcluded " + + "totalApps=${sharedResult.body.appsList.size})", + ) + } + SharedLibraryRefreshResult( + svc.familyAppOwnerSteamIds.toMap(), + freshSuccess = true, + ) + } catch (e: Exception) { + Timber.e( + e, + "GetSharedLibraryApps(includeNonGames=$includeNonGames " + + "includeExcluded=$includeExcluded) failed", + ) + SharedLibraryRefreshResult( + svc.familyAppOwnerSteamIds.toMap(), + freshSuccess = false, + ) + } } suspend fun getActivePreferredCopy(appId: Int): PreferredCopyOption? = selectActivePreferredCopy(appId, getPreferredCopyOptions(appId)) + /** + * Resolves the active preferred copy. Prefers the in-memory lender map; only + * falls back to [PrefManager.preferredFamilyLenders] (sync DataStore read) when + * that map has not been hydrated yet. Call from a background dispatcher. + */ fun selectActivePreferredCopy( appId: Int, options: List, ): PreferredCopyOption? { if (options.isEmpty()) return null - val preferredSteamId = instance?.preferredLenderByAppId?.get(appId) - ?: PrefManager.preferredFamilyLenders[appId] + val preferredMap = instance?.preferredLenderByAppId + val preferredSteamId = when { + preferredMap == null -> PrefManager.preferredFamilyLenders[appId] + // Empty map means not yet hydrated from network/prefs; allow PrefManager fallback. + preferredMap.isEmpty() -> PrefManager.preferredFamilyLenders[appId] + else -> preferredMap[appId] + } if (preferredSteamId != null) { options.firstOrNull { it.lenderSteamId == preferredSteamId }?.let { return it } } @@ -657,7 +1124,10 @@ class SteamService : Service(), IChallengeUrlChanged { svc.preferredLenderByAppId[appId] = lenderSteamId PrefManager.setPreferredFamilyLender(appId, lenderSteamId) applyPreferredLenderLocally(appId, lenderSteamId) - PluviaApp.events.emit(AndroidEvent.PreferredCopyChanged(appId)) + // Emit on Main so Compose listeners can safely update UI state. + withContext(Dispatchers.Main.immediate) { + PluviaApp.events.emit(AndroidEvent.PreferredCopyChanged(appId)) + } true } @@ -675,17 +1145,6 @@ class SteamService : Service(), IChallengeUrlChanged { } } - private fun countDlcForLender( - allLicenses: List, - lenderAccountId: Int, - dlcApps: List, - ): Int { - val licenses = allLicenses.filter { lenderAccountId in it.ownerAccountId } - return dlcApps.count { dlc -> - licenses.any { dlc.id in it.appIds } - } - } - private suspend fun applyPreferredLenderLocally(appId: Int, lenderSteamId: Long) { val svc = instance ?: return val lenderAccountId = SteamID(lenderSteamId).accountID.toInt() @@ -3916,25 +4375,9 @@ class SteamService : Service(), IChallengeUrlChanged { if (familyGroupId == 0L) return try { - val sharedRequest = SteammessagesFamilygroupsSteamclient.CFamilyGroups_GetSharedLibraryApps_Request.newBuilder().apply { - familyGroupid = familyGroupId - includeOwn = true - includeExcluded = false - includeNonGames = false - }.build() - - val sharedResult = familyGroups.getSharedLibraryApps(sharedRequest).await() - if (sharedResult.result == EResult.OK) { - familyAppOwnerSteamIds.clear() - sharedResult.body.appsList.forEach { sharedApp -> - if (sharedApp.ownerSteamidsCount >= 1) { - familyAppOwnerSteamIds[sharedApp.appid] = sharedApp.ownerSteamidsList.toList() - } - } - Timber.i("Cached shared library owners for ${familyAppOwnerSteamIds.size} apps") - } else { - Timber.w("GetSharedLibraryApps failed: ${sharedResult.result}") - } + // Login refresh keeps includeExcluded=false (playable shared games). + // Preferred-copy DLC counts re-fetch with includeExcluded=true on demand. + refreshFamilySharedLibraryOwners(includeNonGames = true, includeExcluded = false) } catch (e: Exception) { Timber.e(e, "GetSharedLibraryApps failed") } @@ -4055,6 +4498,9 @@ class SteamService : Service(), IChallengeUrlChanged { familyGroupId = 0L familyGroupMembers.clear() familyAppOwnerSteamIds.clear() + familySharedLibraryReadyForDlcCounts = false + familySharedLibraryAppMeta.clear() + familySharedLibraryDlcAppIds.clear() preferredLenderByAppId.clear() familyMemberNames.clear() } diff --git a/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt b/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt index 15ca89b414..d97c4ed502 100644 --- a/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt +++ b/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt @@ -26,5 +26,7 @@ data class GameDisplayInfo( /** Status line under Play for Steam Families preferred copy, e.g. "Using your copy". */ val preferredCopyStatusText: String? = null, val showChangePreferredCopy: Boolean = false, + /** True while available Family library copies are being resolved off the main thread. */ + val isLoadingPreferredCopy: Boolean = false, ) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 39a4dc9b9f..01a4913407 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -987,7 +988,27 @@ internal fun AppScreenContent( } } - if (displayInfo.showChangePreferredCopy && onChangePreferredCopy != null) { + if (displayInfo.isLoadingPreferredCopy) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = Color.White.copy(alpha = 0.8f), + ) + Text( + text = stringResource(R.string.loading_preferred_copy), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else if (displayInfo.showChangePreferredCopy && onChangePreferredCopy != null) { Spacer(modifier = Modifier.height(10.dp)) Column(modifier = Modifier.fillMaxWidth()) { displayInfo.preferredCopyStatusText?.let { status -> 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 daf5212493..3d95a920a3 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 @@ -11,15 +11,20 @@ import android.os.Environment import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox @@ -30,6 +35,7 @@ import app.gamenative.ui.component.NoExtractOutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext @@ -214,6 +220,18 @@ class SteamAppScreen : BaseAppScreen() { private val preferredCopyDialogVisibleIds = mutableStateListOf() + /** + * Snapshot state for preferred-copy UI under Play. Held on the companion so the + * change-copy dialog (separate composition) can update status immediately on confirm. + */ + private val preferredCopyUiByAppId = mutableStateMapOf() + + private data class PreferredCopyUiState( + val isLoading: Boolean = false, + val showChange: Boolean = false, + val statusText: String? = null, + ) + fun showPreferredCopyDialog(gameId: Int) { if (gameId !in preferredCopyDialogVisibleIds) preferredCopyDialogVisibleIds.add(gameId) } @@ -224,6 +242,26 @@ class SteamAppScreen : BaseAppScreen() { fun shouldShowPreferredCopyDialog(gameId: Int): Boolean = gameId in preferredCopyDialogVisibleIds + private fun preferredCopyStatusTextFor( + context: Context, + active: PreferredCopyOption?, + hasMultiple: Boolean, + ): String? { + if (!hasMultiple || active == null) return null + return if (active.isSelf) { + context.getString(R.string.using_your_copy) + } else { + val name = active.displayName.ifBlank { + context.getString(R.string.preferred_copy_family_member) + } + context.getString(R.string.shared_from_name, name) + } + } + + private fun setPreferredCopyUi(gameId: Int, state: PreferredCopyUiState) { + preferredCopyUiByAppId[gameId] = state + } + // Shared state for update/verify operation - map of gameId to AppOptionMenuType private val pendingUpdateVerifyOperations = mutableStateMapOf() @@ -361,38 +399,31 @@ class SteamAppScreen : BaseAppScreen() { gameName = appInfo.name, ) - var preferredCopyRefresh by remember(gameId) { mutableIntStateOf(0) } - DisposableEffect(gameId) { - val listener: (AndroidEvent.PreferredCopyChanged) -> Unit = { event -> - if (event.appId == gameId) { - preferredCopyRefresh++ - } - } - PluviaApp.events.on(listener) - onDispose { - PluviaApp.events.off(listener) - } - } - - var showChangePreferredCopy by remember(gameId) { mutableStateOf(false) } - var preferredCopyStatusText by remember(gameId) { mutableStateOf(null) } - LaunchedEffect(gameId, preferredCopyRefresh) { - val options = withContext(Dispatchers.IO) { - SteamService.getPreferredCopyOptions(gameId) + // Read companion Snapshot map so status recomposes when the change-copy dialog updates it. + val preferredCopyUi = preferredCopyUiByAppId[gameId] + LaunchedEffect(gameId) { + if (SteamService.familyGroupId == 0L) { + setPreferredCopyUi(gameId, PreferredCopyUiState()) + return@LaunchedEffect } - val hasMultiple = options.size >= 2 - showChangePreferredCopy = hasMultiple - val active = SteamService.selectActivePreferredCopy(gameId, options) - preferredCopyStatusText = if (!hasMultiple || active == null) { - null - } else if (active.isSelf) { - context.getString(R.string.using_your_copy) - } else { - val name = active.displayName.ifBlank { - context.getString(R.string.preferred_copy_family_member) - } - context.getString(R.string.shared_from_name, name) + setPreferredCopyUi( + gameId, + PreferredCopyUiState(isLoading = true, showChange = preferredCopyUi?.showChange == true), + ) + val (hasMultiple, active) = withContext(Dispatchers.IO) { + val options = SteamService.getPreferredCopyOptions(gameId) + val multiple = options.size >= 2 + val selected = SteamService.selectActivePreferredCopy(gameId, options) + multiple to selected } + setPreferredCopyUi( + gameId, + PreferredCopyUiState( + isLoading = false, + showChange = hasMultiple, + statusText = preferredCopyStatusTextFor(context, active, hasMultiple), + ), + ) } return GameDisplayInfo( @@ -410,8 +441,10 @@ class SteamAppScreen : BaseAppScreen() { playtimeText = playtimeText, compatibilityMessage = compatibilityMessage, compatibilityColor = compatibilityColor, - preferredCopyStatusText = preferredCopyStatusText, - showChangePreferredCopy = showChangePreferredCopy, + preferredCopyStatusText = preferredCopyUi?.statusText, + showChangePreferredCopy = preferredCopyUi?.showChange == true, + isLoadingPreferredCopy = preferredCopyUi?.isLoading == true || + (preferredCopyUi == null && SteamService.familyGroupId != 0L), ) } @@ -1602,29 +1635,75 @@ class SteamAppScreen : BaseAppScreen() { if (showPreferredCopyDialogState) { var options by remember(gameId) { mutableStateOf>(emptyList()) } var current by remember(gameId) { mutableStateOf(null) } + var isLoadingDlcCounts by remember(gameId) { mutableStateOf(true) } val scope = rememberCoroutineScope() LaunchedEffect(gameId) { - val loaded = withContext(Dispatchers.IO) { - SteamService.getPreferredCopyOptions(gameId) + val (loaded, active) = withContext(Dispatchers.IO) { + val options = SteamService.getPreferredCopyOptions(gameId) + options to SteamService.selectActivePreferredCopy(gameId, options) } if (loaded.isEmpty()) { hidePreferredCopyDialog(gameId) return@LaunchedEffect } options = loaded - current = SteamService.selectActivePreferredCopy(gameId, loaded) + current = active + // Always refresh catalog + Family shared-library ownership before treating + // DLC counts as final (provisional 0/low counts previously skipped this). + isLoadingDlcCounts = true + val withCounts = withContext(Dispatchers.IO) { + SteamService.ensurePreferredCopyDlcCounts(gameId, loaded) + } + options = withCounts + current = SteamService.selectActivePreferredCopy(gameId, withCounts) + isLoadingDlcCounts = false } if (options.isNotEmpty()) { SteamPreferredCopyDialog( options = options, currentLenderSteamId = current?.lenderSteamId, + isLoadingDlcCounts = isLoadingDlcCounts, onConfirm = { lenderSteamId -> hidePreferredCopyDialog(gameId) + val selected = options.firstOrNull { it.lenderSteamId == lenderSteamId } + // Update Play-button status immediately; do not wait for a reload cycle. + if (selected != null) { + setPreferredCopyUi( + gameId, + PreferredCopyUiState( + isLoading = false, + showChange = options.size >= 2, + statusText = preferredCopyStatusTextFor( + context, + selected, + hasMultiple = options.size >= 2, + ), + ), + ) + } scope.launch { val ok = withContext(Dispatchers.IO) { SteamService.setPreferredCopy(gameId, lenderSteamId) } if (!ok) { + // Revert to server/local selection if the RPC failed. + val (hasMultiple, active) = withContext(Dispatchers.IO) { + val refreshed = SteamService.getPreferredCopyOptions(gameId) + (refreshed.size >= 2) to + SteamService.selectActivePreferredCopy(gameId, refreshed) + } + setPreferredCopyUi( + gameId, + PreferredCopyUiState( + isLoading = false, + showChange = hasMultiple, + statusText = preferredCopyStatusTextFor( + context, + active, + hasMultiple, + ), + ), + ) SnackbarManager.show(context.getString(R.string.preferred_copy_failed)) } } @@ -1762,6 +1841,7 @@ private fun SteamChangeBranchDialog( private fun SteamPreferredCopyDialog( options: List, currentLenderSteamId: Long?, + isLoadingDlcCounts: Boolean, onConfirm: (lenderSteamId: Long) -> Unit, onDismissRequest: () -> Unit, ) { @@ -1790,13 +1870,27 @@ private fun SteamPreferredCopyDialog( expanded = expanded, onExpandedChange = { expanded = it }, ) { - val label = selectedOption?.let { preferredCopyOptionLabel(it) }.orEmpty() + val showSelectedSpinner = isLoadingDlcCounts NoExtractOutlinedTextField( - value = label, + value = selectedOption?.let { + preferredCopyOptionLabel(it, showDlcCount = !isLoadingDlcCounts) + }.orEmpty(), onValueChange = {}, readOnly = true, singleLine = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + trailingIcon = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showSelectedSpinner) { + CircularProgressIndicator( + modifier = Modifier + .padding(end = 8.dp) + .size(16.dp), + strokeWidth = 2.dp, + ) + } + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + }, modifier = Modifier .fillMaxWidth() .menuAnchor(MenuAnchorType.PrimaryNotEditable), @@ -1806,9 +1900,13 @@ private fun SteamPreferredCopyDialog( onDismissRequest = { expanded = false }, ) { options.forEach { option -> - val withDlc = preferredCopyOptionLabel(option) DropdownMenuItem( - text = { Text(withDlc) }, + text = { + PreferredCopyOptionMenuRow( + option = option, + isLoadingDlcCount = isLoadingDlcCounts, + ) + }, onClick = { selectedSteamId = option.lenderSteamId expanded = false @@ -1836,7 +1934,33 @@ private fun SteamPreferredCopyDialog( } @Composable -private fun preferredCopyOptionLabel(option: PreferredCopyOption): String { +private fun PreferredCopyOptionMenuRow( + option: PreferredCopyOption, + isLoadingDlcCount: Boolean, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = preferredCopyOptionLabel(option, showDlcCount = !isLoadingDlcCount), + modifier = Modifier.weight(1f), + ) + if (isLoadingDlcCount) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + } + } +} + +@Composable +private fun preferredCopyOptionLabel( + option: PreferredCopyOption, + showDlcCount: Boolean = true, +): String { val displayName = option.displayName.ifBlank { if (option.isSelf) { stringResource(R.string.default_user_name) @@ -1849,7 +1973,7 @@ private fun preferredCopyOptionLabel(option: PreferredCopyOption): String { } else { stringResource(R.string.preferred_copy_family_label, displayName) } - return if (option.ownedDlcCount != null) { + return if (showDlcCount && option.ownedDlcCount != null) { stringResource( R.string.preferred_copy_label_with_dlc, base, diff --git a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt index 81c6b7f77f..77e622a1cf 100644 --- a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt +++ b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt @@ -110,8 +110,7 @@ fun KeyValue.generateSteamApp(): SteamApp { homepageUrl = this["extended"]["homepage"].value.orEmpty(), gameManualUrl = this["common"]["extended"]["gamemanualurl"].value.orEmpty(), loadAllBeforeLaunch = this["common"]["extended"]["loadallbeforelaunch"].asBoolean(), - // dlcAppIds = (this["common"]["extended"]["listofdlc"].value).Split(",").Select(uint.Parse).ToArray(), - dlcAppIds = emptyList(), + dlcAppIds = parseListOfDlcAppIds(), isFreeApp = this["common"]["extended"]["isfreeapp"].asBoolean(), dlcForAppId = this["extended"]["dlcforappid"].asInteger(this["common"]["extended"]["dlcforappid"].asInteger()), mustOwnAppToPurchase = this["common"]["extended"]["mustownapptopurchase"].asInteger(), @@ -257,6 +256,16 @@ fun List.generateManifest(): Map = associate { m ) } +/** Parses Steam's comma-separated `listofdlc` extended field into app IDs. */ +private fun KeyValue.parseListOfDlcAppIds(): List { + val raw = this["extended"]["listofdlc"].value + ?: this["common"]["extended"]["listofdlc"].value + ?: return emptyList() + return raw.split(',') + .mapNotNull { it.trim().toIntOrNull() } + .filter { it > 0 && it != INVALID_APP_ID } +} + fun List.toLangImgMap(): Map = mapNotNull { kv -> Language.from(kv.name!!) .takeIf { it != Language.unknown } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 065f3b5106..b13421ce37 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,7 @@ Download alternate game versions from Steam using developer-provided branches. Enter access codes to unlock private branches. Change preferred copy Choose which Steam Family member\'s copy to play. Use a family member\'s copy to access their DLC. + Checking Family library copies… Using your copy Shared from %1$s Your copy (%1$s) From 58b744bbc1c3bc6038ac622d9bb9ef40d6a6d463 Mon Sep 17 00:00:00 2001 From: Pastry Date: Thu, 16 Jul 2026 21:05:32 -0300 Subject: [PATCH 5/6] Merge and fixes --- .../java/app/gamenative/db/dao/SteamAppDao.kt | 6 +-- .../app/gamenative/service/SteamService.kt | 41 ++++++++++++------- .../library/appscreen/SteamAppScreen.kt | 11 +++-- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt b/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt index ed9a63c62a..478ac491fe 100644 --- a/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt @@ -171,9 +171,9 @@ interface SteamAppDao { ) suspend fun findHiddenDLCApps(appId: Int): List? - /** All local DLC rows for a parent app, regardless of license (catalog only). */ - @Query("SELECT * FROM steam_app WHERE dlc_for_app_id = :appId") - suspend fun findDlcAppsForParent(appId: Int): List + /** All local DLC app IDs for a parent app, regardless of license (catalog only). */ + @Query("SELECT id FROM steam_app WHERE dlc_for_app_id = :appId") + suspend fun findDlcAppIdsForParent(appId: Int): List @Query("DELETE from steam_app") suspend fun deleteAll() diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 37ccbf5f12..835da25c63 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -154,6 +154,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.filter @@ -289,6 +290,12 @@ class SteamService : Service(), IChallengeUrlChanged { // The current shared family group the logged in user is joined to. private var familyGroupMembers: ArrayList = arrayListOf() private var familyGroupId: Long = 0L + + private fun setFamilyGroupId(id: Long) { + familyGroupId = id + _familyGroupIdFlow.value = id + } + /** appId → distinct owner steamId64s from GetSharedLibraryApps */ private val familyAppOwnerSteamIds: ConcurrentHashMap> = ConcurrentHashMap() /** @@ -572,6 +579,10 @@ class SteamService : Service(), IChallengeUrlChanged { val familyGroupId: Long get() = instance?.familyGroupId ?: 0L + /** Observable family group id; updates when LoggedOn hydrates (or clears) family sharing. */ + private val _familyGroupIdFlow = MutableStateFlow(0L) + val familyGroupIdFlow: StateFlow = _familyGroupIdFlow.asStateFlow() + suspend fun hasMultiplePreferredCopyOptions(appId: Int): Boolean = getPreferredCopyOptions(appId).size >= 2 @@ -693,13 +704,17 @@ class SteamService : Service(), IChallengeUrlChanged { val packagesFilled = ensureLenderPackageAppIdsReady(lenderAccountIds) val allLicenses = svc.licenseDao.getAllLicenses() - val anyLenderHasLicenses = options.any { option -> - allLicenses.any { option.accountId in it.ownerAccountId } + // License fallback only counts when appIds were filled (package PICS). Empty appIds + // would otherwise yield a fake "0 DLC" after shared-library refresh failure. + val anyLenderHasPopulatedLicenses = options.any { option -> + allLicenses.any { + option.accountId in it.ownerAccountId && it.appIds.isNotEmpty() + } } - if (!sharedReady && !anyLenderHasLicenses) { + if (!sharedReady && !anyLenderHasPopulatedLicenses) { Timber.i( "ensurePreferredCopyDlcCounts appId=$appId dlcIds=${dlcIds.size} " + - "freshSuccess=${refresh.freshSuccess} sharedReady=false noLicenses " + + "freshSuccess=${refresh.freshSuccess} sharedReady=false noPopulatedLicenses " + "packagesFilled=$packagesFilled", ) return@withContext options.map { it.copy(ownedDlcCount = null) } @@ -806,12 +821,6 @@ class SteamService : Service(), IChallengeUrlChanged { val depotIds = pkg.keyValues["depotids"].children.map { it.asInteger() } svc.licenseDao.updateApps(pkg.id, appIds) svc.licenseDao.updateDepots(pkg.id, depotIds) - // Stub rows so downloadable-DLC queries can see newly revealed apps. - appIds.forEach { appid -> - if (svc.appDao.findApp(appid) == null) { - svc.appDao.insert(SteamApp(id = appid, packageId = pkg.id)) - } - } filled++ } } @@ -855,9 +864,9 @@ class SteamService : Service(), IChallengeUrlChanged { } collectFromApp(svc.appDao.findApp(appId), fromListOfDlc, fromDepots) - svc.appDao.findDlcAppsForParent(appId).forEach { - fromParentRows.add(it.id) - ids.add(it.id) + svc.appDao.findDlcAppIdsForParent(appId).forEach { + fromParentRows.add(it) + ids.add(it) } svc.appDao.findDownloadableDLCApps(appId).orEmpty().forEach { fromLicensedRows.add(it.id) @@ -4280,6 +4289,7 @@ class SteamService : Service(), IChallengeUrlChanged { isConnected = false isLoggingOut = false isWaitingForQRAuth = false + setFamilyGroupId(0L) steamClient = null _steamUser = null @@ -4477,9 +4487,10 @@ class SteamService : Service(), IChallengeUrlChanged { steamCollectionsJob = scope.launch { fetchSteamCollections() } // Request family share info if we have a familyGroupId. + // Set id synchronously so UI can observe hydration before the RPC finishes. if (callback.familyGroupId != 0L) { + setFamilyGroupId(callback.familyGroupId) scope.launch { - familyGroupId = callback.familyGroupId val request = SteammessagesFamilygroupsSteamclient.CFamilyGroups_GetFamilyGroup_Request.newBuilder().apply { familyGroupid = callback.familyGroupId }.build() @@ -4506,7 +4517,7 @@ class SteamService : Service(), IChallengeUrlChanged { refreshFamilyPreferredCopyData() } } else { - familyGroupId = 0L + setFamilyGroupId(0L) familyGroupMembers.clear() familyAppOwnerSteamIds.clear() familySharedLibraryReadyForDlcCounts = false 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 61fc513990..6070c129a6 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 @@ -401,8 +401,10 @@ class SteamAppScreen : BaseAppScreen() { // Read companion Snapshot map so status recomposes when the change-copy dialog updates it. val preferredCopyUi = preferredCopyUiByAppId[gameId] - LaunchedEffect(gameId) { - if (SteamService.familyGroupId == 0L) { + // familyGroupId is set asynchronously on LoggedOn; collect so load restarts when it hydrates. + val familyGroupId by SteamService.familyGroupIdFlow.collectAsState() + LaunchedEffect(gameId, familyGroupId) { + if (familyGroupId == 0L) { setPreferredCopyUi(gameId, PreferredCopyUiState()) return@LaunchedEffect } @@ -444,7 +446,7 @@ class SteamAppScreen : BaseAppScreen() { preferredCopyStatusText = preferredCopyUi?.statusText, showChangePreferredCopy = preferredCopyUi?.showChange == true, isLoadingPreferredCopy = preferredCopyUi?.isLoading == true || - (preferredCopyUi == null && SteamService.familyGroupId != 0L), + (preferredCopyUi == null && familyGroupId != 0L), ) } @@ -1647,7 +1649,8 @@ class SteamAppScreen : BaseAppScreen() { var options by remember(gameId) { mutableStateOf>(emptyList()) } var current by remember(gameId) { mutableStateOf(null) } var isLoadingDlcCounts by remember(gameId) { mutableStateOf(true) } - val scope = rememberCoroutineScope() + // Use AdditionalDialogs' scope (above), not a dialog-branch scope: hidePreferredCopyDialog + // leaves this branch and would cancel save/rollback mid-flight after optimistic UI update. LaunchedEffect(gameId) { val (loaded, active) = withContext(Dispatchers.IO) { val options = SteamService.getPreferredCopyOptions(gameId) From bfdcfb7b5906bc483ae8aedbfe5458145d491e3d Mon Sep 17 00:00:00 2001 From: Pastry Date: Thu, 16 Jul 2026 21:21:29 -0300 Subject: [PATCH 6/6] fix(family): reload preferred copy after hydrate familyGroupId emits before owner caches fill; bump a version so Change Preferred Copy reappears after refresh. Co-authored-by: Cursor --- .../app/gamenative/service/SteamService.kt | 23 +++++++++++++++++++ .../library/appscreen/SteamAppScreen.kt | 9 +++++--- 2 files changed, 29 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 835da25c63..37371b4d64 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -296,6 +296,10 @@ class SteamService : Service(), IChallengeUrlChanged { _familyGroupIdFlow.value = id } + private fun bumpFamilyPreferredCopyDataVersion() { + _familyPreferredCopyDataVersion.update { it + 1 } + } + /** appId → distinct owner steamId64s from GetSharedLibraryApps */ private val familyAppOwnerSteamIds: ConcurrentHashMap> = ConcurrentHashMap() /** @@ -583,6 +587,14 @@ class SteamService : Service(), IChallengeUrlChanged { private val _familyGroupIdFlow = MutableStateFlow(0L) val familyGroupIdFlow: StateFlow = _familyGroupIdFlow.asStateFlow() + /** + * Bumps when family preferred-copy caches finish refreshing (owners, preferred lenders). + * [familyGroupIdFlow] alone is not enough: the id is set before those RPCs complete, + * and StateFlow will not re-emit an unchanged id when a later refresh fills the caches. + */ + private val _familyPreferredCopyDataVersion = MutableStateFlow(0) + val familyPreferredCopyDataVersion: StateFlow = _familyPreferredCopyDataVersion.asStateFlow() + suspend fun hasMultiplePreferredCopyOptions(appId: Int): Boolean = getPreferredCopyOptions(appId).size >= 2 @@ -4290,6 +4302,14 @@ class SteamService : Service(), IChallengeUrlChanged { isLoggingOut = false isWaitingForQRAuth = false setFamilyGroupId(0L) + familyGroupMembers.clear() + familyAppOwnerSteamIds.clear() + familySharedLibraryReadyForDlcCounts = false + familySharedLibraryAppMeta.clear() + familySharedLibraryDlcAppIds.clear() + preferredLenderByAppId.clear() + familyMemberNames.clear() + bumpFamilyPreferredCopyDataVersion() steamClient = null _steamUser = null @@ -4449,6 +4469,8 @@ class SteamService : Service(), IChallengeUrlChanged { } applyAllCachedPreferredLenders() + // Notify UI after caches are filled; familyGroupId was already set before this RPC. + bumpFamilyPreferredCopyDataVersion() } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -4525,6 +4547,7 @@ class SteamService : Service(), IChallengeUrlChanged { familySharedLibraryDlcAppIds.clear() preferredLenderByAppId.clear() familyMemberNames.clear() + bumpFamilyPreferredCopyDataVersion() } picsChangesCheckerJob = continuousPICSChangesChecker() 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 6070c129a6..e19d474972 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 @@ -401,9 +401,10 @@ class SteamAppScreen : BaseAppScreen() { // Read companion Snapshot map so status recomposes when the change-copy dialog updates it. val preferredCopyUi = preferredCopyUiByAppId[gameId] - // familyGroupId is set asynchronously on LoggedOn; collect so load restarts when it hydrates. + // familyGroupId flips early on LoggedOn; dataVersion bumps after shared-library refresh. val familyGroupId by SteamService.familyGroupIdFlow.collectAsState() - LaunchedEffect(gameId, familyGroupId) { + val familyPreferredCopyDataVersion by SteamService.familyPreferredCopyDataVersion.collectAsState() + LaunchedEffect(gameId, familyGroupId, familyPreferredCopyDataVersion) { if (familyGroupId == 0L) { setPreferredCopyUi(gameId, PreferredCopyUiState()) return@LaunchedEffect @@ -804,8 +805,10 @@ class SteamAppScreen : BaseAppScreen() { val appInfo = SteamService.getAppInfoOf(gameId) ?: return emptyList() val isDownloadInProgress = SteamService.getDownloadingAppInfoOf(gameId) != null val scope = rememberCoroutineScope() + val familyGroupId by SteamService.familyGroupIdFlow.collectAsState() + val familyPreferredCopyDataVersion by SteamService.familyPreferredCopyDataVersion.collectAsState() var showPreferredCopyMenuOption by remember(gameId) { mutableStateOf(false) } - LaunchedEffect(gameId) { + LaunchedEffect(gameId, familyGroupId, familyPreferredCopyDataVersion) { showPreferredCopyMenuOption = withContext(Dispatchers.IO) { SteamService.hasMultiplePreferredCopyOptions(gameId) }