-
-
Notifications
You must be signed in to change notification settings - Fork 355
feat:Add support for installing native Android game builds (Steam Frame) #1742
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
da3322b
8d3424d
ee35f6c
7387503
3136d13
654e7d9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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. | ||
|
|
@@ -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, | ||
| ) | ||
| } | ||
| } | ||
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (Based on your team's feedback about tests for complex depot-selection logic.) Prompt for AI agents |
||
| ) | ||
| val eligible = eligibleDepots(depots, effectiveLanguage, ownedDlc, licensedDepotIds) | ||
| val eligible = eligibleDepots(depots, effectiveLanguage, ownedDlc, licensedDepotIds, wantAndroid) | ||
|
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() } | ||
|
|
@@ -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 | ||
|
|
@@ -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() | ||
|
|
@@ -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) -> | ||
|
|
@@ -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) | ||
| } | ||
|
|
||
|
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 -> | ||
|
|
@@ -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) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1726,6 +1745,7 @@ class SteamService : Service(), IChallengeUrlChanged { | |
| branch: String, | ||
| containerLanguage: String, | ||
| isUpdateOrVerify: Boolean, | ||
| wantAndroid: Boolean = false, | ||
| ): DownloadInfo? { | ||
| val appDirPath = getAppDirPath(appId) | ||
|
|
||
|
|
@@ -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) -> | ||
|
|
@@ -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 | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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 forOS.from("android")andOS.from(OS.code(EnumSet.of(OS.android)))would protect the new opt-in flow from parser or serialization regressions.Prompt for AI agents