Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/main/java/app/gamenative/db/dao/DbConstants.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package app.gamenative.db.dao

const val SQLITE_MAX_VARS = 999
14 changes: 14 additions & 0 deletions app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ interface SteamAppDao {
@Query("SELECT * FROM steam_app WHERE id = :appId")
suspend fun findApp(appId: Int): SteamApp?

@Query("SELECT * FROM steam_app WHERE id IN (:appIds)")
suspend fun _findApps(appIds: List<Int>): List<SteamApp>

@Transaction
suspend fun findApps(appIds: List<Int>): List<SteamApp> {
if (appIds.isEmpty()) return emptyList()
val results = mutableListOf<SteamApp>()
for (chunkStart in appIds.indices step SQLITE_MAX_VARS) {
val chunkEnd = minOf(chunkStart + SQLITE_MAX_VARS, appIds.size)
results += _findApps(appIds.subList(chunkStart, chunkEnd))
}
return results
}

@Query("SELECT * FROM steam_app AS app WHERE dlc_for_app_id = :appId AND depots <> '{}' AND " +
" EXISTS (" +
" SELECT * FROM steam_license AS license " +
Expand Down
2 changes: 0 additions & 2 deletions app/src/main/java/app/gamenative/db/dao/SteamLicenseDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import androidx.room.Update
import app.gamenative.data.SteamLicense
import kotlin.math.min

val SQLITE_MAX_VARS = 999

@Dao
interface SteamLicenseDao {

Expand Down
122 changes: 112 additions & 10 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ import `in`.dragonbra.javasteam.depotdownloader.data.AppItem
import `in`.dragonbra.javasteam.depotdownloader.data.DownloadItem
import `in`.dragonbra.javasteam.enums.EDepotFileFlag
import `in`.dragonbra.javasteam.enums.ELicenseFlags
import `in`.dragonbra.javasteam.enums.ELicenseType
import `in`.dragonbra.javasteam.enums.EOSType
import `in`.dragonbra.javasteam.enums.EPaymentMethod
import `in`.dragonbra.javasteam.enums.EPersonaState
import `in`.dragonbra.javasteam.enums.EResult
import `in`.dragonbra.javasteam.networking.steam3.ProtocolTypes
Expand Down Expand Up @@ -357,6 +359,70 @@ class SteamService : Service(), IChallengeUrlChanged {
}
}

internal fun selectPreferredPackageId(
existingPackageId: Int,
incomingPackageId: Int,
licensesByPackageId: Map<Int, SteamLicense>,
): Int {
if (existingPackageId == INVALID_PKG_ID) return incomingPackageId
if (incomingPackageId == INVALID_PKG_ID) return existingPackageId

val existingPriority = packageSelectionPriority(existingPackageId, licensesByPackageId[existingPackageId])
val incomingPriority = packageSelectionPriority(incomingPackageId, licensesByPackageId[incomingPackageId])

return if (incomingPriority > existingPriority) incomingPackageId else existingPackageId
}

private data class PackageSelectionPriority(
val hasMetadata: Boolean,
val isNonExpired: Boolean,
val isDurable: Boolean,
val timeCreatedMs: Long,
val packageId: Int,
) : Comparable<PackageSelectionPriority> {
override fun compareTo(other: PackageSelectionPriority): Int =
compareValuesBy(
this,
other,
PackageSelectionPriority::hasMetadata,
PackageSelectionPriority::isNonExpired,
PackageSelectionPriority::isDurable,
PackageSelectionPriority::timeCreatedMs,
PackageSelectionPriority::packageId,
)
}

private fun packageSelectionPriority(
packageId: Int,
license: SteamLicense?,
): PackageSelectionPriority = PackageSelectionPriority(
hasMetadata = license != null,
isNonExpired = license?.licenseFlags?.contains(ELicenseFlags.Expired) != true,
isDurable = license?.let(::isDurableLicense) == true,
timeCreatedMs = license?.timeCreated?.time ?: Long.MIN_VALUE,
packageId = packageId,
)

private fun isDurableLicense(license: SteamLicense): Boolean {
val limitedUseLicense = when (license.licenseType) {
ELicenseType.SinglePurchaseLimitedUse,
ELicenseType.RecurringChargeLimitedUse,
ELicenseType.RecurringChargeLimitedUseWithOverages,
ELicenseType.LimitedUseDelayedActivation -> true
else -> false
}

val temporaryPaymentMethod = when (license.paymentMethod) {
EPaymentMethod.GuestPass,
EPaymentMethod.Promotional,
EPaymentMethod.AutoGrant,
EPaymentMethod.Complimentary -> true
else -> false
}

return !limitedUseLicense && !temporaryPaymentMethod
}

/** Returns true if there is an incomplete download on disk (no complete marker). */
fun hasPartialDownload(appId: Int): Boolean {
if (workshopPausedApps.contains(appId)) return true
Expand Down Expand Up @@ -3895,25 +3961,61 @@ class SteamService : Service(), IChallengeUrlChanged {
val queue = Collections.synchronizedList(mutableListOf<Int>())

db.withTransaction {
picsCallback.packages.values.forEach { pkg ->
val appIds = pkg.keyValues["appids"].children.map { it.asInteger() }
licenseDao.updateApps(pkg.id, appIds)
data class PackageAppsAndDepots(
val packageId: Int,
val appIds: List<Int>,
val depotIds: List<Int>,
)

val depotIds = pkg.keyValues["depotids"].children.map { it.asInteger() }
licenseDao.updateDepots(pkg.id, depotIds)
val packageData = picsCallback.packages.values.map { pkg ->
PackageAppsAndDepots(
packageId = pkg.id,
appIds = pkg.keyValues["appids"].children.map { it.asInteger() },
depotIds = pkg.keyValues["depotids"].children.map { it.asInteger() },
)
}

val appIdsInBatch = packageData
.flatMap { it.appIds }
.distinct()
val appsById = appDao.findApps(appIdsInBatch)
.associateBy { it.id }
.toMutableMap()
val existingPackageIds = appsById.values
.map { it.packageId }
.filter { it != INVALID_PKG_ID }
val packageIdsNeedingMetadata = (packageData.map { it.packageId } + existingPackageIds).distinct()
val licensesByPackageId = licenseDao.findLicenses(packageIdsNeedingMetadata).associateBy { it.packageId }

packageData.forEach { pkg ->
licenseDao.updateApps(pkg.packageId, pkg.appIds)
licenseDao.updateDepots(pkg.packageId, pkg.depotIds)

// Insert a stub row (or update) of SteamApps to the database.
appIds.forEach { appid ->
val steamApp = appDao.findApp(appid)?.copy(packageId = pkg.id)
// Steam can report multiple packages for the same app. Choose a
// stable winner so temporary licenses do not overwrite durable
// purchases based on callback iteration order.
pkg.appIds.forEach { appid ->
val steamApp = appsById[appid]
if (steamApp != null) {
appDao.update(steamApp)
val selectedPackageId = selectPreferredPackageId(
existingPackageId = steamApp.packageId,
incomingPackageId = pkg.packageId,
licensesByPackageId = licensesByPackageId,
)
if (selectedPackageId != steamApp.packageId) {
val updatedSteamApp = steamApp.copy(packageId = selectedPackageId)
appDao.update(updatedSteamApp)
appsById[appid] = updatedSteamApp
}
} else {
val stubSteamApp = SteamApp(id = appid, packageId = pkg.id)
val stubSteamApp = SteamApp(id = appid, packageId = pkg.packageId)
appDao.insert(stubSteamApp)
appsById[appid] = stubSteamApp
}
}

queue.addAll(appIds)
queue.addAll(pkg.appIds)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package app.gamenative.service

import app.gamenative.data.SteamLicense
import `in`.dragonbra.javasteam.enums.ELicenseFlags
import `in`.dragonbra.javasteam.enums.ELicenseType
import `in`.dragonbra.javasteam.enums.EPaymentMethod
import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.Date
import java.util.EnumSet

class SteamPackageSelectionTest {

private fun makeLicense(
packageId: Int,
createdAtMs: Long,
flags: EnumSet<ELicenseFlags> = EnumSet.of(ELicenseFlags.None),
paymentMethod: EPaymentMethod = EPaymentMethod.CreditCard,
licenseType: ELicenseType = ELicenseType.SinglePurchase,
) = SteamLicense(
packageId = packageId,
lastChangeNumber = 0,
timeCreated = Date(createdAtMs),
timeNextProcess = Date(createdAtMs),
minuteLimit = 0,
minutesUsed = 0,
paymentMethod = paymentMethod,
licenseFlags = flags,
purchaseCode = "",
licenseType = licenseType,
territoryCode = 0,
accessToken = 0L,
ownerAccountId = listOf(1),
masterPackageID = 0,
)

@Test
fun `durable purchase beats active guest pass`() {
val guestPass = makeLicense(
packageId = 100,
createdAtMs = 2_000L,
paymentMethod = EPaymentMethod.GuestPass,
licenseType = ELicenseType.SinglePurchaseLimitedUse,
)
val purchased = makeLicense(
packageId = 200,
createdAtMs = 1_000L,
paymentMethod = EPaymentMethod.CreditCard,
licenseType = ELicenseType.SinglePurchase,
)

val selected = SteamService.selectPreferredPackageId(
existingPackageId = purchased.packageId,
incomingPackageId = guestPass.packageId,
licensesByPackageId = mapOf(
guestPass.packageId to guestPass,
purchased.packageId to purchased,
),
)

assertEquals(purchased.packageId, selected)
}

@Test
fun `active temporary package beats expired purchase`() {
val expiredPurchase = makeLicense(
packageId = 100,
createdAtMs = 1_000L,
flags = EnumSet.of(ELicenseFlags.Expired),
paymentMethod = EPaymentMethod.CreditCard,
licenseType = ELicenseType.SinglePurchase,
)
val activeTemporary = makeLicense(
packageId = 200,
createdAtMs = 2_000L,
paymentMethod = EPaymentMethod.GuestPass,
licenseType = ELicenseType.SinglePurchaseLimitedUse,
)

val selected = SteamService.selectPreferredPackageId(
existingPackageId = expiredPurchase.packageId,
incomingPackageId = activeTemporary.packageId,
licensesByPackageId = mapOf(
expiredPurchase.packageId to expiredPurchase,
activeTemporary.packageId to activeTemporary,
),
)

assertEquals(activeTemporary.packageId, selected)
}

@Test
fun `newer durable purchase wins when both packages are durable`() {
val olderPurchase = makeLicense(
packageId = 100,
createdAtMs = 1_000L,
)
val newerPurchase = makeLicense(
packageId = 200,
createdAtMs = 2_000L,
)

val selected = SteamService.selectPreferredPackageId(
existingPackageId = olderPurchase.packageId,
incomingPackageId = newerPurchase.packageId,
licensesByPackageId = mapOf(
olderPurchase.packageId to olderPurchase,
newerPurchase.packageId to newerPurchase,
),
)

assertEquals(newerPurchase.packageId, selected)
}

@Test
fun `autogrant only license is selected for free to play games`() {
val autoGrant = makeLicense(
packageId = 0,
createdAtMs = 1_000L,
paymentMethod = EPaymentMethod.AutoGrant,
licenseType = ELicenseType.SinglePurchase,
)

val selected = SteamService.selectPreferredPackageId(
existingPackageId = SteamService.INVALID_PKG_ID,
incomingPackageId = autoGrant.packageId,
licensesByPackageId = mapOf(autoGrant.packageId to autoGrant),
)

assertEquals(autoGrant.packageId, selected)
}

@Test
fun `durable purchase beats autogrant when both are present`() {
val autoGrant = makeLicense(
packageId = 0,
createdAtMs = 2_000L,
paymentMethod = EPaymentMethod.AutoGrant,
licenseType = ELicenseType.SinglePurchase,
)
val purchased = makeLicense(
packageId = 100,
createdAtMs = 1_000L,
paymentMethod = EPaymentMethod.CreditCard,
licenseType = ELicenseType.SinglePurchase,
)

val selected = SteamService.selectPreferredPackageId(
existingPackageId = autoGrant.packageId,
incomingPackageId = purchased.packageId,
licensesByPackageId = mapOf(
autoGrant.packageId to autoGrant,
purchased.packageId to purchased,
),
)

assertEquals(purchased.packageId, selected)
}

@Test
fun `package id breaks complete ties deterministically`() {
val first = makeLicense(
packageId = 100,
createdAtMs = 1_000L,
)
val second = makeLicense(
packageId = 200,
createdAtMs = 1_000L,
)

val selected = SteamService.selectPreferredPackageId(
existingPackageId = first.packageId,
incomingPackageId = second.packageId,
licensesByPackageId = mapOf(
first.packageId to first,
second.packageId to second,
),
)

assertEquals(second.packageId, selected)
}
}
Loading