diff --git a/.gitattributes b/.gitattributes
index 940dbb5f1..019aa1e8f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -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
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a69109d29..2f799d2b5 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -558,6 +558,12 @@ jobs:
SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
run: python3 tools/components/prepare.py unpack --tree "$SOURCE_TREE"
+ - name: Ubisoft launcher service (x86_64-pc-windows-gnu)
+ run: |
+ set -euo pipefail
+ command -v x86_64-w64-mingw32-gcc >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq gcc-mingw-w64-x86-64; }
+ bash tools/ubisoft-launcher/build.sh
+
- uses: actions/download-artifact@v4
with:
name: xbox-arm64
@@ -794,6 +800,9 @@ jobs:
set -o pipefail
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
+ - name: Ubisoft launcher service host unit tests
+ timeout-minutes: 10
+ run: cargo test --locked --manifest-path tools/ubisoft-launcher/Cargo.toml
python-tests:
name: Python and native regression tests
@@ -886,6 +895,16 @@ jobs:
variants: ${{ needs.build.outputs.variants }}
secrets: inherit
+ scan:
+ needs: sign
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
+ permissions:
+ contents: read
+ uses: ./.github/workflows/scan.yml
+ with:
+ artifact: droiddeck-signed
+ secrets: inherit
+
packs:
needs: [build, checks]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a15159268..eabc77a35 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -63,6 +63,15 @@ jobs:
artifact: droiddeck-release
secrets: inherit
+ scan:
+ needs: sign
+ permissions:
+ contents: read
+ uses: ./.github/workflows/scan.yml
+ with:
+ artifact: droiddeck-release
+ secrets: inherit
+
packs:
needs: find
permissions:
diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml
new file mode 100644
index 000000000..408983955
--- /dev/null
+++ b/.github/workflows/scan.yml
@@ -0,0 +1,45 @@
+name: Scan APKs
+
+on:
+ workflow_call:
+ inputs:
+ artifact:
+ description: Prefix of the signed artifacts to scan, as passed to sign.yml
+ type: string
+ required: true
+ fail_on_detection:
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+jobs:
+ virustotal:
+ name: VirusTotal
+ runs-on: ubuntu-24.04
+ timeout-minutes: 90
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ sparse-checkout: tools/release
+
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: ${{ inputs.artifact }}-*
+ merge-multiple: true
+ path: scan
+
+ - name: Scan
+ continue-on-error: ${{ !inputs.fail_on_detection }}
+ env:
+ VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
+ FAIL_ON_DETECTION: ${{ inputs.fail_on_detection && '1' || '0' }}
+ run: |
+ set -euo pipefail
+ if [ -z "$VT_API_KEY" ]; then
+ echo "::warning title=VirusTotal::no VIRUSTOTAL_API_KEY secret; skipping the scan"
+ exit 0
+ fi
+ bash tools/release/virustotal-scan.sh scan/*.apk
diff --git a/README.md b/README.md
index 3139abd28..73c18762b 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu
100% open source. No ads. No telemetry. No proprietary components. No source withheld.
-
+
@@ -36,9 +36,9 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu
## Install
-1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx is experimental. Mali, Xclipse, PowerVR and Adreno 710 are unsupported. No root needed.
+1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx, Mali, Xclipse and PowerVR are experimental. Adreno 710 is currently unsupported. No root needed.
2. About 3 GB free for the runtime, plus 1.1 GB for the desktop and emulators.
-3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk), install the Linux runtime, press **Play** and sign in to Steam.
+3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.1/DroidDeck-0.7.1.apk), install the Linux runtime, press **Play** and sign in to Steam.
4. On Android 14 and newer, turn on **Disable child process restrictions** in Developer options. On Android 12 and 13, Steam's first launch shows a **Fix it for me** button that walks you through it.
Smart glasses setup is in the [support matrix](docs/smart-glasses-support.md). Release notes are in [docs/releases](docs/releases).
@@ -93,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.
diff --git a/app/build.gradle b/app/build.gradle
index f86084df2..c87e70938 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -131,8 +131,8 @@ android {
applicationId "com.droiddeck.launcher"
minSdkVersion 26
targetSdkVersion 28
- versionCode 18
- versionName "0.7.0"
+ versionCode 19
+ versionName "0.7.1"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5f010b48d..d5bb0dc0d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -91,14 +91,6 @@
-
-
-
-
-
@@ -352,6 +344,14 @@
+
+
+
+
+
#include
#include
+#include
#include
#include
#include
@@ -208,23 +209,33 @@ struct FakeConfig {
bool uinput_enabled;
};
+// The first allocation here can start the host program's own allocator, which may call a hooked
+// function (mimalloc probes NUMA nodes with access()) and land back in config(). A guarded static
+// would throw a recursive-init error there, so the fields are filled without allocating, and a
+// reentry on the initializing thread gets them while other threads wait for the fork handlers.
static FakeConfig &config() {
- static FakeConfig *cfg = [] {
- auto *c = new FakeConfig();
- const char *dir = getenv("FAKE_EVDEV_DIR");
- c->hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input";
- c->udev_data_dir = getenv("FAKE_UDEV_DATA_DIR");
- c->vibration_enabled =
- getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION"));
- const char *identity = getenv("FAKE_EVDEV_IDENTITY");
- c->xbox360_identity = identity && !strcmp(identity, "xbox360");
- c->uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT"));
- pthread_atfork(controller_fork_prepare, controller_fork_parent,
- controller_fork_child);
- Logger::init();
- return c;
- }();
- return *cfg;
+ static FakeConfig cfg;
+ static std::atomic state{0};
+ static __thread bool initializing __attribute__((tls_model("initial-exec")));
+ if (state.load(std::memory_order_acquire) == 2 || initializing) return cfg;
+ int expected = 0;
+ if (!state.compare_exchange_strong(expected, 1, std::memory_order_acq_rel)) {
+ while (state.load(std::memory_order_acquire) != 2) sched_yield();
+ return cfg;
+ }
+ initializing = true;
+ const char *dir = getenv("FAKE_EVDEV_DIR");
+ cfg.hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input";
+ cfg.udev_data_dir = getenv("FAKE_UDEV_DATA_DIR");
+ cfg.vibration_enabled = getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION"));
+ const char *identity = getenv("FAKE_EVDEV_IDENTITY");
+ cfg.xbox360_identity = identity && !strcmp(identity, "xbox360");
+ cfg.uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT"));
+ pthread_atfork(controller_fork_prepare, controller_fork_parent, controller_fork_child);
+ Logger::init();
+ state.store(2, std::memory_order_release);
+ initializing = false;
+ return cfg;
}
static std::recursive_mutex &controller_mutex() {
diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c
index 8d0267cc1..44fea0c76 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_present.c
+++ b/app/src/main/cpp/waylandcomp/src/vk_present.c
@@ -213,7 +213,7 @@ static struct {
} g_map;
static pthread_mutex_t g_game_picture_lock = PTHREAD_MUTEX_INITIALIZER;
static float g_game_picture_rect[4];
-static int g_game_picture_top;
+static int g_game_picture_placed;
static int64_t g_swap_retry_at_ns;
static int g_swap_fail_logged;
@@ -362,7 +362,7 @@ int vkp_completion_unknown(void) { return g_dev_state == -3; }
void vk_present_set_scale_mode(int mode, int alignment) {
if (mode < VKP_MODE_OFF || mode > VKP_MODE_INTEGER) mode = VKP_MODE_FIT;
- if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_BOTTOM) alignment = VKP_ALIGN_CENTER;
+ if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_RIGHT) alignment = VKP_ALIGN_CENTER;
g_mode = mode;
g_align = alignment;
}
@@ -383,6 +383,8 @@ static const char *align_name(int a) {
case VKP_ALIGN_BOTTOM: return "bottom";
case VKP_ALIGN_PICTURE_TOP: return "picture top";
case VKP_ALIGN_PICTURE_BOTTOM: return "picture bottom";
+ case VKP_ALIGN_PICTURE_LEFT: return "picture left";
+ case VKP_ALIGN_PICTURE_RIGHT: return "picture right";
default: return "center";
}
}
@@ -420,9 +422,11 @@ static void update_map(int scene_w, int scene_h) {
}
if (align == VKP_ALIGN_PICTURE_TOP) g_map.off_y = 0;
else if (align == VKP_ALIGN_PICTURE_BOTTOM) g_map.off_y = H - scene_h * g_map.ky;
+ else if (align == VKP_ALIGN_PICTURE_LEFT) g_map.off_x = 0;
+ else if (align == VKP_ALIGN_PICTURE_RIGHT) g_map.off_x = W - scene_w * g_map.kx;
g_map.valid = 1;
pthread_mutex_lock(&g_game_picture_lock);
- g_game_picture_top = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM;
+ g_game_picture_placed = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM || align == VKP_ALIGN_PICTURE_LEFT || align == VKP_ALIGN_PICTURE_RIGHT;
g_game_picture_rect[0] = g_map.off_x;
g_game_picture_rect[1] = g_map.off_y;
g_game_picture_rect[2] = g_map.off_x + scene_w * g_map.kx;
@@ -436,7 +440,7 @@ static void update_map(int scene_w, int scene_h) {
int vkp_game_picture_rect(float rect[4]) {
pthread_mutex_lock(&g_game_picture_lock);
- int active = g_game_picture_top;
+ int active = g_game_picture_placed;
if (active) memcpy(rect, g_game_picture_rect, sizeof(g_game_picture_rect));
pthread_mutex_unlock(&g_game_picture_lock);
return active;
diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h
index 3a76b4c0b..2a363979e 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_present.h
+++ b/app/src/main/cpp/waylandcomp/src/vk_present.h
@@ -15,7 +15,9 @@ int vkp_quest_layer_presentation(void);
enum vkp_scale_mode { VKP_MODE_OFF = 0, VKP_MODE_FIT = 1, VKP_MODE_STRETCH = 2, VKP_MODE_FILL = 3,
VKP_MODE_INTEGER = 4 };
-enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4 };
+enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2,
+ VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4,
+ VKP_ALIGN_PICTURE_LEFT = 5, VKP_ALIGN_PICTURE_RIGHT = 6 };
void vk_present_set_scale_mode(int mode, int alignment);
int vkp_game_picture_rect(float rect[4]);
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy);
diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt
index 5ef39f6b6..2137828dd 100644
--- a/app/src/main/java/com/droiddeck/launcher/App.kt
+++ b/app/src/main/java/com/droiddeck/launcher/App.kt
@@ -61,6 +61,9 @@ 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.StoresState.register(com.droiddeck.launcher.stores.ubisoft.UbisoftBackend)
+ com.droiddeck.launcher.stores.ea.EaBackend.app = this
+ com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend)
}
private fun prefetchAddonPacks() {
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 86e57cd4f..5f39d03c6 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -121,6 +121,7 @@ import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionPaths
+import com.droiddeck.launcher.session.SessionLoadingDecision
import com.droiddeck.launcher.wayland.HdrSupport
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
@@ -172,6 +173,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val alignment = if (!active) ALIGN_CENTER else when (picturePosition) {
"top" -> ALIGN_PICTURE_TOP
"bottom" -> ALIGN_PICTURE_BOTTOM
+ "left" -> ALIGN_PICTURE_LEFT
+ "right" -> ALIGN_PICTURE_RIGHT
else -> ALIGN_CENTER
}
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, alignment)
@@ -286,6 +289,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var directHoldBehavior by mutableStateOf(com.droiddeck.launcher.input.TouchGestureConfig.MOUSE_BEHAVIOR_CLICK)
private var relativeMouseMode by mutableIntStateOf(0)
@Volatile private var appliedRelativeMouseMode: Int? = null
+ private val agentReadyListener: () -> Unit = { relativeMouseRetries = 0; requestRelativeMouse() }
private val appTextFocus get() = wowChat.request != null
private val wowChat by lazy { com.droiddeck.launcher.ui.WowChat(this, ::sendWowChat, ::cancelWowChat, ::readGameField) }
private var wowChatBottom: SecondScreenPresentation? = null
@@ -387,8 +391,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
)
private val mouseBackInput = com.droiddeck.launcher.input.MouseBackInput(
sendRight = { down -> heldButtons.set(32, PointerGestures.BTN_RIGHT, down) },
- mouseAttached = ::mouseAttached,
+ mouseAttached = ::externalMouseAttached,
)
+ private var lastExternalMouse: InputDevice? = null
private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN
private var drawerDirectionDownTime = 0L
private var drawerDirectionDeviceId = -1
@@ -636,6 +641,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
padBridge?.syncConnectedControllers()
refreshControls()
updatePointerCapture()
+ requestRelativeMouse()
updateHandCamera()
refreshSecondScreenDisplays()
}
@@ -651,12 +657,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::padMouse.isInitialized) padMouse.reset()
refreshControls()
updatePointerCapture()
+ requestRelativeMouse()
updateHandCamera()
refreshSecondScreenDisplays()
}
override fun onInputDeviceChanged(deviceId: Int) {
joyConVr?.refresh()
refreshControls()
+ requestRelativeMouse()
refreshSecondScreenDisplays()
}
}
@@ -665,6 +673,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ AgentGuest.readyListener = agentReadyListener
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
runtimeRemovalBlocked = true
@@ -1960,6 +1969,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val nextRoutedTouchMode = effectiveTouchMode()
if (routedTouchMode != nextRoutedTouchMode) gestureTouchStreamGate.reset()
routedTouchMode = nextRoutedTouchMode
+ requestRelativeMouse()
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
@@ -2429,6 +2439,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun watchSession() {
val handler = Handler(Looper.getMainLooper())
var ticks = 0
+ val loadingStartedAt = SystemClock.elapsedRealtime()
val poll = object : Runnable {
override fun run() {
if (!watching) return
@@ -2441,6 +2452,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
finish()
return
}
+ if (loading.visible && !loading.ended && !SessionState.running && !installingRuntime &&
+ SessionLoadingDecision.shouldReturnToLauncher(
+ loadingStartedAt, SystemClock.elapsedRealtime(), SessionState.running, SessionState.phase)) {
+ finish()
+ return
+ }
if (loading.visible && !loading.ended) {
if (!installingRuntime) loading.update(this@SessionActivity, SessionState.logFile)
if (ticks++ % 2 == 0) loading.tick()
@@ -2465,8 +2482,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val hint = sessionExitHint(status)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
- if (status == 0) SessionState.relaunch?.let { next ->
- SessionState.relaunch = null
+ if (status == 0) SessionState.takeRelaunch()?.let { next ->
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
next.putExtra(EXTRA_LINK_TASK, intent.getIntExtra(EXTRA_LINK_TASK, -1))
setIntent(next)
@@ -2668,7 +2684,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
val mouseToGuest = !pipUi && !appTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen && !inputSettingsOpen &&
!SessionState.suspended && !(::loading.isInitialized && (loading.visible || loading.ended))
- if (mouseBackInput.onKeyEvent(event, mouseToGuest)) return true
+ if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 &&
+ com.droiddeck.launcher.input.MouseBackInput.isInjectedBack(event)) lastExternalMouse = null
+ val mouseBackHandled = mouseBackInput.onKeyEvent(event, mouseToGuest)
+ if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 &&
+ com.droiddeck.launcher.input.MouseBackInput.isInjectedBack(event) && mouseToGuest) {
+ val device = lastExternalMouse
+ if (mouseBackHandled && device != null) {
+ Log.i(TAG, String.format(java.util.Locale.ROOT,
+ "injected Back right-click mouse=\"%s\" (%04x:%04x, id %d, sources 0x%x, external=%s, virtual=%s)",
+ device.name, device.vendorId, device.productId, device.id, device.sources,
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || device.isExternal, device.isVirtual))
+ } else if (!mouseBackHandled) {
+ Log.i(TAG, "injected Back no external mouse found")
+ }
+ }
+ if (mouseBackHandled) return true
val backToQam = !pipUi && !appTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
!inputSettingsOpen && !gestureSettingsOpen &&
SessionState.mode == SessionService.MODE_STEAM && SessionState.running && SessionState.firstFrameSeen &&
@@ -2981,7 +3012,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun applyRelativeMouse() {
- val mode = desiredRelativeMouseMode
+ val wanted = desiredRelativeMouseMode
+ val touchpadRoute = com.droiddeck.launcher.input.TouchRouter.decide(com.droiddeck.launcher.input.TouchRouter.Snapshot(
+ uiBlocking = false,
+ mode = routedTouchMode,
+ scrollLayer = false,
+ steamSession = SessionState.mode == SessionService.MODE_STEAM,
+ steamDesktopUi = SessionState.steamUi == "desktop",
+ )) == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD
+ val mode = com.droiddeck.launcher.input.TouchGestureConfig.gamescopeRelativeMouse(wanted, mouseAttached(), touchpadRoute)
if (appliedRelativeMouseMode == mode) return
val request = org.json.JSONObject()
.put("kind", "exec")
@@ -2997,7 +3036,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.w(TAG, "gamescope did not take relative mouse $mode: ${result.exceptionOrNull() ?: answer}")
if (relativeMouseRetries++ < RELATIVE_MOUSE_MAX_RETRIES) uiHandler.postDelayed({ requestRelativeMouse() }, 2_000)
}
- if (desiredRelativeMouseMode != mode) requestRelativeMouse()
+ if (desiredRelativeMouseMode != wanted) requestRelativeMouse()
}
private fun refreshGestureConfig() {
@@ -3192,12 +3231,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
cursorVisible = false
return
}
- val rect = drawnRect() ?: return
+ val rect = if (glassesOutputActive) pointerSurfaceRect() else drawnRect()
+ val pointerRect = rect ?: return
val position = cursorPos
- val x = if (position.x >= 0f) position.x.coerceIn(rect.left, rect.right) else rect.centerX()
- val y = if (position.y >= 0f) position.y.coerceIn(rect.top, rect.bottom) else rect.centerY()
+ val x = if (position.x >= 0f) position.x.coerceIn(pointerRect.left, pointerRect.right) else pointerRect.centerX()
+ val y = if (position.y >= 0f) position.y.coerceIn(pointerRect.top, pointerRect.bottom) else pointerRect.centerY()
val out = SessionState.outputSize
- movePointer(x + dx * rect.width() / out.first, y + dy * rect.height() / out.second)
+ val nextX = (x + dx * pointerRect.width() / out.first)
+ .coerceIn(pointerRect.left, pointerRect.right)
+ val nextY = (y + dy * pointerRect.height() / out.second)
+ .coerceIn(pointerRect.top, pointerRect.bottom)
+ movePointer(nextX, nextY)
// Make frame-callback updates visible to drawing before this frame's traversal.
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
}
@@ -3445,50 +3489,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun bottomPages(): List = sessionBottomPages(BottomPageActions(
takeScreenshot = ::captureScreenshot,
fps = { if (::hud.isInitialized) hud.liveFps else null },
- openSettings = ::openSettingsBelow,
runningGame = { gestureGame },
))
- private fun openSettingsBelow(intent: android.content.Intent, done: () -> Boolean) {
- val presentation = secondScreenPresentation
- if (presentation == null) {
- if (!bottomOutputActive) return
- listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any {
- runCatching { startActivity(android.content.Intent(it)) }
- .onFailure { e -> Log.w(TAG, "could not open ${it.action}", e) }
- .isSuccess
- }
- return
- }
- val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId)
- val opened = listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any {
- runCatching { startActivity(android.content.Intent(it).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), options.toBundle()) }
- .onFailure { e -> Log.w(TAG, "could not open ${it.action} on display ${presentation.display.displayId}", e) }
- .isSuccess
- }
- if (!opened) return
- presentation.hide()
- var checks = 0
- fun showAgain() {
- try {
- presentation.show()
- } catch (e: Exception) {
- Log.w(TAG, "could not show the dashboard again", e)
- if (secondScreenPresentation === presentation) closeSecondScreen(reset = true)
- }
- }
- uiHandler.postDelayed(object : Runnable {
- override fun run() {
- if (secondScreenPresentation !== presentation || presentation.isShowing) return
- if (done()) {
- showAgain()
- return
- }
- if (++checks < SETTINGS_WATCH_CHECKS) uiHandler.postDelayed(this, SETTINGS_WATCH_MS) else showAgain()
- }
- }, SETTINGS_WATCH_MS)
- }
-
private fun captureScreenshot(onDone: (Boolean) -> Unit) {
val source = if (glassesOutputActive) glassesScreen?.surfaceView else if (bottomOutputActive) bottomGame?.surfaceView
else if (::surfaceView.isInitialized) surfaceView else null
@@ -3609,7 +3612,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) {
@@ -3635,8 +3641,12 @@ 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)
@@ -3847,6 +3857,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
gestureTouchStreamGate.reset()
routedTouchMode = next
+ requestRelativeMouse()
if (directTouchCursorHidden()) cursorVisible = false
}
@@ -3939,8 +3950,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val width = outputView().width.takeIf { it > 0 } ?: return null
val height = outputView().height.takeIf { it > 0 } ?: return null
topPictureMapped = CompositorHost.isStarted && WaylandCompositor.nativeGamePictureRect(gamePictureBounds)
- if (topPictureMapped) return android.graphics.RectF(
- gamePictureBounds[0], gamePictureBounds[1], gamePictureBounds[2], gamePictureBounds[3])
+ if (topPictureMapped) {
+ val rect = android.graphics.RectF(
+ gamePictureBounds[0], gamePictureBounds[1],
+ gamePictureBounds[2], gamePictureBounds[3]
+ )
+
+ if (glassesOutputActive) {
+ val mode = glassesScreen?.display?.mode ?: return null
+ val outputWidth = mode.physicalWidth.takeIf { it > 0 } ?: return null
+ val outputHeight = mode.physicalHeight.takeIf { it > 0 } ?: return null
+ val scaleX = width.toFloat() / outputWidth
+ val scaleY = height.toFloat() / outputHeight
+
+ return android.graphics.RectF(
+ rect.left * scaleX,
+ rect.top * scaleY,
+ rect.right * scaleX,
+ rect.bottom * scaleY
+ )
+ }
+
+ return rect
+ }
val out = SessionState.outputSize
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
val drawnW = out.first * scale
@@ -4147,6 +4179,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
isHardwareMouse(d) && d?.supportsSource(InputDevice.SOURCE_TOUCHPAD) != true
}
+ private fun externalMouseAttached(): Boolean {
+ lastExternalMouse = InputDevice.getDeviceIds().map { InputDevice.getDevice(it) }
+ .firstOrNull { device -> device != null && com.droiddeck.launcher.input.MouseBackInput.isExternalMouse(device) }
+ return lastExternalMouse != null
+ }
+
private fun relativeMouseAttached(): Boolean = !isInMultiWindowMode && !glassesOutputActive && InputDevice.getDeviceIds().any { id ->
val d = InputDevice.getDevice(id)
isHardwareMouse(d) && d?.supportsSource(InputDevice.SOURCE_TOUCHPAD) != true
@@ -5353,6 +5391,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onDestroy() {
+ if (AgentGuest.readyListener === agentReadyListener) AgentGuest.readyListener = null
com.droiddeck.launcher.input.HomeButtonShortcuts.detach(this)
relativeMouseRequests.shutdownNow()
backQamInput.cancel()
@@ -5463,8 +5502,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private const val TEXT_FIELD_TAP_MS = 1_000L
private const val PAUSED_FRAME_MAX_EDGE = 1920f
private const val PAUSED_FRAME_REDRAW_MS = 150L
- private const val SETTINGS_WATCH_MS = 1_000L
- private const val SETTINGS_WATCH_CHECKS = 600
private const val BOTTOM_RECOVERY_MS = 1_000L
private const val BOTTOM_RECOVERY_TRIES = 3
private const val TAG = "SessionActivity"
@@ -5506,6 +5543,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private const val ALIGN_CENTER = 0
private const val ALIGN_PICTURE_TOP = 3
private const val ALIGN_PICTURE_BOTTOM = 4
+ private const val ALIGN_PICTURE_LEFT = 5
+ private const val ALIGN_PICTURE_RIGHT = 6
private val RUNTIME_LINES = PackageLines(
R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb,
R.string.session_runtime_checking, R.string.session_runtime_unpacking,
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentCallReceiver.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentCallReceiver.kt
new file mode 100644
index 000000000..6ef8b2d4b
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentCallReceiver.kt
@@ -0,0 +1,41 @@
+package com.droiddeck.launcher.agent
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import java.io.File
+
+/**
+ * The agent bridge for devices whose shell has no `content` command: the same calls as
+ * [AgentBridgeProvider], answered in a file under the app's external files folder.
+ */
+class AgentCallReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val method = intent.getStringExtra("method") ?: return
+ val id = intent.getStringExtra("id")?.takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) } ?: return
+ val arg = intent.getStringExtra("arg")
+ val request = intent.getStringExtra(AgentBridgeProvider.EXTRA_REQUEST)
+ val pending = goAsync()
+ Thread({
+ val json = try {
+ val extras = Bundle().apply { putString(AgentBridgeProvider.EXTRA_REQUEST, request) }
+ context.contentResolver.call("${context.packageName}.agent", method, arg, extras)
+ ?.getString(AgentBridgeProvider.RESULT_JSON) ?: """{"ok":false,"error":{"code":"NO_RESPONSE","message":"no response"}}"""
+ } catch (e: Exception) {
+ org.json.JSONObject().put("ok", false)
+ .put("error", org.json.JSONObject().put("code", "COMMAND_FAILED").put("message", e.message ?: e.javaClass.simpleName)).toString()
+ }
+ try {
+ val dir = File(context.getExternalFilesDir(null), "agent-calls").apply { mkdirs() }
+ File(dir, "$id.json.part").writeText(json)
+ File(dir, "$id.json.part").renameTo(File(dir, "$id.json"))
+ } catch (e: Exception) {
+ android.util.Log.w("AgentCallReceiver", "could not write the answer to $method: ${e.message}")
+ } finally {
+ pending.finish()
+ }
+ }, "agent-call-$id").start()
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt
index eaa2bbc0f..e04dda258 100644
--- a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt
@@ -33,6 +33,7 @@ object AgentGuest {
var keyboardDismissals by mutableIntStateOf(0)
private set
@Volatile private var keyboardRoute = false
+ @Volatile var readyListener: (() -> Unit)? = null
@Volatile private var routeDir: File? = null
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
@@ -59,6 +60,7 @@ object AgentGuest {
when (path) {
"hello.json" -> readJson(File(dir, path))?.let {
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
+ readyListener?.invoke()
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
index 7057cadd3..c7d774bf2 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
@@ -417,7 +417,7 @@ object AddedGames {
private fun scanGame(context: Context, folder: File, out: MutableList, single: AddedExes.Entry? = null, reuse: Boolean = false) {
run {
- val sidecar = StoreGameSidecar.read(folder)
+ val sidecar = StoreGameSidecar.read(folder)?.let { com.droiddeck.launcher.stores.StoreLaunch.adopt(folder, it) }
sidecar?.takeIf { it.id.isNotEmpty() }?.let { SessionPrefs.followStoreGame(context, "${it.store.id}:${it.id}", folder.path) { old -> !File(old).exists() } }
val ranking = rank(folder, reuse)
val candidates = ranking.exes
@@ -457,7 +457,11 @@ object AddedGames {
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id,
exeUncertain = sidecar == null && chosen == null && adopted == null && ranking.uncertain,
- launchOptions = if (sidecar == null) SessionPrefs.addedGameLaunch(context, folder.path) else "",
+ launchOptions = when {
+ sidecar == null -> SessionPrefs.addedGameLaunch(context, folder.path)
+ sidecar.store == Store.UBISOFT -> com.droiddeck.launcher.stores.ubisoft.UbisoftConnect.launchOptions(context)
+ else -> ""
+ },
single = single != null))
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt
index 22044c76d..e9910a67d 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt
@@ -9,7 +9,7 @@ import java.io.File
import java.util.Locale
object WinCompSources {
- enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER, XBOX }
+ enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER, XBOX, UBISOFT }
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List = emptyList())
@@ -83,6 +83,13 @@ object WinCompSources {
}
}
+ /** The prerequisite installers a Ubisoft manifest lists, with `$INSTALL_DIR` dropped. */
+ internal fun ubisoftInstallRuns(setup: String?): List {
+ val runs = setup?.let { runCatching { org.json.JSONObject(it).optJSONArray("installRuns") }.getOrNull() } ?: return emptyList()
+ return (0 until runs.length()).mapNotNull { runs.optJSONObject(it)?.optString("exe") }
+ .map { it.replace('\\', '/').removePrefix("\$INSTALL_DIR").trimStart('/') }.filter { it.isNotEmpty() }
+ }
+
fun forGame(context: Context, game: Library.SteamGame): List {
val folder = game.gameFiles
val out = ArrayList()
@@ -112,6 +119,12 @@ object WinCompSources {
if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false)
parts.forEach { out += Finding(it, original, Origin.AMAZON, true) }
}
+ store == Store.UBISOFT -> ubisoftInstallRuns(sidecar?.extra?.get(com.droiddeck.launcher.stores.ubisoft.UbisoftLaunch.SETUP)).forEach { exe ->
+ val parts = installer(exe)
+ val original = exe.substringAfterLast('/')
+ if (parts.isEmpty()) out += Finding(null, original, Origin.UBISOFT, false)
+ parts.forEach { out += Finding(it, original, Origin.UBISOFT, true) }
+ }
else -> {}
}
if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match ->
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SystemVulkanDriver.java b/app/src/main/java/com/droiddeck/launcher/gpu/SystemVulkanDriver.java
index 25a6e363c..1106ac960 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/SystemVulkanDriver.java
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/SystemVulkanDriver.java
@@ -191,6 +191,7 @@ public final class SystemVulkanDriver {
/** Collect abandoned stages, and obsolete versions once older guests can no longer load them. */
public static synchronized void pruneUnused(Context context, boolean guestsStopped) throws IOException {
+ if (!AddonPack.POWERVR.installed(context)) return;
pruneUnused(runtimeDirectory(context), guestsStopped);
}
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt
index 98dc35074..2aba209de 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt
@@ -6,6 +6,7 @@ import kotlin.math.roundToInt
internal class FrameStats {
private val graph = FloatArray(GRAPH_FRAMES)
private var graphHead = 0
+ private var graphFrames = 0L
private var lastPresentNs = 0L
private var lastFpsUpdateNs = 0L
private var framesSinceUpdate = 0
@@ -38,6 +39,7 @@ internal class FrameStats {
if (lastPresentNs != 0L) {
graph[graphHead] = frametimeNs / 1e6f
graphHead = (graphHead + 1) % GRAPH_FRAMES
+ graphFrames++
}
val elapsed = timeNs - lastFpsUpdateNs
if (elapsed >= FPS_PERIOD_NS) {
@@ -68,6 +70,7 @@ internal class FrameStats {
fun reset() {
graph.fill(0f)
graphHead = 0
+ graphFrames = 0L
lastPresentNs = 0L
lastFpsUpdateNs = 0L
framesSinceUpdate = 0
@@ -86,8 +89,9 @@ internal class FrameStats {
fun frametimeMs(): Float = 1000f / maxOf(fps, 1f)
@Synchronized
- fun graph(out: FloatArray) {
+ fun graph(out: FloatArray): Long {
for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES]
+ return graphFrames
}
@Synchronized
diff --git a/app/src/main/java/com/droiddeck/launcher/input/MouseBackInput.kt b/app/src/main/java/com/droiddeck/launcher/input/MouseBackInput.kt
index 261c88a74..9f8240640 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/MouseBackInput.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/MouseBackInput.kt
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.input
+import android.os.Build
import android.view.InputDevice
import android.view.KeyCharacterMap
import android.view.KeyEvent
@@ -7,7 +8,7 @@ import android.view.KeyEvent
/**
* Some firmware turns a mouse's right button into Back. Most report it from the mouse itself; REDMAGIC's
* cast mode injects it from no device and without the virtual-hard-key flag that the navigation bar and
- * back gesture set, so that form only counts while a mouse is connected.
+ * back gesture set, so that form only counts while an external mouse is connected.
*/
class MouseBackInput(private val sendRight: (Boolean) -> Unit, private val mouseAttached: () -> Boolean) {
private val held = mutableSetOf()
@@ -45,6 +46,21 @@ class MouseBackInput(private val sendRight: (Boolean) -> Unit, private val mouse
}
companion object {
+ fun isExternalMouse(device: InputDevice?): Boolean {
+ if (device == null || device.isVirtual || PadBridge.isSystemVirtualInputName(device.name) ||
+ declaresVirtual(device.name) ||
+ PadBridge.isFromController(device) ||
+ (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !device.isExternal)) return false
+ return (device.supportsSource(InputDevice.SOURCE_MOUSE) ||
+ device.supportsSource(InputDevice.SOURCE_MOUSE_RELATIVE)) &&
+ !device.supportsSource(InputDevice.SOURCE_TOUCHPAD) &&
+ !device.supportsSource(InputDevice.SOURCE_TOUCHSCREEN)
+ }
+
+ private val VIRTUAL_NAME = Regex("\\bvirtual\\b", RegexOption.IGNORE_CASE)
+
+ fun declaresVirtual(name: String?): Boolean = name != null && VIRTUAL_NAME.containsMatchIn(name)
+
fun isFromMouse(event: KeyEvent): Boolean =
event.isFromSource(InputDevice.SOURCE_MOUSE) || event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)
diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt
index d3be47056..035060585 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt
@@ -213,6 +213,9 @@ data class TouchGestureConfig(
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
}
+ fun gamescopeRelativeMouse(mode: Int, mouseAttached: Boolean, touchpad: Boolean): Int =
+ if (mode == RELATIVE_MOUSE_AUTO && !mouseAttached && !touchpad) RELATIVE_MOUSE_OFF else mode
+
private fun normalizeRelativeMouseMode(mode: Int): Int {
return if (mode in RELATIVE_MOUSE_AUTO..RELATIVE_MOUSE_OFF) mode else RELATIVE_MOUSE_AUTO
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt b/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt
deleted file mode 100644
index 45e0890c5..000000000
--- a/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-package com.droiddeck.launcher.session
-
-import android.app.Notification
-import android.content.Context
-import android.service.notification.NotificationListenerService
-import android.service.notification.StatusBarNotification
-import androidx.compose.runtime.mutableStateListOf
-import androidx.core.app.NotificationManagerCompat
-
-data class BottomNotification(val key: String, val app: String, val title: String, val text: String, val postedAt: Long)
-
-object BottomNotifications {
- val items = mutableStateListOf()
-
- fun granted(context: Context): Boolean =
- context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context)
-
- internal fun keep(own: String, pkg: String, ongoing: Boolean, groupSummary: Boolean, title: String, text: String): Boolean =
- pkg != own && !ongoing && !groupSummary && (title.isNotBlank() || text.isNotBlank())
-
- internal fun add(list: MutableList, item: BottomNotification) {
- list.removeAll { it.key == item.key }
- list.add(0, item)
- while (list.size > MAX_ITEMS) list.removeAt(list.lastIndex)
- }
-
- fun clear() = items.clear()
-
- internal fun post(context: Context, sbn: StatusBarNotification) {
- val n = sbn.notification ?: return
- val extras = n.extras
- val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
- val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT) ?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
- if (!keep(context.packageName, sbn.packageName, sbn.isOngoing, (n.flags and Notification.FLAG_GROUP_SUMMARY) != 0, title, text)) return
- val app = runCatching {
- val pm = context.packageManager
- pm.getApplicationLabel(pm.getApplicationInfo(sbn.packageName, 0)).toString()
- }.getOrDefault(sbn.packageName)
- add(items, BottomNotification(sbn.key, app, title, text, sbn.postTime))
- }
-
- internal fun remove(key: String) {
- items.removeAll { it.key == key }
- }
-
- private const val MAX_ITEMS = 30
-}
-
-class BottomNotificationListener : NotificationListenerService() {
- override fun onListenerConnected() {
- BottomNotifications.clear()
- runCatching { activeNotifications }.getOrNull()?.sortedBy { it.postTime }?.forEach { BottomNotifications.post(this, it) }
- }
-
- override fun onNotificationPosted(sbn: StatusBarNotification) = BottomNotifications.post(this, sbn)
-
- override fun onNotificationRemoved(sbn: StatusBarNotification) = BottomNotifications.remove(sbn.key)
-
- override fun onListenerDisconnected() = BottomNotifications.clear()
-}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt
index 962291d2c..d17d40df4 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt
@@ -48,11 +48,19 @@ object ProtonExtras {
internal fun installedIn(dir: File, tool: Tool): List =
dir.listFiles()
- ?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && File(it, "toolmanifest.vdf").isFile }
+ ?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && usable(it) }
?.map { it.name }
?.sortedWith { a, b -> compareVersions(a, b) }
?: emptyList()
+ private fun usable(dir: File): Boolean {
+ val manifest = runCatching { File(dir, "toolmanifest.vdf").readText() }.getOrNull() ?: return false
+ val command = Regex("\\\"commandline\\\"\\s+\\\"/?([^\\\" ]+)").find(manifest)?.groupValues?.getOrNull(1) ?: return false
+ val compat = runCatching { File(dir, "compatibilitytool.vdf").readText() }.getOrNull() ?: return false
+ return Regex("\\\"from_oslist\\\"\\s+\\\"[^\\\"]*windows", RegexOption.IGNORE_CASE).containsMatchIn(compat) &&
+ File(dir, command).isFile && File(dir, command).canExecute()
+ }
+
private val ARCH = Regex("(?i)aarch64|arm64|x86_64|x86-64|amd64")
private val NUMBER = Regex("\\d+")
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt
index 1cb526c90..9b253d88a 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt
@@ -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,
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt
index 26b63fd5c..6c0a07645 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt
@@ -14,6 +14,7 @@ object SessionEvents {
SessionPaths.take()
val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null))
synchronized(SessionState) {
+ SessionState.clearRelaunch()
SessionState.phase = SessionPhase.PREPARING
SessionState.mode = mode
SessionState.sessionId = dir.name
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
index ec70ddf36..82fea84f0 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
@@ -48,6 +48,7 @@ object SessionFiles {
"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-wine-pe" to "usr/local/bin/droiddeck-wine-pe",
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
@@ -201,6 +202,7 @@ object SessionFiles {
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
"usr/local/lib/droiddeck-dirt/ws2_32.dll" to "usr/local/lib/droiddeck-dirt/ws2_32.dll",
LSE_EMU to LSE_EMU,
+ "usr/local/lib/droiddeck-ubisoft/droiddeck-upc.exe" to "usr/local/lib/droiddeck-ubisoft/droiddeck-upc.exe",
) + mangoapp + xclipse.map { it to it } + maliCompat + msitools.map { it to it } + vr + gpuLoad + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLoadingDecision.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLoadingDecision.kt
new file mode 100644
index 000000000..1c5d034df
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLoadingDecision.kt
@@ -0,0 +1,8 @@
+package com.droiddeck.launcher.session
+
+internal object SessionLoadingDecision {
+ const val TIMEOUT_MS = 10_000L
+
+ fun shouldReturnToLauncher(startedAt: Long, now: Long, running: Boolean, phase: SessionPhase): Boolean =
+ !running && phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED) && now - startedAt >= TIMEOUT_MS
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
index 27cffc335..c8e81550a 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
@@ -558,11 +558,11 @@ object SessionPrefs {
fun picturePosition(context: Context): String =
prefs(context).getString("picturePosition", "center")
- ?.takeIf { it in listOf("top", "bottom", "center") } ?: "center"
+ ?.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center"
fun setPicturePosition(context: Context, position: String) {
prefs(context).edit().putString("picturePosition",
- position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply()
+ position.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center").apply()
}
fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true)
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
index cc67bc800..5fb5e37f2 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -1697,8 +1697,8 @@ class SessionService : Service() {
.putExtra(EXTRA_STEAM_UI, ui)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (url.isNotEmpty()) next.putExtra(EXTRA_STEAM_URL, url)
- SessionState.relaunch = next
- android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0) }
+ SessionState.queueRelaunch(next)
+ android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0, relaunch = true) }
}
}
watcher.startWatching()
@@ -2013,7 +2013,7 @@ class SessionService : Service() {
}
}
- private fun stopSession(status: Int, gen: Int? = null) {
+ private fun stopSession(status: Int, gen: Int? = null, relaunch: Boolean = false) {
if (gen != null && gen != sessionGen) {
Log.i(TAG, "ignoring a stop from session $gen; session $sessionGen is current")
return
@@ -2024,6 +2024,7 @@ class SessionService : Service() {
cancelQueuedSteamGame()
gameExitWatchToken++
if (!SessionState.running) return
+ if (relaunch) SessionState.confirmRelaunchStop()
SessionEvents.record("guest.exited", mapOf("status" to status))
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
SessionState.running = false
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
index fa6eb38c9..5f17212e5 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
@@ -73,7 +73,44 @@ object SessionState {
@Volatile
var steamClientArch: String? = null
var steamVr by mutableStateOf(false)
+ @Volatile
var relaunch: android.content.Intent? = null
+ @Volatile
+ private var relaunchSessionId: String? = null
+ @Volatile
+ private var relaunchStopRequested = false
+
+ fun queueRelaunch(intent: android.content.Intent) {
+ synchronized(this) {
+ relaunch = intent
+ relaunchSessionId = sessionId
+ relaunchStopRequested = false
+ }
+ }
+
+ fun confirmRelaunchStop() {
+ synchronized(this) {
+ relaunchStopRequested = relaunch != null && relaunchSessionId == sessionId
+ }
+ }
+
+ fun takeRelaunch(): android.content.Intent? = synchronized(this) {
+ val next = relaunch
+ val valid = relaunchStopRequested && relaunchSessionId == sessionId
+ relaunch = null
+ relaunchSessionId = null
+ relaunchStopRequested = false
+ next.takeIf { valid }
+ }
+
+ fun clearRelaunch() {
+ synchronized(this) {
+ relaunch = null
+ relaunchSessionId = null
+ relaunchStopRequested = false
+ }
+ }
+
@JvmStatic var hdr = false
@Volatile private var deckPadValue = false
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
index 48244c489..f68ab0ce6 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
@@ -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, Store.UBISOFT -> ""
} ?: 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, Store.UBISOFT -> 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, Store.UBISOFT -> null
}
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt
index 4e53c5ea0..980f496b2 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt
@@ -13,11 +13,15 @@ internal object LoginFlows {
Store.EPIC -> epicFlow()
Store.AMAZON -> amazonFlow()
Store.GAMEJOLT -> gameJoltFlow(browserUserAgent)
+ Store.UBISOFT -> ubisoftFlow(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 ubisoftFlow: (String) -> 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 {
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt
index b7d0fbce1..b8a6e8931 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt
@@ -7,7 +7,9 @@ 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"),
+ UBISOFT("ubisoft", "Ubisoft Connect", "Ubisoft"),
+ EA("ea", "EA", "EA");
val folder: String get() = if (this == ITCH) "itch" else shortLabel
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreExe.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreExe.kt
index ad2d478c1..0e9792edc 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreExe.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreExe.kt
@@ -3,7 +3,8 @@ package com.droiddeck.launcher.stores
import java.io.File
object StoreExe {
- private val SKIP = listOf("redist", "unins", "setup", "crash", "report", "helper", "dotnet", "vcredist", "directx", "eac", "easyanticheat", "battleye", "launcher", "updater", "config")
+ private val SKIP = listOf("redist", "unins", "setup", "crash", "report", "helper", "dotnet", "vcredist", "directx", "eac", "easyanticheat", "battleye", "launcher", "updater", "config", "installer", "autorun")
+ private val SKIP_DIRS = setOf("installer_resources", "redist", "_commonredist", "support")
fun pick(folder: File, hint: String?, title: String): String {
val hinted = hint?.takeIf { it.isNotBlank() }?.replace('\\', '/')?.trimStart('/')?.let { File(folder, it) }
@@ -28,7 +29,7 @@ object StoreExe {
if (SKIP.none { lower.contains(it) }) out.add(f)
}
}
- for (f in files) if (f.isDirectory && !f.name.startsWith(".")) collect(f, out, depth + 1)
+ for (f in files) if (f.isDirectory && !f.name.startsWith(".") && f.name.lowercase() !in SKIP_DIRS) collect(f, out, depth + 1)
}
private fun depth(root: File, f: File): Int = relative(root, f).count { it == '/' }
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt
index 378d7fa74..222b53702 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt
@@ -11,9 +11,14 @@ object StoreLaunch {
const val LAUNCHER = ".droiddeck-launch.bat"
const val EPIC_CODE = ".droiddeck-epic-code"
const val EPIC_ARGS = ".droiddeck-epic-args"
+ const val UBISOFT_LAUNCH = ".droiddeck-ubisoft.json"
+ private const val UBISOFT_SERVICE = "Z:\\usr\\local\\lib\\droiddeck-ubisoft\\droiddeck-upc.exe"
+ 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 || sidecar.store == Store.UBISOFT
val file = File(folder, LAUNCHER)
if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
file.writeText(launcherText(sidecar))
@@ -49,6 +54,21 @@ 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.UBISOFT) {
+ lines += "if not exist \"%~dp0$UBISOFT_LAUNCH\" goto direct"
+ lines += "if not exist \"$UBISOFT_SERVICE\" goto direct"
+ lines += "\"$UBISOFT_SERVICE\" \"%~dp0$UBISOFT_LAUNCH\""
+ lines += "goto :eof"
+ lines += ":direct"
+ lines += run
+ } 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
}
@@ -73,6 +93,17 @@ object StoreLaunch {
return Library.addedGame(context, game)
}
+ /** Gives a Ubisoft game installed before it needed one the launcher that starts the launcher service. */
+ fun adopt(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
+ if (sidecar.store != Store.UBISOFT || !sidecar.isInstalled) return sidecar
+ val wrongExe = !sidecar.exeFile(folder).isFile || sidecar.exe.contains("installer", ignoreCase = true) || sidecar.exe.contains("autorun", ignoreCase = true)
+ if (sidecar.launcher == LAUNCHER && !wrongExe) return sidecar
+ val exe = if (wrongExe) StoreExe.pick(folder, null, sidecar.title).ifEmpty { sidecar.exe } else sidecar.exe
+ if (exe == sidecar.exe && sidecar.launcher == LAUNCHER &&
+ runCatching { File(folder, LAUNCHER).readText() }.getOrNull() == launcherText(sidecar)) return sidecar
+ return runCatching { writeLauncher(folder, sidecar.copy(exe = exe)).also { it.write(folder) } }.getOrDefault(sidecar)
+ }
+
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
if (sidecar.launcher != LAUNCHER) return
val file = File(folder, LAUNCHER)
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt
index b4b9f7669..18f5a161e 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt
@@ -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" -> {
@@ -69,6 +76,15 @@ object StoreLaunchRequests {
JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason)
}
}
+ "ubisoft-launch" -> {
+ val id = request.optString("id")
+ if (id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-id")
+ else {
+ lastLaunched = Store.UBISOFT to id
+ runCatching { com.droiddeck.launcher.stores.ubisoft.UbisoftLaunch.prepare(app, id) }
+ .getOrElse { JSONObject().put("ok", false).put("reason", it.javaClass.simpleName) }
+ }
+ }
"cloud-down" -> {
val store = Store.byId(request.optString("store"))
val id = request.optString("id")
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt
index 228d97cd7..a07e0be85 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt
@@ -47,6 +47,9 @@ class StoreLoginActivity : ComponentActivity() {
?.takeIf { "Chrome/" in it }
?: "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36"
+ private const val PAGE_TRIES = 20
+ private const val PAGE_RETRY_MS = 500L
+
fun generateState(): String {
val bytes = ByteArray(24)
java.security.SecureRandom().nextBytes(bytes)
@@ -60,6 +63,8 @@ class StoreLoginActivity : ComponentActivity() {
fun isRedirect(url: String): Boolean
fun finish(activity: StoreLoginActivity, view: WebView, url: String): String?
val readsPage: Boolean get() = false
+ val pageScript: String get() = "(function(){ try { return document.body.innerText; } catch(e){ return ''; } })()"
+ fun pageReady(result: String): Boolean = true
fun forgetSignIn(done: () -> Unit) = done()
}
@@ -323,6 +328,14 @@ class StoreLoginActivity : ComponentActivity() {
}
}
+ private fun readPage(view: WebView, f: Flow, left: Int) {
+ view.evaluateJavascript(f.pageScript) { json ->
+ val result = json ?: ""
+ if (!f.pageReady(result) && left > 0) view.postDelayed({ readPage(view, f, left - 1) }, PAGE_RETRY_MS)
+ else Thread({ settle(f.finish(this, view, result)) }, "store-login").start()
+ }
+ }
+
private fun complete(view: WebView, url: String) {
if (!captured.compareAndSet(false, true)) return
val f = flow ?: return
@@ -332,11 +345,7 @@ class StoreLoginActivity : ComponentActivity() {
titleText.text = getString(R.string.stores_login_finishing)
if (!f.readsPage) view.stopLoading()
}
- if (f.readsPage) {
- view.evaluateJavascript("(function(){ try { return document.body.innerText; } catch(e){ return ''; } })()") { json ->
- Thread({ settle(f.finish(this, view, json ?: "")) }, "store-login").start()
- }
- } else Thread({ settle(f.finish(this, view, url)) }, "store-login").start()
+ if (f.readsPage) readPage(view, f, PAGE_TRIES) else Thread({ settle(f.finish(this, view, url)) }, "store-login").start()
}
private fun settle(problem: String?) {
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt
index 545c981e5..5e8ce7c6d 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt
@@ -55,7 +55,8 @@ 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.GOG, Store.XBOX, Store.BATTLENET -> {}
+ Store.EA -> com.droiddeck.launcher.stores.ea.EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
+ Store.GOG, Store.XBOX, Store.BATTLENET, Store.UBISOFT -> {}
}
}
}, pool).exceptionally { error ->
@@ -111,7 +112,8 @@ object StoreSizes {
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
AmazonManifest.parse(bytes).totalInstallSize
}
- Store.GOG, Store.XBOX, Store.BATTLENET -> 0L
+ Store.EA -> com.droiddeck.launcher.stores.ea.EaSizes.size(context, item)
+ Store.GOG, Store.XBOX, Store.BATTLENET, Store.UBISOFT -> 0L
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item)
} }
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaApi.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaApi.kt
new file mode 100644
index 000000000..185e23f42
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaApi.kt
@@ -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,
+ )
+
+ 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,
+ )
+
+ 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()
+
+ 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, String?> {
+ val owned = page.optJSONObject("me")?.optJSONObject("ownedGameProducts") ?: return emptyList() 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 {
+ val out = ArrayList()
+ 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 {
+ 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): List = 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 {
+ 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 {
+ val out = ArrayList()
+ 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()
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaAuth.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaAuth.kt
new file mode 100644
index 000000000..67fb8e655
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaAuth.kt
@@ -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 = 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>): 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 = 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) }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaBackend.kt
new file mode 100644
index 000000000..091243ae5
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaBackend.kt
@@ -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()
+ private val checked = java.util.concurrent.ConcurrentHashMap()
+ 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)
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaCrypto.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaCrypto.kt
new file mode 100644
index 000000000..d36a54108
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaCrypto.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaHttp.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaHttp.kt
new file mode 100644
index 000000000..193c4d741
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaHttp.kt
@@ -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>) {
+ 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>): String =
+ values.joinToString("&") { (k, v) -> URLEncoder.encode(k, "UTF-8") + "=" + URLEncoder.encode(v, "UTF-8") }
+
+ fun request(
+ url: String,
+ method: String = "GET",
+ headers: Map = 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()
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaInstaller.kt
new file mode 100644
index 000000000..22d973592
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaInstaller.kt
@@ -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 {
+ 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,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLaunch.kt
new file mode 100644
index 000000000..a9b623a2d
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLaunch.kt
@@ -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? =
+ 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 = 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()
+ 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).filter { it.optString("productId") == id }
+ val types = rows.joinToString(",") { it.optString("entitlementType") + "/" + it.optString("entitlementTag") + "/" + it.optString("status") }
+ com.droiddeck.launcher.stores.StoresState.logLine("ea $id has no licence; entitlements: ${types.ifEmpty { "none" }}")
+ }
+ 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
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLibrary.kt
new file mode 100644
index 000000000..2a90b2550
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLibrary.kt
@@ -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 = 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> = synchronized(cacheLock) { generation.get() to cached(app) }
+
+ internal fun storeCache(app: Context, games: List, 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 {
+ 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, offers: Map): List> {
+ 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, gen: Int): Map {
+ val prefs = EaPrefs.get(app)
+ val now = System.currentTimeMillis()
+ val out = java.util.concurrent.ConcurrentHashMap()
+ 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, 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, 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 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
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLsx.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLsx.kt
new file mode 100644
index 000000000..f9e6b286a
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLsx.kt
@@ -0,0 +1,446 @@
+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 = 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, val children: List>)
+
+ 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