mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-08 17:11:46 -10:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
065d904fa9 | ||
|
|
b2f2978b71 | ||
|
|
d27144c13d | ||
|
|
c3ddc3f69c | ||
|
|
7ecfbf7478 | ||
|
|
5e438e9248 | ||
|
|
6b110581ac | ||
|
|
92742f37c1 | ||
|
|
ecd916d43f | ||
|
|
8bdff5763b | ||
|
|
a7c10e3f51 | ||
|
|
1760f008ae | ||
|
|
3bb279f635 | ||
|
|
3fef1f3fa1 | ||
|
|
a4f26799ab | ||
|
|
08a6a3cba8 | ||
|
|
005fec2125 | ||
|
|
2e70b8ed73 | ||
|
|
74c0f2127a | ||
|
|
2bd7df5e1a | ||
|
|
b7b96875a1 | ||
|
|
51e5e3a2a4 | ||
|
|
c755d05473 | ||
|
|
aa883830ca | ||
|
|
45e39b7aaf | ||
|
|
bc99cacd02 | ||
|
|
2beeb715fd | ||
|
|
39469e6e8f | ||
|
|
ab0b1ad371 | ||
|
|
68d9fbcefb | ||
|
|
7ed7a46b8f | ||
|
|
eac510d1d7 | ||
|
|
bcf33685ec | ||
|
|
c3bcfc8593 | ||
|
|
c27c6a336a | ||
|
|
db81d233b0 | ||
|
|
e7d11a5feb | ||
|
|
e7eed182b4 | ||
|
|
4f6a1fcd17 | ||
|
|
a68429fcc4 | ||
|
|
4605b4a756 | ||
|
|
942dc1d587 | ||
|
|
56911e9f25 | ||
|
|
a4aa19d2ac | ||
|
|
94a7330491 | ||
|
|
35b77fdbf5 | ||
|
|
15baa296bb | ||
|
|
fc0e0b04cf | ||
|
|
afb23680f7 | ||
|
|
95511dc5c0 | ||
|
|
e4a4775be5 | ||
|
|
77ef10d601 | ||
|
|
228a1311ae | ||
|
|
b67bdcd0b3 | ||
|
|
df265ea3a1 | ||
|
|
914b3588e1 | ||
|
|
2ade269be3 | ||
|
|
0d034b1996 |
No files matched your search
@@ -10,3 +10,5 @@ tools/mangoapp/mangoapp text eol=lf
|
||||
app/src/main/assets/desktop-removal/*.json text eol=lf
|
||||
app/src/main/assets/desktop-removal/*.txt text eol=lf
|
||||
*.pdf binary
|
||||
|
||||
app/src/main/resources/ea/graphql/*.gql -text
|
||||
@@ -313,6 +313,11 @@ jobs:
|
||||
- name: Build the DiRT 3 / DiRT Showdown ws2_32 stand-in
|
||||
run: python3 tools/components/prepare.py dirt-ws2
|
||||
|
||||
- name: Cache graphics driver inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/build/quest-inputs
|
||||
key: quest-inputs-${{ hashFiles('tools/quest/build-driver.py') }}
|
||||
- name: Prepare graphics driver variants
|
||||
run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
- name: Record and pack prepared components
|
||||
@@ -558,9 +563,11 @@ jobs:
|
||||
- name: Cache checksum-pinned pack inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/droiddeck-pack-inputs
|
||||
key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }}
|
||||
restore-keys: pack-inputs-v1-
|
||||
path: |
|
||||
~/.cache/droiddeck-pack-inputs
|
||||
~/.cache/droiddeck-build
|
||||
key: pack-inputs-v2-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env', 'tools/powervr/release.json') }}
|
||||
restore-keys: pack-inputs-v2-
|
||||
- name: Build the add-on packs
|
||||
run: |
|
||||
python3 tools/powervr/fetch.py
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 2
|
||||
outputs:
|
||||
labels: ${{ steps.runner.outputs.labels || '"ubuntu-24.04"' }}
|
||||
labels: ${{ steps.runner.outputs.labels || steps.fork.outputs.labels || '"ubuntu-24.04"' }}
|
||||
steps:
|
||||
- name: Pick the compile runner
|
||||
id: runner
|
||||
@@ -36,3 +36,25 @@ jobs:
|
||||
fi
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Pick the fork runner
|
||||
id: fork
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
labels='"ubuntu-24.04"'
|
||||
gist=https://api.github.com/gists/0c7ed7ad6d6493de84f5123689c64ae6
|
||||
body=$(curl -fsS --max-time 15 -H "Authorization: Bearer $GH_TOKEN" "$gist" 2>/dev/null || curl -fsS --max-time 15 "$gist" 2>/dev/null || echo '{}')
|
||||
IDLE_AT=$(jq -r 'select(.owner.login == "maxjivi05") | .files["idle-at"].content // "0"' <<< "$body" 2>/dev/null || echo 0)
|
||||
echo "Heartbeat: ${IDLE_AT:-none}"
|
||||
age=-1
|
||||
if [[ "${IDLE_AT:-0}" =~ ^[0-9]+$ ]] && [ "${IDLE_AT:-0}" -gt 0 ]; then
|
||||
age=$(( $(date +%s) - IDLE_AT ))
|
||||
fi
|
||||
if [ "$age" -ge 0 ] && [ "$age" -lt 180 ]; then
|
||||
labels='"droiddeck-fork"'
|
||||
fi
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linux desktop, integration with GOG, Epic Games, Xbox, and other storefronts, Steam VR, and more!
|
||||
|
||||
<p align="center"><b>100% open source. No ads. No telemetry. No source withheld.</b></p>
|
||||
<p align="center"><b>100% open source. No ads. No telemetry. No proprietary components. No source withheld.</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.6.9%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.6.9"></a>
|
||||
@@ -79,7 +79,8 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- droiddeck-ntsync follows the [Linux](https://github.com/torvalds/linux) ntsync semantics in `Documentation/userspace-api/ntsync.rst` (GPL-2.0).
|
||||
- The proot fast path orders bindings like [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later).
|
||||
- DiRT 3 and DiRT Showdown keep their telemetry socket the way RoccoC's DiRTTelemetryErrorFix (MIT) does, based on the fix by Carsten Gottschling.
|
||||
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `TU_DEBUG=sysmem`.
|
||||
- Zink on PowerVR keeps colour textures in colour with Icenowy Zheng's upstream [Mesa](https://gitlab.freedesktop.org/mesa/mesa) fix d55418dae7 (MIT), carried as a runtime patch.
|
||||
- Games and AppImage, Flatpak and Snap programs such as emulators use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client and Chromium-based apps keep `TU_DEBUG=sysmem`.
|
||||
- Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose `311210.py` and `570940.py` fail under ARM64 Proton.
|
||||
- [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans uses the launch option from its README and D3D12 feature level 12_1.
|
||||
- The arm64 client's sign in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest).
|
||||
@@ -92,6 +93,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- Epic Games and GOG build on [Phobos](https://github.com/phobos665)'s adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl). GOG install script registry entries are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher) (GPL-3.0) reads them.
|
||||
- Epic's official Linux launcher preview runs through FEX. That setup is outside Epic's listed Linux support.
|
||||
- Battle.net is by [@kayakyakr](https://github.com/kayakyakr). It runs Blizzard's own client in Proton (downloaded from Blizzard, not included) and shows key art from the [Battle.net Shop](https://shop.battle.net). Without Steam, its games see the pad as an Xbox 360 controller via `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`.
|
||||
- The EA store follows the EA app's protocols as documented by [ArmchairDevelopers](https://github.com/ArmchairDevelopers)' [Maxima](https://github.com/ArmchairDevelopers/Maxima) (GPL-3.0): sign-in, the GraphQL queries (included verbatim, since EA matches them by hash), build archives, online activation licences and the LSX game endpoint. The code is DroidDeck's own.
|
||||
- Battle.net installs drive Blizzard's Agent the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) does, with requests from [wowdev.wiki](https://wowdev.wiki/Agent). Read without taking code: [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader.
|
||||
- World of Warcraft addon imports replace an addon's folder from a temporary copy, as [WowUp](https://github.com/WowUp/WowUp) does, and read flavour folders the way [WoWDeck](https://github.com/sigboe/wowdeck) does. No code from either is used.
|
||||
- The World of Warcraft addon Download window searches [WoWInterface](https://www.wowinterface.com) through its MMOUI API. [CurseForge](https://www.curseforge.com/wow) opens in the user's browser, with no API and no key.
|
||||
|
||||
+2
-2
@@ -131,8 +131,8 @@ android {
|
||||
applicationId "com.droiddeck.launcher"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 28
|
||||
versionCode 17
|
||||
versionName "0.6.9"
|
||||
versionCode 18
|
||||
versionName "0.7.0"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
|
||||
@@ -61,6 +61,8 @@ class App : Application() {
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
|
||||
com.droiddeck.launcher.sync.Syncthing.init(this)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend)
|
||||
com.droiddeck.launcher.stores.ea.EaBackend.app = this
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend)
|
||||
}
|
||||
|
||||
private fun prefetchAddonPacks() {
|
||||
|
||||
@@ -603,11 +603,19 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
}
|
||||
|
||||
fun selectDriver(id: String, linux: Boolean) {
|
||||
if (!linux && id == TurnipDriver.AUTO) {
|
||||
DriverBundle.selectDisplay(activity, id)
|
||||
pairModeOpen = false
|
||||
lastAutoCheck = 0L
|
||||
setMode(auto = true)
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
when {
|
||||
bundle != null -> useBundle(bundle)
|
||||
linux -> SessionPrefs.setLinuxDriver(activity, id)
|
||||
else -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
else -> DriverBundle.selectDisplay(activity, id)
|
||||
}
|
||||
pairModeOpen = false
|
||||
setMode(auto = false, mix = true)
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -873,6 +874,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
return false
|
||||
}
|
||||
Library.recordPlayed(this, game)
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
|
||||
.returnTo(returnHome)
|
||||
@@ -888,7 +890,15 @@ class FrontEndActivity : ComponentActivity() {
|
||||
putExtra(SessionService.EXTRA_STEAM_URL,
|
||||
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
|
||||
}
|
||||
}, steamSession = true)
|
||||
}
|
||||
.playing(game), steamSession = true)
|
||||
}
|
||||
|
||||
private fun Intent.playing(game: Library.SteamGame): Intent =
|
||||
apply { if (game.library == Library.ADDED) putExtra(SessionActivity.EXTRA_PLAYED_GAME, game.gameIdString) }
|
||||
|
||||
private fun recordPlayed(intent: Intent) {
|
||||
intent.getStringExtra(SessionActivity.EXTRA_PLAYED_GAME)?.let { Library.recordPlayed(this, it) }
|
||||
}
|
||||
|
||||
private fun Intent.returnTo(returnHome: Boolean): Intent {
|
||||
@@ -915,14 +925,17 @@ class FrontEndActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
|
||||
.putExtra(SessionService.EXTRA_GAME_ID, com.droiddeck.launcher.session.NoSteamLaunch.scope(game))
|
||||
.playing(game)
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, returnHome) { pendingSwitch = next } -> {}
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
|
||||
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, next, returnHome) -> {}
|
||||
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args -> {
|
||||
recordPlayed(next)
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
@@ -1123,6 +1136,10 @@ class FrontEndActivity : ComponentActivity() {
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
val prootPack by com.droiddeck.launcher.core.AddonPack.PROOT.state.collectAsState()
|
||||
androidx.compose.runtime.LaunchedEffect(prootPack) {
|
||||
if (prootPack == com.droiddeck.launcher.core.AddonPack.State.Ready && installed == null) refresh()
|
||||
}
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
|
||||
@@ -2276,14 +2293,14 @@ class FrontEndActivity : ComponentActivity() {
|
||||
when {
|
||||
!SessionState.running -> startSessionOrWait(next)
|
||||
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
handToBattleNet(full) { pendingSwitch = next } -> {}
|
||||
handToBattleNet(full, next) -> {}
|
||||
else -> pendingSwitch = next
|
||||
}
|
||||
}
|
||||
}, "battlenet-open").start()
|
||||
}
|
||||
|
||||
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
|
||||
private fun handToBattleNet(args: List<String>, next: Intent, returnHome: Boolean = false): Boolean {
|
||||
if (SessionState.mode != SessionService.MODE_RUN || SessionState.program != com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET) return false
|
||||
val library = args.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
|
||||
val sessionLibrary = SessionState.programArgs.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
|
||||
@@ -2294,10 +2311,13 @@ class FrontEndActivity : ComponentActivity() {
|
||||
val handed = request.isEmpty() || com.droiddeck.launcher.stores.battlenet.BattleNetClient.request(LinuxRuntime.sessionRoot(app), request)
|
||||
ui.post {
|
||||
if (isDestroyed) return@post
|
||||
if (!handed) otherwise()
|
||||
else startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
if (!handed) pendingSwitch = next
|
||||
else {
|
||||
recordPlayed(next)
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.returnTo(returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
}
|
||||
}
|
||||
}, "battlenet-request").start()
|
||||
return true
|
||||
|
||||
@@ -178,6 +178,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
private var unfoldedPanel by mutableStateOf<OnScreenControls?>(null)
|
||||
private var unfoldedColumn: android.widget.LinearLayout? = null
|
||||
private var unfoldedDeckSurface: com.droiddeck.launcher.ui.InputSurfaceView? = null
|
||||
private var unfoldedDeckPanel by mutableStateOf<com.droiddeck.launcher.input.DeckPanel?>(null)
|
||||
private var deckPanelLayouts: Map<com.droiddeck.launcher.input.DeckPanel, com.droiddeck.launcher.input.SavedInputLayout?> = emptyMap()
|
||||
private lateinit var sessionRoot: FrameLayout
|
||||
private var inputRouter: InputRouter? = null
|
||||
private var physicalInputBindings: com.droiddeck.launcher.input.PhysicalInputBindings? = null
|
||||
@@ -565,6 +568,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var sessionActivityDisplayId = Display.DEFAULT_DISPLAY
|
||||
private lateinit var displayManager: DisplayManager
|
||||
private var secondScreenPresentation: SecondScreenPresentation? = null
|
||||
private val inputWindowDisplays = mutableListOf<Int>()
|
||||
private val bottomScreenListener: () -> Unit = {
|
||||
if (secondScreenPresentation?.display?.displayId?.let(com.droiddeck.launcher.input.DualScreen::taken) == true) closeSecondScreen(reset = false)
|
||||
updateBottomGame()
|
||||
@@ -977,7 +981,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
} else {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
CursorOverlay({ cursorPos }, cursorVisible && !uiPointer && !bottomOutputActive,
|
||||
CursorOverlay({ cursorPos }, cursorVisible && (!uiPointer || onlyPcKeyboardOpen()) && !bottomOutputActive,
|
||||
resources.displayMetrics.density,
|
||||
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
|
||||
if (memoryHud.text.isNotEmpty()) HudText(memoryHud.text, hudColor, hudOpacity)
|
||||
@@ -1222,7 +1226,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
|
||||
oscMode = oscMode, suspendPolicy = suspendPolicy,
|
||||
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
|
||||
micAvailable = SessionState.micAvailable && !SessionState.suspended,
|
||||
micEnabled = SessionState.micEnabled,
|
||||
@@ -1443,6 +1447,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
},
|
||||
onClose = { drawerOpen = false },
|
||||
onDismissScreen = if (docked) ({ selectSecondScreenMode(SecondScreenMode.NONE) }) else null,
|
||||
components = drawerComponents,
|
||||
onComponentsRefresh = {
|
||||
refreshDrawerComponents()
|
||||
@@ -2225,6 +2230,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
intent.getStringExtra(SessionService.EXTRA_GAME_ID),
|
||||
sessionActivityDisplayId,
|
||||
)
|
||||
intent.getStringExtra(EXTRA_PLAYED_GAME)?.let { com.droiddeck.launcher.frontend.Library.recordPlayed(this, it) }
|
||||
}
|
||||
applyFrameGen()
|
||||
applyGameAlignment()
|
||||
@@ -2315,9 +2321,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (::directTouchHold.isInitialized) directTouchHold.cancel()
|
||||
if (::twoFingerGestures.isInitialized) twoFingerGestures.cancel()
|
||||
if (::scrollTouch.isInitialized) scrollTouch.cancel()
|
||||
scrollTouchGesture = false
|
||||
gesturePointerReady = false
|
||||
if (sessionTouchDown) dismissTouch = true
|
||||
}
|
||||
|
||||
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
|
||||
@@ -2826,6 +2834,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return onScreenControls?.let { takes(it, it::takesTouchAt) } == true ||
|
||||
steamTouchControls?.let { takes(it, it::takesTouchAt) } == true ||
|
||||
unfoldedPanel?.let { takes(it, it::takesTouchAt) } == true ||
|
||||
unfoldedDeckSurface?.let { takes(it, it::takesTouchAt) } == true ||
|
||||
unfoldedColumn?.getChildAt(0)?.let { takes(it) { _, _ -> true } } == true
|
||||
}
|
||||
|
||||
@@ -3378,6 +3387,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
var presentation = secondScreenPresentation
|
||||
if (presentation?.display?.displayId != target.displayId) {
|
||||
if (target.displayId in inputWindowDisplays) {
|
||||
panelMode(mode)
|
||||
return
|
||||
}
|
||||
dropDockedDrawer()
|
||||
presentation?.closeControls()
|
||||
presentation = secondScreenPresentation(target)
|
||||
@@ -3518,8 +3531,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val main = window.decorView.display
|
||||
if (role == InputRole.PRIMARY) return bottomGame?.display ?: main
|
||||
if (bottomGame != null) return main
|
||||
return displayManager.getDisplay(selectedSecondScreenDisplay)
|
||||
?: secondScreenDisplays.firstOrNull()?.let { displayManager.getDisplay(it.id) }
|
||||
return com.droiddeck.launcher.input.SecondScreenDisplays.editorCandidates(selectedSecondScreenDisplay,
|
||||
secondScreenDisplays.map { it.id }, com.droiddeck.launcher.input.DualScreen::taken).firstNotNullOfOrNull(displayManager::getDisplay)
|
||||
}
|
||||
|
||||
fun inputWindowShown(displayId: Int) { inputWindowDisplays += displayId }
|
||||
|
||||
fun inputWindowClosed(displayId: Int) {
|
||||
inputWindowDisplays -= displayId
|
||||
if (started && displayId !in inputWindowDisplays && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
||||
}
|
||||
|
||||
private fun refreshInputProfile() {
|
||||
@@ -3530,6 +3550,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
adaptiveSticks = primaryInputLayout?.adaptiveSticks
|
||||
quickButtons = primaryInputLayout?.takeIf { it.editable }?.let { l -> l.quickButtons.associateWith { it !in l.style.hidden } }.orEmpty()
|
||||
secondaryInputLayout = library.panelLayout(desktop, secondScreenMode.panel(this))
|
||||
deckPanelLayouts = com.droiddeck.launcher.input.DeckPanel.entries.associateWith { library.panelLayout(desktop, it.bindings) }
|
||||
inputUsesLocalActions = InputControl.padControls.any { inputProfile.action(InputRole.PHYSICAL, it) !is InputAction.Native }
|
||||
inputUsesPointer = inputProfile.bindings.values.any { bindings ->
|
||||
bindings.values.any { it is InputAction.Pointer || it is InputAction.MouseButton || it is InputAction.Scroll }
|
||||
@@ -3543,6 +3564,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onScreenControls?.updateLayout(primaryInputLayout)
|
||||
bottomInputControls?.updateLayout(primaryInputLayout)
|
||||
unfoldedPanel?.updateLayout(primaryInputLayout)
|
||||
if (unfoldedPanel != null) applyUnfoldedControls()
|
||||
showUnfoldedDeckPanel()
|
||||
updateOnScreenPad()
|
||||
secondScreenPresentation?.refreshControls()
|
||||
}
|
||||
@@ -3586,7 +3609,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val width = sessionRoot.width
|
||||
val height = sessionRoot.height
|
||||
val wanted = controlsBelowWanted()
|
||||
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height)
|
||||
val tabRow = unfoldedColumn?.getChildAt(0)?.height?.let {
|
||||
it - (com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt()
|
||||
} ?: 0
|
||||
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height, tabRow)
|
||||
else FrameLayout.LayoutParams.MATCH_PARENT
|
||||
val params = surfaceView.layoutParams as FrameLayout.LayoutParams
|
||||
if (params.height != gameHeight) {
|
||||
@@ -3594,11 +3620,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}"
|
||||
else "unfolded controls: game full screen")
|
||||
}
|
||||
val dock = com.droiddeck.launcher.input.OnScreenLayout.Dock(!com.droiddeck.launcher.session.SessionDisplay.foldable(this), SessionState.deckPad)
|
||||
val portrait = !com.droiddeck.launcher.session.SessionDisplay.foldable(this)
|
||||
val dock = if (SessionState.deckPad) com.droiddeck.launcher.input.OnScreenLayout.Dock.deck(portrait, deckPanelLayouts[com.droiddeck.launcher.input.DeckPanel.GAMEPAD])
|
||||
else com.droiddeck.launcher.input.OnScreenLayout.Dock(portrait, false)
|
||||
var panel = unfoldedPanel
|
||||
if (panel != null && (!wanted || panel.dock != dock)) {
|
||||
sessionRoot.removeView(unfoldedColumn)
|
||||
unfoldedColumn = null
|
||||
unfoldedDeckSurface = null
|
||||
unfoldedDeckPanel = null
|
||||
unfoldedPanel = null
|
||||
panel = null
|
||||
if (!wanted) updateOnScreenControls()
|
||||
@@ -3608,21 +3638,47 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
panel = OnScreenControls(this, padBridge, dock).also { it.updateLayout(primaryInputLayout) }
|
||||
val column = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
addView(unfoldedBar(), android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
(com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt()))
|
||||
addView(unfoldedBar().apply {
|
||||
addOnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
|
||||
if (bottom - top != oldBottom - oldTop) post { applyUnfoldedControls() }
|
||||
}
|
||||
}, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT))
|
||||
addView(panel, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
}
|
||||
sessionRoot.addView(column, sessionRoot.indexOfChild(surfaceView) + 1)
|
||||
unfoldedColumn = column
|
||||
unfoldedPanel = panel
|
||||
showUnfoldedDeckPanel()
|
||||
updateOnScreenControls()
|
||||
}
|
||||
unfoldedColumn?.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM)
|
||||
}
|
||||
|
||||
private fun unfoldedBar(): View = com.droiddeck.launcher.ui.bottomScreenBarView(this) {
|
||||
com.droiddeck.launcher.ui.sessionBarModel(getString(R.string.deck_tab_gamepad), { chooseUnfoldedControls(false) },
|
||||
{ drawerOpen = true }, ::tapSteamButton, ::tapQam)
|
||||
val deck = unfoldedDeckPanel
|
||||
com.droiddeck.launcher.ui.sessionBarModel(getString(if (deck != null) R.string.second_mode_deck_controls else R.string.deck_tab_gamepad),
|
||||
{ chooseUnfoldedControls(false) }, { drawerOpen = true }, ::tapSteamButton, ::tapQam,
|
||||
tabs = deck?.let { com.droiddeck.launcher.ui.deckPanelTabs(this, it, ::selectDeckPanel) })
|
||||
}
|
||||
|
||||
private fun showUnfoldedDeckPanel() {
|
||||
val column = unfoldedColumn
|
||||
val panel = unfoldedPanel
|
||||
val selected = SessionPrefs.deckPanel(this).takeIf { column != null && panel?.dock?.grips == true }
|
||||
unfoldedDeckPanel = selected
|
||||
val layout = selected?.takeIf { it != com.droiddeck.launcher.input.DeckPanel.GAMEPAD }?.let { deckPanelLayouts[it] }
|
||||
if (layout != null && unfoldedDeckSurface?.tag == layout) return
|
||||
unfoldedDeckSurface?.let { it.releaseInputs(); column?.removeView(it) }
|
||||
unfoldedDeckSurface = null
|
||||
if (column == null || panel == null) return
|
||||
if (layout == null) { panel.visibility = View.VISIBLE; return }
|
||||
panel.releaseAll()
|
||||
panel.visibility = View.GONE
|
||||
unfoldedDeckSurface = com.droiddeck.launcher.ui.InputSurfaceView(this, padBridge, layout.elements, InputRole.PRIMARY, layout.style, haptics = true).also {
|
||||
it.tag = layout
|
||||
column.addView(it, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun bottomKeyboardPresentation(): SecondScreenPresentation? {
|
||||
@@ -3918,9 +3974,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var lockedSwipeX = 0f
|
||||
private var lockedSwipeY = 0f
|
||||
private var dismissTouch = false
|
||||
private var sessionTouchDown = false
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
sessionTouchDown = true
|
||||
dismissTouch = false
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> sessionTouchDown = false
|
||||
}
|
||||
if (drawerOpen && drawerDocked && event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
drawerLayerOpen = false
|
||||
drawerOpenState = false
|
||||
@@ -3932,7 +3996,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) dismissTouch = false
|
||||
return true
|
||||
}
|
||||
val uiBlocking = drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended
|
||||
val uiBlocking = drawerOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && !uiBlocking && routedTouchMode != SessionPrefs.TOUCH_GESTURES) {
|
||||
scrollTouchGesture = scrollTouchMode()
|
||||
scrollRestX = 0f
|
||||
@@ -3960,7 +4024,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
com.droiddeck.launcher.input.TouchRouter.Route.SCROLL -> scrollTouch.onTouch(event)
|
||||
else -> {
|
||||
if (com.droiddeck.launcher.input.TouchRouter.steamKeyboardZone(snapshot)) {
|
||||
val wasActive = threeFingerTap.active
|
||||
val wasActive = threeFingerTap.active && event.actionMasked != MotionEvent.ACTION_DOWN
|
||||
if (threeFingerTap.onTouch(event)) padBridge?.triggerSteamKeyboard()
|
||||
if (threeFingerTap.active && !wasActive) {
|
||||
if (route == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD) touchpad.cancel()
|
||||
@@ -4036,9 +4100,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) lockedSwipeUsed = false
|
||||
if (lockedSwipeUsed && (event.actionMasked != MotionEvent.ACTION_MOVE || !programLocksPointer())) return true
|
||||
if (programLocksPointer() && event.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
if (lockedSwipeUsed) directTouchHold.cancelPendingHold() else directTouchHold.cancelTouch()
|
||||
lockedSwipeUsed = true
|
||||
directTouchHold.cancelPendingHold()
|
||||
val scale = sceneScale(rect)
|
||||
sendSceneDelta((event.x - swipeX) * scale, (event.y - swipeY) * scale)
|
||||
return true
|
||||
@@ -4155,6 +4220,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
applyPlatformPointer(appUiOwnsPointer())
|
||||
}
|
||||
|
||||
private fun onlyPcKeyboardOpen(): Boolean =
|
||||
pcKeyboardOpen && !drawerOpen && !achievementsOpen && !inputSettingsOpen && !pipUi && !glassesOutputActive && !touchEditorOpen && !wowChatOnTop
|
||||
|
||||
private fun appUiOwnsPointer(): Boolean =
|
||||
drawerOpen || pcKeyboardOpen || achievementsOpen || inputSettingsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen || wowChatOnTop
|
||||
|
||||
@@ -4245,7 +4313,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
updateOnScreenPad()
|
||||
val c = onScreenControls
|
||||
glassesSlot = when {
|
||||
c == null || c.visibility != View.VISIBLE -> 0
|
||||
c == null || c.visibility != View.VISIBLE || c.isPinnedOnly -> 0
|
||||
c.isButtonsOnly -> -1
|
||||
primaryInputLayout?.let { l -> l.elements.any { it.quickButton == InputCommand.KEYBOARD && !l.style.hides(it) } }
|
||||
?: (controllerSettings?.keyboardButton != false) -> 2
|
||||
@@ -4260,12 +4328,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
|
||||
?: SessionPrefs.oscMode(this)
|
||||
val controls = onScreenControls ?: return
|
||||
fun showPinned() {
|
||||
controls.setPinnedOnly(true)
|
||||
val pinned = controls.hasPinned && !pipUi && !inputSettingsOpen
|
||||
if (!pinned) controls.releaseAll()
|
||||
controls.visibility = if (pinned) View.VISIBLE else View.GONE
|
||||
}
|
||||
if (pipUi || unfoldedPanel != null || pcKeyboardOpen || inputSettingsOpen ||
|
||||
(SessionState.mode == SessionService.MODE_STEAM && !SessionPrefs.builtinPad(this))) {
|
||||
onScreenButtonsVisible = false
|
||||
hideBottomControls()
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
showPinned()
|
||||
steamTouchControls?.let { it.cancelEditing(); it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
applyTouchRouting()
|
||||
@@ -4298,7 +4371,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val drawn = show && !keyboardCoversPad()
|
||||
if (steam != null) {
|
||||
hideBottomControls()
|
||||
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
|
||||
showPinned()
|
||||
if (!drawn) { steam.cancelEditing(); steam.releaseAll() }
|
||||
steam.visibility = if (drawn) View.VISIBLE else View.GONE
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
|
||||
@@ -4307,7 +4380,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return
|
||||
}
|
||||
val bottomRoot = bottomGame?.surfaceView?.parent as? android.view.ViewGroup
|
||||
if (drawn && bottomRoot != null) {
|
||||
if (bottomRoot != null && (drawn || controls.hasPinned)) {
|
||||
controls.releaseAll(); controls.visibility = View.GONE
|
||||
var bottom = bottomInputControls
|
||||
if (bottom?.parent !== bottomRoot) {
|
||||
@@ -4320,13 +4393,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
bottom.updateLayout(primaryInputLayout)
|
||||
bottom.setButtonsOnly(buttonsOnly)
|
||||
bottom.setPinnedOnly(!drawn)
|
||||
bottom.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
hideBottomControls()
|
||||
if (drawn == (controls.visibility == View.VISIBLE)) return
|
||||
if (!drawn) controls.releaseAll()
|
||||
controls.visibility = if (drawn) View.VISIBLE else View.GONE
|
||||
val wasDrawn = controls.visibility == View.VISIBLE && !controls.isPinnedOnly
|
||||
if (!drawn) showPinned() else { controls.setPinnedOnly(false); controls.visibility = View.VISIBLE }
|
||||
if (drawn == wasDrawn) return
|
||||
Log.i(TAG, "on-screen controls " + when {
|
||||
!drawn -> "hidden"
|
||||
buttonsOnly -> "Steam + QAM"
|
||||
@@ -5069,7 +5143,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.KEYBOARD_TRACKPAD.label), listOf(keyboardExtra)))
|
||||
SecondScreenMode.DECK_CONTROLS -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.DECK_CONTROLS.label),
|
||||
tabs = com.droiddeck.launcher.ui.deckPanelTabs(this@SessionActivity, SessionPrefs.deckPanel(this@SessionActivity), ::selectDeckPanel)))
|
||||
else -> Unit
|
||||
else -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(secondScreenMode.label)))
|
||||
}
|
||||
androidx.compose.runtime.key(profileLayout) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
@@ -5390,6 +5464,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
const val EXTRA_RETURN_HOME = "returnHome"
|
||||
const val EXTRA_LINK_TASK = "linkTask"
|
||||
const val EXTRA_STEAMVR = "steamVr"
|
||||
const val EXTRA_PLAYED_GAME = "com.droiddeck.launcher.PLAYED_GAME"
|
||||
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
|
||||
private const val LEAVE_TIMEOUT_MS = 1_500L
|
||||
private const val TEXT_FIELD_TAP_MS = 1_000L
|
||||
|
||||
@@ -18,9 +18,10 @@ object Library {
|
||||
val source: String = SOURCE_STEAM,
|
||||
val storeId: String? = null,
|
||||
val logo: File? = null,
|
||||
val installedAt: Long = 0L,
|
||||
) {
|
||||
fun withArt(art: File?, hero: File?, icon: File?, logo: File? = this.logo) =
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo, installedAt)
|
||||
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
|
||||
@@ -31,6 +32,7 @@ object Library {
|
||||
}
|
||||
}
|
||||
const val ADDED = "added"
|
||||
private const val PLAYED = "added_games_played"
|
||||
const val EPIC_LAUNCHER = "epic-launcher"
|
||||
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
|
||||
const val BATTLENET_LAUNCHER = "battlenet-launcher"
|
||||
@@ -221,6 +223,18 @@ object Library {
|
||||
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
fun recordPlayed(context: Context, game: SteamGame) {
|
||||
if (game.library == ADDED) recordPlayed(context, game.gameIdString)
|
||||
}
|
||||
|
||||
fun recordPlayed(context: Context, gameId: String) {
|
||||
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).edit()
|
||||
.putLong(gameId, System.currentTimeMillis() / 1000).apply()
|
||||
}
|
||||
|
||||
internal fun recordedPlay(context: Context, gameId: Long): Long =
|
||||
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).getLong(java.lang.Long.toUnsignedString(gameId), 0L)
|
||||
|
||||
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
|
||||
val config = AddedGames.accountConfig(root) ?: return emptyMap()
|
||||
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
|
||||
@@ -249,12 +263,13 @@ object Library {
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, lastPlayed = lastPlayed, gameFiles = g.folder,
|
||||
hero = art.hero ?: art.header, lastPlayed = maxOf(lastPlayed, recordedPlay(context, g.gameId)), gameFiles = g.folder,
|
||||
protonPrefix = if (g.source == com.droiddeck.launcher.stores.Store.BATTLENET.id) File(com.droiddeck.launcher.runtime.BattleNet.prefix(context), "pfx").takeIf { it.isDirectory }
|
||||
else protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
logo = art.logo,
|
||||
installedAt = g.folder.lastModified(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ object LibraryCache {
|
||||
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
|
||||
storeId = o.optString("storeId").ifEmpty { null },
|
||||
logo = o.file("logo"),
|
||||
installedAt = o.optLong("installedAt"),
|
||||
)
|
||||
} to x64
|
||||
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
|
||||
@@ -44,7 +45,7 @@ object LibraryCache {
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon).putFile("logo", g.logo)
|
||||
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
|
||||
.put("source", g.source).put("installedAt", g.installedAt).apply { g.storeId?.let { put("storeId", it) } },
|
||||
)
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
|
||||
@@ -159,6 +159,12 @@ object DriverBundle {
|
||||
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
|
||||
}
|
||||
|
||||
fun selectDisplay(context: Context, id: String) {
|
||||
val left = active(context)
|
||||
SessionPrefs.setAndroidDriver(context, id)
|
||||
if (left != null && left.androidId != id) SessionPrefs.setLinuxDriver(context, "")
|
||||
}
|
||||
|
||||
fun remove(context: Context, bundle: Bundle) {
|
||||
TurnipDriver(context).remove(bundle.androidId)
|
||||
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
@@ -33,6 +34,8 @@ object BottomScreenGuests {
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun current(arrivedAt: Long, ownShownAt: Long?): Boolean = ownShownAt == null || arrivedAt >= ownShownAt
|
||||
|
||||
internal fun ignorable(type: Int): Boolean = type == AccessibilityWindowInfo.TYPE_INPUT_METHOD ||
|
||||
type == AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY ||
|
||||
type == AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER ||
|
||||
@@ -43,7 +46,7 @@ object BottomScreenGuests {
|
||||
}
|
||||
|
||||
class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
private class Arrival(val pkg: String)
|
||||
private class Arrival(val pkg: String, val at: Long)
|
||||
|
||||
private val thread = HandlerThread("bottom-screen").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
@@ -52,6 +55,8 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
private var homes: Set<String> = emptySet()
|
||||
private var keyboards: Set<String> = emptySet()
|
||||
private var holders: Map<Int, String?> = emptyMap()
|
||||
private var ownWindows: Map<Int, Set<Int>> = emptyMap()
|
||||
private val ownShownAt = mutableMapOf<Int, Long>()
|
||||
private val check = Runnable { evaluate() }
|
||||
|
||||
fun start() {
|
||||
@@ -72,7 +77,7 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
|
||||
val pkg = event.packageName?.toString() ?: return
|
||||
if (event.displayId == Display.DEFAULT_DISPLAY || pkg == own || pkg == SYSTEM_UI) return
|
||||
arrivals[event.displayId] = Arrival(pkg)
|
||||
arrivals[event.displayId] = Arrival(pkg, event.eventTime)
|
||||
}
|
||||
AccessibilityEvent.TYPE_WINDOWS_CHANGED -> Unit
|
||||
else -> return
|
||||
@@ -97,19 +102,25 @@ class BottomScreenWatch(private val service: AccessibilityService) {
|
||||
val all = runCatching { service.windowsOnAllDisplays }.getOrNull() ?: return
|
||||
val displays = service.getSystemService(DisplayManager::class.java) ?: return
|
||||
val taken = mutableMapOf<Int, String?>()
|
||||
val shown = mutableMapOf<Int, Set<Int>>()
|
||||
val now = SystemClock.uptimeMillis()
|
||||
for (i in 0 until all.size()) {
|
||||
val id = all.keyAt(i)
|
||||
if (id == Display.DEFAULT_DISPLAY) continue
|
||||
val display = displays.getDisplay(id)?.takeIf(DualScreen::builtIn) ?: continue
|
||||
val windows = all.valueAt(i)
|
||||
val topPkg = windows.asSequence()
|
||||
.filter { !BottomScreenGuests.ignorable(it.type) && cover(it, display) >= BottomScreenGuests.MIN_COVER }
|
||||
.sortedByDescending { it.layer }
|
||||
.firstNotNullOfOrNull { packageOf(it)?.takeUnless { pkg -> pkg == own } }
|
||||
val arrival = seen[id]
|
||||
if (topPkg != null && arrival != null) arrivals.remove(id, arrival)
|
||||
.map { it to packageOf(it) }
|
||||
val ours = windows.filter { it.second == own }.map { it.first.id }.toSet()
|
||||
if (!ownWindows[id].orEmpty().containsAll(ours)) ownShownAt[id] = now
|
||||
shown[id] = ours
|
||||
val topPkg = windows.firstNotNullOfOrNull { it.second?.takeUnless { pkg -> pkg == own } }
|
||||
val arrival = seen[id]?.takeIf { BottomScreenGuests.current(it.at, ownShownAt[id]) }
|
||||
seen[id]?.takeIf { topPkg != null || arrival == null }?.let { arrivals.remove(id, it) }
|
||||
BottomScreenGuests.holder(topPkg, arrival?.pkg?.takeUnless { it in keyboards }, homes)?.let { taken[id] = it }
|
||||
}
|
||||
ownWindows = shown
|
||||
if (current !== this) return
|
||||
if (taken != holders) Log.i(TAG, "bottom screen holders: $taken")
|
||||
holders = taken
|
||||
|
||||
@@ -73,7 +73,7 @@ object ControllerPrefs {
|
||||
keyboardButton = p.getBoolean("keyboardButton", true),
|
||||
selectSteam = p.getBoolean("selectSteam", false),
|
||||
joyConVr = joyConVr(context),
|
||||
snap = p.getBoolean("snap", true),
|
||||
snap = snap(context),
|
||||
customLayout = p.all.keys.any { it.startsWith("layout.") },
|
||||
mapping = mappable.associate { (id, _) -> id to target(context, id) },
|
||||
deadZone = deadZone(context),
|
||||
@@ -87,11 +87,6 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putString("map.$id", target).apply()
|
||||
}
|
||||
|
||||
fun resetMapping(context: Context) {
|
||||
val p = prefs(context)
|
||||
p.edit().apply { p.all.keys.filter { it.startsWith("map.") }.forEach { remove(it) } }.apply()
|
||||
}
|
||||
|
||||
fun setTint(context: Context, tint: Int) {
|
||||
prefs(context).edit().putInt("tint", tint).apply()
|
||||
}
|
||||
@@ -129,10 +124,6 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("steamButton", on).apply()
|
||||
}
|
||||
|
||||
fun setQamButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("qamButton", on).apply()
|
||||
}
|
||||
|
||||
fun setKeyboardButton(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("keyboardButton", on).apply()
|
||||
}
|
||||
@@ -147,6 +138,8 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("joyConVr", on).apply()
|
||||
}
|
||||
|
||||
fun snap(context: Context): Boolean = prefs(context).getBoolean("snap", true)
|
||||
|
||||
fun setSnap(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("snap", on).apply()
|
||||
}
|
||||
@@ -172,9 +165,4 @@ object ControllerPrefs {
|
||||
fun resetAll(context: Context) {
|
||||
prefs(context).edit().clear().putBoolean("rumble", true).apply()
|
||||
}
|
||||
|
||||
fun resetAllLayouts(context: Context) {
|
||||
val p = prefs(context)
|
||||
p.edit().apply { p.all.keys.filter { it.startsWith("layout.") }.forEach { remove(it) } }.apply()
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,14 @@ class DirectTouchHold(
|
||||
handler.removeCallbacks(hold)
|
||||
}
|
||||
|
||||
fun cancelTouch() {
|
||||
handler.removeCallbacks(hold)
|
||||
handler.removeCallbacks(lift)
|
||||
liftPending = false
|
||||
if (forwarded) touch.cancel()
|
||||
forwarded = false
|
||||
}
|
||||
|
||||
fun onTouch(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
if (liftPending) {
|
||||
|
||||
@@ -4,12 +4,13 @@ import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Display
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
object InputLibraries {
|
||||
private const val VERSION = 12
|
||||
private const val VERSION = 14
|
||||
private const val KEY = "library"
|
||||
private const val RECOVERY = "library-recovery."
|
||||
const val OVERLAY = "builtin.overlay"
|
||||
@@ -30,6 +31,8 @@ object InputLibraries {
|
||||
if (version < 11) library = neutralStockLayouts(library)
|
||||
if (version < 10) library = restyleGamepads(context, library)
|
||||
if (version < 12) library = barStockLayouts(library)
|
||||
if (version < 13) library = LegacyScreenKeyMigration.pin(library)
|
||||
if (version < 14) library = deckGamepadSurfaces(context, library)
|
||||
if (version < 6 || !p.contains(LegacyScreenKeyMigration.BACKUP)) {
|
||||
val legacy = LegacyScreenKeyMigration.snapshot(context)
|
||||
if (legacy != null) {
|
||||
@@ -263,6 +266,15 @@ object InputLibraries {
|
||||
return addStockLayouts(result)
|
||||
}
|
||||
|
||||
private fun deckGamepadSurfaces(context: Context, library: InputLibrary): InputLibrary {
|
||||
val leftPad = SessionPrefs.gamepadSurface(context, right = false) == SessionPrefs.SURFACE_TRACKPAD
|
||||
val rightStick = SessionPrefs.gamepadSurface(context, right = true) == SessionPrefs.SURFACE_STICK
|
||||
if (!leftPad && !rightStick) return library
|
||||
val saved = library.layouts.firstOrNull { it.id == DeckPanel.GAMEPAD.layout } ?: return library
|
||||
if (saved.elements != InputStockLayouts.gamepad(leftPad = false, rightStick = false)) return library
|
||||
return library.withLayout(saved.copy(elements = InputStockLayouts.gamepad(leftPad, rightStick))).validate()
|
||||
}
|
||||
|
||||
private fun migrationBindingId(library: InputLibrary, binding: String, layout: String): String {
|
||||
val seed = JSONArray(listOf(binding, layout)).toString().toByteArray(Charsets.UTF_8)
|
||||
val base = "migrated.bindings." + UUID.nameUUIDFromBytes(seed)
|
||||
@@ -292,7 +304,7 @@ object InputLibraries {
|
||||
for (e in layout.elements) elements.put(JSONObject().put("id", e.id).put("kind", e.kind.name)
|
||||
.put("control", e.control.id).put("name", e.name).put("x", e.x.toDouble()).put("y", e.y.toDouble())
|
||||
.put("width", e.width.toDouble()).put("height", e.height.toDouble()).put("floating", e.floating).put("hideWhenIdle", e.hideWhenIdle).put("mouseGestures", e.mouseGestures).put("toggle", e.toggle).put("icon", e.icon).put("image", e.image)
|
||||
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel))
|
||||
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel).put("alwaysShown", e.alwaysShown))
|
||||
layouts.put(JSONObject().put("id", layout.id).put("name", layout.name).put("template", layout.template.name).put("elements", elements)
|
||||
.put("tint", layout.style.tint ?: JSONObject.NULL).put("opacity", layout.style.opacity).put("screen", layout.screen.name).put("aspect", layout.aspect.toDouble()).put("scale", layout.scale.toDouble())
|
||||
.put("hidden", JSONArray(layout.style.hidden.map { it.name }.sorted())).put("origin", layout.origin ?: JSONObject.NULL))
|
||||
@@ -333,7 +345,7 @@ object InputLibraries {
|
||||
e.getDouble("y").toFloat(), e.getDouble("width").toFloat(), e.getDouble("height").toFloat(),
|
||||
e.getString("id"), InputElementKind.valueOf(e.getString("kind")), e.getString("name"),
|
||||
e.optBoolean("floating", true), e.optBoolean("hideWhenIdle", false), e.optBoolean("mouseGestures", false), e.optBoolean("toggle", false), e.optInt("icon", 0), e.optString("image", ""),
|
||||
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true))
|
||||
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true), e.optBoolean("alwaysShown", false))
|
||||
}
|
||||
SavedInputLayout(layout.getString("id"), layout.getString("name"), elements, InputLayoutTemplate.valueOf(layout.getString("template")),
|
||||
InputSurfaceStyle(if (!layout.has("tint")) ControllerPrefs.STEAM_BLUE else if (layout.isNull("tint")) null else layout.getInt("tint"), layout.optInt("opacity", 100),
|
||||
|
||||
@@ -45,6 +45,14 @@ internal object InputStockLayouts {
|
||||
InputSurfaceElement(InputControl.SELECT, .42f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL),
|
||||
InputSurfaceElement(InputControl.START, .51f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL))
|
||||
}
|
||||
fun gamepad(leftPad: Boolean, rightStick: Boolean) = gamepad().map {
|
||||
when {
|
||||
leftPad && it.control == InputControl.LS -> InputSurfaceElement(InputControl.LEFT_PAD, it.x, it.y, it.width, it.height)
|
||||
rightStick && it.control == InputControl.RIGHT_PAD -> InputSurfaceElement(InputControl.RS, it.x, it.y, it.width, it.height)
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
|
||||
fun layouts() = listOf(
|
||||
SavedInputLayout(InputLibraries.DECK, "Deck grips + trackpads", pads() + grips(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
|
||||
SavedInputLayout(InputLibraries.TRACKPAD, "Trackpad + keyboard", listOf(
|
||||
|
||||
@@ -34,6 +34,7 @@ data class InputSurfaceElement(
|
||||
val opacity: Float = 1f,
|
||||
val buttonShape: InputButtonShape = InputButtonShape.ROUNDED,
|
||||
val showLabel: Boolean = true,
|
||||
val alwaysShown: Boolean = false,
|
||||
) {
|
||||
val hasClick get() = kind == InputElementKind.STICK || kind == InputElementKind.TRACKPAD
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class InputSurfaceSource(private val pad: PadBridge, private val role: InputRole
|
||||
private val gestureRight = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(273)) else null
|
||||
private val gestureMiddle = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(274)) else null
|
||||
val mouseGestures get() = element.mouseGestures && route?.action is InputAction.Pointer
|
||||
val directions get() = route?.action as? InputAction.Directions
|
||||
|
||||
fun gestureContact(touching: Boolean) { if (!closed) route?.update(0f, 0f, touching) }
|
||||
fun pointerDelta(dx: Float, dy: Float) {
|
||||
|
||||
@@ -225,7 +225,8 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
|
||||
)
|
||||
}
|
||||
|
||||
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean): List<InputSurfaceElement> {
|
||||
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean, left: InputControl = InputControl.LS,
|
||||
right: InputControl = InputControl.RS): List<InputSurfaceElement> {
|
||||
val boxes = GamepadGeometry.layout(width.toFloat(), height.toFloat(), resources.displayMetrics.density, portrait)
|
||||
val placed = mutableListOf<InputSurfaceElement>()
|
||||
fun box(control: InputControl, slot: GamepadGeometry.Slot, shape: InputButtonShape) {
|
||||
@@ -243,9 +244,9 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
|
||||
b.cx + direction.first * spread, b.cy + direction.second * spread, radius)
|
||||
}
|
||||
}
|
||||
box(InputControl.LS, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
|
||||
box(left, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
|
||||
cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND)
|
||||
box(InputControl.RS, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
|
||||
box(right, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
|
||||
cross(FACE_ORDER, GamepadGeometry.Slot.RIGHT_SECOND)
|
||||
box(InputControl.LT, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER, InputButtonShape.PILL)
|
||||
box(InputControl.LB, GamepadGeometry.Slot.LEFT_INNER_SHOULDER, InputButtonShape.PILL)
|
||||
@@ -254,7 +255,8 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
|
||||
box(InputControl.SELECT, GamepadGeometry.Slot.VIEW, InputButtonShape.PILL)
|
||||
box(InputControl.START, GamepadGeometry.Slot.MENU, InputButtonShape.PILL)
|
||||
if (grips) GRIPS.forEach { (control, slot) -> box(control, slot, InputButtonShape.ROUNDED) }
|
||||
return withGeometry(stored, placed) + placed.filter { p -> GRIPS.any { it.first == p.control } && stored.none { it.id == p.id } }
|
||||
val surfaces = listOf(left, right).filter { it.isTrackpad }
|
||||
return withGeometry(stored, placed) + placed.filter { p -> (GRIPS.any { it.first == p.control } || p.control in surfaces) && stored.none { it.id == p.id } }
|
||||
}
|
||||
|
||||
private fun circle(e: InputSurfaceElement, cx: Float, cy: Float, radius: Float) =
|
||||
|
||||
@@ -40,7 +40,7 @@ internal object LegacyScreenKeyMigration {
|
||||
if (result.profiles.any { it.id == id }) continue
|
||||
val converted = convert(context, set.second, metrics.widthPixels, metrics.heightPixels, metrics.density)
|
||||
if (converted.isEmpty()) continue
|
||||
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first }, style = style, screen = InputLayoutScreen.MAIN,
|
||||
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first.copy(alwaysShown = true) }, style = style, screen = InputLayoutScreen.MAIN,
|
||||
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
|
||||
val bindings = SavedInputBindings("$id.bindings", set.first, false, layout.id, elements = converted.associate { it.first.id to it.second })
|
||||
val base = library.profile(library.steam)
|
||||
@@ -68,6 +68,17 @@ internal object LegacyScreenKeyMigration {
|
||||
return result.validate()
|
||||
}
|
||||
|
||||
fun pin(library: InputLibrary): InputLibrary {
|
||||
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" }?.elements.orEmpty()
|
||||
return library.copy(layouts = library.layouts.map { layout ->
|
||||
val imported = layout.id.startsWith("migrated.screenkeys.")
|
||||
layout.copy(elements = layout.elements.map { e ->
|
||||
val key = e.id.startsWith("imported:") && (imported || keys.any { it.id == e.id && it.kind == e.kind && it.name == e.name })
|
||||
if (key) e.copy(alwaysShown = true) else e
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
internal fun convert(context: Context, raw: String, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
|
||||
convert(context, parse(raw), width, height, density)
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
|
||||
private set
|
||||
var isQuickHidden = false
|
||||
private set
|
||||
var isPinnedOnly = false
|
||||
private set
|
||||
val hasPinned get() = layout?.elements?.any { it.alwaysShown } == true
|
||||
var onTouchMiss: ((MotionEvent) -> Unit)?
|
||||
get() = surface.onTouchMiss
|
||||
set(value) { surface.onTouchMiss = value }
|
||||
@@ -61,6 +64,13 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
|
||||
render()
|
||||
}
|
||||
|
||||
fun setPinnedOnly(enabled: Boolean) {
|
||||
if (isPinnedOnly == enabled) return
|
||||
releaseAll()
|
||||
isPinnedOnly = enabled
|
||||
render()
|
||||
}
|
||||
|
||||
private data class Placement(val layout: SavedInputLayout?, val width: Int, val height: Int, val safe: Rect, val buttonsOnly: Boolean)
|
||||
|
||||
private fun placed(): List<InputSurfaceElement> {
|
||||
@@ -72,12 +82,16 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
|
||||
|
||||
private fun render() {
|
||||
val elements = placed()
|
||||
surface.updateCustomLayout(if (isQuickHidden) elements.filter { it.id == "builtin:keyboard" } else elements)
|
||||
surface.updateCustomLayout(when {
|
||||
isPinnedOnly -> elements.filter { it.alwaysShown }
|
||||
isQuickHidden -> elements.filter { it.id == "builtin:keyboard" || it.alwaysShown }
|
||||
else -> elements
|
||||
})
|
||||
val style = layout?.style ?: InputSurfaceStyle()
|
||||
surface.updateStyle(style)
|
||||
quickToggle.tint = style.tint
|
||||
quickToggle.alpha = style.opacity / 100f
|
||||
quickToggle.visibility = if (isButtonsOnly || layout == null || dock != null) GONE else VISIBLE
|
||||
quickToggle.visibility = if (isButtonsOnly || isPinnedOnly || layout == null || dock != null) GONE else VISIBLE
|
||||
quickToggle.invalidate()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,18 @@ import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
object OnScreenLayout {
|
||||
data class Dock(val portrait: Boolean, val grips: Boolean)
|
||||
data class Dock(val portrait: Boolean, val grips: Boolean, val left: InputControl = InputControl.LS, val right: InputControl = InputControl.RS) {
|
||||
companion object {
|
||||
fun deck(portrait: Boolean, gamepad: SavedInputLayout?): Dock {
|
||||
fun side(right: Boolean) = gamepad?.elements?.firstOrNull {
|
||||
(it.kind == InputElementKind.STICK || it.kind == InputElementKind.TRACKPAD) && (it.x + it.width / 2f >= .5f) == right
|
||||
}?.kind
|
||||
val left = if (side(false) == InputElementKind.TRACKPAD) InputControl.LEFT_PAD else InputControl.LS
|
||||
val right = if (side(true) == InputElementKind.STICK) InputControl.RS else InputControl.RIGHT_PAD
|
||||
return Dock(portrait, true, left, right)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TOLERANCE = .01f
|
||||
private const val KEYBOARD_ID = "builtin:keyboard"
|
||||
@@ -17,13 +28,13 @@ object OnScreenLayout {
|
||||
val stored = layout?.elements ?: return emptyList()
|
||||
if (width <= 0 || height <= 0) return stored
|
||||
val generator = LegacyGamepadLayout(context, width, height, safe, layout.scale)
|
||||
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored)
|
||||
val (reference, generated) = reference(context, layout, width, height)
|
||||
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored) + fit(stored.filter { it.alwaysShown }, reference.first, reference.second, width, height, safe)
|
||||
val gamepad = if (generated) stored.filter(::isGamepad) else emptyList()
|
||||
val others = fit(stored.filterNot { generated && isGamepad(it) }, reference.first, reference.second, width, height, safe)
|
||||
val others = fit(stored.filterNot { generated && isGamepad(it) || dock != null && it.alwaysShown }, reference.first, reference.second, width, height, safe)
|
||||
val placed = when {
|
||||
gamepad.isEmpty() -> emptyList()
|
||||
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips)
|
||||
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips, dock.left, dock.right)
|
||||
else -> generator.regenerate(gamepad)
|
||||
}
|
||||
val byId = (placed + others).associateBy { it.id }
|
||||
|
||||
@@ -52,4 +52,7 @@ object SecondScreenDisplays {
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
fun editorCandidates(selected: Int, available: List<Int>, taken: (Int) -> Boolean): List<Int> =
|
||||
(listOf(selected) + available).distinct().filterNot(taken)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ object DesktopCatalog {
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.json"
|
||||
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
|
||||
const val PROTON_SEED_ID = "proton-arm64"
|
||||
const val APPIMAGE_EXEC = "/usr/local/bin/droiddeck-appimage-exec"
|
||||
|
||||
class Entry(
|
||||
val id: String, val name: String, val tier: Int, val version: String, val kind: String,
|
||||
@@ -216,7 +217,7 @@ object DesktopCatalog {
|
||||
target.setExecutable(true, false)
|
||||
FileUtils.writeString(File(root, "usr/share/applications/droiddeck-${entry.id}.desktop"),
|
||||
"[Desktop Entry]\nType=Application\nName=${entry.name}\n" +
|
||||
"Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Exec=$APPIMAGE_EXEC /opt/appimages/${entry.id}.AppImage\n" +
|
||||
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
|
||||
}
|
||||
else -> {
|
||||
|
||||
@@ -21,9 +21,11 @@ object LegacyNames {
|
||||
"etc/bannerlator-network.json" to "etc/droiddeck-network.json",
|
||||
"etc/bannerlator-wifi-scan-request" to "etc/droiddeck-wifi-scan-request",
|
||||
)
|
||||
private const val MENU_DIR = "usr/local/share/applications"
|
||||
private const val OLD_BIN = "/usr/local/bin/bannerlator-"
|
||||
private const val NEW_BIN = "/usr/local/bin/droiddeck-"
|
||||
private val MENU_REWRITES = listOf(
|
||||
Triple("usr/local/share/applications", "/usr/local/bin/bannerlator-", "/usr/local/bin/droiddeck-"),
|
||||
Triple("usr/share/applications", "Exec=env APPIMAGE_EXTRACT_AND_RUN=1 /opt/appimages/",
|
||||
"Exec=${DesktopCatalog.APPIMAGE_EXEC} /opt/appimages/"),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
@@ -95,14 +97,16 @@ object LegacyNames {
|
||||
}
|
||||
|
||||
private fun rewriteMenuEntries(root: File) {
|
||||
val entries = File(root, MENU_DIR).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: return
|
||||
for (entry in entries) {
|
||||
val text = FileUtils.readString(entry) ?: continue
|
||||
if (!text.contains(OLD_BIN)) continue
|
||||
val staged = File(entry.parentFile, entry.name + ".staged")
|
||||
if (!FileUtils.writeString(staged, text.replace(OLD_BIN, NEW_BIN)) || !staged.renameTo(entry)) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not rewrite $entry")
|
||||
for ((dir, old, new) in MENU_REWRITES) {
|
||||
val entries = File(root, dir).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: continue
|
||||
for (entry in entries) {
|
||||
val text = FileUtils.readString(entry) ?: continue
|
||||
if (!text.contains(old)) continue
|
||||
val staged = File(entry.parentFile, entry.name + ".staged")
|
||||
if (!FileUtils.writeString(staged, text.replace(old, new)) || !staged.renameTo(entry)) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not rewrite $entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,12 @@ public final class LinuxRuntimeInstaller {
|
||||
private LinuxRuntimeInstaller() {}
|
||||
|
||||
public static String installedVersion(Context context) {
|
||||
return LinuxRuntime.isInstalled(context) ? rootfsVersion(context) : null;
|
||||
}
|
||||
|
||||
private static String rootfsVersion(Context context) {
|
||||
File marker = new File(LinuxRuntime.rootDir(context), VERSION_FILE);
|
||||
if (!marker.isFile() || !LinuxRuntime.isInstalled(context)) return null;
|
||||
if (!marker.isFile()) return null;
|
||||
String v = FileUtils.readString(marker);
|
||||
return v == null ? null : v.trim();
|
||||
}
|
||||
@@ -144,6 +148,10 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
};
|
||||
boolean desktop = DesktopCatalog.INSTANCE.desktopInstalled(app);
|
||||
if (packsOnly(app, release, progress)) {
|
||||
job.ok = true;
|
||||
return true;
|
||||
}
|
||||
job.ok = installOnce(app, release, progress);
|
||||
if (job.ok && desktop) keepDesktop(app, progress);
|
||||
return job.ok;
|
||||
@@ -283,6 +291,31 @@ public final class LinuxRuntimeInstaller {
|
||||
for (File file : files) file.delete();
|
||||
}
|
||||
|
||||
private static kotlin.jvm.functions.Function1<AddonPack.State, kotlin.Unit> packProgress(ProgressListener listener, String stage) {
|
||||
return state -> {
|
||||
if (listener != null && state instanceof AddonPack.State.Downloading) {
|
||||
listener.onProgress(Step.DOWNLOADING, stage, Math.round(((AddonPack.State.Downloading) state).getProgress() * 100f));
|
||||
}
|
||||
return kotlin.Unit.INSTANCE;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean packsOnly(Context context, Release release, ProgressListener listener) {
|
||||
if (!LinuxRuntime.hasRootfs(context) || LinuxRuntime.isInstalled(context) || !release.version.equals(rootfsVersion(context))) return false;
|
||||
File root = LinuxRuntime.rootDir(context);
|
||||
try {
|
||||
recoverInterruptedSwap(root, new File(root.getParentFile(), LinuxRuntime.DIR + ".new"), new File(root.getParentFile(), LinuxRuntime.DIR + ".old"));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "packs only: " + e);
|
||||
return false;
|
||||
}
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
return AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& LinuxRuntime.isInstalled(context);
|
||||
}
|
||||
|
||||
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
|
||||
File archive = new File(context.getCacheDir(), archiveName(release));
|
||||
boolean keepPartial = false;
|
||||
@@ -294,10 +327,10 @@ public final class LinuxRuntimeInstaller {
|
||||
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context)
|
||||
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
|
||||
&& !SessionPrefs.allowStockProot(context))
|
||||
throw new IOException("Cannot download proot");
|
||||
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
|
||||
if (!AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))) throw new IOException("Cannot download DirectAudio");
|
||||
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
|
||||
if (listener != null) {
|
||||
listener.onProgress(Step.DOWNLOADING, downloading,
|
||||
|
||||
@@ -35,6 +35,7 @@ object UserApps {
|
||||
private const val GITHUB_REPO = "github-repo"
|
||||
private const val GITHUB_TAG = "github-tag"
|
||||
private const val GITHUB_PUBLISHED = "github-published"
|
||||
private const val GITHUB_ASSET = "github-asset"
|
||||
|
||||
enum class Kind { SCRIPT, APPIMAGE, FLATPAK }
|
||||
|
||||
@@ -56,7 +57,7 @@ object UserApps {
|
||||
sealed class Source {
|
||||
class Script(val file: File) : Source()
|
||||
class AppImage(val file: File) : Source()
|
||||
class GitHub(val repo: String) : Source()
|
||||
class GitHub(val repo: String, val asset: String? = null) : Source()
|
||||
class Flatpak(val id: String) : Source()
|
||||
class FlatpakBundle(val file: File) : Source()
|
||||
}
|
||||
@@ -113,7 +114,7 @@ object UserApps {
|
||||
when (val s = request.source) {
|
||||
is Source.Script -> addScript(context, s.file, name, icon, request.meta, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, s.asset, name, icon, onProgress)
|
||||
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
|
||||
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
|
||||
}
|
||||
@@ -305,14 +306,26 @@ object UserApps {
|
||||
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
|
||||
}
|
||||
|
||||
internal fun pickAsset(names: List<String>): String? {
|
||||
fun githubAsset(input: String): String? =
|
||||
Regex("github\\.com/[\\w.-]+/[\\w.-]+/releases/(?:download/[^/]+|latest/download)/([^/?#]+\\.appimage)(?:[?#].*)?$", RegexOption.IGNORE_CASE)
|
||||
.find(input.trim())?.groupValues?.get(1)?.let { runCatching { java.net.URLDecoder.decode(it, "UTF-8") }.getOrNull() }
|
||||
|
||||
private fun assetVariant(name: String): Set<String> =
|
||||
name.lowercase().replace(Regex("x86[_-]64|amd64"), "x64").replace(Regex("x86[_-]32|i[36]86"), "x32")
|
||||
.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() && !Regex("[0-9a-f]{7,}|v?[0-9]+").matches(it) }.toSet()
|
||||
|
||||
internal fun pickAsset(names: List<String>, wanted: String? = null): String? {
|
||||
val images = names.filter { it.endsWith(".appimage", ignoreCase = true) }
|
||||
if (wanted != null) {
|
||||
images.firstOrNull { it == wanted }?.let { return it }
|
||||
images.firstOrNull { assetVariant(it) == assetVariant(wanted) }?.let { return it }
|
||||
}
|
||||
return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { Regex("x86_64|x86-64|amd64|x64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
}
|
||||
|
||||
private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> {
|
||||
private fun latestRelease(context: Context, repo: String, wanted: String? = null): Pair<Release?, String?> {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15")
|
||||
val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() }
|
||||
?: return null to context.getString(R.string.user_apps_github_unreachable, repo)
|
||||
@@ -321,7 +334,7 @@ object UserApps {
|
||||
if (r.optBoolean("draft")) return@mapNotNull null
|
||||
val assets = r.optJSONArray("assets") ?: return@mapNotNull null
|
||||
val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it } }
|
||||
pickAsset(byName.keys.toList())?.let { asset ->
|
||||
pickAsset(byName.keys.toList(), wanted)?.let { asset ->
|
||||
r.optBoolean("prerelease") to byName.getValue(asset).let { a ->
|
||||
Release(r.optString("tag_name", asset), r.optString("published_at"), asset, a.getString("browser_download_url"),
|
||||
Hashes.githubSha256(a.optString("digest")))
|
||||
@@ -344,11 +357,11 @@ object UserApps {
|
||||
}
|
||||
|
||||
private fun githubMeta(repo: String, release: Release) =
|
||||
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published)
|
||||
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published, GITHUB_ASSET to release.asset)
|
||||
|
||||
private fun addFromGitHub(context: Context, repo: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
private fun addFromGitHub(context: Context, repo: String, asset: String?, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
onProgress(context.getString(R.string.user_apps_github_checking, repo), -1)
|
||||
val (release, problem) = latestRelease(context, repo)
|
||||
val (release, problem) = latestRelease(context, repo, asset)
|
||||
if (release == null) return problem
|
||||
return withDownload(context, release, onProgress) { file ->
|
||||
AppImageManager.import(context, file, name ?: repo.substringAfter('/'), icon, githubMeta(repo, release)) { onProgress(it, -1) }
|
||||
@@ -381,9 +394,10 @@ object UserApps {
|
||||
|
||||
fun checkUpdate(context: Context, app: App): UpdateCheck {
|
||||
val repo = app.repo ?: return UpdateCheck.Failed(context.getString(R.string.user_apps_update_failed))
|
||||
val (release, problem) = latestRelease(context, repo)
|
||||
val dir = AppImageManager.dir(context, app.key.substringAfter(':'))
|
||||
val (release, problem) = latestRelease(context, repo, FileUtils.readString(File(dir, GITHUB_ASSET))?.trim()?.takeIf { it.isNotEmpty() })
|
||||
if (release == null) return UpdateCheck.Failed(problem ?: context.getString(R.string.user_apps_update_failed))
|
||||
val published = FileUtils.readString(File(AppImageManager.dir(context, app.key.substringAfter(':')), GITHUB_PUBLISHED))?.trim().orEmpty()
|
||||
val published = FileUtils.readString(File(dir, GITHUB_PUBLISHED))?.trim().orEmpty()
|
||||
val newer = if (published.isNotEmpty() && release.published.isNotEmpty()) release.published > published else release.tag != app.version
|
||||
return if (newer) UpdateCheck.Available(release) else UpdateCheck.Current(app.version ?: release.tag)
|
||||
}
|
||||
|
||||
@@ -68,8 +68,9 @@ object GameStorage {
|
||||
externalStoragePath: Boolean = isExternalStoragePath(path),
|
||||
): Boolean = when {
|
||||
state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY -> true
|
||||
path.isDirectory -> true
|
||||
externalStoragePath -> false
|
||||
state == Environment.MEDIA_UNKNOWN || state == null -> path.isDirectory || path.parentFile?.isDirectory == true
|
||||
state == Environment.MEDIA_UNKNOWN || state == null -> path.parentFile?.isDirectory == true
|
||||
else -> false
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ object SessionDisplay {
|
||||
|
||||
const val UNFOLDED_MIN_DP = 600
|
||||
|
||||
fun unfoldedGameHeight(width: Int, height: Int): Int =
|
||||
minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv()
|
||||
fun unfoldedGameHeight(width: Int, height: Int, tabRow: Int = 0): Int =
|
||||
(minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) - tabRow.coerceAtLeast(0)).coerceAtLeast(0) and 1.inv()
|
||||
|
||||
fun unfoldedControlsWanted(
|
||||
enabled: Boolean,
|
||||
|
||||
@@ -40,6 +40,7 @@ object SessionFiles {
|
||||
WinComponents.publish(context)
|
||||
val files = arrayOf(
|
||||
"usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env",
|
||||
"usr/local/bin/droiddeck-game-gpu" to "usr/local/bin/droiddeck-game-gpu",
|
||||
"usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory",
|
||||
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
|
||||
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
|
||||
@@ -112,6 +113,7 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
|
||||
"usr/local/bin/droiddeck-flatpak-run" to "usr/local/bin/droiddeck-flatpak-run",
|
||||
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
|
||||
"usr/local/bin/droiddeck-appimage-exec" to "usr/local/bin/droiddeck-appimage-exec",
|
||||
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
|
||||
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
|
||||
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
|
||||
|
||||
@@ -1555,10 +1555,6 @@ object SessionPrefs {
|
||||
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
|
||||
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
|
||||
|
||||
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
|
||||
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
|
||||
}
|
||||
|
||||
fun deckPanel(context: Context): com.droiddeck.launcher.input.DeckPanel =
|
||||
com.droiddeck.launcher.input.DeckPanel.from(prefs(context).getString("deckPanel", null))
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ object CloudSaves {
|
||||
val item = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
|
||||
}
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> ""
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> ""
|
||||
} ?: return null
|
||||
p.edit()
|
||||
.putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE })
|
||||
@@ -148,7 +148,7 @@ object CloudSaves {
|
||||
val dir = when (store) {
|
||||
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
|
||||
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> null
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null
|
||||
}
|
||||
return dir to (if (dir == null) "unresolved" else "ok")
|
||||
}
|
||||
@@ -173,7 +173,7 @@ object CloudSaves {
|
||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
|
||||
}
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> null
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null
|
||||
}
|
||||
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
|
||||
|
||||
@@ -13,11 +13,13 @@ internal object LoginFlows {
|
||||
Store.EPIC -> epicFlow()
|
||||
Store.AMAZON -> amazonFlow()
|
||||
Store.GAMEJOLT -> gameJoltFlow(browserUserAgent)
|
||||
Store.EA -> eaFlow(browserUserAgent)
|
||||
Store.ITCH, Store.XBOX, Store.BATTLENET -> null
|
||||
}
|
||||
|
||||
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
@Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
@Volatile var eaFlow: (String) -> StoreLoginActivity.Flow? = { null }
|
||||
@Volatile var gameJoltFlow: (String) -> StoreLoginActivity.Flow? = { null }
|
||||
|
||||
private class GogFlow(private val state: String, override val userAgent: String) : StoreLoginActivity.Flow {
|
||||
|
||||
@@ -7,7 +7,8 @@ enum class Store(val id: String, val label: String, val shortLabel: String) {
|
||||
XBOX("xbox", "Xbox", "Xbox"),
|
||||
ITCH("itch", "Itch.io", "Itch.io"),
|
||||
GAMEJOLT("gamejolt", "Game Jolt", "Game Jolt"),
|
||||
BATTLENET("battlenet", "Battle.net", "Battle.net");
|
||||
BATTLENET("battlenet", "Battle.net", "Battle.net"),
|
||||
EA("ea", "EA", "EA");
|
||||
|
||||
val folder: String get() = if (this == ITCH) "itch" else shortLabel
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ private fun releaseDateValue(raw: String): Long =
|
||||
runCatching { java.time.LocalDate.parse(day).toEpochDay() }.getOrDefault(0L)
|
||||
} ?: 0L
|
||||
|
||||
internal fun entryTime(first: Long?, fallback: Long?): Long = first?.takeIf { it > 0 } ?: fallback?.takeIf { it > 0 } ?: 0L
|
||||
|
||||
fun <T> sortGames(
|
||||
items: List<T>,
|
||||
sort: String,
|
||||
|
||||
@@ -11,9 +11,12 @@ object StoreLaunch {
|
||||
const val LAUNCHER = ".droiddeck-launch.bat"
|
||||
const val EPIC_CODE = ".droiddeck-epic-code"
|
||||
const val EPIC_ARGS = ".droiddeck-epic-args"
|
||||
const val EA_ENV = ".droiddeck-ea-env"
|
||||
/** A sleeping stand-in that the launch hook installs; older EA SDKs require a running Origin process. */
|
||||
const val EA_ORIGIN = "C:\\Program Files (x86)\\Origin\\Origin.exe"
|
||||
|
||||
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
|
||||
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC
|
||||
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC || sidecar.store == Store.EA
|
||||
val file = File(folder, LAUNCHER)
|
||||
if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
|
||||
file.writeText(launcherText(sidecar))
|
||||
@@ -49,6 +52,14 @@ object StoreLaunch {
|
||||
lines += "del \"%~dp0$EPIC_ARGS\""
|
||||
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
|
||||
lines += "set \"$CLIENT_VAR=\" & \"%~dp0$exe\" %$CLIENT_VAR%"
|
||||
} else if (sidecar.store == Store.EA) {
|
||||
lines += "if not exist \"%~dp0$EA_ENV\" goto run"
|
||||
lines += "for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0$EA_ENV\") do set \"%%a=%%b\""
|
||||
lines += "del \"%~dp0$EA_ENV\""
|
||||
lines += ":run"
|
||||
lines += "if exist \"$EA_ORIGIN\" start \"\" \"$EA_ORIGIN\""
|
||||
lines += run
|
||||
lines += "taskkill /f /im Origin.exe >nul 2>&1"
|
||||
} else {
|
||||
lines += run
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ object StoreLaunchRequests {
|
||||
|
||||
fun stop() {
|
||||
com.droiddeck.launcher.stores.xbox.XboxRuntime.stop()
|
||||
com.droiddeck.launcher.stores.ea.EaLsx.stop()
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
@@ -58,6 +59,12 @@ object StoreLaunchRequests {
|
||||
"xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease")))
|
||||
"xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
|
||||
"ea-prepare" -> runCatching { lastLaunched = Store.EA to request.optString("id"); com.droiddeck.launcher.stores.ea.EaLaunch.prepare(app, request) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("reason", "error-" + it.javaClass.simpleName) }
|
||||
"ea-exited" -> {
|
||||
com.droiddeck.launcher.stores.ea.EaLsx.stop(request.optString("id"))
|
||||
JSONObject().put("ok", true)
|
||||
}
|
||||
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") }
|
||||
"epic-code" -> {
|
||||
|
||||
@@ -55,6 +55,7 @@ object StoreSizes {
|
||||
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.ITCH, Store.GAMEJOLT -> {}
|
||||
Store.EA -> com.droiddeck.launcher.stores.ea.EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.GOG, Store.XBOX, Store.BATTLENET -> {}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +112,7 @@ object StoreSizes {
|
||||
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
|
||||
AmazonManifest.parse(bytes).totalInstallSize
|
||||
}
|
||||
Store.EA -> com.droiddeck.launcher.stores.ea.EaSizes.size(context, item)
|
||||
Store.GOG, Store.XBOX, Store.BATTLENET -> 0L
|
||||
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
|
||||
Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item)
|
||||
|
||||
@@ -204,7 +204,8 @@ object BattleNetBackend : StoreBackend {
|
||||
fun installedGames(context: Context): List<InstalledStoreGame> = installed(context).map { game ->
|
||||
val art = BattleNet.art(context, game.title)
|
||||
val cover = art.cover?.let { Uri.fromFile(it).toString() }
|
||||
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
|
||||
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", installedAt = game.folder.lastModified(),
|
||||
cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
|
||||
}
|
||||
|
||||
private fun shelves(context: Context): StoreShelves {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.net.Uri
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class EaApiException(val status: Int, message: String) : Exception(message)
|
||||
|
||||
object EaApi {
|
||||
private const val GRAPHQL = "https://service-aggregation-layer.juno.ea.com/graphql"
|
||||
private const val LOCALE = "DEFAULT"
|
||||
|
||||
class Player(val userId: String, val personaId: String, val displayName: String, val eaId: String)
|
||||
|
||||
class OwnedProduct(
|
||||
val offerId: String,
|
||||
val productId: String,
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val baseSlug: String,
|
||||
val gameType: String,
|
||||
val downloadable: Boolean,
|
||||
val trial: Boolean,
|
||||
val ownership: List<String>,
|
||||
)
|
||||
|
||||
class Offer(
|
||||
val offerId: String,
|
||||
val contentId: String,
|
||||
val displayName: String,
|
||||
val displayType: String,
|
||||
val downloadable: Boolean,
|
||||
val executePathOverride: String,
|
||||
val installCheckOverride: String,
|
||||
val executeParameters: String,
|
||||
val multiplayerId: String,
|
||||
val masterTitleId: String,
|
||||
val gameVersion: String,
|
||||
val cloudSaves: String,
|
||||
val locales: List<String>,
|
||||
)
|
||||
|
||||
class Images(val tall: String?, val wide: String?, val logo: String?)
|
||||
|
||||
class Build(val buildId: String, val gameVersion: String, val liveDate: String)
|
||||
|
||||
private val queries = HashMap<String, String>()
|
||||
|
||||
internal fun query(name: String): String = synchronized(queries) {
|
||||
queries.getOrPut(name) {
|
||||
val stream = EaApi::class.java.getResourceAsStream("/ea/graphql/$name.gql") ?: throw IllegalStateException("missing query $name")
|
||||
stream.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun persisted(name: String): String =
|
||||
JSONObject().put("persistedQuery", JSONObject().put("version", 1).put("sha256Hash", EaCrypto.hex(EaCrypto.sha256(query(name).toByteArray())))).toString()
|
||||
|
||||
internal fun call(token: String, name: String, variables: JSONObject): JSONObject {
|
||||
val headers = mapOf("Authorization" to "Bearer $token", "Accept" to "application/json")
|
||||
val get = Uri.parse(GRAPHQL).buildUpon()
|
||||
.appendQueryParameter("extensions", persisted(name))
|
||||
.appendQueryParameter("operationName", name)
|
||||
.appendQueryParameter("variables", variables.toString())
|
||||
.build().toString()
|
||||
var reply = EaHttp.request(get, headers = headers)
|
||||
var json = runCatching { JSONObject(reply.text) }.getOrNull()
|
||||
if (!reply.ok || json == null || json.has("errors")) {
|
||||
val body = JSONObject()
|
||||
.put("extensions", JSONObject(persisted(name)))
|
||||
.put("variables", variables)
|
||||
.put("operationName", name)
|
||||
.put("query", query(name))
|
||||
reply = EaHttp.request(GRAPHQL, "POST", headers + ("Content-Type" to "application/json"), body.toString().toByteArray())
|
||||
json = runCatching { JSONObject(reply.text) }.getOrNull()
|
||||
}
|
||||
if (reply.status == 401) throw EaApiException(401, "EA refused the session")
|
||||
if (json == null) throw EaApiException(reply.status, "EA gave no answer to $name (${reply.status})")
|
||||
json.optJSONArray("errors")?.optJSONObject(0)?.let { throw EaApiException(reply.status, "$name: ${it.optString("message")}") }
|
||||
return json.optJSONObject("data") ?: throw EaApiException(reply.status, "$name returned no data")
|
||||
}
|
||||
|
||||
fun player(token: String): Player {
|
||||
val me = call(token, "GetUserPlayer", JSONObject()).optJSONObject("me") ?: throw EaApiException(0, "no player")
|
||||
val p = me.optJSONObject("player")
|
||||
return Player(
|
||||
userId = me.optString("pd"),
|
||||
personaId = p?.optString("psd").orEmpty(),
|
||||
displayName = p?.optString("displayName").orEmpty(),
|
||||
eaId = p?.optString("uniqueName").orEmpty().ifEmpty { p?.optString("displayName").orEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseOwned(page: JSONObject): Pair<List<OwnedProduct>, String?> {
|
||||
val owned = page.optJSONObject("me")?.optJSONObject("ownedGameProducts") ?: return emptyList<OwnedProduct>() to null
|
||||
val items = owned.optJSONArray("items") ?: JSONArray()
|
||||
val list = (0 until items.length()).mapNotNull { items.optJSONObject(it) }.mapNotNull { o ->
|
||||
val product = o.optJSONObject("product") ?: return@mapNotNull null
|
||||
val base = product.optJSONObject("baseItem")
|
||||
val user = product.optJSONObject("gameProductUser")
|
||||
val methods = user?.optJSONArray("ownershipMethods")
|
||||
OwnedProduct(
|
||||
offerId = o.optString("originOfferId").ifEmpty { return@mapNotNull null },
|
||||
productId = product.optString("id"),
|
||||
name = (base?.optString("title")?.takeIf { it.isNotBlank() } ?: product.optString("name")).replace("\n", " ").trim(),
|
||||
slug = product.optString("gameSlug"),
|
||||
baseSlug = base?.optString("baseGameSlug").orEmpty().takeIf { it != "null" }.orEmpty(),
|
||||
gameType = base?.optString("gameType").orEmpty(),
|
||||
downloadable = product.optBoolean("downloadable", true),
|
||||
trial = product.optJSONObject("trialDetails")?.optString("trialType").orEmpty().let { it.isNotEmpty() && it != "null" },
|
||||
ownership = (0 until (methods?.length() ?: 0)).map { methods!!.optString(it) },
|
||||
)
|
||||
}
|
||||
val next = owned.optString("next").takeIf { it.isNotBlank() && it != "null" }
|
||||
return list to next
|
||||
}
|
||||
|
||||
fun owned(token: String): List<OwnedProduct> {
|
||||
val out = ArrayList<OwnedProduct>()
|
||||
var next: String? = "0"
|
||||
var guard = 0
|
||||
while (next != null && guard++ < 50) {
|
||||
val variables = JSONObject()
|
||||
.put("isMac", false)
|
||||
.put("limit", 100)
|
||||
.put("next", next)
|
||||
.put("type", JSONArray().put("DIGITAL_FULL_GAME").put("PACKAGED_FULL_GAME"))
|
||||
.put("storefronts", JSONArray().put("EA"))
|
||||
.put("platforms", JSONArray().put("PC"))
|
||||
val (items, after) = parseOwned(call(token, "getPreloadedOwnedGames", variables))
|
||||
out += items
|
||||
next = after?.takeIf { it != next && items.isNotEmpty() }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun parseOffers(data: JSONObject): List<Offer> {
|
||||
val arr = data.optJSONArray("legacyOffers") ?: return emptyList()
|
||||
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.mapNotNull { o ->
|
||||
val downloads = o.optJSONArray("downloads")
|
||||
val version = (0 until (downloads?.length() ?: 0)).mapNotNull { downloads!!.optJSONObject(it) }
|
||||
.firstOrNull { it.optString("downloadType").equals("LIVE", true) }?.optString("gameVersion")
|
||||
?: downloads?.optJSONObject(0)?.optString("gameVersion")
|
||||
val locales = o.optJSONArray("softwareLocales")
|
||||
fun s(k: String) = o.optString(k).takeIf { it != "null" }.orEmpty()
|
||||
Offer(
|
||||
offerId = s("offerId").ifEmpty { return@mapNotNull null },
|
||||
contentId = s("contentId"),
|
||||
displayName = s("displayName"),
|
||||
displayType = s("displayType"),
|
||||
downloadable = o.optBoolean("isDownloadable", true),
|
||||
executePathOverride = s("executePathOverride"),
|
||||
installCheckOverride = s("installCheckOverride"),
|
||||
executeParameters = s("executeParameters"),
|
||||
multiplayerId = s("multiplayerId"),
|
||||
masterTitleId = s("primaryMasterTitleId"),
|
||||
gameVersion = version.orEmpty().takeIf { it != "null" }.orEmpty(),
|
||||
cloudSaves = s("cloudSaveConfigurationOverride"),
|
||||
locales = (0 until (locales?.length() ?: 0)).map { locales!!.optString(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun offers(token: String, ids: List<String>): List<Offer> = ids.distinct().chunked(50).flatMap { chunk ->
|
||||
parseOffers(call(token, "getLegacyCatalogDefs", JSONObject().put("offerIds", JSONArray(chunk))))
|
||||
}
|
||||
|
||||
private fun path(o: JSONObject?, vararg keys: String): String? {
|
||||
var cur = o ?: return null
|
||||
for (k in keys.dropLast(1)) cur = cur.optJSONObject(k) ?: return null
|
||||
return cur.optString(keys.last()).takeIf { it.startsWith("http") }
|
||||
}
|
||||
|
||||
internal fun parseImages(data: JSONObject): Images {
|
||||
val game = data.optJSONObject("game")
|
||||
val key = game?.optJSONObject("keyArt")
|
||||
val pack = game?.optJSONObject("packArt")
|
||||
return Images(
|
||||
tall = path(pack, "aspect9x16Image", "path") ?: path(key, "aspect9x16Image", "path"),
|
||||
wide = path(key, "aspect16x9Image", "path") ?: path(key, "largestImage", "path") ?: path(pack, "aspect2x1Image", "path"),
|
||||
logo = path(game, "primaryLogo", "largestImage", "path") ?: path(game, "primaryLogo", "path"),
|
||||
)
|
||||
}
|
||||
|
||||
fun images(token: String, slug: String): Images =
|
||||
parseImages(call(token, "GameImages", JSONObject().put("gameSlug", slug).put("locale", LOCALE)))
|
||||
|
||||
fun downloadUrl(token: String, offerId: String, buildId: String? = null): String {
|
||||
val data = call(token, "downloadUrl", JSONObject().put("offerId", offerId).put("buildId", buildId.orEmpty()))
|
||||
return data.optJSONObject("downloadUrl")?.optString("url").orEmpty().ifEmpty { throw EaApiException(0, "EA gave no download link") }
|
||||
}
|
||||
|
||||
fun builds(token: String, offerId: String): List<Build> {
|
||||
val arr = call(token, "availableBuilds", JSONObject().put("offerId", offerId)).optJSONArray("availableBuilds") ?: return emptyList()
|
||||
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map {
|
||||
Build(it.optString("buildId"), it.optString("gameVersion").takeIf { v -> v != "null" }.orEmpty(), it.optString("buildLiveDate"))
|
||||
}
|
||||
}
|
||||
|
||||
fun legacyEntitlements(token: String): List<JSONObject> {
|
||||
val out = ArrayList<JSONObject>()
|
||||
var next: String? = "0"
|
||||
var guard = 0
|
||||
while (next != null && guard++ < 50) {
|
||||
val variables = JSONObject().put("limit", 100).put("next", next).put("storeFronts", JSONArray().put("EA"))
|
||||
val page = call(token, "getLegacyEntitlements", variables).optJSONObject("me")?.optJSONObject("legacyEntitlements") ?: break
|
||||
val items = page.optJSONArray("items") ?: JSONArray()
|
||||
for (i in 0 until items.length()) items.optJSONObject(i)?.let(out::add)
|
||||
next = page.optString("next").takeIf { it.isNotBlank() && it != "null" && it != next && items.length() > 0 }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun entitlements(token: String, group: String): JSONArray {
|
||||
val variables = JSONObject()
|
||||
.put("productIds", JSONArray())
|
||||
.put("includeChildGroups", false)
|
||||
.put("groupNames", JSONArray().put(group))
|
||||
.put("entitlementTag", "")
|
||||
.put("pageSize", 100)
|
||||
.put("pageNumber", 1)
|
||||
return call(token, "sdkEntitlements", variables).optJSONObject("me")?.optJSONObject("sdkEntitlements")?.optJSONArray("entitlements") ?: JSONArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAccounts
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
object EaAuth {
|
||||
private const val TAG = "EaAuth"
|
||||
const val CLIENT_ID = "JUNO_PC_CLIENT"
|
||||
private const val CLIENT_SECRET = "4mRLtYMb6vq9qglomWEaT4ChxsXWcyqbQpuBNfMPOYOiDmYYQmjuaBsF2Zp0RyVeWkfqhE9TuGgAw7te"
|
||||
const val AUTH_URL = "https://accounts.ea.com/connect/auth"
|
||||
private const val TOKEN_URL = "https://accounts.ea.com/connect/token"
|
||||
const val REDIRECT = "qrc:///html/login_successful.html"
|
||||
private const val REFRESH_MARGIN_MS = 5 * 60 * 1000L
|
||||
private val refreshLock = Any()
|
||||
private val random = SecureRandom()
|
||||
|
||||
private val PC_SIGN_KEYS = mapOf(
|
||||
"v1" to "ISa3dpGOc8wW7Adn4auACSQmaccrOyR2".toByteArray(),
|
||||
"v2" to "nt5FfJbdPzNcl2pkC3zgjO43Knvscxft".toByteArray(),
|
||||
)
|
||||
|
||||
val OPAQUE_SCOPES = listOf(
|
||||
"basic.commerce.cartv2", "service.atom", "dp.client.default", "signin", "social_recommendation_user",
|
||||
"basic.optin.write", "basic.commerce.cartv2.write", "basic.billing", "external.social_information_ups_admin",
|
||||
)
|
||||
|
||||
class Account(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresAt: Long,
|
||||
val userId: String,
|
||||
val personaId: String,
|
||||
val displayName: String,
|
||||
val eaId: String,
|
||||
)
|
||||
|
||||
class Pending(val url: String, val verifier: String)
|
||||
|
||||
internal fun fromJson(o: JSONObject): Account? {
|
||||
val access = o.optString("access_token").ifEmpty { return null }
|
||||
return Account(
|
||||
accessToken = access,
|
||||
refreshToken = o.optString("refresh_token"),
|
||||
expiresAt = o.optLong("expires_at"),
|
||||
userId = o.optString("user_id"),
|
||||
personaId = o.optString("persona_id"),
|
||||
displayName = o.optString("display_name"),
|
||||
eaId = o.optString("ea_id"),
|
||||
)
|
||||
}
|
||||
|
||||
fun load(context: Context): Account? = StoreAccounts.read(context.applicationContext, Store.EA)?.let(::fromJson)
|
||||
|
||||
private fun deviceId(context: Context): String {
|
||||
val prefs = EaPrefs.get(context)
|
||||
prefs.getString("device", null)?.let { return it }
|
||||
val made = EaCrypto.hex(ByteArray(16).also(random::nextBytes))
|
||||
prefs.edit().putString("device", made).apply()
|
||||
return made
|
||||
}
|
||||
|
||||
internal fun pcSign(device: String, now: Date = Date(), version: String = if (random.nextBoolean()) "v1" else "v2"): String {
|
||||
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(now)
|
||||
val mid = EaCrypto.fnv1a(("DroidDeck" + device.uppercase() + "DroidDeck").toByteArray()).toString()
|
||||
val json = "{\"av\":\"v1\",\"bsn\":\"None\",\"gid\":0,\"hsn\":\"None\",\"mid\":\"$mid\",\"msn\":\"${device.uppercase()}\",\"sv\":\"$version\",\"ts\":\"$stamp\"}"
|
||||
val payload = EaCrypto.base64Url(json.toByteArray())
|
||||
val signature = EaCrypto.base64Url(EaCrypto.hmacSha256(PC_SIGN_KEYS.getValue(version), payload.toByteArray()))
|
||||
return "$payload.$signature"
|
||||
}
|
||||
|
||||
internal fun authUrl(
|
||||
device: String,
|
||||
responseType: String,
|
||||
challenge: String? = null,
|
||||
accessToken: String? = null,
|
||||
clientId: String = CLIENT_ID,
|
||||
scopes: List<String> = emptyList(),
|
||||
tokenFormat: String? = null,
|
||||
expiresIn: Int? = null,
|
||||
): String {
|
||||
val b = Uri.parse(AUTH_URL).buildUpon()
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("sbiod_enabled", "false")
|
||||
.appendQueryParameter("response_type", responseType)
|
||||
.appendQueryParameter("locale", "en_US")
|
||||
.appendQueryParameter("pc_sign", pcSign(device))
|
||||
.appendQueryParameter("nonce", random.nextInt().toString())
|
||||
if (scopes.isNotEmpty()) b.appendQueryParameter("scope", scopes.joinToString(" "))
|
||||
if (challenge != null) {
|
||||
b.appendQueryParameter("code_challenge_method", "S256")
|
||||
b.appendQueryParameter("code_challenge", challenge)
|
||||
}
|
||||
if (accessToken != null) b.appendQueryParameter("access_token", accessToken)
|
||||
if (tokenFormat != null) b.appendQueryParameter("token_format", tokenFormat)
|
||||
if (expiresIn != null) b.appendQueryParameter("expires_in", expiresIn.toString())
|
||||
return b.build().toString()
|
||||
}
|
||||
|
||||
fun begin(context: Context): Pending {
|
||||
val verifier = EaCrypto.base64Url(ByteArray(32).also(random::nextBytes))
|
||||
val challenge = EaCrypto.base64Url(EaCrypto.sha256(verifier.toByteArray()))
|
||||
return Pending(authUrl(deviceId(context), "code", challenge = challenge), verifier)
|
||||
}
|
||||
|
||||
/** Pulls one parameter out of the redirect EA sends to its desktop client's local page. */
|
||||
internal fun redirectValue(location: String, name: String): String? {
|
||||
val normal = location.replace("qrc:///html", "http://127.0.0.1").replace("qrc:/html", "http://127.0.0.1")
|
||||
val uri = Uri.parse(normal)
|
||||
uri.getQueryParameter(name)?.let { return it }
|
||||
val fragment = uri.encodedFragment ?: return null
|
||||
return Uri.parse("http://x/?$fragment").getQueryParameter(name)
|
||||
}
|
||||
|
||||
private fun tokenCall(values: List<Pair<String, String>>): JSONObject {
|
||||
val reply = EaHttp.request(
|
||||
TOKEN_URL, "POST",
|
||||
mapOf("Content-Type" to "application/x-www-form-urlencoded"),
|
||||
EaHttp.form(values + listOf("client_id" to CLIENT_ID, "client_secret" to CLIENT_SECRET)).toByteArray(),
|
||||
follow = false,
|
||||
)
|
||||
val json = runCatching { JSONObject(reply.text) }.getOrNull()
|
||||
if (!reply.ok || json == null || json.optString("access_token").isEmpty()) {
|
||||
throw IllegalStateException("EA token request failed (${reply.status}): ${json?.optString("error_description")?.ifEmpty { json.optString("error") } ?: ""}")
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
fun exchange(context: Context, code: String, verifier: String) {
|
||||
val token = tokenCall(
|
||||
listOf(
|
||||
"grant_type" to "authorization_code", "code" to code, "code_verifier" to verifier,
|
||||
"redirect_uri" to REDIRECT, "token_format" to "JWS",
|
||||
),
|
||||
)
|
||||
val access = token.getString("access_token")
|
||||
val player = runCatching { EaApi.player(access) }.onFailure { Log.w(TAG, "player: ${it.message}") }.getOrNull()
|
||||
val record = JSONObject()
|
||||
.put("access_token", access)
|
||||
.put("refresh_token", token.optString("refresh_token"))
|
||||
.put("expires_at", System.currentTimeMillis() + token.optLong("expires_in", 3600) * 1000)
|
||||
.put("user_id", player?.userId.orEmpty())
|
||||
.put("persona_id", player?.personaId.orEmpty())
|
||||
.put("display_name", player?.displayName.orEmpty())
|
||||
.put("ea_id", player?.eaId.orEmpty())
|
||||
StoreAccounts.write(context.applicationContext, Store.EA, record)
|
||||
}
|
||||
|
||||
private fun fresh(a: Account) = a.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || a.refreshToken.isEmpty()
|
||||
|
||||
private fun refreshLocked(context: Context, a: Account): String? {
|
||||
if (a.refreshToken.isEmpty()) return null
|
||||
val token = try {
|
||||
tokenCall(listOf("grant_type" to "refresh_token", "refresh_token" to a.refreshToken))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "refresh: ${e.message}")
|
||||
return null
|
||||
}
|
||||
val access = token.getString("access_token")
|
||||
val player = if (load(context)?.userId.isNullOrEmpty()) runCatching { EaApi.player(access) }.getOrNull() else null
|
||||
return access.takeIf { storeRefreshed(context, access, token.optLong("expires_in", 3600), token.optString("refresh_token"), player) }
|
||||
}
|
||||
|
||||
/** Writes a refreshed token into the saved sign-in, unless sign-out removed it meanwhile. */
|
||||
internal fun storeRefreshed(context: Context, access: String, expiresIn: Long, refresh: String, player: EaApi.Player? = null): Boolean =
|
||||
synchronized(refreshLock) {
|
||||
val app = context.applicationContext
|
||||
val record = StoreAccounts.read(app, Store.EA) ?: return false
|
||||
record.put("access_token", access).put("expires_at", System.currentTimeMillis() + expiresIn * 1000)
|
||||
refresh.takeIf { it.isNotEmpty() }?.let { record.put("refresh_token", it) }
|
||||
player?.let { record.put("user_id", it.userId).put("persona_id", it.personaId).put("display_name", it.displayName).put("ea_id", it.eaId) }
|
||||
StoreAccounts.write(app, Store.EA, record)
|
||||
true
|
||||
}
|
||||
|
||||
fun accessToken(context: Context): String? {
|
||||
val current = load(context) ?: return null
|
||||
if (fresh(current)) return current.accessToken
|
||||
synchronized(refreshLock) {
|
||||
val again = load(context) ?: return null
|
||||
if (fresh(again)) return again.accessToken
|
||||
return refreshLocked(context, again) ?: again.accessToken.takeIf { again.expiresAt > System.currentTimeMillis() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshNow(context: Context, stale: String?): String? = synchronized(refreshLock) {
|
||||
val current = load(context) ?: return null
|
||||
if (stale != null && current.accessToken != stale) return current.accessToken
|
||||
refreshLocked(context, current)
|
||||
}
|
||||
|
||||
/** Asks EA to mint a code or token for another client from the signed-in session, as the EA app does for games. */
|
||||
fun mint(
|
||||
context: Context,
|
||||
clientId: String,
|
||||
responseType: String,
|
||||
scopes: List<String> = emptyList(),
|
||||
tokenFormat: String? = null,
|
||||
expiresIn: Int? = null,
|
||||
): String? {
|
||||
val access = accessToken(context) ?: return null
|
||||
val url = authUrl(deviceId(context), responseType, accessToken = access, clientId = clientId, scopes = scopes, tokenFormat = tokenFormat, expiresIn = expiresIn)
|
||||
val reply = runCatching { EaHttp.request(url, follow = false) }.getOrElse {
|
||||
Log.w(TAG, "mint $clientId: ${it.javaClass.simpleName}")
|
||||
return null
|
||||
}
|
||||
val location = reply.header("Location") ?: run {
|
||||
Log.w(TAG, "mint $clientId: no redirect (${reply.status})")
|
||||
return null
|
||||
}
|
||||
if (location.startsWith("https://signin.ea.com")) {
|
||||
Log.w(TAG, "mint $clientId: the session needs a new sign-in")
|
||||
return null
|
||||
}
|
||||
return redirectValue(location, if (responseType == "token") "access_token" else responseType)
|
||||
}
|
||||
|
||||
/** Waits for any refresh in progress, so it cannot write the sign-in back afterwards. */
|
||||
fun signOut(context: Context) {
|
||||
synchronized(refreshLock) { StoreAccounts.clear(context.applicationContext, Store.EA) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.InstalledStoreGame
|
||||
import com.droiddeck.launcher.stores.LoginFlows
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreBackend
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreLoginActivity
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.WebCookies
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
|
||||
object EaBackend : StoreBackend {
|
||||
override val store: Store = Store.EA
|
||||
|
||||
@Volatile internal var app: Context? = null
|
||||
|
||||
init {
|
||||
LoginFlows.eaFlow = { ua -> app?.let { EaLogin(EaAuth.begin(it), ua) } }
|
||||
}
|
||||
|
||||
override fun signIn(context: Context) {
|
||||
app = context.applicationContext
|
||||
context.startActivity(
|
||||
Intent(context, StoreLoginActivity::class.java)
|
||||
.putExtra(StoreLoginActivity.EXTRA_STORE, store.id)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
|
||||
override fun signOut(context: Context) {
|
||||
val app = context.applicationContext
|
||||
EaAuth.signOut(app)
|
||||
EaLibrary.reset(app)
|
||||
EaUpdates.available.clear()
|
||||
runCatching { WebCookies.expire(EaLogin.SIGN_IN_URLS, ".ea.com", listOf("/")) {} }
|
||||
}
|
||||
|
||||
override fun syncLibrary(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
this.app = app
|
||||
Thread({ EaLibrary.sync(app, force) }, "ea-sync").start()
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {}
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
|
||||
val app = context.applicationContext
|
||||
if (!EaNative.ready()) {
|
||||
StoresState.logLine(EaNative.ENGINE_MISSING)
|
||||
StoresState.post { StoresState.problems[store] = EaNative.ENGINE_MISSING }
|
||||
return
|
||||
}
|
||||
val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root)
|
||||
val entry = DownloadEntry(store, item.id, item.title, cover = item.tallImageUrl ?: item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))
|
||||
DownloadQueue.enqueue(app, entry) { EaInstaller(app, item, folder) }
|
||||
}
|
||||
|
||||
override fun uninstall(context: Context, game: InstalledStoreGame) {}
|
||||
}
|
||||
|
||||
internal class EaLogin(private val pending: EaAuth.Pending, override val userAgent: String) : StoreLoginActivity.Flow {
|
||||
override val startUrl: String get() = pending.url
|
||||
|
||||
override fun isRedirect(url: String): Boolean = url.startsWith("qrc:", ignoreCase = true)
|
||||
|
||||
override fun forgetSignIn(done: () -> Unit) {
|
||||
runCatching { WebCookies.expire(SIGN_IN_URLS, ".ea.com", listOf("/")) { done() } }.onFailure { done() }
|
||||
}
|
||||
|
||||
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
|
||||
val code = EaAuth.redirectValue(url, "code") ?: return activity.getString(R.string.stores_login_error_verification)
|
||||
return try {
|
||||
EaAuth.exchange(activity, code, pending.verifier)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.w("EaLogin", "sign-in failed: ${e.message}")
|
||||
activity.getString(R.string.stores_login_error_generic)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SIGN_IN_URLS = listOf("https://accounts.ea.com", "https://signin.ea.com", "https://www.ea.com")
|
||||
}
|
||||
}
|
||||
|
||||
object EaUpdates {
|
||||
val available = mutableStateMapOf<String, String>()
|
||||
private val checked = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
private const val RECHECK_MS = 30 * 60 * 1000L
|
||||
|
||||
fun check(context: Context, game: InstalledStoreGame, force: Boolean = false) {
|
||||
val app = context.applicationContext
|
||||
val id = game.sidecar.id
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - (checked[id] ?: 0L) < RECHECK_MS) return
|
||||
checked[id] = now
|
||||
Thread({
|
||||
val token = EaAuth.accessToken(app)
|
||||
val offer = token?.let { runCatching { EaApi.offers(it, listOf(id)) }.getOrNull() }?.firstOrNull()
|
||||
if (offer == null) {
|
||||
checked.remove(id)
|
||||
return@Thread
|
||||
}
|
||||
val live = offer.gameVersion
|
||||
val installed = game.sidecar.installVersion
|
||||
StoresState.post {
|
||||
if (live.isNotEmpty() && installed.isNotEmpty() && live != installed) available[game.key] = live else available.remove(game.key)
|
||||
}
|
||||
}, "ea-update-check").start()
|
||||
}
|
||||
|
||||
fun start(context: Context, game: InstalledStoreGame) {
|
||||
val app = context.applicationContext
|
||||
val root = game.folder.parentFile?.parentFile ?: return
|
||||
val known = EaLibrary.game(app, game.sidecar.id)
|
||||
val item = known?.let { EaLibrary.toCatalogItem(app, it) } ?: CatalogItem(
|
||||
Store.EA, game.sidecar.id, game.sidecar.title, game.sidecar.hero, tallImageUrl = game.sidecar.cover, owned = true, extra = game.sidecar.extra,
|
||||
)
|
||||
available.remove(game.key)
|
||||
StoresState.install(app, item, root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
internal object EaCrypto {
|
||||
val LSX_DEFAULT_KEY = ByteArray(16) { it.toByte() }
|
||||
|
||||
private val OOA_KEY = intArrayOf(65, 50, 114, 45, 208, 130, 239, 176, 220, 100, 87, 197, 118, 104, 202, 9)
|
||||
.map { it.toByte() }.toByteArray()
|
||||
|
||||
private const val PRIME_10K = 104729L
|
||||
private const val PRIME_20K = 224737L
|
||||
private const val PRIME_30K = 350377L
|
||||
|
||||
fun ecbEncrypt(data: ByteArray, key: ByteArray): ByteArray =
|
||||
Cipher.getInstance("AES/ECB/PKCS5Padding").run { init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES")); doFinal(data) }
|
||||
|
||||
fun ecbDecrypt(data: ByteArray, key: ByteArray): ByteArray =
|
||||
Cipher.getInstance("AES/ECB/PKCS5Padding").run { init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES")); doFinal(data) }
|
||||
|
||||
fun licenseEncrypt(data: ByteArray): ByteArray =
|
||||
Cipher.getInstance("AES/CBC/PKCS5Padding").run { init(Cipher.ENCRYPT_MODE, SecretKeySpec(OOA_KEY, "AES"), IvParameterSpec(ByteArray(16))); doFinal(data) }
|
||||
|
||||
fun licenseDecrypt(data: ByteArray): ByteArray =
|
||||
Cipher.getInstance("AES/CBC/PKCS5Padding").run { init(Cipher.DECRYPT_MODE, SecretKeySpec(OOA_KEY, "AES"), IvParameterSpec(ByteArray(16))); doFinal(data) }
|
||||
|
||||
fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
|
||||
|
||||
fun unhex(text: String): ByteArray {
|
||||
val digits = text.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
if (digits.length % 2 != 0) return ByteArray(0)
|
||||
return ByteArray(digits.length / 2) { i -> digits.substring(i * 2, i * 2 + 2).toInt(16).toByte() }
|
||||
}
|
||||
|
||||
/** The session key both sides derive after the LSX challenge, from a seed the game picks. */
|
||||
fun lsxKey(seed: Int): ByteArray {
|
||||
if (seed == 0) return LSX_DEFAULT_KEY.copyOf()
|
||||
var state = 7L
|
||||
fun next(): Int {
|
||||
state = (state * 214013L + 2531011L) and 0xFFFFFFFFL
|
||||
return ((state ushr 16) and 0xFFFF).toInt()
|
||||
}
|
||||
val start = (next().toLong() + seed) and 0xFFFFFFFFL
|
||||
state = start
|
||||
return ByteArray(16) { next().toByte() }
|
||||
}
|
||||
|
||||
fun challengeAnswer(key: String): String = hex(ecbEncrypt(key.toByteArray(Charsets.US_ASCII), LSX_DEFAULT_KEY))
|
||||
|
||||
fun challengeSeed(version: String, answer: String): Int? = when (version) {
|
||||
"2" -> 0
|
||||
"3" -> (answer[0].code shl 8) or answer[1].code
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** EA's launch handshake code, which changes every UTC day. */
|
||||
fun rtpCode(day: LocalDate = LocalDate.now(ZoneOffset.UTC)): Long {
|
||||
val time = ((PRIME_10K * day.year) xor (day.monthValue * PRIME_20K) xor (day.dayOfMonth * PRIME_30K)) and 0xFFFFFFFFL
|
||||
return (time xor ((time shl 16) and 0xFFFFFFFFL) xor (time ushr 16)) and 0xFFFFFFFFL
|
||||
}
|
||||
|
||||
fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
|
||||
fun sha1(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-1").digest(data)
|
||||
|
||||
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray =
|
||||
Mac.getInstance("HmacSHA256").run { init(SecretKeySpec(key, "HmacSHA256")); doFinal(data) }
|
||||
|
||||
fun fnv1a(data: ByteArray): ULong {
|
||||
var hash = 0xcbf29ce484222325UL
|
||||
for (b in data) hash = (hash xor (b.toUByte().toULong())) * 0x100000001b3UL
|
||||
return hash
|
||||
}
|
||||
|
||||
fun base64Url(data: ByteArray): String =
|
||||
java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(data)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
internal class EaReply(val status: Int, val body: ByteArray, val headers: Map<String, List<String>>) {
|
||||
val ok: Boolean get() = status in 200..299
|
||||
val text: String get() = body.toString(Charsets.UTF_8)
|
||||
fun header(name: String): String? = headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value?.firstOrNull()
|
||||
}
|
||||
|
||||
internal object EaHttp {
|
||||
const val USER_AGENT = "Mozilla/5.0 EA Desktop/13.735.2.6250"
|
||||
private const val TIMEOUT = 30_000
|
||||
|
||||
fun form(values: List<Pair<String, String>>): String =
|
||||
values.joinToString("&") { (k, v) -> URLEncoder.encode(k, "UTF-8") + "=" + URLEncoder.encode(v, "UTF-8") }
|
||||
|
||||
fun request(
|
||||
url: String,
|
||||
method: String = "GET",
|
||||
headers: Map<String, String> = emptyMap(),
|
||||
body: ByteArray? = null,
|
||||
follow: Boolean = true,
|
||||
): EaReply {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
try {
|
||||
c.requestMethod = method
|
||||
c.connectTimeout = TIMEOUT
|
||||
c.readTimeout = TIMEOUT
|
||||
c.instanceFollowRedirects = follow
|
||||
c.setRequestProperty("User-Agent", USER_AGENT)
|
||||
for ((k, v) in headers) c.setRequestProperty(k, v)
|
||||
if (body != null) {
|
||||
c.doOutput = true
|
||||
c.outputStream.use { it.write(body) }
|
||||
}
|
||||
val status = c.responseCode
|
||||
val stream = if (status >= 400) c.errorStream else c.inputStream
|
||||
val bytes = stream?.use { it.readBytes() } ?: ByteArray(0)
|
||||
return EaReply(status, bytes, c.headerFields.filterKeys { it != null })
|
||||
} finally {
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreExe
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstalls
|
||||
import com.droiddeck.launcher.stores.StoreLanguage
|
||||
import com.droiddeck.launcher.stores.amazon.AmazonNative
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||
import com.droiddeck.launcher.stores.download.StoreDownloadTier
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
internal class EaInstaller(
|
||||
private val app: Context,
|
||||
private val item: CatalogItem,
|
||||
private val folder: File,
|
||||
) : DownloadQueue.DownloadJob {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
|
||||
override fun run(handle: DownloadQueue.JobHandle): String? {
|
||||
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in))
|
||||
var token = EaAuth.accessToken(app) ?: throw IllegalStateException("Not signed in to EA")
|
||||
val url = try {
|
||||
EaApi.downloadUrl(token, item.id)
|
||||
} catch (e: EaApiException) {
|
||||
if (e.status != 401) throw IllegalStateException(e.message)
|
||||
token = EaAuth.refreshNow(app, token) ?: throw IllegalStateException("The EA sign-in has expired")
|
||||
EaApi.downloadUrl(token, item.id)
|
||||
}
|
||||
val game = EaLibrary.game(app, item.id)
|
||||
val version = runCatching { EaApi.offers(token, listOf(item.id)).firstOrNull()?.gameVersion }.getOrNull().orEmpty()
|
||||
.ifEmpty { game?.version.orEmpty() }
|
||||
val cover = item.tallImageUrl ?: item.imageUrl
|
||||
StoreInstalls.begin(folder, Store.EA, item.id, item.title, cover, item.imageUrl)
|
||||
if (cancelled.get()) return null
|
||||
|
||||
val tier = StoreDownloadTier.current(app)
|
||||
handle.log("ea: \"${item.title}\" tier=${tier.id} window=${tier.networkWindow}")
|
||||
val request = JSONObject()
|
||||
.put("url", url)
|
||||
.put("gameDir", folder.absolutePath)
|
||||
.put("maxWorkers", tier.networkWindow.coerceIn(1, 64))
|
||||
.put("processWorkers", tier.processWorkers)
|
||||
.put("caBundle", "")
|
||||
val shown = AtomicLong(-1)
|
||||
val downloading = AtomicBoolean(false)
|
||||
val listener = object : AmazonNative.InstallListener {
|
||||
override fun onStatus(text: String) { handle.stage(DownloadStage.MANIFEST, text) }
|
||||
override fun onCheck(doneBytes: Long, totalBytes: Long, files: Int, filesTotal: Int) {
|
||||
handle.stage(DownloadStage.MANIFEST, "Checking files…")
|
||||
handle.stageProgress(doneBytes, totalBytes, files, filesTotal, bytes = true)
|
||||
}
|
||||
override fun onSizes(pendingBytes: Long, totalBytes: Long) { handle.diskSize(totalBytes) }
|
||||
override fun onProgress(done: Long, total: Long, files: Int, filesTotal: Int) {
|
||||
if (downloading.compareAndSet(false, true)) handle.stage(DownloadStage.DOWNLOAD)
|
||||
while (true) {
|
||||
val before = shown.get()
|
||||
if (done < before) return
|
||||
if (shown.compareAndSet(before, done)) break
|
||||
}
|
||||
handle.progress(done, total, "Downloading ($files/$filesTotal files)")
|
||||
}
|
||||
override fun onLog(line: String) { handle.log("ea: $line") }
|
||||
override fun beforeReplace(): Boolean = try {
|
||||
StoreInstalls.markUpdating(folder)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
handle.log("ea: the install record could not be switched to updating (${e.javaClass.simpleName})")
|
||||
false
|
||||
}
|
||||
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) = Unit
|
||||
}
|
||||
val outcome = EaNative.install(request, listener, cancelled)
|
||||
if (outcome.cancelled || cancelled.get() && !outcome.success) return null
|
||||
if (!outcome.success) throw IllegalStateException(outcome.error.ifEmpty { "The EA install failed" })
|
||||
val result = outcome.result ?: JSONObject()
|
||||
result.optLong("totalBytes").takeIf { it > 0 }?.let { EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply() }
|
||||
|
||||
handle.stage(DownloadStage.INSTALL, app.getString(R.string.stores_registering))
|
||||
val sidecar = sidecarFor(app, item, folder, game, version, cover) { handle.log("ea: $it") }
|
||||
StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) })
|
||||
return folder.path
|
||||
}
|
||||
|
||||
override fun cancel(deleteFiles: Boolean) {
|
||||
cancelled.set(true)
|
||||
if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun locale(context: Context, available: List<String>): String {
|
||||
val wanted = StoreLanguage.localeFor(null, context)
|
||||
val exact = "${wanted.language}_${wanted.country}"
|
||||
return available.firstOrNull { it.equals(exact, true) }
|
||||
?: available.firstOrNull { it.substringBefore('_').equals(wanted.language, true) }
|
||||
?: available.firstOrNull { it.equals("en_US", true) }
|
||||
?: "en_US"
|
||||
}
|
||||
|
||||
internal fun sidecarFor(app: Context, item: CatalogItem, folder: File, game: EaGame?, version: String, cover: String?, log: (String) -> Unit): StoreGameSidecar {
|
||||
val manifest = EaManifest.read(folder)
|
||||
if (manifest == null) log("no installerdata.xml in ${folder.name}")
|
||||
val launcher = manifest?.launcher()
|
||||
val candidates = listOfNotNull(launcher?.path, game?.executePath?.takeIf { it.isNotEmpty() })
|
||||
var exe = candidates.firstNotNullOfOrNull { EaPaths.relativeOnDisk(folder, EaPaths.relative(it)) }.orEmpty()
|
||||
if (exe.isEmpty()) exe = StoreExe.pick(folder, null, item.title)
|
||||
if (exe.isEmpty()) { log("no exe found; pick one in Steam settings › Added games"); exe = "game.exe" } else log("exe=$exe")
|
||||
val args = EaPaths.splitArgs(listOf(game?.parameters.orEmpty(), launcher?.parameters.orEmpty()).filter { it.isNotBlank() }.joinToString(" "))
|
||||
val keys = JSONArray()
|
||||
listOfNotNull(game?.installCheck, game?.executePath, launcher?.path).mapNotNull { EaPaths.registered(it) }
|
||||
.distinctBy { it.key.lowercase() + "|" + it.value.lowercase() }
|
||||
.forEach { keys.put(JSONArray().put(it.key).put(it.value)) }
|
||||
val extra = linkedMapOf(
|
||||
"contentId" to (game?.contentId ?: item.extra["contentId"].orEmpty()),
|
||||
"slug" to (game?.slug ?: item.extra["slug"].orEmpty()),
|
||||
"multiplayerId" to game?.multiplayerId.orEmpty(),
|
||||
"masterTitleId" to game?.masterTitleId.orEmpty(),
|
||||
"locale" to locale(app, game?.locales.orEmpty()),
|
||||
"registry" to keys.toString(),
|
||||
"touchup" to manifest?.touchupPath.orEmpty().replace('\\', '/').trimStart('/'),
|
||||
"touchupArgs" to manifest?.touchupParameters.orEmpty(),
|
||||
"contentIds" to manifest?.contentIds.orEmpty().joinToString(","),
|
||||
"displayName" to manifest?.title(locale(app, game?.locales.orEmpty())).orEmpty(),
|
||||
).filterValues { it.isNotEmpty() }
|
||||
return StoreGameSidecar(
|
||||
Store.EA, item.id, item.title, exe = exe, args = args,
|
||||
installVersion = version.ifEmpty { manifest?.version.orEmpty() }, installedAt = System.currentTimeMillis(),
|
||||
cover = cover, hero = item.imageUrl, extra = extra, cloud = false, logo = item.logoUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object EaLaunch {
|
||||
private const val TAG = "EaLaunch"
|
||||
const val ENV_FILE = ".droiddeck-ea-env"
|
||||
|
||||
private fun installed(context: Context, id: String): Pair<File, StoreGameSidecar>? =
|
||||
StoreInstallRoot.gameFolders(context).firstNotNullOfOrNull { f ->
|
||||
StoreGameSidecar.read(f)?.takeIf { it.store == Store.EA && it.id == id && it.isInstalled }?.let { f to it }
|
||||
}
|
||||
|
||||
internal fun environment(
|
||||
offerId: String,
|
||||
contentId: String,
|
||||
locale: String,
|
||||
generic: String,
|
||||
access: String,
|
||||
opaque: String,
|
||||
account: EaAuth.Account,
|
||||
session: String = UUID.randomUUID().toString(),
|
||||
): LinkedHashMap<String, String> = linkedMapOf(
|
||||
"EAAuthCode" to "unavailable",
|
||||
"EAEgsProxyIpcPort" to "0",
|
||||
"EAEntitlementSource" to "EA",
|
||||
"EAExternalSource" to "EA",
|
||||
"EAFreeTrialGame" to "false",
|
||||
"EAGameLocale" to locale,
|
||||
"EAGenericAuthToken" to generic,
|
||||
"EALaunchCode" to "unavailable",
|
||||
"EALaunchOwner" to "EA",
|
||||
"EALaunchEAID" to account.eaId.ifEmpty { account.displayName },
|
||||
"EALaunchEnv" to "production",
|
||||
"EALaunchOfflineMode" to "false",
|
||||
"EALsxPort" to EaLsx.PORT.toString(),
|
||||
"EARtPLaunchCode" to EaCrypto.rtpCode().toString(),
|
||||
"EASecureLaunchTokenTemp" to account.userId,
|
||||
"EASteamProxyIpcPort" to "0",
|
||||
"OriginSessionKey" to session,
|
||||
"ContentId" to contentId,
|
||||
"EAOnErrorExitRetCode" to "1",
|
||||
"EAConnectionId" to offerId,
|
||||
"EALicenseToken" to offerId,
|
||||
"EALaunchUserAuthToken" to opaque,
|
||||
"EAAccessTokenJWS" to access,
|
||||
)
|
||||
|
||||
/** Adds what installs made before the manifest's content ids and title were recorded are missing. */
|
||||
internal fun backfill(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
|
||||
if (sidecar.extra.containsKey("contentIds") || sidecar.extra.containsKey("displayName")) return sidecar
|
||||
val manifest = EaManifest.read(folder) ?: return sidecar
|
||||
val locale = sidecar.extra["locale"].orEmpty().ifEmpty { "en_US" }
|
||||
val added = mapOf(
|
||||
"contentIds" to manifest.contentIds.joinToString(","),
|
||||
"displayName" to manifest.title(locale).orEmpty(),
|
||||
).filterValues { it.isNotEmpty() }
|
||||
if (added.isEmpty()) return sidecar
|
||||
val updated = sidecar.copy(extra = sidecar.extra + added)
|
||||
runCatching { updated.write(folder) }.onFailure { Log.w(TAG, "sidecar: ${it.javaClass.simpleName}") }
|
||||
return updated
|
||||
}
|
||||
|
||||
/** Answers the guest's request right before an EA game starts: session, licence and the LSX endpoint. */
|
||||
fun prepare(context: Context, request: JSONObject): JSONObject {
|
||||
val app = context.applicationContext
|
||||
val id = request.optString("id")
|
||||
val reply = JSONObject().put("ok", false)
|
||||
val (folder, recorded) = installed(app, id) ?: return reply.put("reason", "not-installed")
|
||||
val sidecar = backfill(folder, recorded)
|
||||
runCatching { com.droiddeck.launcher.stores.StoreLaunch.refreshLauncher(folder, sidecar) }
|
||||
File(folder, ENV_FILE).delete()
|
||||
val account = EaAuth.load(app) ?: return reply.put("reason", "signed-out")
|
||||
val access = EaAuth.accessToken(app) ?: return reply.put("reason", "sign-in-expired")
|
||||
val contentId = sidecar.extra["contentId"].orEmpty()
|
||||
val locale = sidecar.extra["locale"].orEmpty().ifEmpty { "en_US" }
|
||||
val core = EaOoa.coreDir(folder, sidecar.exe)
|
||||
val hashVersion = core?.let(EaOoa::activationVersion) ?: 1
|
||||
val hardware = request.optJSONObject("hardware")?.let(EaOoa.Hardware::fromJson)?.takeIf { it.complete(hashVersion) }
|
||||
val legacy = EaOoa.legacyCore(core)
|
||||
|
||||
val lifetime = if (legacy) 14400 else 550
|
||||
val opaque = EaAuth.mint(app, EaAuth.CLIENT_ID, "token", EaAuth.OPAQUE_SCOPES, "OPAQUE", lifetime).orEmpty()
|
||||
val known = (sidecar.extra["contentIds"].orEmpty().split(',') + sidecar.extra["multiplayerId"].orEmpty()).map { it.trim() }.filter { it.isNotEmpty() }.toSet()
|
||||
val lsx = EaLsx.start(app, EaLsx.Game(id, contentId, sidecar.title, sidecar.installVersion, locale, hardware, hashVersion, opaque,
|
||||
System.currentTimeMillis() + (lifetime - 30) * 1000L, known))
|
||||
val env = environment(id, contentId, locale, if (legacy && opaque.isNotEmpty()) opaque else access, access, opaque, EaAuth.load(app) ?: account)
|
||||
val tmp = File(folder, "$ENV_FILE.tmp")
|
||||
tmp.writeText(env.entries.joinToString("\r\n", postfix = "\r\n") { "${it.key}=${it.value}" })
|
||||
tmp.renameTo(File(folder, ENV_FILE))
|
||||
|
||||
val licenses = JSONArray()
|
||||
val notes = ArrayList<String>()
|
||||
if (core != null) {
|
||||
val ids = (listOf(contentId) + sidecar.extra["contentIds"].orEmpty().split(',')).map { it.trim() }.filter { it.isNotEmpty() }.distinct()
|
||||
if (hardware == null) notes += "no-hardware" else {
|
||||
val hash = EaOoa.machineHash(hardware, hashVersion)
|
||||
for (content in ids) {
|
||||
notes += try {
|
||||
for ((path, data) in EaOoa.files(core, EaOoa.request(access, content, hash))) {
|
||||
licenses.put(JSONObject().put("path", path).put("data", Base64.encodeToString(data, Base64.NO_WRAP)))
|
||||
}
|
||||
"$content:ok"
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "licence $content for $id: ${e.message}")
|
||||
com.droiddeck.launcher.stores.StoresState.logLine("ea licence $content for $id v$hashVersion legacy=$legacy: ${e.message}")
|
||||
"$content:refused"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (core != null && notes.isNotEmpty() && notes.none { it.endsWith(":ok") }) runCatching {
|
||||
val rows = EaApi.legacyEntitlements(access)
|
||||
com.droiddeck.launcher.stores.StoresState.logLine("ea legacy entitlements for $id: ${rows.size} rows")
|
||||
for (r in rows) com.droiddeck.launcher.stores.StoresState.logLine("ea legacy " + listOf("productId", "entitlementTag", "entitlementType", "externalType", "groupName", "status", "statusReasonCode", "originPermissions", "isSubscription", "useCount", "version", "grantDate").joinToString(" ") { k -> "$k=${r.optString(k)}" } + " cdKey=" + (if (r.optString("cdKey").let { it.isNotEmpty() && it != "null" }) "yes" else "no"))
|
||||
}.onFailure { com.droiddeck.launcher.stores.StoresState.logLine("ea legacy entitlements for $id: ${it.javaClass.simpleName} ${it.message.orEmpty().take(300)}") }
|
||||
val licenseNote = if (core == null) "none-needed" else "v$hashVersion " + notes.joinToString(",")
|
||||
Log.i(TAG, "prepared $id lsx=$lsx legacy=$legacy opaque=${opaque.isNotEmpty()} licence=$licenseNote")
|
||||
return reply.put("ok", true).put("lsx", lsx).put("license", licenseNote).put("licenses", licenses)
|
||||
}
|
||||
}
|
||||
|
||||
object EaSizes {
|
||||
fun size(context: Context, item: com.droiddeck.launcher.stores.CatalogItem): Long {
|
||||
val token = EaAuth.accessToken(context) ?: return 0L
|
||||
val url = runCatching { EaApi.downloadUrl(token, item.id) }.getOrNull() ?: return 0L
|
||||
return EaNative.call("zip.size", JSONObject().put("url", url))?.optLong("totalBytes") ?: 0L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.mergeItems
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object EaPrefs {
|
||||
const val FILE = "stores.ea"
|
||||
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
data class EaGame(
|
||||
val offerId: String,
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val tall: String = "",
|
||||
val wide: String = "",
|
||||
val logo: String = "",
|
||||
val version: String = "",
|
||||
val executePath: String = "",
|
||||
val installCheck: String = "",
|
||||
val parameters: String = "",
|
||||
val multiplayerId: String = "",
|
||||
val masterTitleId: String = "",
|
||||
val cloudSaves: String = "",
|
||||
val locales: List<String> = emptyList(),
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject()
|
||||
.put("offerId", offerId).put("contentId", contentId).put("title", title).put("slug", slug)
|
||||
.put("tall", tall).put("wide", wide).put("logo", logo).put("version", version)
|
||||
.put("executePath", executePath).put("installCheck", installCheck).put("parameters", parameters)
|
||||
.put("multiplayerId", multiplayerId).put("masterTitleId", masterTitleId).put("cloudSaves", cloudSaves)
|
||||
.put("locales", JSONArray(locales))
|
||||
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject): EaGame? {
|
||||
val locales = o.optJSONArray("locales")
|
||||
return EaGame(
|
||||
offerId = o.optString("offerId").ifEmpty { return null },
|
||||
contentId = o.optString("contentId"),
|
||||
title = o.optString("title"),
|
||||
slug = o.optString("slug"),
|
||||
tall = o.optString("tall"),
|
||||
wide = o.optString("wide"),
|
||||
logo = o.optString("logo"),
|
||||
version = o.optString("version"),
|
||||
executePath = o.optString("executePath"),
|
||||
installCheck = o.optString("installCheck"),
|
||||
parameters = o.optString("parameters"),
|
||||
multiplayerId = o.optString("multiplayerId"),
|
||||
masterTitleId = o.optString("masterTitleId"),
|
||||
cloudSaves = o.optString("cloudSaves"),
|
||||
locales = (0 until (locales?.length() ?: 0)).map { locales!!.optString(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object EaLibrary {
|
||||
private const val KEY_CACHE = "library_cache"
|
||||
private const val KEY_SYNCED_AT = "library_synced_at"
|
||||
private const val THROTTLE_MS = 6 * 60 * 60 * 1000L
|
||||
private const val ART_TTL_MS = 7 * 24 * 60 * 60 * 1000L
|
||||
private const val ART_THREADS = 4
|
||||
const val STORE_URL = "https://www.ea.com/ea-app/library"
|
||||
private val gate = SyncGate()
|
||||
private val generation = java.util.concurrent.atomic.AtomicInteger()
|
||||
|
||||
/** Drops the cache and makes any sync already running drop its results, in one step. */
|
||||
fun reset(context: Context) {
|
||||
synchronized(cacheLock) {
|
||||
generation.incrementAndGet()
|
||||
EaPrefs.get(context).edit().remove(KEY_CACHE).remove(KEY_SYNCED_AT).commit()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun snapshot(app: Context): Pair<Int, List<EaGame>> = synchronized(cacheLock) { generation.get() to cached(app) }
|
||||
|
||||
internal fun storeCache(app: Context, games: List<EaGame>, gen: Int): Boolean = synchronized(cacheLock) {
|
||||
gen == generation.get() &&
|
||||
EaPrefs.get(app).edit().putString(KEY_CACHE, JSONArray(games.map { it.toJson() }).toString()).putLong(KEY_SYNCED_AT, System.currentTimeMillis()).commit()
|
||||
}
|
||||
|
||||
private val cacheLock = Any()
|
||||
private val store = Store.EA
|
||||
|
||||
fun cached(context: Context): List<EaGame> {
|
||||
val text = EaPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList()
|
||||
val arr = runCatching { JSONArray(text) }.getOrNull() ?: return emptyList()
|
||||
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.let(EaGame::fromJson) }
|
||||
}
|
||||
|
||||
fun game(context: Context, offerId: String): EaGame? = cached(context).firstOrNull { it.offerId == offerId }
|
||||
|
||||
|
||||
fun toCatalogItem(context: Context, g: EaGame): CatalogItem {
|
||||
val prefs = EaPrefs.get(context)
|
||||
val tall = g.tall.ifEmpty { prefs.getString("poster_${g.offerId}", null).orEmpty() }
|
||||
return CatalogItem(
|
||||
store = store,
|
||||
id = g.offerId,
|
||||
title = g.title.ifBlank { g.slug },
|
||||
imageUrl = g.wide.ifEmpty { tall }.ifEmpty { null },
|
||||
tallImageUrl = tall.ifEmpty { g.wide }.ifEmpty { null },
|
||||
publisher = "Electronic Arts",
|
||||
owned = true,
|
||||
sizeBytes = prefs.getLong("size_${g.offerId}", 0L),
|
||||
storeUrl = STORE_URL,
|
||||
extra = mapOf("contentId" to g.contentId, "slug" to g.slug, "version" to g.version),
|
||||
logoUrl = g.logo.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
/** One entry per game: owned editions of the same base game collapse onto the base offer. */
|
||||
internal fun pick(owned: List<EaApi.OwnedProduct>, offers: Map<String, EaApi.Offer>): List<Pair<EaApi.OwnedProduct, EaApi.Offer>> {
|
||||
val usable = owned.filter { o ->
|
||||
val offer = offers[o.offerId] ?: return@filter false
|
||||
!o.trial && o.downloadable && offer.downloadable &&
|
||||
(offer.displayType.isEmpty() || offer.displayType.equals("FullGame", true)) &&
|
||||
(o.gameType.isEmpty() || o.gameType.equals("BASE_GAME", true))
|
||||
}
|
||||
return usable.groupBy { it.baseSlug.ifEmpty { it.slug }.ifEmpty { it.offerId } }.values.map { group ->
|
||||
val chosen = group.firstOrNull { it.baseSlug.isEmpty() } ?: group.first()
|
||||
chosen to offers.getValue(chosen.offerId)
|
||||
}.sortedBy { it.first.name.lowercase() }
|
||||
}
|
||||
|
||||
private fun art(app: Context, token: String, slugs: Collection<String>, gen: Int): Map<String, EaApi.Images> {
|
||||
val prefs = EaPrefs.get(app)
|
||||
val now = System.currentTimeMillis()
|
||||
val out = java.util.concurrent.ConcurrentHashMap<String, EaApi.Images>()
|
||||
val pool = Executors.newFixedThreadPool(ART_THREADS)
|
||||
try {
|
||||
for (slug in slugs.filter { it.isNotEmpty() }) {
|
||||
if (gen != generation.get()) break
|
||||
val cached = prefs.getString("art_$slug", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
if (cached != null && now - cached.optLong("at") < ART_TTL_MS) {
|
||||
out[slug] = EaApi.Images(cached.optString("tall").ifEmpty { null }, cached.optString("wide").ifEmpty { null }, cached.optString("logo").ifEmpty { null })
|
||||
continue
|
||||
}
|
||||
pool.execute {
|
||||
if (gen != generation.get()) return@execute
|
||||
val images = runCatching { EaApi.images(token, slug) }.getOrNull() ?: return@execute
|
||||
out[slug] = images
|
||||
prefs.edit().putString(
|
||||
"art_$slug",
|
||||
JSONObject().put("at", now).put("tall", images.tall.orEmpty()).put("wide", images.wide.orEmpty()).put("logo", images.logo.orEmpty()).toString(),
|
||||
).apply()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pool.shutdown()
|
||||
pool.awaitTermination(2, TimeUnit.MINUTES)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun posters(app: Context, games: List<EaGame>, gen: Int) {
|
||||
val prefs = EaPrefs.get(app)
|
||||
val todo = games.filter { it.tall.isEmpty() && !prefs.contains("poster_${it.offerId}") }.take(40)
|
||||
if (todo.isEmpty()) return
|
||||
val pool = Executors.newFixedThreadPool(ART_THREADS)
|
||||
try {
|
||||
for (g in todo) pool.execute {
|
||||
if (gen != generation.get()) return@execute
|
||||
prefs.edit().putString("poster_${g.offerId}", runCatching { StoreNet.sgdbPoster(g.title) }.getOrDefault("")).apply()
|
||||
}
|
||||
} finally {
|
||||
pool.shutdown()
|
||||
pool.awaitTermination(5, TimeUnit.MINUTES)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish(app: Context, games: List<EaGame>, gen: Int = generation.get()) {
|
||||
if (gen != generation.get()) return
|
||||
val items = games.map { toCatalogItem(app, it) }
|
||||
StoresState.post {
|
||||
if (gen != generation.get()) return@post
|
||||
StoresState.library[store] = mergeItems(StoresState.library[store], items)
|
||||
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
|
||||
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> withToken(app: Context, token: String, call: (String) -> T): T = try {
|
||||
call(token)
|
||||
} catch (e: EaApiException) {
|
||||
if (e.status != 401) throw e
|
||||
call(EaAuth.refreshNow(app, token) ?: throw e)
|
||||
}
|
||||
|
||||
fun sync(context: Context, force: Boolean) {
|
||||
if (!gate.enter()) return
|
||||
var forced = force
|
||||
do {
|
||||
runCatching { syncOnce(context.applicationContext, forced) }
|
||||
forced = true
|
||||
} while (gate.leave())
|
||||
}
|
||||
|
||||
private fun syncOnce(app: Context, force: Boolean) {
|
||||
val (gen, cache) = snapshot(app)
|
||||
val prefs = EaPrefs.get(app)
|
||||
val quiet = cache.isNotEmpty() && !force
|
||||
fun status(id: Int) { if (!quiet) StoresState.post { StoresState.status[store] = app.getString(id) } }
|
||||
fun problem(text: String?) {
|
||||
StoresState.post {
|
||||
StoresState.status.remove(store)
|
||||
if (text == null) StoresState.problems.remove(store) else StoresState.problems[store] = text
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (cache.isNotEmpty()) publish(app, cache, gen)
|
||||
if (!force && cache.isNotEmpty() && System.currentTimeMillis() - prefs.getLong(KEY_SYNCED_AT, 0L) in 0 until THROTTLE_MS) {
|
||||
return problem(null)
|
||||
}
|
||||
status(R.string.stores_sync_sign_in)
|
||||
val token = EaAuth.accessToken(app) ?: return problem(StoresState.notSignedInLine(store))
|
||||
status(R.string.stores_sync_game_list)
|
||||
val owned = withToken(app, token) { EaApi.owned(it) }
|
||||
val offers = withToken(app, token) { EaApi.offers(it, owned.map { o -> o.offerId }) }.associateBy { it.offerId }
|
||||
val picked = pick(owned, offers)
|
||||
StoresState.logLine("ea: ${owned.size} owned products, ${picked.size} games")
|
||||
for ((o, offer) in picked) StoresState.logLine(
|
||||
"ea: owned ${o.offerId} content=${offer.contentId} master=${offer.masterTitleId} mp=${offer.multiplayerId} via=${o.ownership.joinToString("+")} type=${o.gameType}")
|
||||
if (picked.isEmpty()) return problem(if (cache.isEmpty()) app.getString(R.string.stores_sync_no_games, store.label) else null)
|
||||
if (gen != generation.get()) return
|
||||
status(R.string.stores_sync_cover_art)
|
||||
val images = art(app, token, picked.map { it.first.slug }, gen)
|
||||
val games = picked.map { (o, offer) ->
|
||||
val img = images[o.slug]
|
||||
EaGame(
|
||||
offerId = o.offerId, contentId = offer.contentId, title = o.name.ifBlank { offer.displayName }, slug = o.slug,
|
||||
tall = img?.tall.orEmpty(), wide = img?.wide.orEmpty(), logo = img?.logo.orEmpty(), version = offer.gameVersion,
|
||||
executePath = offer.executePathOverride, installCheck = offer.installCheckOverride, parameters = offer.executeParameters,
|
||||
multiplayerId = offer.multiplayerId, masterTitleId = offer.masterTitleId, cloudSaves = offer.cloudSaves, locales = offer.locales,
|
||||
)
|
||||
}
|
||||
if (!storeCache(app, games, gen)) return
|
||||
posters(app, games, gen)
|
||||
if (gen != generation.get()) return
|
||||
publish(app, games, gen)
|
||||
problem(null)
|
||||
} catch (e: EaApiException) {
|
||||
StoresState.logLine("ea: library sync failed: ${e.message}")
|
||||
if (e.status == 401) StoresState.markSignInExpired(store)
|
||||
problem(if (cache.isEmpty()) e.message else null)
|
||||
} catch (e: Exception) {
|
||||
StoresState.logLine("ea: library sync failed: ${e.javaClass.simpleName} ${e.message.orEmpty()}")
|
||||
problem(if (cache.isEmpty()) e.message ?: app.getString(R.string.stores_sync_failed) else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One sync at a time; a request made meanwhile makes the running one go round again rather than being lost. */
|
||||
internal class SyncGate {
|
||||
private var busy = false
|
||||
private var again = false
|
||||
|
||||
/** True when the caller should run; false when a running sync will pick the request up. */
|
||||
@Synchronized
|
||||
fun enter(): Boolean {
|
||||
if (busy) {
|
||||
again = true
|
||||
return false
|
||||
}
|
||||
busy = true
|
||||
return true
|
||||
}
|
||||
|
||||
/** True when another round was requested while running; the caller stays the runner. */
|
||||
@Synchronized
|
||||
fun leave(): Boolean {
|
||||
if (again) {
|
||||
again = false
|
||||
return true
|
||||
}
|
||||
busy = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.io.StringReader
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* The local SDK endpoint EA games talk to in place of the EA app (LSX: XML over TCP, AES after a
|
||||
* challenge). It answers for the one game DroidDeck last prepared.
|
||||
*/
|
||||
object EaLsx {
|
||||
private const val TAG = "EaLsx"
|
||||
const val PORT = 3216
|
||||
private const val SENDER = "EALS"
|
||||
private const val CHALLENGE_KEY = "cacf897a20b6d612ad0c05e011df52bb"
|
||||
private const val CHALLENGE_VERSION = "10,5,30,15625"
|
||||
private const val LANGUAGES = "ar_SA,de_DE,en_US,es_ES,es_MX,fr_FR,it_IT,ja_JP,ko_KR,pl_PL,pt_BR,ru_RU,zh_CN,zh_TW"
|
||||
|
||||
private val SERVICES = listOf(
|
||||
"EbisuSDK" to "SDK", "EbisuSDK" to "PROFILE", "XMPP" to "PRESENCE", "XMPP" to "FRIENDS", "Commerce" to "COMMERCE",
|
||||
"EbisuSDK" to "RECENTPLAYER", "EbisuSDK" to "IGO", "EbisuSDK" to "MISC", "EALS" to "LOGIN", "EbisuSDK" to "UTILITY",
|
||||
"XMPP" to "XMPP", "XMPP" to "CHAT", "EbisuSDK" to "IGO_EVENT", "EALS" to "EALS_EVENTS", "EbisuSDK" to "LOGIN_EVENT",
|
||||
"XMPP" to "INVITE_EVENT", "EbisuSDK" to "PROFILE_EVENT", "XMPP" to "PRESENCE_EVENT", "XMPP" to "FRIENDS_EVENT",
|
||||
"Commerce" to "COMMERCE_EVENT", "XMPP" to "CHAT_EVENT", "EbisuSDK" to "DOWNLOAD_EVENT", "EbisuSDK" to "PERMISSION",
|
||||
"EbisuSDK" to "RESOURCES", "EbisuSDK" to "BLOCKED_USERS", "EbisuSDK" to "BLOCKED_USER_EVENT", "EbisuSDK" to "GET_USERID",
|
||||
"EbisuSDK" to "ONLINE_STATUS_EVENT", "EbisuSDK" to "ACHIEVEMENT", "EbisuSDK" to "ACHIEVEMENT_EVENT",
|
||||
"EbisuSDK" to "BROADCAST_EVENT", "PI" to "PROGRESSIVE_INSTALLATION", "PI" to "PROGRESSIVE_INSTALLATION_EVENT",
|
||||
"EbisuSDK" to "CONTENT",
|
||||
)
|
||||
|
||||
class Game(
|
||||
val offerId: String,
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val version: String,
|
||||
val locale: String,
|
||||
val hardware: EaOoa.Hardware?,
|
||||
val hashVersion: Int,
|
||||
val token: String = "",
|
||||
val tokenExpiresAt: Long = 0L,
|
||||
val contentIds: Set<String> = emptySet(),
|
||||
) {
|
||||
val validToken: String? get() = token.takeIf { it.isNotEmpty() && System.currentTimeMillis() < tokenExpiresAt }
|
||||
}
|
||||
|
||||
/** The parts of a request a reply depends on. */
|
||||
class Message(val recipient: String, val id: String, val name: String, val attrs: Map<String, String>, val children: List<Pair<String, String>>)
|
||||
|
||||
interface Account {
|
||||
val userId: String
|
||||
val personaId: String
|
||||
val persona: String
|
||||
fun authCode(clientId: String, scope: String?): String?
|
||||
fun license(contentId: String, requestTicket: String, ticketEngine: String): String?
|
||||
fun entitlements(group: String): List<Map<String, String>>
|
||||
fun token(): String?
|
||||
}
|
||||
|
||||
@Volatile var game: Game? = null
|
||||
@Volatile private var account: Account? = null
|
||||
/** The one session of the current launch that may act for the account. */
|
||||
/** Bumped whenever the launch changes; a connection accepted under an older one is not admitted. */
|
||||
@Volatile internal var generation = 0
|
||||
private val holder = java.util.concurrent.atomic.AtomicReference<Any?>(null)
|
||||
private var server: ServerSocket? = null
|
||||
private val lock = Any()
|
||||
private const val MAX_CLIENTS = 8
|
||||
private val WHITESPACE = Regex("[ \t]+")
|
||||
|
||||
fun start(context: Context, game: Game): Boolean {
|
||||
synchronized(lock) {
|
||||
closeSessions()
|
||||
this.game = game
|
||||
account = AppAccount(context.applicationContext)
|
||||
runCatching { server?.close() }
|
||||
return try {
|
||||
val socket = ServerSocket().apply {
|
||||
reuseAddress = true
|
||||
bind(java.net.InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT), 8)
|
||||
}
|
||||
server = socket
|
||||
Thread({ accept(socket) }, "ea-lsx").apply { isDaemon = true }.start()
|
||||
Log.i(TAG, "listening on $PORT")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not listen on $PORT: ${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the endpoint; with [offerId], only if it still serves that game, so a late exit cannot stop a newer launch. */
|
||||
fun stop(offerId: String? = null) {
|
||||
synchronized(lock) {
|
||||
if (offerId != null && game?.offerId != offerId) return
|
||||
runCatching { server?.close() }
|
||||
server = null
|
||||
game = null
|
||||
account = null
|
||||
closeSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeSessions() {
|
||||
generation++
|
||||
holder.set(null)
|
||||
synchronized(sessions) { sessions.forEach { it.trusted = false } }
|
||||
for (socket in open) runCatching { socket.close() }
|
||||
open.clear()
|
||||
}
|
||||
|
||||
private val open = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<Socket, Boolean>())
|
||||
private const val HANDSHAKE_TIMEOUT_MS = 15_000
|
||||
private const val UNTRUSTED_IDLE_MS = 10 * 60 * 1000
|
||||
|
||||
private fun accept(socket: ServerSocket) {
|
||||
while (!socket.isClosed) {
|
||||
val client = runCatching { socket.accept() }.getOrNull() ?: continue
|
||||
val verified = ownerUid(client)?.let { it == android.os.Process.myUid() }
|
||||
if (verified == false) {
|
||||
Log.w(TAG, "refused a connection from another app")
|
||||
runCatching { client.close() }
|
||||
continue
|
||||
}
|
||||
val session = runCatching { Session(client, client.getOutputStream(), verified == true) }.getOrNull()
|
||||
val live = session != null && synchronized(lock) { server === socket && admit(session).also { if (it) open += client } }
|
||||
if (!live) {
|
||||
runCatching { client.close() }
|
||||
continue
|
||||
}
|
||||
Thread({ try { serve(client, session!!) } finally { synchronized(sessions) { sessions -= session!! }; open -= client } }, "ea-lsx-client").apply { isDaemon = true }.start()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The uid owning the connecting socket, or null where SELinux hides /proc/net. The guest runs
|
||||
* under this app's uid, so any other uid is another app; an unknown owner never gets credentials.
|
||||
*/
|
||||
private fun ownerUid(client: Socket): Int? {
|
||||
val port = client.port
|
||||
return listOf("/proc/net/tcp6", "/proc/net/tcp").firstNotNullOfOrNull { path ->
|
||||
runCatching { java.io.File(path).readLines() }.getOrNull()?.let { owner(it, port) }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun owner(lines: List<String>, port: Int): Int? = lines.drop(1).firstNotNullOfOrNull { line ->
|
||||
val f = line.trim().split(WHITESPACE)
|
||||
val local = f.getOrNull(1)?.substringAfterLast(':')?.toIntOrNull(16)
|
||||
val remote = f.getOrNull(2)?.substringAfterLast(':')?.toIntOrNull(16)
|
||||
if (local == port && remote == PORT) f.getOrNull(7)?.toIntOrNull() else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [session] unless the cap is reached by sessions that cannot be replaced; otherwise the
|
||||
* oldest session that never claimed the prepared game (then the oldest untrusted one) makes room.
|
||||
* Counting and eviction share one lock, so the live total never passes the cap.
|
||||
*/
|
||||
internal fun admit(session: Session): Boolean = synchronized(sessions) {
|
||||
if (sessions.count { !it.evicted } >= MAX_CLIENTS) {
|
||||
val candidates = sessions.filter { !it.trusted && !it.evicted }
|
||||
val victim = candidates.firstOrNull { !it.claimed } ?: candidates.firstOrNull() ?: return false
|
||||
victim.evicted = true
|
||||
runCatching { victim.socket.close() }
|
||||
}
|
||||
sessions += session
|
||||
true
|
||||
}
|
||||
|
||||
internal val sessions = LinkedHashSet<Session>()
|
||||
|
||||
/** Trust and eviction exclude each other: an evicted session never becomes trusted, a trusted one is never evicted. */
|
||||
internal fun promote(session: Session, matches: Boolean) {
|
||||
synchronized(sessions) { session.trusted = matches && !session.evicted && session.born == generation && holder.compareAndSet(null, session) }
|
||||
}
|
||||
|
||||
internal class Session(val socket: Socket, val output: OutputStream, val verified: Boolean) {
|
||||
val born = generation
|
||||
val connectedAt = System.currentTimeMillis()
|
||||
@Volatile var evicted = false
|
||||
@Volatile var claimed = false
|
||||
var key: ByteArray? = null
|
||||
@Volatile var trusted = false
|
||||
var pending: ByteArray? = null
|
||||
|
||||
@Synchronized
|
||||
fun send(xml: String) {
|
||||
val key = this.key
|
||||
val text = if (key == null) xml else EaCrypto.hex(EaCrypto.ecbEncrypt(xml.toByteArray(), key))
|
||||
output.write(text.toByteArray())
|
||||
output.write(0)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun note(line: String) = com.droiddeck.launcher.stores.StoresState.logLine("ea lsx: $line")
|
||||
|
||||
private fun serve(client: Socket, session: Session) {
|
||||
client.tcpNoDelay = true
|
||||
client.soTimeout = HANDSHAKE_TIMEOUT_MS
|
||||
val seen = LinkedHashSet<String>()
|
||||
var why = "closed by the game"
|
||||
note("game connected")
|
||||
try {
|
||||
session.send("<LSX><Event sender=\"$SENDER\"><Challenge build=\"release\" key=\"$CHALLENGE_KEY\" version=\"$CHALLENGE_VERSION\"/></Event></LSX>")
|
||||
val input = client.getInputStream()
|
||||
val pending = ByteArrayOutputStream()
|
||||
val chunk = ByteArray(16 * 1024)
|
||||
while (true) {
|
||||
if (session.key == null) {
|
||||
val left = session.connectedAt + HANDSHAKE_TIMEOUT_MS - System.currentTimeMillis()
|
||||
if (left <= 0) throw java.net.SocketTimeoutException()
|
||||
client.soTimeout = left.toInt()
|
||||
}
|
||||
val n = input.read(chunk)
|
||||
if (n < 0) break
|
||||
pending.write(chunk, 0, n)
|
||||
for (frame in frames(pending, session.key)) {
|
||||
for (one in Regex("<LSX>.*?</LSX>", RegexOption.DOT_MATCHES_ALL).findAll(frame)) {
|
||||
val message = parse(one.value) ?: continue
|
||||
if (seen.add(message.name)) note("request ${message.name}")
|
||||
val reply = runCatching { answer(session, message) }
|
||||
.onFailure { note("${message.name} failed: ${it.javaClass.simpleName}") }.getOrNull()
|
||||
if (reply != null) session.send("<LSX><Response sender=\"${esc(message.recipient)}\" id=\"${esc(message.id)}\">$reply</Response></LSX>")
|
||||
session.pending?.let { session.key = it; session.pending = null }
|
||||
if (session.key != null) {
|
||||
val idle = if (session.trusted) 0 else UNTRUSTED_IDLE_MS
|
||||
if (client.soTimeout != idle) client.soTimeout = idle
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pending.size() > 4 * 1024 * 1024) { why = "message too large"; break }
|
||||
}
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
why = "no trusted handshake in time"
|
||||
} catch (e: Exception) {
|
||||
why = e.javaClass.simpleName
|
||||
} finally {
|
||||
holder.compareAndSet(session, null)
|
||||
note("game disconnected ($why)")
|
||||
runCatching { client.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete messages in what has arrived so far. Messages normally end in a NUL; one that does
|
||||
* not counts once it holds a whole `<LSX>` element, plain or decrypted.
|
||||
*/
|
||||
private fun frames(pending: ByteArrayOutputStream, key: ByteArray?): List<String> {
|
||||
val bytes = pending.toByteArray()
|
||||
val out = ArrayList<String>()
|
||||
var start = 0
|
||||
for (i in bytes.indices) {
|
||||
if (bytes[i].toInt() != 0) continue
|
||||
if (i > start) decode(String(bytes, start, i - start, Charsets.UTF_8), key)?.let(out::add)
|
||||
start = i + 1
|
||||
}
|
||||
var rest = String(bytes, start, bytes.size - start, Charsets.UTF_8)
|
||||
if (rest.isNotBlank()) decode(rest, key)?.takeIf { it.contains("</LSX>") }?.let { out += it; rest = "" }
|
||||
pending.reset()
|
||||
pending.write(rest.toByteArray(Charsets.UTF_8))
|
||||
return out
|
||||
}
|
||||
|
||||
private fun decode(frame: String, key: ByteArray?): String? {
|
||||
val text = frame.trim()
|
||||
if (text.isEmpty()) return null
|
||||
if (text.startsWith("<")) return text
|
||||
val k = key ?: return null
|
||||
return runCatching { String(EaCrypto.ecbDecrypt(EaCrypto.unhex(text), k)) }.getOrNull()
|
||||
}
|
||||
|
||||
internal fun parse(xml: String): Message? = runCatching {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser()
|
||||
parser.setInput(StringReader(xml))
|
||||
var recipient = ""
|
||||
var id = ""
|
||||
var name = ""
|
||||
val attrs = HashMap<String, String>()
|
||||
val children = ArrayList<Pair<String, String>>()
|
||||
var depth = 0
|
||||
var child = ""
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
depth++
|
||||
when (depth) {
|
||||
2 -> if (parser.name == "Request") {
|
||||
recipient = parser.getAttributeValue(null, "recipient").orEmpty()
|
||||
id = parser.getAttributeValue(null, "id").orEmpty()
|
||||
} else return@runCatching null
|
||||
3 -> {
|
||||
name = parser.name
|
||||
for (i in 0 until parser.attributeCount) attrs[parser.getAttributeName(i)] = parser.getAttributeValue(i)
|
||||
}
|
||||
4 -> child = parser.name
|
||||
}
|
||||
}
|
||||
XmlPullParser.TEXT -> if (depth == 4) children += child to parser.text.trim()
|
||||
XmlPullParser.END_TAG -> depth--
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
if (name.isEmpty()) null else Message(recipient, id, name, attrs, children)
|
||||
}.getOrNull()
|
||||
|
||||
private fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
|
||||
private fun tag(name: String, vararg attrs: Pair<String, Any?>, body: String? = null): String {
|
||||
val a = attrs.filter { it.second != null }.joinToString("") { " ${it.first}=\"${esc(it.second.toString())}\"" }
|
||||
return if (body == null) "<$name$a/>" else "<$name$a>$body</$name>"
|
||||
}
|
||||
|
||||
private fun now() = LocalDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
|
||||
|
||||
private fun answer(session: Session, m: Message): String? {
|
||||
val g = game
|
||||
val who = account
|
||||
val person = who?.takeIf { session.trusted }
|
||||
val locale = g?.locale ?: "en_US"
|
||||
return when (m.name) {
|
||||
"ChallengeResponse" -> {
|
||||
val answer = EaCrypto.challengeAnswer(m.attrs["key"].orEmpty())
|
||||
val version = m.attrs["version"].orEmpty()
|
||||
val seed = EaCrypto.challengeSeed(version, answer) ?: 0
|
||||
val content = m.children.firstOrNull { it.first == "ContentId" }?.second.orEmpty()
|
||||
session.claimed = g != null && content.isNotEmpty() && (content == g.contentId || content in g.contentIds)
|
||||
val matches = session.verified && session.claimed
|
||||
promote(session, matches)
|
||||
note("challenge version '$version' content '$content' ${when { session.trusted -> "matches the prepared game"; matches -> "matches, but another session holds this launch"; !session.verified -> "comes from an unverified peer"; else -> "does not match the prepared game" }}")
|
||||
session.pending = EaCrypto.lsxKey(seed)
|
||||
Log.i(TAG, "game connected: ${m.children.firstOrNull { it.first == "Title" }?.second.orEmpty()}")
|
||||
tag("ChallengeAccepted", "response" to answer)
|
||||
}
|
||||
"GetConfig" -> tag("GetConfigResponse", body = SERVICES.joinToString("") { tag("Service", "Name" to it.first, "Facility" to it.second) })
|
||||
"GetProfile" -> tag(
|
||||
"GetProfileResponse",
|
||||
"Persona" to person?.persona.orEmpty(), "SubscriberLevel" to 0, "CommerceCurrency" to "USD", "IsTrialSubscriber" to false,
|
||||
"Country" to "US", "UserId" to person?.userId.orEmpty(), "GeoCountry" to "US", "AvatarId" to "", "IsSubscriber" to false,
|
||||
"IsSteamSubscriber" to false, "PersonaId" to person?.personaId.orEmpty(), "IsUnderAge" to false, "UserIndex" to 0,
|
||||
)
|
||||
"GetSetting" -> tag("GetSettingResponse", "Setting" to when (m.attrs["SettingId"]) {
|
||||
"ENVIRONMENT" -> "production"
|
||||
"LANGUAGE" -> locale
|
||||
else -> "false"
|
||||
})
|
||||
"GetGameInfo" -> tag("GetGameInfoResponse", "GameInfo" to when (m.attrs["GameInfoId"]) {
|
||||
"FREETRIAL" -> "false"
|
||||
"LANGUAGES" -> LANGUAGES
|
||||
"INSTALLED_LANGUAGE" -> locale
|
||||
"INSTALLED_VERSION", "AVAILABLE_VERSION" -> g?.version.orEmpty()
|
||||
else -> ""
|
||||
})
|
||||
"GetAllGameInfo" -> tag(
|
||||
"GetAllGameInfoResponse",
|
||||
"FullGamePurchased" to true, "FullGameReleased" to true, "InstalledVersion" to (g?.version ?: "0"), "MaxGroupSize" to 16,
|
||||
"Languages" to LANGUAGES, "Expiration" to "0000-00-00T00:00:00", "UpToDate" to true, "HasExpiration" to false,
|
||||
"EntitlementSource" to "EA", "AvailableVersion" to (g?.version ?: "0"), "DisplayName" to g?.title.orEmpty(),
|
||||
"FreeTrial" to false, "InstalledLanguage" to locale, "FullGameReleaseDate" to "2000-01-01T00:00:00", "SystemTime" to now(),
|
||||
)
|
||||
"GetInternetConnectedState" -> tag("InternetConnectedState", "connected" to 1)
|
||||
"IsProgressiveInstallationAvailable" -> tag("IsProgressiveInstallationAvailableResponse", "ItemId" to m.attrs["ItemId"].orEmpty(), "Available" to false)
|
||||
"AreChunksInstalled" -> tag(
|
||||
"AreChunksInstalledResponse", "ItemId" to m.attrs["ItemId"].orEmpty(), "Installed" to true,
|
||||
body = m.children.joinToString("") { tag(it.first, body = esc(it.second)) },
|
||||
)
|
||||
"GetAuthCode" -> tag("AuthCode", "value" to (who?.takeIf { session.trusted }?.authCode(m.attrs["ClientId"].orEmpty(), m.attrs["Scope"])?.takeIf { session.trusted } ?: "invalid"))
|
||||
"GetAuthToken" -> tag("AuthToken", "value" to if (!session.trusted) "" else (g?.validToken ?: who?.token().orEmpty()))
|
||||
"RequestLicense" -> {
|
||||
val token = who?.takeIf { session.trusted }?.license(g?.contentId.orEmpty(), m.attrs["RequestTicket"].orEmpty(), m.attrs["TicketEngine"].orEmpty())?.takeIf { session.trusted }.orEmpty()
|
||||
tag("RequestLicenseResponse", "License" to token)
|
||||
}
|
||||
"QueryEntitlements" -> {
|
||||
val group = m.attrs["Group"].orEmpty()
|
||||
val rows = who?.takeIf { session.trusted }?.entitlements(group).orEmpty()
|
||||
tag("QueryEntitlementsResponse", body = rows.joinToString("") { e ->
|
||||
tag(
|
||||
"Entitlement",
|
||||
"LastModifiedDate" to "0000-00-00T00:00:00", "EntitlementId" to e["id"], "UseCount" to (e["useCount"] ?: "0"),
|
||||
"Version" to (e["version"] ?: "0"), "ItemId" to e["productId"], "ResourceId" to "", "GrantDate" to e["grantDate"],
|
||||
"Group" to group, "EntitlementTag" to e["entitlementTag"], "Type" to e["entitlementType"],
|
||||
"Expiration" to (e["terminationDate"]?.takeIf { it.isNotEmpty() && it != "null" } ?: "0000-00-00T00:00:00"), "Source" to "",
|
||||
)
|
||||
})
|
||||
}
|
||||
"GetPresence" -> tag("GetPresenceResponse", "UserId" to person?.userId.orEmpty(), "Presence" to "INGAME")
|
||||
"QueryPresence" -> tag("QueryPresenceResponse", body = "")
|
||||
"QueryFriends" -> tag("QueryFriendsResponse", body = "")
|
||||
"GetBlockList" -> tag("GetBlockListResponse", "Return" to "Success", body = "")
|
||||
"QueryOffers" -> tag("QueryOffersResponse", body = "")
|
||||
"QueryImage" -> tag("QueryImageResponse", "Result" to 0, body = "")
|
||||
"GetVoipStatus" -> tag("GetVoipStatusResponse", "Available" to false, "Active" to false)
|
||||
"ShowIGOWindow", "SetDownloaderUtilization" -> null
|
||||
else -> tag("ErrorSuccess", "Code" to 0, "Description" to "")
|
||||
}
|
||||
}
|
||||
|
||||
private class AppAccount(private val app: Context) : Account {
|
||||
private val saved get() = EaAuth.load(app)
|
||||
override val userId: String get() = saved?.userId.orEmpty()
|
||||
override val personaId: String get() = saved?.personaId.orEmpty()
|
||||
override val persona: String get() = saved?.eaId?.ifEmpty { null } ?: saved?.displayName.orEmpty()
|
||||
|
||||
override fun authCode(clientId: String, scope: String?): String? =
|
||||
EaAuth.mint(app, clientId, "code", scopes = scope?.split(' ')?.filter { it.isNotBlank() }.orEmpty())
|
||||
|
||||
override fun license(contentId: String, requestTicket: String, ticketEngine: String): String? {
|
||||
val g = game ?: return null
|
||||
val hw = g.hardware ?: return null
|
||||
val token = EaAuth.accessToken(app) ?: return null
|
||||
return runCatching {
|
||||
EaOoa.request(token, contentId, EaOoa.machineHash(hw, g.hashVersion), requestTicket.ifEmpty { null }, ticketEngine.ifEmpty { null }).gameToken
|
||||
}.onFailure { Log.w(TAG, "licence: ${it.message}") }.getOrNull()
|
||||
}
|
||||
|
||||
override fun token(): String? = EaAuth.mint(app, EaAuth.CLIENT_ID, "token", EaAuth.OPAQUE_SCOPES, "OPAQUE", 14400)
|
||||
|
||||
override fun entitlements(group: String): List<Map<String, String>> {
|
||||
val token = EaAuth.accessToken(app) ?: return emptyList()
|
||||
val arr = runCatching { EaApi.entitlements(token, group) }.getOrNull() ?: return emptyList()
|
||||
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map { o ->
|
||||
o.keys().asSequence().associateWith { k -> o.optString(k) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.File
|
||||
import java.io.StringReader
|
||||
|
||||
/** What `__Installer/installerdata.xml` in an EA build says about running and finishing the install. */
|
||||
class EaManifest(
|
||||
val version: String,
|
||||
val launchers: List<Launcher>,
|
||||
val touchupPath: String,
|
||||
val touchupParameters: String,
|
||||
val contentIds: List<String> = emptyList(),
|
||||
val titles: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
fun title(locale: String): String? = titles[locale] ?: titles["en_US"] ?: titles.values.firstOrNull()
|
||||
|
||||
class Launcher(val path: String, val parameters: String, val trial: Boolean)
|
||||
|
||||
fun launcher(): Launcher? = launchers.firstOrNull { !it.trial } ?: launchers.firstOrNull()
|
||||
|
||||
companion object {
|
||||
const val RELATIVE = "__Installer/installerdata.xml"
|
||||
|
||||
fun decode(bytes: ByteArray): String {
|
||||
val utf16 = bytes.size >= 2 && ((bytes[0] == 0xFF.toByte() && bytes[1] == 0xFE.toByte()) || (bytes[0] == 0xFE.toByte() && bytes[1] == 0xFF.toByte()))
|
||||
val text = if (utf16) String(bytes, Charsets.UTF_16) else String(bytes, Charsets.UTF_8)
|
||||
return text.trimStart('')
|
||||
}
|
||||
|
||||
fun read(folder: File): EaManifest? {
|
||||
val file = File(folder, RELATIVE).takeIf { it.isFile } ?: EaPaths.find(folder, RELATIVE) ?: return null
|
||||
return runCatching { parse(decode(file.readBytes())) }.getOrNull()
|
||||
}
|
||||
|
||||
fun parse(xml: String): EaManifest {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser()
|
||||
parser.setInput(StringReader(xml.replace(Regex("^<\\?xml[^>]*\\?>"), "")))
|
||||
val stack = ArrayList<String>()
|
||||
var version = ""
|
||||
val launchers = ArrayList<Launcher>()
|
||||
var touchupPath = ""
|
||||
var touchupParams = ""
|
||||
var path = ""
|
||||
var params = ""
|
||||
var trial = false
|
||||
val contentIds = ArrayList<String>()
|
||||
val titles = LinkedHashMap<String, String>()
|
||||
var locale = ""
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
val name = parser.name
|
||||
stack += name
|
||||
if (name.equals("gameVersion", true)) {
|
||||
version = parser.getAttributeValue(null, "version") ?: version
|
||||
}
|
||||
if (name.equals("launcher", true)) { path = ""; params = ""; trial = false }
|
||||
if (name.equals("localeInfo", true)) locale = parser.getAttributeValue(null, "locale").orEmpty()
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
val text = parser.text.trim()
|
||||
val tag = stack.lastOrNull().orEmpty()
|
||||
val parent = stack.getOrNull(stack.size - 2).orEmpty()
|
||||
if (text.isNotEmpty()) when {
|
||||
parent.equals("launcher", true) && tag.equals("filePath", true) -> path = text
|
||||
parent.equals("launcher", true) && tag.equals("parameters", true) -> params = text
|
||||
parent.equals("launcher", true) && tag.equals("trial", true) -> trial = text.equals("true", true)
|
||||
(parent.equals("touchup", true) || parent.equals("executable", true)) && tag.equals("filePath", true) -> touchupPath = text
|
||||
(parent.equals("touchup", true) || parent.equals("executable", true)) && tag.equals("parameters", true) -> touchupParams = text
|
||||
parent.equals("contentIDs", true) && tag.equals("contentID", true) -> contentIds += text
|
||||
parent.equals("localeInfo", true) && tag.equals("title", true) && locale.isNotEmpty() -> titles[locale] = text
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
if (parser.name.equals("launcher", true) && path.isNotEmpty()) launchers += Launcher(path, params, trial)
|
||||
stack.removeLastOrNull()
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return EaManifest(version, launchers, touchupPath, touchupParams, contentIds, titles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object EaPaths {
|
||||
private val KEY = Regex("^\\[([^\\]]+)\\](.*)$")
|
||||
|
||||
/** `[HKEY_LOCAL_MACHINE\SOFTWARE\Publisher\Game\Install Dir]bin\game.exe` splits into the registry key, value and relative path. */
|
||||
class Registered(val key: String, val value: String, val rest: String)
|
||||
|
||||
fun registered(path: String): Registered? {
|
||||
val m = KEY.find(path.trim()) ?: return null
|
||||
val full = m.groupValues[1]
|
||||
val cut = full.lastIndexOf('\\').takeIf { it > 0 } ?: return null
|
||||
return Registered(full.substring(0, cut), full.substring(cut + 1), m.groupValues[2])
|
||||
}
|
||||
|
||||
fun relative(path: String): String {
|
||||
val rest = registered(path)?.rest ?: path.replace("{installLocation}", "", ignoreCase = true)
|
||||
return rest.replace('\\', '/').trimStart('/').split('/').filter { it.isNotEmpty() && it != "." }.joinToString("/")
|
||||
}
|
||||
|
||||
/** The real file for a path the manifest may spell in a different case. */
|
||||
fun find(folder: File, relative: String): File? {
|
||||
var cur = folder
|
||||
for (part in relative.replace('\\', '/').split('/').filter { it.isNotEmpty() }) {
|
||||
val names = cur.listFiles() ?: return null
|
||||
cur = names.firstOrNull { it.name == part } ?: names.firstOrNull { it.name.equals(part, true) } ?: return null
|
||||
}
|
||||
return cur.takeIf { it.exists() && it != folder }
|
||||
}
|
||||
|
||||
fun relativeOnDisk(folder: File, relative: String): String? =
|
||||
find(folder, relative)?.relativeTo(folder)?.path?.replace(File.separatorChar, '/')
|
||||
|
||||
fun splitArgs(text: String): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
val cur = StringBuilder()
|
||||
var quoted = false
|
||||
var any = false
|
||||
for (c in text) {
|
||||
when {
|
||||
c == '"' -> { quoted = !quoted; any = true }
|
||||
c.isWhitespace() && !quoted -> { if (any || cur.isNotEmpty()) out += cur.toString(); cur.clear(); any = false }
|
||||
else -> cur.append(c)
|
||||
}
|
||||
}
|
||||
if (any || cur.isNotEmpty()) out += cur.toString()
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.StoresNative
|
||||
import com.droiddeck.launcher.stores.amazon.AmazonNative
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object EaNative {
|
||||
private const val TAG = "EaNative"
|
||||
const val ENGINE_MISSING = "EA: the store engine is not available"
|
||||
|
||||
fun ready(): Boolean = StoresNative.ensureLoaded()
|
||||
|
||||
fun call(op: String, request: JSONObject): JSONObject? {
|
||||
if (!ready()) return null
|
||||
val text = try { nativeCall(op, request.toString()) } catch (t: Throwable) {
|
||||
Log.w(TAG, "$op: ${t.javaClass.simpleName}")
|
||||
null
|
||||
} ?: return null
|
||||
return runCatching { JSONObject(text) }.getOrNull()
|
||||
}
|
||||
|
||||
fun install(request: JSONObject, events: AmazonNative.InstallListener, cancelled: AtomicBoolean): AmazonNative.InstallOutcome {
|
||||
if (!ready()) return AmazonNative.InstallOutcome(false, false, ENGINE_MISSING, null)
|
||||
val done = CountDownLatch(1)
|
||||
val holder = arrayOfNulls<AmazonNative.InstallOutcome>(1)
|
||||
val bridge = object : AmazonNative.InstallListener by events {
|
||||
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) {
|
||||
holder[0] = AmazonNative.InstallOutcome(success, cancelled, error, result.takeIf { it.isNotEmpty() }?.let { runCatching { JSONObject(it) }.getOrNull() })
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
val handle = try { nativeInstall(request.toString(), bridge) } catch (t: Throwable) {
|
||||
return AmazonNative.InstallOutcome(false, false, "The EA install could not start: ${t.javaClass.simpleName}", null)
|
||||
}
|
||||
if (handle == 0L) return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The EA install could not start", null)
|
||||
try {
|
||||
var forwarded = false
|
||||
while (true) {
|
||||
val finished = try { done.await(250, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) { cancelled.set(true); false }
|
||||
if (finished) break
|
||||
if (!forwarded && cancelled.get()) { nativeCancel(handle); forwarded = true }
|
||||
}
|
||||
} finally {
|
||||
nativeRelease(handle)
|
||||
}
|
||||
return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The EA install ended without a result", null)
|
||||
}
|
||||
|
||||
@JvmStatic private external fun nativeCall(op: String, request: String): String?
|
||||
@JvmStatic private external fun nativeInstall(request: String, listener: AmazonNative.InstallListener): Long
|
||||
@JvmStatic private external fun nativeCancel(handle: Long)
|
||||
@JvmStatic private external fun nativeRelease(handle: Long)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.File
|
||||
import java.io.StringReader
|
||||
import java.net.URLEncoder
|
||||
import java.security.SecureRandom
|
||||
|
||||
/** Origin Online Activation: the per-machine licence an EA game's activation layer checks before it runs. */
|
||||
object EaOoa {
|
||||
private const val LICENSES = "https://proxy.novafusion.ea.com/licenses"
|
||||
const val LICENSE_DIR = "ProgramData/Electronic Arts/EA Services/License"
|
||||
private const val SIGNATURE_SLOT = 65
|
||||
|
||||
/**
|
||||
* What the game sees of the machine inside its Wine prefix: firmware and OS values from Wine's
|
||||
* WMI, CPU values from CPUID executed as x86 code, since an emulated game sees the emulator's
|
||||
* CPUID rather than the host CPU WMI describes.
|
||||
*/
|
||||
class Hardware(
|
||||
val boardManufacturer: String,
|
||||
val boardSerial: String,
|
||||
val biosManufacturer: String,
|
||||
val biosSerial: String,
|
||||
val osInstallDate: String,
|
||||
val osSerial: String,
|
||||
val volumeSerial: String,
|
||||
val gpuPnpId: String,
|
||||
val cpuVendor: String,
|
||||
val cpuEdx: String,
|
||||
val cpuEax: String,
|
||||
val cpuEcx: String,
|
||||
val brand: String,
|
||||
val hostname: String,
|
||||
) {
|
||||
/** Whether the probe reported everything the hash of this activation version is built from. */
|
||||
fun complete(version: Int): Boolean {
|
||||
val needed = arrayListOf(boardManufacturer, biosManufacturer)
|
||||
when {
|
||||
version <= 1 -> needed += listOf(hostname, osInstallDate, osSerial)
|
||||
version == 2 -> needed += listOf(osInstallDate, osSerial, volumeSerial, cpuVendor, cpuEdx, cpuEcx)
|
||||
version == 3 -> needed += listOf(volumeSerial, cpuVendor, cpuEdx, cpuEcx)
|
||||
else -> needed += listOf(volumeSerial, cpuVendor, cpuEdx, cpuEax)
|
||||
}
|
||||
return needed.all { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject): Hardware {
|
||||
fun s(k: String) = o.optString(k).trim()
|
||||
return Hardware(
|
||||
boardManufacturer = s("boardManufacturer"),
|
||||
boardSerial = s("boardSerial").ifEmpty { "None" },
|
||||
biosManufacturer = s("biosManufacturer"),
|
||||
biosSerial = s("biosSerial").ifEmpty { "None" },
|
||||
osInstallDate = s("osInstallDate"),
|
||||
osSerial = s("osSerial"),
|
||||
volumeSerial = s("volumeSerial").replace("-", "").lowercase().let { if (it.isEmpty()) it else it.padStart(8, '0') },
|
||||
gpuPnpId = s("gpuPnpId").ifEmpty { "None" },
|
||||
cpuVendor = s("cpuVendor"),
|
||||
cpuEdx = s("cpuEdx").uppercase(),
|
||||
cpuEax = s("cpuEax").uppercase(),
|
||||
cpuEcx = s("cpuEcx").lowercase(),
|
||||
brand = o.optString("cpuBrand"),
|
||||
hostname = s("hostname"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun machineHash(hw: Hardware, version: Int): String {
|
||||
val edxEax = hw.cpuEdx + hw.cpuEax
|
||||
val edx = hw.cpuEdx.lowercase()
|
||||
val shortGpu = hw.gpuPnpId.substringBeforeLast('\\', hw.gpuPnpId)
|
||||
val parts = arrayListOf(hw.boardManufacturer, hw.boardSerial)
|
||||
when {
|
||||
version <= 1 -> parts += listOf(hw.hostname, hw.biosManufacturer, hw.biosSerial, hw.osInstallDate, hw.osSerial)
|
||||
version == 2 -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.osInstallDate, hw.osSerial, hw.volumeSerial, shortGpu, hw.cpuVendor, edx, hw.cpuEcx)
|
||||
version == 3 -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.volumeSerial, shortGpu, hw.cpuVendor, edx, hw.cpuEcx)
|
||||
else -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.volumeSerial, hw.gpuPnpId, hw.cpuVendor, edxEax)
|
||||
}
|
||||
val text = StringBuilder(parts.joinToString(";")).append(';')
|
||||
if (version >= 2) text.append(hw.brand.take(47).padEnd(47, '\u0000')).append(';')
|
||||
val digest = EaCrypto.sha1(text.toString().toByteArray(Charsets.ISO_8859_1))
|
||||
return if (version < 4) digest.joinToString("") { Integer.toHexString(it.toInt() and 0xFF) } else EaCrypto.hex(digest)
|
||||
}
|
||||
|
||||
/** Version of the game's activation layer, which decides how the machine hash is built. */
|
||||
fun activationVersion(core: File): Int {
|
||||
val dll = listOf("activation.dll", "awc.dll").firstNotNullOfOrNull { EaPaths.find(core, it) } ?: return 1
|
||||
val (major, minor, patch) = productVersion(dll.readBytes()) ?: return 1
|
||||
return when {
|
||||
major >= 5 -> 4
|
||||
major == 4 && (minor > 7 || (minor == 7 && patch >= 6)) -> 3
|
||||
major == 4 && minor == 7 -> 2
|
||||
major == 4 -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
/** The activation layer's folder: `Core` beside the game's exe, where EA ships it, or at the game root. */
|
||||
fun coreDir(game: File, exe: String): File? {
|
||||
val exeDir = exe.replace('\\', '/').substringBeforeLast('/', "")
|
||||
val candidates = listOfNotNull(exeDir.takeIf { it.isNotEmpty() }?.let { "$it/Core" }, "Core")
|
||||
return candidates.firstNotNullOfOrNull { EaPaths.find(game, it)?.takeIf { f -> f.isDirectory } }
|
||||
}
|
||||
|
||||
/** Games built on the 2012-era EA Core client, which want an opaque token rather than a JWS one. */
|
||||
fun legacyCore(core: File?): Boolean = core != null && EaPaths.find(core, "EACore.dll") != null
|
||||
|
||||
private fun encodedSignature(core: File): Boolean = EaPaths.find(core, "activation.exe") != null
|
||||
|
||||
internal fun productVersion(pe: ByteArray): Triple<Int, Int, Int>? {
|
||||
val magic = byteArrayOf(0xBD.toByte(), 0x04, 0xEF.toByte(), 0xFE.toByte())
|
||||
var i = 0
|
||||
while (i + 24 <= pe.size) {
|
||||
if (pe[i] == magic[0] && pe[i + 1] == magic[1] && pe[i + 2] == magic[2] && pe[i + 3] == magic[3]) {
|
||||
fun u32(at: Int) = (pe[at].toLong() and 0xFF) or ((pe[at + 1].toLong() and 0xFF) shl 8) or ((pe[at + 2].toLong() and 0xFF) shl 16) or ((pe[at + 3].toLong() and 0xFF) shl 24)
|
||||
val ms = u32(i + 16)
|
||||
val ls = u32(i + 20)
|
||||
return Triple((ms ushr 16).toInt(), (ms and 0xFFFF).toInt(), (ls ushr 16).toInt())
|
||||
}
|
||||
i++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class License(val fields: LinkedHashMap<String, String>, val namespace: String, val signature: String) {
|
||||
val gameToken: String? get() = fields["GameToken"]?.takeIf { it.isNotEmpty() }
|
||||
val contentId: String get() = fields["ContentId"].orEmpty()
|
||||
}
|
||||
|
||||
internal fun parseLicense(xml: String, signature: String): License {
|
||||
val parser = XmlPullParserFactory.newInstance().apply { isNamespaceAware = false }.newPullParser()
|
||||
parser.setInput(StringReader(xml.replace(Regex("^<\\?xml[^>]*\\?>"), "").trim()))
|
||||
val fields = LinkedHashMap<String, String>()
|
||||
var ns = ""
|
||||
var depth = 0
|
||||
var tag = ""
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
depth++
|
||||
if (depth == 1) ns = parser.getAttributeValue(null, "xmlns").orEmpty() else tag = parser.name
|
||||
if (depth == 2) fields[tag] = ""
|
||||
}
|
||||
XmlPullParser.TEXT -> if (depth == 2) fields[tag] = fields[tag].orEmpty() + parser.text
|
||||
XmlPullParser.END_TAG -> depth--
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return License(fields, ns, signature)
|
||||
}
|
||||
|
||||
private fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
|
||||
fun licenseXml(l: License): String {
|
||||
val order = listOf("CipherKey", "MachineHash", "ContentId", "UserId", "GameToken", "GrantTime", "StartTime", "Nonce")
|
||||
val body = StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>")
|
||||
body.append("<License xmlns=\"").append(esc(l.namespace)).append("\">")
|
||||
for (k in order) {
|
||||
val v = l.fields[k] ?: continue
|
||||
if (k == "GameToken" && v.isEmpty()) continue
|
||||
body.append('<').append(k).append('>').append(esc(v)).append("</").append(k).append('>')
|
||||
}
|
||||
return body.append("</License>").toString()
|
||||
}
|
||||
|
||||
fun licenseBlob(l: License, encoded: Boolean): ByteArray {
|
||||
val signature = if (encoded) l.signature.toByteArray() else java.util.Base64.getDecoder().decode(l.signature)
|
||||
val head = ByteArray(SIGNATURE_SLOT)
|
||||
System.arraycopy(signature, 0, head, 0, signature.size.coerceAtMost(SIGNATURE_SLOT))
|
||||
return head + EaCrypto.licenseEncrypt(licenseXml(l).toByteArray())
|
||||
}
|
||||
|
||||
fun request(token: String, contentId: String, machineHash: String, requestToken: String? = null, requestType: String? = null): License {
|
||||
val nonce = EaCrypto.hex(ByteArray(4).also(SecureRandom()::nextBytes))
|
||||
val params = mutableListOf("contentId" to contentId, "machineHash" to machineHash, "nonce" to nonce, "ea_eadmtoken" to token)
|
||||
if (requestToken != null) params += "requestToken" to requestToken
|
||||
if (requestType != null) params += "requestType" to requestType
|
||||
val url = LICENSES + "?" + params.joinToString("&") { (k, v) -> k + "=" + URLEncoder.encode(v, "UTF-8") }
|
||||
val reply = EaHttp.request(url, headers = mapOf("X-Requester-Id" to "Origin Online Activation", "User-Agent" to "EACTransaction"))
|
||||
if (!reply.ok) throw IllegalStateException("EA refused the licence (${reply.status}): ${reply.text.replace(Regex("(eadmToken value=\")[^\"]*"), "$1<redacted>\"").take(600)}")
|
||||
val signature = reply.header("x-signature") ?: throw IllegalStateException("EA's licence had no signature")
|
||||
return parseLicense(String(EaCrypto.licenseDecrypt(reply.body), Charsets.UTF_8), signature)
|
||||
}
|
||||
|
||||
/** Licence files for a game about to start, named as the activation layer looks for them. */
|
||||
fun files(core: File, license: License): List<Pair<String, ByteArray>> {
|
||||
val blob = licenseBlob(license, encodedSignature(core))
|
||||
val id = license.contentId
|
||||
return listOf("$LICENSE_DIR/$id.dlf" to blob, "$LICENSE_DIR/${id}_cached.dlf" to blob)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@ object GogBackend : StoreBackend {
|
||||
}, "gog-sync").start()
|
||||
}
|
||||
|
||||
override fun search(context: Context, query: String): List<CatalogItem> = GogStoreCatalog.search(query)
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
GogStoreCatalog.cachedFeatured(app)?.let { StoresState.post { StoresState.shelves[store] = it } }
|
||||
|
||||
@@ -58,16 +58,17 @@ object GogLibrary {
|
||||
val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn
|
||||
onStatus(app.getString(R.string.stores_sync_game_list))
|
||||
val listing = ownedListing(token)
|
||||
val account = ownedIds(token)
|
||||
val listed: List<GogGame> = listing?.games
|
||||
?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
|
||||
?: account?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
|
||||
?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable))
|
||||
val named = listing != null
|
||||
StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids")
|
||||
val ownedSet = listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }
|
||||
val cachedList = cached(app)
|
||||
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
|
||||
val ownedSet = ownedSet(listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }, account, cachedIds)
|
||||
StoresState.logLine(if (named) "GOG: listing ${listed.size} games, ${ownedSet.size} owned ids" else "GOG: listing unreadable, ${listed.size} owned ids")
|
||||
val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS
|
||||
val heavy = force || cachedList.isEmpty() || stale
|
||||
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
|
||||
val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
|
||||
skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty())
|
||||
val idsToFetch = if (heavy) ownedSet.toList() else ownedSet.filter { it !in cachedIds && it !in skipped }
|
||||
@@ -133,6 +134,9 @@ object GogLibrary {
|
||||
return all.ifEmpty { null }?.let { Listing(it, owned) }
|
||||
}
|
||||
|
||||
fun ownedSet(listed: Set<String>, account: List<String>?, cached: Set<String> = emptySet()): Set<String> =
|
||||
if (account == null) listed + cached else listed + account
|
||||
|
||||
private fun ownedIds(token: String): List<String>? {
|
||||
val json = StoreNet.get(OWNED_URL, bearer = token, userAgent = GogAuth.GALAXY_UA) ?: return null
|
||||
val owned = runCatching { JSONObject(json).optJSONArray("owned") }.getOrNull() ?: return null
|
||||
|
||||
@@ -151,7 +151,7 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: (
|
||||
?.let { UserApps.Request(UserApps.Source.Script(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
AddSource.APPIMAGE -> imagePath?.takeIf { it.endsWith(".appimage", ignoreCase = true) }?.let { File(it) }
|
||||
?.let { UserApps.Request(UserApps.Source.AppImage(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it), custom, iconPath) to (custom ?: it) }
|
||||
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it, UserApps.githubAsset(repo)), custom, iconPath) to (custom ?: it) }
|
||||
AddSource.FLATPAK -> bundlePath?.takeIf { it.endsWith(".flatpak", ignoreCase = true) }?.let { File(it) }
|
||||
?.let { UserApps.Request(UserApps.Source.FlatpakBundle(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
?: flatpakId?.let { UserApps.Request(UserApps.Source.Flatpak(it), custom, iconPath) to (custom ?: it) }
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.InstalledStoreGame
|
||||
import com.droiddeck.launcher.stores.ea.EaUpdates
|
||||
|
||||
@Composable
|
||||
internal fun EaUpdateButton(installed: InstalledStoreGame, enabled: Boolean) {
|
||||
val ctx = LocalContext.current
|
||||
LaunchedEffect(installed.key, installed.sidecar.installVersion) { EaUpdates.check(ctx, installed) }
|
||||
if (EaUpdates.available[installed.key] == null) return
|
||||
SecondaryButton(stringResource(R.string.common_update), enabled = enabled) { EaUpdates.start(ctx, installed) }
|
||||
}
|
||||
@@ -430,6 +430,7 @@ internal fun GameActions(
|
||||
openUrl(ctx, item?.storeUrl.orEmpty().ifBlank { GameJoltApi.pageUrl("game", installed.sidecar.id) })
|
||||
}
|
||||
}
|
||||
if (store == Store.EA && installed != null) EaUpdateButton(installed, download == null && StoresState.uninstalling[installed.key] == null)
|
||||
if (store == Store.GOG && installed != null) GogMaintenanceActions(installed)
|
||||
if (store == Store.XBOX && id != null) XboxInstalledStream(id, game?.name ?: item?.title.orEmpty(), item)
|
||||
if (store == Store.XBOX && installed != null && id != null) {
|
||||
@@ -915,7 +916,7 @@ private fun AddonRow(name: String, target: WowAddons.Target, a: FrontEndActions)
|
||||
|
||||
@Composable
|
||||
private fun CloudRows(g: Library.SteamGame) {
|
||||
val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON } ?: return
|
||||
val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON && it != Store.EA } ?: return
|
||||
val id = g.storeId ?: return
|
||||
val context = LocalContext.current.applicationContext
|
||||
var busy by remember(g.gameId) { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -16,7 +16,7 @@ import com.droiddeck.launcher.input.*
|
||||
internal fun InputActionEditor(
|
||||
control: InputControl, current: InputAction, title: String, onBack: () -> Unit, onSave: (InputAction) -> Unit,
|
||||
element: InputSurfaceElement? = null, nativeTargets: Boolean = true, detail: String? = null,
|
||||
properties: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
properties: (@Composable ColumnScope.(InputAction) -> Unit)? = null,
|
||||
click: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -46,7 +46,7 @@ internal fun InputActionEditor(
|
||||
SettingsPage(host, title, onBack, eyebrow = stringResource(R.string.input_properties), lede = detail,
|
||||
action = { PrimaryButton(stringResource(R.string.gesture_save_changes), enabled = valid(draft),
|
||||
modifier = Modifier.testTag("input-action-save")) { onSave(draft) } }) {
|
||||
properties?.invoke(this)
|
||||
properties?.invoke(this, draft)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SheetChips(listOf(stringResource(R.string.input_route_controller), stringResource(R.string.input_route_local), stringResource(R.string.input_disabled)), route) {
|
||||
when (it) {
|
||||
|
||||
@@ -58,12 +58,17 @@ internal fun InputDisplayContent(host: ComponentActivity, display: Display, onDi
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowShown(display.displayId)
|
||||
showPresentation()
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
|
||||
if (event == Lifecycle.Event.ON_START) showPresentation()
|
||||
}
|
||||
host.lifecycle.addObserver(observer)
|
||||
onDispose { host.lifecycle.removeObserver(observer); presentation.dismiss() }
|
||||
onDispose {
|
||||
host.lifecycle.removeObserver(observer)
|
||||
presentation.dismiss()
|
||||
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowClosed(display.displayId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
internal fun InputElementProperties(element: InputSurfaceElement, onChange: (InputSurfaceElement) -> Unit) {
|
||||
internal fun InputElementProperties(element: InputSurfaceElement, action: InputAction? = null, onChange: (InputSurfaceElement) -> Unit) {
|
||||
val current by rememberUpdatedState(element)
|
||||
val change by rememberUpdatedState(onChange)
|
||||
OutlinedTextField(element.name, { onChange(element.copy(name = it.take(80))) }, label = { Text(stringResource(R.string.input_name)) },
|
||||
@@ -72,7 +72,9 @@ internal fun InputElementProperties(element: InputSurfaceElement, onChange: (Inp
|
||||
}
|
||||
val host = rememberMenuHost()
|
||||
if ((element.kind == InputElementKind.BUTTON || element.kind == InputElementKind.TRIGGER) && element.icon == 0 && element.image.isEmpty() ||
|
||||
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:"))
|
||||
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:") ||
|
||||
(element.kind == InputElementKind.STICK || element.kind == InputElementKind.DPAD) && (action as? InputAction.Directions)
|
||||
?.let { listOf(it.up, it.right, it.down, it.left).plus(if (element.kind == InputElementKind.STICK) it.diagonals.orEmpty() else emptyList()).any { code -> code != 0 } } == true)
|
||||
ToggleRow(host, "element-label", stringResource(R.string.input_show_label), null, element.showLabel) { onChange(element.copy(showLabel = it)) }
|
||||
SliderRow(stringResource(R.string.ctrl_opacity), null, (element.opacity * 100).roundToInt().coerceIn(20, 100), 20..100, step = 5,
|
||||
format = { "$it%" }, modifier = Modifier.testTag("setting-element-opacity")) { onChange(element.copy(opacity = it / 100f)) }
|
||||
|
||||
@@ -169,7 +169,7 @@ private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embe
|
||||
?: b.controls[editing.control] ?: InputAction.Native()
|
||||
InputActionEditor(editing.control, current, if (editing.click) stringResource(R.string.input_click_name, editing.element!!.name)
|
||||
else editing.element?.name ?: editing.key?.label ?: editing.control.label,
|
||||
properties = { if (!editing.click) elementProperties?.let { e -> InputElementProperties(e) { elementProperties = it } } },
|
||||
properties = { action -> if (!editing.click) elementProperties?.let { e -> InputElementProperties(e, action) { elementProperties = it } } },
|
||||
onBack = { edit = null }, element = editing.element, nativeTargets = editing.key == null, click = editing.click,
|
||||
detail = if (library.usersOfBindings(b.id).size > 1) stringResource(R.string.input_shared_detail, b.name, library.usersOfBindings(b.id).size)
|
||||
else "${profile.name} · ${roleLabel(role)}", onSave = { action ->
|
||||
|
||||
@@ -69,7 +69,7 @@ private class GamepadScaler(private val context: android.content.Context, privat
|
||||
}
|
||||
|
||||
private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputBindings, val defaults: List<InputSurfaceElement>? = null,
|
||||
private val gamepad: GamepadScaler? = null) {
|
||||
private val gamepad: GamepadScaler? = null, snap: Boolean = true) {
|
||||
var elements by mutableStateOf(layout.elements)
|
||||
var actions by mutableStateOf(preset.elements)
|
||||
var clicks by mutableStateOf(preset.clicks)
|
||||
@@ -77,7 +77,7 @@ private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputB
|
||||
var toolsExpanded by mutableStateOf(false)
|
||||
var toolsX by mutableFloatStateOf(.02f)
|
||||
var toolsY by mutableFloatStateOf(.02f)
|
||||
var snap by mutableStateOf(true)
|
||||
var snap by mutableStateOf(snap)
|
||||
var adding by mutableStateOf(false)
|
||||
var properties by mutableStateOf(false)
|
||||
var clickProperties by mutableStateOf(false)
|
||||
@@ -132,7 +132,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
|
||||
val (w, h) = OnScreenLayout.generatedSize(context, savedLayout, metrics.widthPixels, metrics.heightPixels) ?: (metrics.widthPixels to metrics.heightPixels)
|
||||
GamepadScaler(display, w, h)
|
||||
}
|
||||
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad)
|
||||
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad, ControllerPrefs.snap(context))
|
||||
}
|
||||
val manager = remember { context.getSystemService(DisplayManager::class.java) }
|
||||
var revision by remember { mutableIntStateOf(0) }
|
||||
@@ -162,7 +162,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
|
||||
val listener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(id: Int) { revision++ }
|
||||
override fun onDisplayRemoved(id: Int) { revision++ }
|
||||
override fun onDisplayChanged(id: Int) { revision++ }
|
||||
override fun onDisplayChanged(id: Int) = Unit
|
||||
}
|
||||
manager.registerDisplayListener(listener, Handler(Looper.getMainLooper()))
|
||||
onDispose { manager.unregisterDisplayListener(listener) }
|
||||
@@ -171,13 +171,18 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
|
||||
val presentation = SurfaceEditorPresentation(host, target, transparent = host is SessionActivity && role == InputRole.PRIMARY) {
|
||||
SurfaceCanvas(draft, transparent = host is SessionActivity && role == InputRole.PRIMARY)
|
||||
}
|
||||
(host as? SessionActivity)?.inputWindowShown(target.displayId)
|
||||
runCatching { presentation.show() }.onFailure { failure = it.message }
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
|
||||
if (event == Lifecycle.Event.ON_START) runCatching { presentation.show() }
|
||||
}
|
||||
host.lifecycle.addObserver(observer)
|
||||
onDispose { host.lifecycle.removeObserver(observer); presentation.dismiss() }
|
||||
onDispose {
|
||||
host.lifecycle.removeObserver(observer)
|
||||
presentation.dismiss()
|
||||
(host as? SessionActivity)?.inputWindowClosed(target.displayId)
|
||||
}
|
||||
}
|
||||
// Compose 1.6 custom dialog sizing subtracts system bars even when they are hidden.
|
||||
Dialog(onDismissRequest = ::leave, properties = DialogProperties(usePlatformDefaultWidth = true, decorFitsSystemWindows = false)) {
|
||||
@@ -198,8 +203,8 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
|
||||
if (draft.clickProperties) draft.clickAction(element) else draft.action(element),
|
||||
if (draft.clickProperties) stringResource(R.string.input_click_name, element.name) else element.name,
|
||||
onBack = { draft.properties = false }, element = element, click = draft.clickProperties,
|
||||
properties = {
|
||||
if (!draft.clickProperties) InputElementProperties(properties) { properties = it }
|
||||
properties = { action ->
|
||||
if (!draft.clickProperties) InputElementProperties(properties, action) { properties = it }
|
||||
if (layoutUsers > 1) Text(stringResource(R.string.input_editor_shared, layoutUsers),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 12.dp))
|
||||
@@ -309,13 +314,14 @@ private fun FloatingSurfaceTools(draft: SurfaceDraft, onSave: () -> Unit, onLeav
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SurfaceToolbar(draft: SurfaceDraft, onSave: () -> Unit, onLeave: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
FlowRow(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = onLeave)
|
||||
SecondaryButton(stringResource(R.string.input_editor_add), enabled = draft.elements.size < 64, compact = true) { draft.adding = true }
|
||||
SecondaryButton(stringResource(R.string.input_editor_undo), enabled = draft.history.isNotEmpty(), compact = true) { draft.undo() }
|
||||
if (draft.defaults != null) SecondaryButton(stringResource(R.string.input_reset_positions), enabled = draft.positioned != null, compact = true) { draft.resetPositions() }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(draft.snap, { draft.snap = it })
|
||||
Checkbox(draft.snap, { draft.snap = it; ControllerPrefs.setSnap(context, it) })
|
||||
Text(stringResource(R.string.ctrl_editor_snap))
|
||||
}
|
||||
PrimaryButton(stringResource(R.string.input_editor_done), compact = true, onClick = onSave)
|
||||
|
||||
@@ -32,10 +32,12 @@ import com.droiddeck.launcher.input.InputSurfaceStyle
|
||||
import com.droiddeck.launcher.input.pointerSubset
|
||||
import com.droiddeck.launcher.input.InputSurfaceSource
|
||||
import com.droiddeck.launcher.input.InputElementKind
|
||||
import com.droiddeck.launcher.input.InputAction
|
||||
import com.droiddeck.launcher.input.InputButtonIcons
|
||||
import com.droiddeck.launcher.input.InputButtonShape
|
||||
import com.droiddeck.launcher.input.InputButtonImages
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.pcKeyLabel
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.hypot
|
||||
@@ -195,7 +197,7 @@ class InputSurfaceView(
|
||||
canvas.addView(when (element.kind) {
|
||||
InputElementKind.STICK -> StickView(context, element, source, element.floating, element.hideWhenIdle && pad != null)
|
||||
InputElementKind.TRACKPAD -> TrackpadView(context, element.control == InputControl.RIGHT_PAD, source = source)
|
||||
InputElementKind.DPAD -> DirectionPad(context, source)
|
||||
InputElementKind.DPAD -> DirectionPad(context, element, source)
|
||||
else -> HoldButton(context, element, source)
|
||||
}.apply { tag = element; contentDescription = element.name; styleElement(this); source?.onStateChanged = { postInvalidate() } })
|
||||
}
|
||||
@@ -541,6 +543,7 @@ class InputSurfaceView(
|
||||
if (touching || clicking) shade(color, 160, 1f) else shade(color, 120, 0.35f), if (touching) light(color, 230, 0.7f) else light(color, 150, 0.25f))
|
||||
if (!element.showLabel) return
|
||||
labelPaint.color = textColor(touching)
|
||||
if (drawDirectionLabels(canvas, source, false, cx, cy, radius * 0.66f, radius * 0.34f, labelPaint)) return
|
||||
labelPaint.textSize = knobRadius * 0.8f
|
||||
val label = if (element.name == element.control.label) glyph(element.control) else element.name
|
||||
canvas.drawText(label, cx + knobX, cy + knobY + labelPaint.textSize * 0.35f, labelPaint)
|
||||
@@ -552,6 +555,9 @@ class InputSurfaceView(
|
||||
canvas.drawCircle(cx, cy, reach(), ring)
|
||||
knob.color = if (touching) ACCENT else KNOB
|
||||
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
|
||||
if (!element.showLabel) return
|
||||
labelPaint.color = if (touching) ACCENT else TEXT
|
||||
drawDirectionLabels(canvas, source, false, cx, cy, reach() * 1.32f, reach() * 0.68f, labelPaint)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@@ -613,10 +619,11 @@ class InputSurfaceView(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DirectionPad(context: Context, private val source: InputSurfaceSource? = null) : View(context) {
|
||||
private inner class DirectionPad(context: Context, private val element: InputSurfaceElement, private val source: InputSurfaceSource? = null) : View(context) {
|
||||
private val density = resources.displayMetrics.density
|
||||
private var held = 0
|
||||
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER; typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD); color = TEXT }
|
||||
private val rect = RectF()
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
@@ -636,6 +643,7 @@ class InputSurfaceView(
|
||||
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
|
||||
arm.color = KNOB
|
||||
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
|
||||
if (element.showLabel) drawDirectionLabels(canvas, source, true, cx, cy, (half + thick) / 2f, thick * 1.7f, label)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@@ -717,6 +725,29 @@ class InputSurfaceView(
|
||||
else -> control.id.uppercase()
|
||||
}
|
||||
|
||||
private val CARDINAL = listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)
|
||||
private val DIAGONAL = listOf(.7071f to -.7071f, .7071f to .7071f, -.7071f to .7071f, -.7071f to -.7071f)
|
||||
|
||||
internal fun directionLabels(context: Context, action: InputAction.Directions?, cardinalOnly: Boolean): List<Pair<Pair<Float, Float>, String>> {
|
||||
if (action == null) return emptyList()
|
||||
val codes = listOf(action.up, action.right, action.down, action.left).zip(CARDINAL) +
|
||||
if (cardinalOnly) emptyList() else action.diagonals.orEmpty().zip(DIAGONAL)
|
||||
return codes.filter { it.first != 0 }.map { (code, unit) -> unit to pcKeyLabel(context, code) }
|
||||
}
|
||||
|
||||
private fun View.drawDirectionLabels(canvas: Canvas, source: InputSurfaceSource?, cardinalOnly: Boolean, cx: Float, cy: Float,
|
||||
distance: Float, box: Float, paint: Paint): Boolean {
|
||||
val labels = directionLabels(context, source?.directions, cardinalOnly)
|
||||
paint.style = Paint.Style.FILL
|
||||
for ((unit, text) in labels) {
|
||||
paint.textSize = box * .6f
|
||||
val measured = paint.measureText(text)
|
||||
if (measured > box) paint.textSize *= box / measured
|
||||
canvas.drawText(text, cx + unit.first * distance, cy + unit.second * distance - (paint.ascent() + paint.descent()) / 2f, paint)
|
||||
}
|
||||
return labels.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int, paint: Paint) {
|
||||
val (fx, fy) = when (direction) { 0 -> 0f to -1f; 1 -> 1f to 0f; 2 -> 0f to 1f; else -> -1f to 0f }
|
||||
val path = Path()
|
||||
|
||||
@@ -109,6 +109,7 @@ import com.droiddeck.launcher.stores.itch.ItchUpdates
|
||||
import com.droiddeck.launcher.stores.gog.GogMaintenance
|
||||
import com.droiddeck.launcher.stores.retainOrder
|
||||
import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter
|
||||
import com.droiddeck.launcher.stores.entryTime
|
||||
import com.droiddeck.launcher.stores.sortGames
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -296,8 +297,8 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
|
||||
val sizesPending = sizeSort && !sizesWaited && candidates.any { sizeOf(it) == null }
|
||||
val sorted = sortGames(
|
||||
candidates, sort, title = { it.title }, releaseDate = { it.item?.releaseDate.orEmpty() },
|
||||
lastPlayed = { e -> e.game?.lastPlayed ?: e.storeKey?.let { lastPlayed[it] } ?: 0L },
|
||||
installedAt = { e -> e.storeKey?.let { installedAt[it] } ?: 0L },
|
||||
lastPlayed = { e -> entryTime(e.game?.lastPlayed, e.storeKey?.let { lastPlayed[it] }) },
|
||||
installedAt = { e -> entryTime(e.storeKey?.let { installedAt[it] }, e.game?.installedAt) },
|
||||
size = { e -> sizeOf(e) ?: 0L },
|
||||
)
|
||||
var stable by remember { mutableStateOf(emptyList<LibraryEntry>()) }
|
||||
@@ -741,7 +742,7 @@ internal fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?, showDownlo
|
||||
}
|
||||
}
|
||||
|
||||
private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available || it in GameJoltUpdates.available } == true ||
|
||||
private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available || it in GameJoltUpdates.available || it in com.droiddeck.launcher.stores.ea.EaUpdates.available } == true ||
|
||||
e.source == Store.GOG.id && (e.item?.id ?: e.game?.storeId)?.let { GogMaintenance.checks[it]?.state } == GogMaintenance.State.AVAILABLE
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -323,7 +323,7 @@ class SecondScreenPresentation(
|
||||
context.getString(if (keyboardShown.value) R.string.second_hide_keyboard else R.string.second_show_keyboard),
|
||||
icon = R.drawable.ic_bottom_keyboard, active = keyboardShown.value, tag = "bottom-keyboard") { showKeyboard() }))
|
||||
SecondScreenMode.DECK_CONTROLS -> model(swap, deckPanelTabs(context, deckPanel.value, ::selectDeckPanel))
|
||||
SecondScreenMode.DASHBOARD -> model(swap)
|
||||
SecondScreenMode.DASHBOARD, SecondScreenMode.CUSTOM_CONTROLS -> model(swap)
|
||||
SecondScreenMode.TERMINAL -> model(listOf(BarExtra(context.getString(R.string.drawer_keyboard), icon = R.drawable.ic_bottom_keyboard,
|
||||
tag = "bottom-keyboard") { terminalPanel?.showKeyboard() }))
|
||||
else -> null
|
||||
|
||||
@@ -219,7 +219,6 @@ class DrawerActions(
|
||||
val frameGen: FrameGen.Mode,
|
||||
val lossless: Lossless.State,
|
||||
val oscMode: String,
|
||||
val onScreenButtonsVisible: Boolean,
|
||||
val suspendPolicy: String,
|
||||
val steamDownloadsInBackground: Boolean = false,
|
||||
val micAvailable: Boolean = false,
|
||||
@@ -325,6 +324,7 @@ class DrawerActions(
|
||||
val onStop: () -> Unit,
|
||||
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
|
||||
val onClose: () -> Unit,
|
||||
val onDismissScreen: (() -> Unit)? = null,
|
||||
val components: ComponentsManager.Snapshot? = null,
|
||||
val systemPowerVr: Boolean = false,
|
||||
val onComponentsRefresh: () -> Unit = {},
|
||||
@@ -488,7 +488,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
) {
|
||||
SteamActionPill(
|
||||
stringResource(R.string.common_close), Icons.Outlined.Close, focus.track(page, "close"),
|
||||
) { host.open = null; a.onClose() }
|
||||
) { host.open = null; (a.onDismissScreen ?: a.onClose)() }
|
||||
if (roomyHeader) Spacer(Modifier.weight(1f))
|
||||
SteamActionPill(
|
||||
stringResource(R.string.stores_dl_pause), Icons.Outlined.Pause, focus.track(page, "pause"),
|
||||
|
||||
@@ -33,6 +33,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
|
||||
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
|
||||
Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00))
|
||||
Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF))
|
||||
Store.EA.id -> SourceColours(Color(0xFF3A0E12), Color(0xFFFF9A9A), Color(0xFFFF4747))
|
||||
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
|
||||
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
|
||||
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
|
||||
@@ -48,6 +49,7 @@ internal fun sourceLabel(source: String): String = when (source) {
|
||||
Store.ITCH.id -> Store.ITCH.label
|
||||
Store.GAMEJOLT.id -> Store.GAMEJOLT.label
|
||||
Store.BATTLENET.id -> Store.BATTLENET.label
|
||||
Store.EA.id -> Store.EA.label
|
||||
else -> stringResource(R.string.source_added)
|
||||
}
|
||||
|
||||
|
||||
@@ -1157,6 +1157,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
|
||||
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
|
||||
Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00)
|
||||
Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3)
|
||||
Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030)
|
||||
null -> return base
|
||||
}
|
||||
val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White
|
||||
|
||||
@@ -1259,7 +1259,6 @@
|
||||
<string name="deck_tab_gamepad">يد التحكّم</string>
|
||||
<string name="deck_surface_stick">عصا التحكّم</string>
|
||||
<string name="deck_surface_trackpad">لوحة اللمس</string>
|
||||
<string name="deck_surface_switch">التبديل بين عصا التحكّم ولوحة اللمس</string>
|
||||
<string name="deck_close">إغلاق عناصر التحكّم على الشاشة الثانية</string>
|
||||
<string name="deck_open_steam_menu">فتح قائمة Steam</string>
|
||||
<string name="deck_right_trackpad">لوحة اللمس اليمنى</string>
|
||||
|
||||
@@ -1308,7 +1308,6 @@
|
||||
<string name="deck_tab_gamepad">Control</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
|
||||
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
|
||||
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad derecho</string>
|
||||
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Геймпад</string>
|
||||
<string name="deck_surface_stick">Стик</string>
|
||||
<string name="deck_surface_trackpad">Тракпад</string>
|
||||
<string name="deck_surface_switch">Превключване между стик и тракпад</string>
|
||||
<string name="deck_close">Затваряне на контролите за втория екран</string>
|
||||
<string name="deck_open_steam_menu">Отваряне на менюто на Steam</string>
|
||||
<string name="deck_right_trackpad">Десен тракпад</string>
|
||||
|
||||
@@ -1245,7 +1245,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Páčka</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Přepnout mezi páčkou a trackpadem</string>
|
||||
<string name="deck_close">Zavřít ovládání druhé obrazovky</string>
|
||||
<string name="deck_open_steam_menu">Otevřít nabídku Steamu</string>
|
||||
<string name="deck_right_trackpad">Pravý trackpad</string>
|
||||
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Skift mellem stick og trackpad</string>
|
||||
<string name="deck_close">Luk kontrollerne til den anden skærm</string>
|
||||
<string name="deck_open_steam_menu">Åbn Steam-menuen</string>
|
||||
<string name="deck_right_trackpad">Højre trackpad</string>
|
||||
|
||||
@@ -1223,7 +1223,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Zwischen Stick und Trackpad wechseln</string>
|
||||
<string name="deck_close">Steuerung auf dem Zweitbildschirm schließen</string>
|
||||
<string name="deck_open_steam_menu">Steam-Menü öffnen</string>
|
||||
<string name="deck_right_trackpad">Rechtes Trackpad</string>
|
||||
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Χειριστήριο</string>
|
||||
<string name="deck_surface_stick">Μοχλός</string>
|
||||
<string name="deck_surface_trackpad">Πινακίδα αφής</string>
|
||||
<string name="deck_surface_switch">Εναλλαγή μεταξύ μοχλού και πινακίδας αφής</string>
|
||||
<string name="deck_close">Κλείσιμο χειριστηρίων δεύτερης οθόνης</string>
|
||||
<string name="deck_open_steam_menu">Άνοιγμα μενού Steam</string>
|
||||
<string name="deck_right_trackpad">Δεξιά πινακίδα αφής</string>
|
||||
|
||||
@@ -1238,7 +1238,6 @@
|
||||
<string name="deck_tab_gamepad">Mando</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
|
||||
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
|
||||
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad derecho</string>
|
||||
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Peliohjain</string>
|
||||
<string name="deck_surface_stick">Tatti</string>
|
||||
<string name="deck_surface_trackpad">Kosketuslevy</string>
|
||||
<string name="deck_surface_switch">Vaihda tatin ja kosketuslevyn välillä</string>
|
||||
<string name="deck_close">Sulje toisen näytön ohjaimet</string>
|
||||
<string name="deck_open_steam_menu">Avaa Steam-valikko</string>
|
||||
<string name="deck_right_trackpad">Oikea kosketuslevy</string>
|
||||
|
||||
@@ -1238,7 +1238,6 @@
|
||||
<string name="deck_tab_gamepad">Manette</string>
|
||||
<string name="deck_surface_stick">Joystick</string>
|
||||
<string name="deck_surface_trackpad">Pavé tactile</string>
|
||||
<string name="deck_surface_switch">Basculer entre joystick et pavé tactile</string>
|
||||
<string name="deck_close">Fermer les commandes du deuxième écran</string>
|
||||
<string name="deck_open_steam_menu">Ouvrir le menu Steam</string>
|
||||
<string name="deck_right_trackpad">Pavé tactile droit</string>
|
||||
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">गेमपैड</string>
|
||||
<string name="deck_surface_stick">स्टिक</string>
|
||||
<string name="deck_surface_trackpad">ट्रैकपैड</string>
|
||||
<string name="deck_surface_switch">स्टिक और ट्रैकपैड के बीच बदलें</string>
|
||||
<string name="deck_close">दूसरी स्क्रीन के कंट्रोल बंद करें</string>
|
||||
<string name="deck_open_steam_menu">Steam मेनू खोलें</string>
|
||||
<string name="deck_right_trackpad">दायां ट्रैकपैड</string>
|
||||
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Kontroller</string>
|
||||
<string name="deck_surface_stick">Kar</string>
|
||||
<string name="deck_surface_trackpad">Érintőpad</string>
|
||||
<string name="deck_surface_switch">Váltás a kar és az érintőpad között</string>
|
||||
<string name="deck_close">Második képernyős vezérlők bezárása</string>
|
||||
<string name="deck_open_steam_menu">Steam-menü megnyitása</string>
|
||||
<string name="deck_right_trackpad">Jobb érintőpad</string>
|
||||
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stik</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Beralih antara stik dan trackpad</string>
|
||||
<string name="deck_close">Tutup kontrol layar kedua</string>
|
||||
<string name="deck_open_steam_menu">Buka menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad kanan</string>
|
||||
|
||||
@@ -1230,7 +1230,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Levetta</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Alterna tra levetta e trackpad</string>
|
||||
<string name="deck_close">Chiudi i controlli sul secondo schermo</string>
|
||||
<string name="deck_open_steam_menu">Apri il menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad destro</string>
|
||||
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">ゲームパッド</string>
|
||||
<string name="deck_surface_stick">スティック</string>
|
||||
<string name="deck_surface_trackpad">トラックパッド</string>
|
||||
<string name="deck_surface_switch">スティックとトラックパッドを切り替え</string>
|
||||
<string name="deck_close">セカンドスクリーンコントロールを閉じる</string>
|
||||
<string name="deck_open_steam_menu">Steamメニューを開く</string>
|
||||
<string name="deck_right_trackpad">右トラックパッド</string>
|
||||
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">게임패드</string>
|
||||
<string name="deck_surface_stick">스틱</string>
|
||||
<string name="deck_surface_trackpad">터치패드</string>
|
||||
<string name="deck_surface_switch">스틱과 터치패드 간 전환</string>
|
||||
<string name="deck_close">보조 화면 컨트롤 닫기</string>
|
||||
<string name="deck_open_steam_menu">Steam 메뉴 열기</string>
|
||||
<string name="deck_right_trackpad">오른쪽 터치패드</string>
|
||||
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stik</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Tukar antara stik dan trackpad</string>
|
||||
<string name="deck_close">Tutup kawalan skrin kedua</string>
|
||||
<string name="deck_open_steam_menu">Buka menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad kanan</string>
|
||||
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Kontroller</string>
|
||||
<string name="deck_surface_stick">Stikke</string>
|
||||
<string name="deck_surface_trackpad">Styreflate</string>
|
||||
<string name="deck_surface_switch">Bytt mellom stikke og styreflate</string>
|
||||
<string name="deck_close">Lukk kontroller for andre skjerm</string>
|
||||
<string name="deck_open_steam_menu">Åpne Steam-menyen</string>
|
||||
<string name="deck_right_trackpad">Høyre styreflate</string>
|
||||
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Wisselen tussen stick en trackpad</string>
|
||||
<string name="deck_close">Bediening op tweede scherm sluiten</string>
|
||||
<string name="deck_open_steam_menu">Steam-menu openen</string>
|
||||
<string name="deck_right_trackpad">Rechtertrackpad</string>
|
||||
|
||||
@@ -1245,7 +1245,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Gałka</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Przełącz między gałką a trackpadem</string>
|
||||
<string name="deck_close">Zamknij sterowanie na drugim ekranie</string>
|
||||
<string name="deck_open_steam_menu">Otwórz menu Steam</string>
|
||||
<string name="deck_right_trackpad">Prawy trackpad</string>
|
||||
|
||||
@@ -1230,7 +1230,6 @@
|
||||
<string name="deck_tab_gamepad">Comando</string>
|
||||
<string name="deck_surface_stick">Joystick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Alternar entre joystick e trackpad</string>
|
||||
<string name="deck_close">Fechar controlos do segundo ecrã</string>
|
||||
<string name="deck_open_steam_menu">Abrir menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad direito</string>
|
||||
|
||||
@@ -1230,7 +1230,6 @@
|
||||
<string name="deck_tab_gamepad">Controle</string>
|
||||
<string name="deck_surface_stick">Analógico</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Alternar entre analógico e trackpad</string>
|
||||
<string name="deck_close">Fechar controles da segunda tela</string>
|
||||
<string name="deck_open_steam_menu">Abrir o menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad direito</string>
|
||||
|
||||
@@ -1238,7 +1238,6 @@
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Manșă</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Comută între manșă și trackpad</string>
|
||||
<string name="deck_close">Închide comenzile celui de-al doilea ecran</string>
|
||||
<string name="deck_open_steam_menu">Deschide meniul Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad-ul drept</string>
|
||||
|
||||
@@ -1282,7 +1282,6 @@
|
||||
<string name="deck_tab_gamepad">Геймпад</string>
|
||||
<string name="deck_surface_stick">Стик</string>
|
||||
<string name="deck_surface_trackpad">Тачпад</string>
|
||||
<string name="deck_surface_switch">Переключение между стиком и тачпадом</string>
|
||||
<string name="deck_close">Закрыть управление вторым экраном</string>
|
||||
<string name="deck_open_steam_menu">Открыть меню Steam</string>
|
||||
<string name="deck_right_trackpad">Правый тачпад</string>
|
||||
|
||||
@@ -1231,7 +1231,6 @@
|
||||
<string name="deck_tab_gamepad">Handkontroll</string>
|
||||
<string name="deck_surface_stick">Styrspak</string>
|
||||
<string name="deck_surface_trackpad">Styrplatta</string>
|
||||
<string name="deck_surface_switch">Växla mellan styrspak och styrplatta</string>
|
||||
<string name="deck_close">Stäng kontrollerna på andra skärmen</string>
|
||||
<string name="deck_open_steam_menu">Öppna Steam-menyn</string>
|
||||
<string name="deck_right_trackpad">Höger styrplatta</string>
|
||||
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">เกมแพด</string>
|
||||
<string name="deck_surface_stick">สติ๊ก</string>
|
||||
<string name="deck_surface_trackpad">แทร็กแพด</string>
|
||||
<string name="deck_surface_switch">สลับระหว่างสติ๊กและแทร็กแพด</string>
|
||||
<string name="deck_close">ปิดการควบคุมหน้าจอที่สอง</string>
|
||||
<string name="deck_open_steam_menu">เปิดเมนู Steam</string>
|
||||
<string name="deck_right_trackpad">แทร็กแพดขวา</string>
|
||||
|
||||
@@ -1223,7 +1223,6 @@
|
||||
<string name="deck_tab_gamepad">Oyun kolu</string>
|
||||
<string name="deck_surface_stick">Çubuk</string>
|
||||
<string name="deck_surface_trackpad">Dokunmatik yüzey</string>
|
||||
<string name="deck_surface_switch">Çubuk ile dokunmatik yüzey arasında geçiş yap</string>
|
||||
<string name="deck_close">İkinci ekran kontrollerini kapat</string>
|
||||
<string name="deck_open_steam_menu">Steam menüsünü aç</string>
|
||||
<string name="deck_right_trackpad">Sağ dokunmatik yüzey</string>
|
||||
|
||||
@@ -1245,7 +1245,6 @@
|
||||
<string name="deck_tab_gamepad">Геймпад</string>
|
||||
<string name="deck_surface_stick">Стік</string>
|
||||
<string name="deck_surface_trackpad">Трекпад</string>
|
||||
<string name="deck_surface_switch">Перемкнути між стіком і трекпадом</string>
|
||||
<string name="deck_close">Закрити керування на другому екрані</string>
|
||||
<string name="deck_open_steam_menu">Відкрити меню Steam</string>
|
||||
<string name="deck_right_trackpad">Правий трекпад</string>
|
||||
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">Tay cầm</string>
|
||||
<string name="deck_surface_stick">Cần</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Chuyển giữa cần và trackpad</string>
|
||||
<string name="deck_close">Đóng điều khiển màn hình phụ</string>
|
||||
<string name="deck_open_steam_menu">Mở menu Steam</string>
|
||||
<string name="deck_right_trackpad">Trackpad phải</string>
|
||||
|
||||
@@ -1308,7 +1308,6 @@
|
||||
<string name="deck_tab_gamepad">手柄</string>
|
||||
<string name="deck_surface_stick">摇杆</string>
|
||||
<string name="deck_surface_trackpad">触控板</string>
|
||||
<string name="deck_surface_switch">在摇杆与触控板之间切换</string>
|
||||
<string name="deck_close">关闭副屏控制</string>
|
||||
<string name="deck_open_steam_menu">打开 Steam 菜单</string>
|
||||
<string name="deck_right_trackpad">右触控板</string>
|
||||
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">控制器</string>
|
||||
<string name="deck_surface_stick">搖桿</string>
|
||||
<string name="deck_surface_trackpad">觸控板</string>
|
||||
<string name="deck_surface_switch">在搖桿和觸控板之間切換</string>
|
||||
<string name="deck_close">關閉第二螢幕控制</string>
|
||||
<string name="deck_open_steam_menu">開啟 Steam 選單</string>
|
||||
<string name="deck_right_trackpad">右側觸控板</string>
|
||||
|
||||
@@ -1224,7 +1224,6 @@
|
||||
<string name="deck_tab_gamepad">手把</string>
|
||||
<string name="deck_surface_stick">搖桿</string>
|
||||
<string name="deck_surface_trackpad">觸控板</string>
|
||||
<string name="deck_surface_switch">在搖桿與觸控板之間切換</string>
|
||||
<string name="deck_close">關閉第二螢幕控制</string>
|
||||
<string name="deck_open_steam_menu">開啟 Steam 選單</string>
|
||||
<string name="deck_right_trackpad">右側觸控板</string>
|
||||
|
||||
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user