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
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_LOGS" />
<uses-permission android:name="android.permission.VIBRATE" />
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/java/app/gamenative/data/DepotInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ data class DepotInfo(
val systemDefined: Boolean = false,
val steamDeck: Boolean = false,
) {
/** Windows or OS-untagged (neither Linux nor macOS) */
/** Windows or OS-untagged (neither Linux, macOS nor Android) */
val isWindowsCompatible: Boolean
get() = osList.contains(OS.windows) ||
(!osList.contains(OS.linux) && !osList.contains(OS.macos))
(!osList.contains(OS.linux) && !osList.contains(OS.macos) && !osList.contains(OS.android))

/** Explicitly tagged for Android (Steam Frame / Lepton depots) */
val isAndroidCompatible: Boolean
get() = osList.contains(OS.android)
}
1 change: 1 addition & 0 deletions app/src/main/java/app/gamenative/enums/OS.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum class OS(val code: Int) {
windows(0x01),
macos(0x02),
linux(0x04),
android(0x08),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Android depot selection depends on this enum being recognized both from Steam's "android" metadata and from the persisted bitmask, but neither path has regression coverage. A focused test for OS.from("android") and OS.from(OS.code(EnumSet.of(OS.android))) would protect the new opt-in flow from parser or serialization regressions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/enums/OS.kt, line 11:

<comment>Android depot selection depends on this enum being recognized both from Steam's `"android"` metadata and from the persisted bitmask, but neither path has regression coverage. A focused test for `OS.from("android")` and `OS.from(OS.code(EnumSet.of(OS.android)))` would protect the new opt-in flow from parser or serialization regressions.</comment>

<file context>
@@ -8,6 +8,7 @@ enum class OS(val code: Int) {
     windows(0x01),
     macos(0x02),
     linux(0x04),
+    android(0x08),
     ;
 
</file context>

;

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ object DownloadService {
}

fun getSizeFromStoreDisplay (appId: Int, branch: String = "public"): String {
val depots = SteamService.getDownloadableDepots(appId)
val depots = SteamService.getDownloadableDepots(appId, wantAndroid = SteamService.isAndroidPlatform(appId))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
val installBytes = depots.values.sumOf { (it.manifests[branch] ?: it.manifests["public"])?.size ?: 0L }
return StorageUtils.formatBinarySize(installBytes)
}
Expand Down
81 changes: 57 additions & 24 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import app.gamenative.enums.SaveLocation
import app.gamenative.enums.SyncResult
import app.gamenative.events.AndroidEvent
import app.gamenative.events.SteamEvent
import app.gamenative.utils.AndroidGameLauncher
import app.gamenative.utils.CaseInsensitiveFileSystem
import app.gamenative.utils.ContainerUtils
import app.gamenative.utils.FileUtils
Expand Down Expand Up @@ -841,6 +842,7 @@ class SteamService : Service(), IChallengeUrlChanged {
licensedDepotIds: Set<Int>? = null,
hasSteamUnlockedBranch: Boolean = false,
dlcAppIdsWithSingleDepots: Set<Int>? = null,
wantAndroid: Boolean = false,
): Boolean {
if (depot.manifests.isEmpty() && depot.encryptedManifests.isNotEmpty() && !hasSteamUnlockedBranch)
return false
Expand All @@ -850,9 +852,14 @@ class SteamService : Service(), IChallengeUrlChanged {
depot.sharedInstall
if (!hasContent)
return false
// 2. Supported OS
if (!depot.isWindowsCompatible)
// 2. Supported OS (Windows/untagged by default, or the Android depot when the
// user opted into the Android platform for this container)
if (wantAndroid) {
if (!depot.isAndroidCompatible)
return false
} else if (!depot.isWindowsCompatible) {
return false
}
// 3. 64-bit or indeterminate
// Arch selection: allow 64-bit and Unknown always.
// Allow 32-bit only when no 64-bit depot exists.
Expand Down Expand Up @@ -915,12 +922,14 @@ class SteamService : Service(), IChallengeUrlChanged {
preferredLanguage: String,
ownedDlc: Map<Int, DepotInfo>?,
licensedDepotIds: Set<Int>?,
wantAndroid: Boolean = false,
): Collection<DepotInfo> {
val dlcAppIdsWithSingleDepots = getDlcAppIdsWithSingleDepot(depots)
return depots.values.filter { depot ->
filterForDownloadableDepots(depot, prefer64Bit = false, preferNonDeckWindows = false, preferredLanguage,
ownedDlc, licensedDepotIds,
dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots
dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots,
wantAndroid = wantAndroid,
)
}
}
Expand All @@ -935,23 +944,25 @@ class SteamService : Service(), IChallengeUrlChanged {
ownedDlc: Map<Int, DepotInfo>?,
licensedDepotIds: Set<Int>?,
hasSteamUnlockedBranch: Boolean = false,
wantAndroid: Boolean = false,
): Map<Int, DepotInfo> {
val dlcAppIdsWithSingleDepots = getDlcAppIdsWithSingleDepot(depots)
val effectiveLanguage = SteamUtils.effectiveDepotLanguage(
depots, preferredLanguage, ownedDlc, licensedDepotIds, hasSteamUnlockedBranch,
depots, preferredLanguage, ownedDlc, licensedDepotIds, hasSteamUnlockedBranch, wantAndroid,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Android depot selection now depends on this language-resolution flag, but the test suite has no Android-mode coverage for preferred-language, English fallback, or neutral-depot cases, allowing a later change to silently produce zero Android depots. A focused Android parameterized/helper path in SteamUtilsDepotLanguageTest would lock down these three call-site changes.

(Based on your team's feedback about tests for complex depot-selection logic.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/SteamService.kt, line 951:

<comment>Android depot selection now depends on this language-resolution flag, but the test suite has no Android-mode coverage for preferred-language, English fallback, or neutral-depot cases, allowing a later change to silently produce zero Android depots. A focused Android parameterized/helper path in `SteamUtilsDepotLanguageTest` would lock down these three call-site changes.

(Based on your team's feedback about tests for complex depot-selection logic.) </comment>

<file context>
@@ -948,7 +948,7 @@ class SteamService : Service(), IChallengeUrlChanged {
             val dlcAppIdsWithSingleDepots = getDlcAppIdsWithSingleDepot(depots)
             val effectiveLanguage = SteamUtils.effectiveDepotLanguage(
-                depots, preferredLanguage, ownedDlc, licensedDepotIds, hasSteamUnlockedBranch,
+                depots, preferredLanguage, ownedDlc, licensedDepotIds, hasSteamUnlockedBranch, wantAndroid,
             )
             val eligible = eligibleDepots(depots, effectiveLanguage, ownedDlc, licensedDepotIds, wantAndroid)
</file context>

)
val eligible = eligibleDepots(depots, effectiveLanguage, ownedDlc, licensedDepotIds)
val eligible = eligibleDepots(depots, effectiveLanguage, ownedDlc, licensedDepotIds, wantAndroid)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
val has64Bit = eligible.any { it.osArch == OSArch.Arch64 }
val hasNonDeckWin = eligible.any { !it.steamDeck && it.isWindowsCompatible }
val hasNonDeckWin = eligible.any { !it.steamDeck && (if (wantAndroid) it.isAndroidCompatible else it.isWindowsCompatible) }
return depots.filter { (_, depot) ->
filterForDownloadableDepots(depot, has64Bit, hasNonDeckWin, effectiveLanguage,
ownedDlc, licensedDepotIds,
dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots
dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots,
wantAndroid = wantAndroid,
)
}
}

fun getMainAppDepots(appId: Int, containerLanguage: String): Map<Int, DepotInfo> {
fun getMainAppDepots(appId: Int, containerLanguage: String, wantAndroid: Boolean = false): Map<Int, DepotInfo> {
val appInfo = getAppInfoOf(appId) ?: return emptyMap()
val ownedDlc = runBlocking { getOwnedAppDlc(appId) }
val hasSteamUnlockedBranch = runBlocking { getSteamUnlockedBranches(appId).isNotEmpty() }
Expand All @@ -974,7 +985,7 @@ class SteamService : Service(), IChallengeUrlChanged {
}
}

val baseDepots = resolveDownloadableDepots(appInfo.depots, containerLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch)
val baseDepots = resolveDownloadableDepots(appInfo.depots, containerLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch, wantAndroid)

// Find in the depots of mainApp, that if any of the depotID is actually belongs to another steam_app entry
// override the dlcAppId to the corresponding app id
Expand All @@ -995,28 +1006,28 @@ class SteamService : Service(), IChallengeUrlChanged {
* Get downloadable depots for a given app (default language), including all DLCs
* @return Map of app ID to depot ID to depot info
*/
fun getDownloadableDepots(appId: Int): Map<Int, DepotInfo> {
fun getDownloadableDepots(appId: Int, wantAndroid: Boolean = false): Map<Int, DepotInfo> {
val preferredLanguage = PrefManager.containerLanguage
return getDownloadableDepots(appId, preferredLanguage)
return getDownloadableDepots(appId, preferredLanguage, wantAndroid)
}

/**
* Get downloadable depots for a given app (container language), including all DLCs
* @return Map of app ID to depot ID to depot info
*/
fun getDownloadableDepots(appId: Int, preferredLanguage: String): Map<Int, DepotInfo> {
fun getDownloadableDepots(appId: Int, preferredLanguage: String, wantAndroid: Boolean = false): Map<Int, DepotInfo> {
val appInfo = getAppInfoOf(appId) ?: return emptyMap()
val ownedDlc = runBlocking { getOwnedAppDlc(appId) }
val hasSteamUnlockedBranch = runBlocking { getSteamUnlockedBranches(appId).isNotEmpty() }
val licensedDepots = getLicensedDepotIds(appId).orEmpty().toMutableSet()

val map = getMainAppDepots(appId, preferredLanguage).toMutableMap()
val map = getMainAppDepots(appId, preferredLanguage, wantAndroid).toMutableMap()

// parent app's arch applies to DLC arch selection
val mainLanguage = SteamUtils.effectiveDepotLanguage(
appInfo.depots, preferredLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch,
appInfo.depots, preferredLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch, wantAndroid,
)
val has64Bit = eligibleDepots(appInfo.depots, mainLanguage, ownedDlc, licensedDepots)
val has64Bit = eligibleDepots(appInfo.depots, mainLanguage, ownedDlc, licensedDepots, wantAndroid)
.any { it.osArch == OSArch.Arch64 }

val indirectDlcApps = getDownloadableDlcAppsOf(appId).orEmpty()
Expand All @@ -1025,15 +1036,16 @@ class SteamService : Service(), IChallengeUrlChanged {
val dlcLicensedDepots = getLicensedDepotIds(dlcApp.id)
// Resolve the DLC's own language too, so DLC that omits the container language installs.
val dlcLanguage = SteamUtils.effectiveDepotLanguage(
dlcApp.depots, preferredLanguage, null, dlcLicensedDepots, hasSteamUnlockedBranch,
dlcApp.depots, preferredLanguage, null, dlcLicensedDepots, hasSteamUnlockedBranch, wantAndroid,
)
val dlcEligible = eligibleDepots(dlcApp.depots, dlcLanguage, null, dlcLicensedDepots)
val dlcHasNonDeckWin = dlcEligible.any { !it.steamDeck && it.isWindowsCompatible }
val dlcEligible = eligibleDepots(dlcApp.depots, dlcLanguage, null, dlcLicensedDepots, wantAndroid)
val dlcHasNonDeckWin = dlcEligible.any { !it.steamDeck && (if (wantAndroid) it.isAndroidCompatible else it.isWindowsCompatible) }
dlcApp.depots
.filter { (_, depot) ->
filterForDownloadableDepots(depot, has64Bit, dlcHasNonDeckWin, dlcLanguage,
null, dlcLicensedDepots, hasSteamUnlockedBranch,
dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots
dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots,
wantAndroid = wantAndroid,
)
}
.forEach { (depotId, depot) ->
Expand Down Expand Up @@ -1407,6 +1419,13 @@ class SteamService : Service(), IChallengeUrlChanged {
}
}

/** Whether the container for [appId] has the game's Android (Steam Frame / Lepton) depot selected. */
fun isAndroidPlatform(appId: Int): Boolean {
val context = instance?.applicationContext ?: return false
val container = ContainerManager(context).getContainerById("STEAM_${appId}")
return container?.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
fun downloadApp(appId: Int, dlcAppIds: List<Int>, branch: String = "public", isUpdateOrVerify: Boolean): DownloadInfo? {
if (!checkWifiOrNotify()) return null
return getAppInfoOf(appId)?.let { appInfo ->
Expand All @@ -1416,17 +1435,17 @@ class SteamService : Service(), IChallengeUrlChanged {
} else {
PrefManager.containerLanguage
}
val wantAndroid = container?.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)

Timber.tag("SteamService").d("downloadApp: downloading app $appId with language $containerLanguage, branch $branch")

val depots = getDownloadableDepots(appId = appId, preferredLanguage = containerLanguage)
val depots = getDownloadableDepots(appId = appId, preferredLanguage = containerLanguage, wantAndroid = wantAndroid)
downloadApp(
appId = appId,
downloadableDepots = depots,
userSelectedDlcAppIds = dlcAppIds,
branch = branch,
containerLanguage = containerLanguage,
isUpdateOrVerify = isUpdateOrVerify)
isUpdateOrVerify = isUpdateOrVerify,
wantAndroid = wantAndroid)
}
}

Expand Down Expand Up @@ -1726,6 +1745,7 @@ class SteamService : Service(), IChallengeUrlChanged {
branch: String,
containerLanguage: String,
isUpdateOrVerify: Boolean,
wantAndroid: Boolean = false,
): DownloadInfo? {
val appDirPath = getAppDirPath(appId)

Expand All @@ -1741,7 +1761,7 @@ class SteamService : Service(), IChallengeUrlChanged {
}

// Depots from Main game
val mainDepots = getMainAppDepots(appId, containerLanguage)
val mainDepots = getMainAppDepots(appId, containerLanguage, wantAndroid)
var mainAppDepots = mainDepots.filter { (_, depot) ->
depot.dlcAppId == INVALID_APP_ID
} + mainDepots.filter { (_, depot) ->
Expand Down Expand Up @@ -2221,6 +2241,19 @@ class SteamService : Service(), IChallengeUrlChanged {
PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(appId, false))
}
}

// Android platform: the download itself doesn't put the game on the device —
// kick off the system install prompt right away instead of waiting for a
// manual Play click.
if (isAndroidPlatform(appId)) {
when (AndroidGameLauncher.installAndLaunch(svc.applicationContext, appId)) {
AndroidGameLauncher.Result.InstallStarted ->
SnackbarManager.show(svc.getString(R.string.android_game_install_started))
AndroidGameLauncher.Result.Failed ->
SnackbarManager.show(svc.getString(R.string.android_game_launch_failed))
AndroidGameLauncher.Result.Launched -> Unit
}
}
}
}
}
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/java/app/gamenative/ui/PluviaMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1614,12 +1614,34 @@ fun preLaunchApp(
// create container if it does not already exist
// TODO: combine somehow with container creation in HomeLibraryAppScreen
val containerManager = ContainerManager(context)

// If a container already exists and is set to the Android platform, short-circuit before
// any Wine-specific setup below. getOrCreateContainer() can extract a Wine container
// pattern when creating a brand-new container, which a native Android launch neither
// needs nor should be able to fail because of.
if (containerManager.hasContainer(appId)) {
val existing = containerManager.getContainerById(appId)
if (existing?.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)) {
setLoadingDialogVisible(false)
onSuccess(context, appId)
return@launch
}
}

val container = if (useTemporaryOverride) {
ContainerUtils.getOrCreateContainerWithOverride(context, appId)
} else {
ContainerUtils.getOrCreateContainer(context, appId)
}

// Native Android (Steam Frame / Lepton) build: no Wine/Proton container, no manifest
// components to resolve — skip straight to launch.
if (container.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
setLoadingDialogVisible(false)
onSuccess(context, appId)
return@launch
}

// Clear session metadata on every launch to ensure fresh values
container.clearSessionMetadata()

Expand Down
Loading