diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 75dcbc181c..77212bcdc8 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) } } } @@ -1446,4 +1447,52 @@ 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") + + 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() = decodePreferredFamilyLenders(getPref(PREFERRED_FAMILY_LENDERS_JSON, "{}")) + 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?) { + 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() }) + } + } + } + } } 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/db/dao/SteamAppDao.kt b/app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt index ff9a1aff8a..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,6 +171,10 @@ interface SteamAppDao { ) suspend fun findHiddenDLCApps(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/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 2dfaa0e278..37371b4d64 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.SteammessagesCloudconfigstoreSteamclient @@ -151,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 @@ -159,6 +163,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 @@ -283,6 +289,39 @@ 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 + } + + private fun bumpFamilyPreferredCopyDataVersion() { + _familyPreferredCopyDataVersion.update { it + 1 } + } + + /** 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 */ + private val familyMemberNames: ConcurrentHashMap = ConcurrentHashMap() private val appTokens: ConcurrentHashMap = ConcurrentHashMap() @@ -329,6 +368,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 @@ -538,6 +580,632 @@ class SteamService : Service(), IChallengeUrlChanged { val familyMembers: List get() = instance?.familyGroupMembers ?: emptyList() + 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() + + /** + * 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 + + 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] + val ownerSource = when { + !cachedOwners.isNullOrEmpty() -> { + ownerSteamIds.addAll(cachedOwners) + "sharedLibrary" + } + else -> null + } + + // Load licenses once; reuse for owner discovery (fallback) and package lookup. + val allLicenses = svc.licenseDao.getAllLicenses() + val licensesForApp = allLicenses.filter { appId in it.appIds } + 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 + } + + 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()) { + Timber.d( + "getPreferredCopyOptions appId=$appId owners=0 source=$finalOwnerSource " + + "sharedCached=${cachedOwners?.size ?: 0} licensesForApp=${licensesForApp.size}", + ) + return@withContext emptyList() + } + + // DLC counts stay null here; the preferred-copy modal always runs + // ensurePreferredCopyDlcCounts before treating counts as final. + val selfDisplayName = PrefManager.steamUserName + + 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 + PreferredCopyOption( + lenderSteamId = steamId64, + accountId = accountId, + displayName = if (isSelf) { + selfDisplayName + } else { + svc.familyMemberNames[steamId64].orEmpty() + }, + isSelf = isSelf, + packageId = packageId, + 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() + // 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 && !anyLenderHasPopulatedLicenses) { + Timber.i( + "ensurePreferredCopyDlcCounts appId=$appId dlcIds=${dlcIds.size} " + + "freshSuccess=${refresh.freshSuccess} sharedReady=false noPopulatedLicenses " + + "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) + 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.findDlcAppIdsForParent(appId).forEach { + fromParentRows.add(it) + ids.add(it) + } + 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 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 } + } + 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) + // Emit on Main so Compose listeners can safely update UI state. + withContext(Dispatchers.Main.immediate) { + PluviaApp.events.emit(AndroidEvent.PreferredCopyChanged(appId)) + } + true + } + + 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 { + ELicenseFlags.Expired in license.licenseFlags -> 0 + else -> 1 + } + } + } + + private suspend fun applyPreferredLenderLocally(appId: Int, lenderSteamId: Long) { + val svc = instance ?: return + val lenderAccountId = SteamID(lenderSteamId).accountID.toInt() + 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( + 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 @@ -2335,14 +3003,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(), @@ -3627,6 +4301,15 @@ class SteamService : Service(), IChallengeUrlChanged { isConnected = false 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 @@ -3725,6 +4408,71 @@ class SteamService : Service(), IChallengeUrlChanged { } } + private suspend fun refreshFamilyPreferredCopyData() { + val familyGroups = _steamFamilyGroups ?: return + if (familyGroupId == 0L) return + + try { + // 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") + } + + 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 + } + } + // 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}") + 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() + // Notify UI after caches are filled; familyGroupId was already set before this RPC. + bumpFamilyPreferredCopyDataVersion() + } + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) private fun onLoggedOn(callback: LoggedOnCallback) { Timber.i("Logged onto Steam: ${callback.result}") @@ -3761,7 +4509,9 @@ 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 { val request = SteammessagesFamilygroupsSteamclient.CFamilyGroups_GetFamilyGroup_Request.newBuilder().apply { familyGroupid = callback.familyGroupId @@ -3777,12 +4527,27 @@ 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 { + setFamilyGroupId(0L) + familyGroupMembers.clear() + familyAppOwnerSteamIds.clear() + familySharedLibraryReadyForDlcCounts = false + familySharedLibraryAppMeta.clear() + familySharedLibraryDlcAppIds.clear() + preferredLenderByAppId.clear() + familyMemberNames.clear() + bumpFamilyPreferredCopyDataVersion() } picsChangesCheckerJob = continuousPICSChangesChecker() @@ -4020,6 +4785,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 { @@ -4216,6 +4986,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() + } } } @@ -4448,13 +5228,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() } @@ -4476,13 +5272,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..d97c4ed502 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,10 @@ 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, + /** 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/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 884332a57d..0cce6d10b1 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -99,6 +99,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) } @@ -273,6 +283,7 @@ class LibraryViewModel @Inject constructor( } PluviaApp.events.on(onInstallStatusChanged) + PluviaApp.events.on(onPreferredCopyChanged) PluviaApp.events.on(onCustomGameImagesFetched) PluviaApp.events.on(onRecommendationToggleChanged) @@ -308,6 +319,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..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 @@ -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 @@ -61,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 @@ -570,6 +572,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 +987,51 @@ internal fun AppScreenContent( } } } + + 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 -> + 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( + role = Role.Button, + 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 0d5e3c987e..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 @@ -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 @@ -43,6 +49,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 @@ -211,6 +218,50 @@ class SteamAppScreen : BaseAppScreen() { fun shouldShowBranchDialog(gameId: Int): Boolean = gameId in branchDialogVisibleIds + 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) + } + + fun hidePreferredCopyDialog(gameId: Int) { + preferredCopyDialogVisibleIds.remove(gameId) + } + + 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() @@ -348,6 +399,36 @@ class SteamAppScreen : BaseAppScreen() { gameName = appInfo.name, ) + // Read companion Snapshot map so status recomposes when the change-copy dialog updates it. + val preferredCopyUi = preferredCopyUiByAppId[gameId] + // familyGroupId flips early on LoggedOn; dataVersion bumps after shared-library refresh. + val familyGroupId by SteamService.familyGroupIdFlow.collectAsState() + val familyPreferredCopyDataVersion by SteamService.familyPreferredCopyDataVersion.collectAsState() + LaunchedEffect(gameId, familyGroupId, familyPreferredCopyDataVersion) { + if (familyGroupId == 0L) { + setPreferredCopyUi(gameId, PreferredCopyUiState()) + return@LaunchedEffect + } + 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( name = appInfo.name, developer = appInfo.developer, @@ -363,9 +444,21 @@ class SteamAppScreen : BaseAppScreen() { playtimeText = playtimeText, compatibilityMessage = compatibilityMessage, compatibilityColor = compatibilityColor, + preferredCopyStatusText = preferredCopyUi?.statusText, + showChangePreferredCopy = preferredCopyUi?.showChange == true, + isLoadingPreferredCopy = preferredCopyUi?.isLoading == true || + (preferredCopyUi == null && familyGroupId != 0L), ) } + override fun onChangePreferredCopyClick( + context: Context, + libraryItem: LibraryItem, + ): (() -> Unit)? { + // Visibility is gated by GameDisplayInfo.showChangePreferredCopy (loaded async). + return { showPreferredCopyDialog(libraryItem.gameId) } + } + override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean { return SteamService.isAppInstalled(libraryItem.gameId) } @@ -712,6 +805,14 @@ 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, familyGroupId, familyPreferredCopyDataVersion) { + showPreferredCopyMenuOption = withContext(Dispatchers.IO) { + SteamService.hasMultiplePreferredCopyOptions(gameId) + } + } val options = mutableListOf( AppMenuOption( @@ -802,6 +903,16 @@ class SteamAppScreen : BaseAppScreen() { ) }, ), + ) + + if (showPreferredCopyMenuOption) { + options += AppMenuOption( + AppOptionMenuType.ChangePreferredCopy, + onClick = { showPreferredCopyDialog(gameId) }, + ) + } + + options += listOf( AppMenuOption( AppOptionMenuType.ForceCloudSync, onClick = { @@ -1527,6 +1638,97 @@ 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) { + var options by remember(gameId) { mutableStateOf>(emptyList()) } + var current by remember(gameId) { mutableStateOf(null) } + var isLoadingDlcCounts by remember(gameId) { mutableStateOf(true) } + // 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) + options to SteamService.selectActivePreferredCopy(gameId, options) + } + if (loaded.isEmpty()) { + hidePreferredCopyDialog(gameId) + return@LaunchedEffect + } + options = 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)) + } + } + }, + onDismissRequest = { hidePreferredCopyDialog(gameId) }, + ) + } + } } } @@ -1651,6 +1853,154 @@ private fun SteamChangeBranchDialog( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SteamPreferredCopyDialog( + options: List, + currentLenderSteamId: Long?, + isLoadingDlcCounts: Boolean, + 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 showSelectedSpinner = isLoadingDlcCounts + NoExtractOutlinedTextField( + value = selectedOption?.let { + preferredCopyOptionLabel(it, showDlcCount = !isLoadingDlcCounts) + }.orEmpty(), + onValueChange = {}, + readOnly = true, + singleLine = true, + 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), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { + PreferredCopyOptionMenuRow( + option = option, + isLoadingDlcCount = isLoadingDlcCounts, + ) + }, + 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)) + } + }, + ) +} + +@Composable +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) + } 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 (showDlcCount && 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/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/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 955d1b1e60..9af812f857 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,6 +80,18 @@ 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. + Checking Family library copies… + Using your copy + 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 Check Code Branch unlocked!