chore: merge main into feat/lse-emu

This commit is contained in:
Kurt Himebauch committed 2026-11-10 15:12:15 -05:00
commit e87eebfaec
194 files changed
+12217 -1137

No files matched your search

+2
View File
@@ -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
+19
View File
@@ -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'
+9
View File
@@ -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:
+45
View File
@@ -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
+4 -3
View File
@@ -10,7 +10,7 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu
<p align="center"><b>100% open source. No ads. No telemetry. No proprietary components. No source withheld.</b></p>
<p align="center">
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.6.9%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.6.9"></a>
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.1/DroidDeck-0.7.1.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.7.1%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.7.1"></a>
<a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a>
</p>
@@ -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.
+2 -2
View File
@@ -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}"
+8 -8
View File
@@ -91,14 +91,6 @@
</intent-filter>
<meta-data android:name="android.accessibilityservice" android:resource="@xml/home_button_service" />
</service>
<service android:name=".session.BottomNotificationListener"
android:label="@string/bottom_notifications_listener"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<receiver android:name=".core.ShortcutsListingReceiver" android:exported="false"
android:process=":shortcuts_sync">
<intent-filter>
@@ -352,6 +344,14 @@
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<receiver
android:name=".agent.AgentCallReceiver"
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action android:name="${applicationId}.AGENT_CALL" />
</intent-filter>
</receiver>
<provider
android:name=".agent.AgentBridgeProvider"
android:authorities="${applicationId}.agent"
+27 -16
View File
@@ -30,6 +30,7 @@
#include <linux/uinput.h>
#include <poll.h>
#include <pthread.h>
#include <sched.h>
#include <signal.h>
#include <stdarg.h>
#include <stdbool.h>
@@ -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<int> 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() {
@@ -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;
@@ -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);
@@ -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() {
@@ -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<BottomPage> = 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,
@@ -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()
}
}
@@ -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) }
@@ -417,7 +417,7 @@ object AddedGames {
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, 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))
}
}
@@ -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<String> = emptyList())
@@ -83,6 +83,13 @@ object WinCompSources {
}
}
/** The prerequisite installers a Ubisoft manifest lists, with `$INSTALL_DIR` dropped. */
internal fun ubisoftInstallRuns(setup: String?): List<String> {
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<Finding> {
val folder = game.gameFiles
val out = ArrayList<Finding>()
@@ -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 ->
@@ -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);
}
@@ -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
@@ -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<Int>()
@@ -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)
@@ -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
}
@@ -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<BottomNotification>()
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<BottomNotification>, 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()
}
@@ -48,11 +48,19 @@ object ProtonExtras {
internal fun installedIn(dir: File, tool: Tool): List<String> =
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+")
@@ -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,
@@ -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
@@ -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)
@@ -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
}
@@ -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)
@@ -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
@@ -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
@@ -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? {
@@ -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 {
@@ -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
@@ -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 == '/' }
@@ -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)
@@ -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")
@@ -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?) {
@@ -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)
} }
@@ -0,0 +1,224 @@
package com.droiddeck.launcher.stores.ea
import android.net.Uri
import org.json.JSONArray
import org.json.JSONObject
class EaApiException(val status: Int, message: String) : Exception(message)
object EaApi {
private const val GRAPHQL = "https://service-aggregation-layer.juno.ea.com/graphql"
private const val LOCALE = "DEFAULT"
class Player(val userId: String, val personaId: String, val displayName: String, val eaId: String)
class OwnedProduct(
val offerId: String,
val productId: String,
val name: String,
val slug: String,
val baseSlug: String,
val gameType: String,
val downloadable: Boolean,
val trial: Boolean,
val ownership: List<String>,
)
class Offer(
val offerId: String,
val contentId: String,
val displayName: String,
val displayType: String,
val downloadable: Boolean,
val executePathOverride: String,
val installCheckOverride: String,
val executeParameters: String,
val multiplayerId: String,
val masterTitleId: String,
val gameVersion: String,
val cloudSaves: String,
val locales: List<String>,
)
class Images(val tall: String?, val wide: String?, val logo: String?)
class Build(val buildId: String, val gameVersion: String, val liveDate: String)
private val queries = HashMap<String, String>()
internal fun query(name: String): String = synchronized(queries) {
queries.getOrPut(name) {
val stream = EaApi::class.java.getResourceAsStream("/ea/graphql/$name.gql") ?: throw IllegalStateException("missing query $name")
stream.use { it.readBytes().toString(Charsets.UTF_8) }
}
}
private fun persisted(name: String): String =
JSONObject().put("persistedQuery", JSONObject().put("version", 1).put("sha256Hash", EaCrypto.hex(EaCrypto.sha256(query(name).toByteArray())))).toString()
internal fun call(token: String, name: String, variables: JSONObject): JSONObject {
val headers = mapOf("Authorization" to "Bearer $token", "Accept" to "application/json")
val get = Uri.parse(GRAPHQL).buildUpon()
.appendQueryParameter("extensions", persisted(name))
.appendQueryParameter("operationName", name)
.appendQueryParameter("variables", variables.toString())
.build().toString()
var reply = EaHttp.request(get, headers = headers)
var json = runCatching { JSONObject(reply.text) }.getOrNull()
if (!reply.ok || json == null || json.has("errors")) {
val body = JSONObject()
.put("extensions", JSONObject(persisted(name)))
.put("variables", variables)
.put("operationName", name)
.put("query", query(name))
reply = EaHttp.request(GRAPHQL, "POST", headers + ("Content-Type" to "application/json"), body.toString().toByteArray())
json = runCatching { JSONObject(reply.text) }.getOrNull()
}
if (reply.status == 401) throw EaApiException(401, "EA refused the session")
if (json == null) throw EaApiException(reply.status, "EA gave no answer to $name (${reply.status})")
json.optJSONArray("errors")?.optJSONObject(0)?.let { throw EaApiException(reply.status, "$name: ${it.optString("message")}") }
return json.optJSONObject("data") ?: throw EaApiException(reply.status, "$name returned no data")
}
fun player(token: String): Player {
val me = call(token, "GetUserPlayer", JSONObject()).optJSONObject("me") ?: throw EaApiException(0, "no player")
val p = me.optJSONObject("player")
return Player(
userId = me.optString("pd"),
personaId = p?.optString("psd").orEmpty(),
displayName = p?.optString("displayName").orEmpty(),
eaId = p?.optString("uniqueName").orEmpty().ifEmpty { p?.optString("displayName").orEmpty() },
)
}
internal fun parseOwned(page: JSONObject): Pair<List<OwnedProduct>, String?> {
val owned = page.optJSONObject("me")?.optJSONObject("ownedGameProducts") ?: return emptyList<OwnedProduct>() to null
val items = owned.optJSONArray("items") ?: JSONArray()
val list = (0 until items.length()).mapNotNull { items.optJSONObject(it) }.mapNotNull { o ->
val product = o.optJSONObject("product") ?: return@mapNotNull null
val base = product.optJSONObject("baseItem")
val user = product.optJSONObject("gameProductUser")
val methods = user?.optJSONArray("ownershipMethods")
OwnedProduct(
offerId = o.optString("originOfferId").ifEmpty { return@mapNotNull null },
productId = product.optString("id"),
name = (base?.optString("title")?.takeIf { it.isNotBlank() } ?: product.optString("name")).replace("\n", " ").trim(),
slug = product.optString("gameSlug"),
baseSlug = base?.optString("baseGameSlug").orEmpty().takeIf { it != "null" }.orEmpty(),
gameType = base?.optString("gameType").orEmpty(),
downloadable = product.optBoolean("downloadable", true),
trial = product.optJSONObject("trialDetails")?.optString("trialType").orEmpty().let { it.isNotEmpty() && it != "null" },
ownership = (0 until (methods?.length() ?: 0)).map { methods!!.optString(it) },
)
}
val next = owned.optString("next").takeIf { it.isNotBlank() && it != "null" }
return list to next
}
fun owned(token: String): List<OwnedProduct> {
val out = ArrayList<OwnedProduct>()
var next: String? = "0"
var guard = 0
while (next != null && guard++ < 50) {
val variables = JSONObject()
.put("isMac", false)
.put("limit", 100)
.put("next", next)
.put("type", JSONArray().put("DIGITAL_FULL_GAME").put("PACKAGED_FULL_GAME"))
.put("storefronts", JSONArray().put("EA"))
.put("platforms", JSONArray().put("PC"))
val (items, after) = parseOwned(call(token, "getPreloadedOwnedGames", variables))
out += items
next = after?.takeIf { it != next && items.isNotEmpty() }
}
return out
}
internal fun parseOffers(data: JSONObject): List<Offer> {
val arr = data.optJSONArray("legacyOffers") ?: return emptyList()
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.mapNotNull { o ->
val downloads = o.optJSONArray("downloads")
val version = (0 until (downloads?.length() ?: 0)).mapNotNull { downloads!!.optJSONObject(it) }
.firstOrNull { it.optString("downloadType").equals("LIVE", true) }?.optString("gameVersion")
?: downloads?.optJSONObject(0)?.optString("gameVersion")
val locales = o.optJSONArray("softwareLocales")
fun s(k: String) = o.optString(k).takeIf { it != "null" }.orEmpty()
Offer(
offerId = s("offerId").ifEmpty { return@mapNotNull null },
contentId = s("contentId"),
displayName = s("displayName"),
displayType = s("displayType"),
downloadable = o.optBoolean("isDownloadable", true),
executePathOverride = s("executePathOverride"),
installCheckOverride = s("installCheckOverride"),
executeParameters = s("executeParameters"),
multiplayerId = s("multiplayerId"),
masterTitleId = s("primaryMasterTitleId"),
gameVersion = version.orEmpty().takeIf { it != "null" }.orEmpty(),
cloudSaves = s("cloudSaveConfigurationOverride"),
locales = (0 until (locales?.length() ?: 0)).map { locales!!.optString(it) },
)
}
}
fun offers(token: String, ids: List<String>): List<Offer> = ids.distinct().chunked(50).flatMap { chunk ->
parseOffers(call(token, "getLegacyCatalogDefs", JSONObject().put("offerIds", JSONArray(chunk))))
}
private fun path(o: JSONObject?, vararg keys: String): String? {
var cur = o ?: return null
for (k in keys.dropLast(1)) cur = cur.optJSONObject(k) ?: return null
return cur.optString(keys.last()).takeIf { it.startsWith("http") }
}
internal fun parseImages(data: JSONObject): Images {
val game = data.optJSONObject("game")
val key = game?.optJSONObject("keyArt")
val pack = game?.optJSONObject("packArt")
return Images(
tall = path(pack, "aspect9x16Image", "path") ?: path(key, "aspect9x16Image", "path"),
wide = path(key, "aspect16x9Image", "path") ?: path(key, "largestImage", "path") ?: path(pack, "aspect2x1Image", "path"),
logo = path(game, "primaryLogo", "largestImage", "path") ?: path(game, "primaryLogo", "path"),
)
}
fun images(token: String, slug: String): Images =
parseImages(call(token, "GameImages", JSONObject().put("gameSlug", slug).put("locale", LOCALE)))
fun downloadUrl(token: String, offerId: String, buildId: String? = null): String {
val data = call(token, "downloadUrl", JSONObject().put("offerId", offerId).put("buildId", buildId.orEmpty()))
return data.optJSONObject("downloadUrl")?.optString("url").orEmpty().ifEmpty { throw EaApiException(0, "EA gave no download link") }
}
fun builds(token: String, offerId: String): List<Build> {
val arr = call(token, "availableBuilds", JSONObject().put("offerId", offerId)).optJSONArray("availableBuilds") ?: return emptyList()
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map {
Build(it.optString("buildId"), it.optString("gameVersion").takeIf { v -> v != "null" }.orEmpty(), it.optString("buildLiveDate"))
}
}
fun legacyEntitlements(token: String): List<JSONObject> {
val out = ArrayList<JSONObject>()
var next: String? = "0"
var guard = 0
while (next != null && guard++ < 50) {
val variables = JSONObject().put("limit", 100).put("next", next).put("storeFronts", JSONArray().put("EA"))
val page = call(token, "getLegacyEntitlements", variables).optJSONObject("me")?.optJSONObject("legacyEntitlements") ?: break
val items = page.optJSONArray("items") ?: JSONArray()
for (i in 0 until items.length()) items.optJSONObject(i)?.let(out::add)
next = page.optString("next").takeIf { it.isNotBlank() && it != "null" && it != next && items.length() > 0 }
}
return out
}
fun entitlements(token: String, group: String): JSONArray {
val variables = JSONObject()
.put("productIds", JSONArray())
.put("includeChildGroups", false)
.put("groupNames", JSONArray().put(group))
.put("entitlementTag", "")
.put("pageSize", 100)
.put("pageNumber", 1)
return call(token, "sdkEntitlements", variables).optJSONObject("me")?.optJSONObject("sdkEntitlements")?.optJSONArray("entitlements") ?: JSONArray()
}
}
@@ -0,0 +1,230 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import android.net.Uri
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAccounts
import org.json.JSONObject
import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
object EaAuth {
private const val TAG = "EaAuth"
const val CLIENT_ID = "JUNO_PC_CLIENT"
private const val CLIENT_SECRET = "4mRLtYMb6vq9qglomWEaT4ChxsXWcyqbQpuBNfMPOYOiDmYYQmjuaBsF2Zp0RyVeWkfqhE9TuGgAw7te"
const val AUTH_URL = "https://accounts.ea.com/connect/auth"
private const val TOKEN_URL = "https://accounts.ea.com/connect/token"
const val REDIRECT = "qrc:///html/login_successful.html"
private const val REFRESH_MARGIN_MS = 5 * 60 * 1000L
private val refreshLock = Any()
private val random = SecureRandom()
private val PC_SIGN_KEYS = mapOf(
"v1" to "ISa3dpGOc8wW7Adn4auACSQmaccrOyR2".toByteArray(),
"v2" to "nt5FfJbdPzNcl2pkC3zgjO43Knvscxft".toByteArray(),
)
val OPAQUE_SCOPES = listOf(
"basic.commerce.cartv2", "service.atom", "dp.client.default", "signin", "social_recommendation_user",
"basic.optin.write", "basic.commerce.cartv2.write", "basic.billing", "external.social_information_ups_admin",
)
class Account(
val accessToken: String,
val refreshToken: String,
val expiresAt: Long,
val userId: String,
val personaId: String,
val displayName: String,
val eaId: String,
)
class Pending(val url: String, val verifier: String)
internal fun fromJson(o: JSONObject): Account? {
val access = o.optString("access_token").ifEmpty { return null }
return Account(
accessToken = access,
refreshToken = o.optString("refresh_token"),
expiresAt = o.optLong("expires_at"),
userId = o.optString("user_id"),
personaId = o.optString("persona_id"),
displayName = o.optString("display_name"),
eaId = o.optString("ea_id"),
)
}
fun load(context: Context): Account? = StoreAccounts.read(context.applicationContext, Store.EA)?.let(::fromJson)
private fun deviceId(context: Context): String {
val prefs = EaPrefs.get(context)
prefs.getString("device", null)?.let { return it }
val made = EaCrypto.hex(ByteArray(16).also(random::nextBytes))
prefs.edit().putString("device", made).apply()
return made
}
internal fun pcSign(device: String, now: Date = Date(), version: String = if (random.nextBoolean()) "v1" else "v2"): String {
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(now)
val mid = EaCrypto.fnv1a(("DroidDeck" + device.uppercase() + "DroidDeck").toByteArray()).toString()
val json = "{\"av\":\"v1\",\"bsn\":\"None\",\"gid\":0,\"hsn\":\"None\",\"mid\":\"$mid\",\"msn\":\"${device.uppercase()}\",\"sv\":\"$version\",\"ts\":\"$stamp\"}"
val payload = EaCrypto.base64Url(json.toByteArray())
val signature = EaCrypto.base64Url(EaCrypto.hmacSha256(PC_SIGN_KEYS.getValue(version), payload.toByteArray()))
return "$payload.$signature"
}
internal fun authUrl(
device: String,
responseType: String,
challenge: String? = null,
accessToken: String? = null,
clientId: String = CLIENT_ID,
scopes: List<String> = emptyList(),
tokenFormat: String? = null,
expiresIn: Int? = null,
): String {
val b = Uri.parse(AUTH_URL).buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("sbiod_enabled", "false")
.appendQueryParameter("response_type", responseType)
.appendQueryParameter("locale", "en_US")
.appendQueryParameter("pc_sign", pcSign(device))
.appendQueryParameter("nonce", random.nextInt().toString())
if (scopes.isNotEmpty()) b.appendQueryParameter("scope", scopes.joinToString(" "))
if (challenge != null) {
b.appendQueryParameter("code_challenge_method", "S256")
b.appendQueryParameter("code_challenge", challenge)
}
if (accessToken != null) b.appendQueryParameter("access_token", accessToken)
if (tokenFormat != null) b.appendQueryParameter("token_format", tokenFormat)
if (expiresIn != null) b.appendQueryParameter("expires_in", expiresIn.toString())
return b.build().toString()
}
fun begin(context: Context): Pending {
val verifier = EaCrypto.base64Url(ByteArray(32).also(random::nextBytes))
val challenge = EaCrypto.base64Url(EaCrypto.sha256(verifier.toByteArray()))
return Pending(authUrl(deviceId(context), "code", challenge = challenge), verifier)
}
/** Pulls one parameter out of the redirect EA sends to its desktop client's local page. */
internal fun redirectValue(location: String, name: String): String? {
val normal = location.replace("qrc:///html", "http://127.0.0.1").replace("qrc:/html", "http://127.0.0.1")
val uri = Uri.parse(normal)
uri.getQueryParameter(name)?.let { return it }
val fragment = uri.encodedFragment ?: return null
return Uri.parse("http://x/?$fragment").getQueryParameter(name)
}
private fun tokenCall(values: List<Pair<String, String>>): JSONObject {
val reply = EaHttp.request(
TOKEN_URL, "POST",
mapOf("Content-Type" to "application/x-www-form-urlencoded"),
EaHttp.form(values + listOf("client_id" to CLIENT_ID, "client_secret" to CLIENT_SECRET)).toByteArray(),
follow = false,
)
val json = runCatching { JSONObject(reply.text) }.getOrNull()
if (!reply.ok || json == null || json.optString("access_token").isEmpty()) {
throw IllegalStateException("EA token request failed (${reply.status}): ${json?.optString("error_description")?.ifEmpty { json.optString("error") } ?: ""}")
}
return json
}
fun exchange(context: Context, code: String, verifier: String) {
val token = tokenCall(
listOf(
"grant_type" to "authorization_code", "code" to code, "code_verifier" to verifier,
"redirect_uri" to REDIRECT, "token_format" to "JWS",
),
)
val access = token.getString("access_token")
val player = runCatching { EaApi.player(access) }.onFailure { Log.w(TAG, "player: ${it.message}") }.getOrNull()
val record = JSONObject()
.put("access_token", access)
.put("refresh_token", token.optString("refresh_token"))
.put("expires_at", System.currentTimeMillis() + token.optLong("expires_in", 3600) * 1000)
.put("user_id", player?.userId.orEmpty())
.put("persona_id", player?.personaId.orEmpty())
.put("display_name", player?.displayName.orEmpty())
.put("ea_id", player?.eaId.orEmpty())
StoreAccounts.write(context.applicationContext, Store.EA, record)
}
private fun fresh(a: Account) = a.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || a.refreshToken.isEmpty()
private fun refreshLocked(context: Context, a: Account): String? {
if (a.refreshToken.isEmpty()) return null
val token = try {
tokenCall(listOf("grant_type" to "refresh_token", "refresh_token" to a.refreshToken))
} catch (e: Exception) {
Log.w(TAG, "refresh: ${e.message}")
return null
}
val access = token.getString("access_token")
val player = if (load(context)?.userId.isNullOrEmpty()) runCatching { EaApi.player(access) }.getOrNull() else null
return access.takeIf { storeRefreshed(context, access, token.optLong("expires_in", 3600), token.optString("refresh_token"), player) }
}
/** Writes a refreshed token into the saved sign-in, unless sign-out removed it meanwhile. */
internal fun storeRefreshed(context: Context, access: String, expiresIn: Long, refresh: String, player: EaApi.Player? = null): Boolean =
synchronized(refreshLock) {
val app = context.applicationContext
val record = StoreAccounts.read(app, Store.EA) ?: return false
record.put("access_token", access).put("expires_at", System.currentTimeMillis() + expiresIn * 1000)
refresh.takeIf { it.isNotEmpty() }?.let { record.put("refresh_token", it) }
player?.let { record.put("user_id", it.userId).put("persona_id", it.personaId).put("display_name", it.displayName).put("ea_id", it.eaId) }
StoreAccounts.write(app, Store.EA, record)
true
}
fun accessToken(context: Context): String? {
val current = load(context) ?: return null
if (fresh(current)) return current.accessToken
synchronized(refreshLock) {
val again = load(context) ?: return null
if (fresh(again)) return again.accessToken
return refreshLocked(context, again) ?: again.accessToken.takeIf { again.expiresAt > System.currentTimeMillis() }
}
}
fun refreshNow(context: Context, stale: String?): String? = synchronized(refreshLock) {
val current = load(context) ?: return null
if (stale != null && current.accessToken != stale) return current.accessToken
refreshLocked(context, current)
}
/** Asks EA to mint a code or token for another client from the signed-in session, as the EA app does for games. */
fun mint(
context: Context,
clientId: String,
responseType: String,
scopes: List<String> = emptyList(),
tokenFormat: String? = null,
expiresIn: Int? = null,
): String? {
val access = accessToken(context) ?: return null
val url = authUrl(deviceId(context), responseType, accessToken = access, clientId = clientId, scopes = scopes, tokenFormat = tokenFormat, expiresIn = expiresIn)
val reply = runCatching { EaHttp.request(url, follow = false) }.getOrElse {
Log.w(TAG, "mint $clientId: ${it.javaClass.simpleName}")
return null
}
val location = reply.header("Location") ?: run {
Log.w(TAG, "mint $clientId: no redirect (${reply.status})")
return null
}
if (location.startsWith("https://signin.ea.com")) {
Log.w(TAG, "mint $clientId: the session needs a new sign-in")
return null
}
return redirectValue(location, if (responseType == "token") "access_token" else responseType)
}
/** Waits for any refresh in progress, so it cannot write the sign-in back afterwards. */
fun signOut(context: Context) {
synchronized(refreshLock) { StoreAccounts.clear(context.applicationContext, Store.EA) }
}
}
@@ -0,0 +1,131 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import android.content.Intent
import android.util.Log
import android.webkit.WebView
import androidx.compose.runtime.mutableStateMapOf
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.LoginFlows
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.WebCookies
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
object EaBackend : StoreBackend {
override val store: Store = Store.EA
@Volatile internal var app: Context? = null
init {
LoginFlows.eaFlow = { ua -> app?.let { EaLogin(EaAuth.begin(it), ua) } }
}
override fun signIn(context: Context) {
app = context.applicationContext
context.startActivity(
Intent(context, StoreLoginActivity::class.java)
.putExtra(StoreLoginActivity.EXTRA_STORE, store.id)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
override fun signOut(context: Context) {
val app = context.applicationContext
EaAuth.signOut(app)
EaLibrary.reset(app)
EaUpdates.available.clear()
runCatching { WebCookies.expire(EaLogin.SIGN_IN_URLS, ".ea.com", listOf("/")) {} }
}
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
this.app = app
Thread({ EaLibrary.sync(app, force) }, "ea-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {}
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
val app = context.applicationContext
if (!EaNative.ready()) {
StoresState.logLine(EaNative.ENGINE_MISSING)
StoresState.post { StoresState.problems[store] = EaNative.ENGINE_MISSING }
return
}
val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root)
val entry = DownloadEntry(store, item.id, item.title, cover = item.tallImageUrl ?: item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))
DownloadQueue.enqueue(app, entry) { EaInstaller(app, item, folder) }
}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
}
internal class EaLogin(private val pending: EaAuth.Pending, override val userAgent: String) : StoreLoginActivity.Flow {
override val startUrl: String get() = pending.url
override fun isRedirect(url: String): Boolean = url.startsWith("qrc:", ignoreCase = true)
override fun forgetSignIn(done: () -> Unit) {
runCatching { WebCookies.expire(SIGN_IN_URLS, ".ea.com", listOf("/")) { done() } }.onFailure { done() }
}
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
val code = EaAuth.redirectValue(url, "code") ?: return activity.getString(R.string.stores_login_error_verification)
return try {
EaAuth.exchange(activity, code, pending.verifier)
null
} catch (e: Exception) {
Log.w("EaLogin", "sign-in failed: ${e.message}")
activity.getString(R.string.stores_login_error_generic)
}
}
companion object {
val SIGN_IN_URLS = listOf("https://accounts.ea.com", "https://signin.ea.com", "https://www.ea.com")
}
}
object EaUpdates {
val available = mutableStateMapOf<String, String>()
private val checked = java.util.concurrent.ConcurrentHashMap<String, Long>()
private const val RECHECK_MS = 30 * 60 * 1000L
fun check(context: Context, game: InstalledStoreGame, force: Boolean = false) {
val app = context.applicationContext
val id = game.sidecar.id
val now = System.currentTimeMillis()
if (!force && now - (checked[id] ?: 0L) < RECHECK_MS) return
checked[id] = now
Thread({
val token = EaAuth.accessToken(app)
val offer = token?.let { runCatching { EaApi.offers(it, listOf(id)) }.getOrNull() }?.firstOrNull()
if (offer == null) {
checked.remove(id)
return@Thread
}
val live = offer.gameVersion
val installed = game.sidecar.installVersion
StoresState.post {
if (live.isNotEmpty() && installed.isNotEmpty() && live != installed) available[game.key] = live else available.remove(game.key)
}
}, "ea-update-check").start()
}
fun start(context: Context, game: InstalledStoreGame) {
val app = context.applicationContext
val root = game.folder.parentFile?.parentFile ?: return
val known = EaLibrary.game(app, game.sidecar.id)
val item = known?.let { EaLibrary.toCatalogItem(app, it) } ?: CatalogItem(
Store.EA, game.sidecar.id, game.sidecar.title, game.sidecar.hero, tallImageUrl = game.sidecar.cover, owned = true, extra = game.sidecar.extra,
)
available.remove(game.key)
StoresState.install(app, item, root)
}
}
@@ -0,0 +1,83 @@
package com.droiddeck.launcher.stores.ea
import java.security.MessageDigest
import java.time.LocalDate
import java.time.ZoneOffset
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
internal object EaCrypto {
val LSX_DEFAULT_KEY = ByteArray(16) { it.toByte() }
private val OOA_KEY = intArrayOf(65, 50, 114, 45, 208, 130, 239, 176, 220, 100, 87, 197, 118, 104, 202, 9)
.map { it.toByte() }.toByteArray()
private const val PRIME_10K = 104729L
private const val PRIME_20K = 224737L
private const val PRIME_30K = 350377L
fun ecbEncrypt(data: ByteArray, key: ByteArray): ByteArray =
Cipher.getInstance("AES/ECB/PKCS5Padding").run { init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES")); doFinal(data) }
fun ecbDecrypt(data: ByteArray, key: ByteArray): ByteArray =
Cipher.getInstance("AES/ECB/PKCS5Padding").run { init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES")); doFinal(data) }
fun licenseEncrypt(data: ByteArray): ByteArray =
Cipher.getInstance("AES/CBC/PKCS5Padding").run { init(Cipher.ENCRYPT_MODE, SecretKeySpec(OOA_KEY, "AES"), IvParameterSpec(ByteArray(16))); doFinal(data) }
fun licenseDecrypt(data: ByteArray): ByteArray =
Cipher.getInstance("AES/CBC/PKCS5Padding").run { init(Cipher.DECRYPT_MODE, SecretKeySpec(OOA_KEY, "AES"), IvParameterSpec(ByteArray(16))); doFinal(data) }
fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
fun unhex(text: String): ByteArray {
val digits = text.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
if (digits.length % 2 != 0) return ByteArray(0)
return ByteArray(digits.length / 2) { i -> digits.substring(i * 2, i * 2 + 2).toInt(16).toByte() }
}
/** The session key both sides derive after the LSX challenge, from a seed the game picks. */
fun lsxKey(seed: Int): ByteArray {
if (seed == 0) return LSX_DEFAULT_KEY.copyOf()
var state = 7L
fun next(): Int {
state = (state * 214013L + 2531011L) and 0xFFFFFFFFL
return ((state ushr 16) and 0xFFFF).toInt()
}
val start = (next().toLong() + seed) and 0xFFFFFFFFL
state = start
return ByteArray(16) { next().toByte() }
}
fun challengeAnswer(key: String): String = hex(ecbEncrypt(key.toByteArray(Charsets.US_ASCII), LSX_DEFAULT_KEY))
fun challengeSeed(version: String, answer: String): Int? = when (version) {
"2" -> 0
"3" -> (answer[0].code shl 8) or answer[1].code
else -> null
}
/** EA's launch handshake code, which changes every UTC day. */
fun rtpCode(day: LocalDate = LocalDate.now(ZoneOffset.UTC)): Long {
val time = ((PRIME_10K * day.year) xor (day.monthValue * PRIME_20K) xor (day.dayOfMonth * PRIME_30K)) and 0xFFFFFFFFL
return (time xor ((time shl 16) and 0xFFFFFFFFL) xor (time ushr 16)) and 0xFFFFFFFFL
}
fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)
fun sha1(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-1").digest(data)
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray =
Mac.getInstance("HmacSHA256").run { init(SecretKeySpec(key, "HmacSHA256")); doFinal(data) }
fun fnv1a(data: ByteArray): ULong {
var hash = 0xcbf29ce484222325UL
for (b in data) hash = (hash xor (b.toUByte().toULong())) * 0x100000001b3UL
return hash
}
fun base64Url(data: ByteArray): String =
java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(data)
}
@@ -0,0 +1,47 @@
package com.droiddeck.launcher.stores.ea
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
internal class EaReply(val status: Int, val body: ByteArray, val headers: Map<String, List<String>>) {
val ok: Boolean get() = status in 200..299
val text: String get() = body.toString(Charsets.UTF_8)
fun header(name: String): String? = headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value?.firstOrNull()
}
internal object EaHttp {
const val USER_AGENT = "Mozilla/5.0 EA Desktop/13.735.2.6250"
private const val TIMEOUT = 30_000
fun form(values: List<Pair<String, String>>): String =
values.joinToString("&") { (k, v) -> URLEncoder.encode(k, "UTF-8") + "=" + URLEncoder.encode(v, "UTF-8") }
fun request(
url: String,
method: String = "GET",
headers: Map<String, String> = emptyMap(),
body: ByteArray? = null,
follow: Boolean = true,
): EaReply {
val c = URL(url).openConnection() as HttpURLConnection
try {
c.requestMethod = method
c.connectTimeout = TIMEOUT
c.readTimeout = TIMEOUT
c.instanceFollowRedirects = follow
c.setRequestProperty("User-Agent", USER_AGENT)
for ((k, v) in headers) c.setRequestProperty(k, v)
if (body != null) {
c.doOutput = true
c.outputStream.use { it.write(body) }
}
val status = c.responseCode
val stream = if (status >= 400) c.errorStream else c.inputStream
val bytes = stream?.use { it.readBytes() } ?: ByteArray(0)
return EaReply(status, bytes, c.headerFields.filterKeys { it != null })
} finally {
c.disconnect()
}
}
}
@@ -0,0 +1,140 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreExe
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLanguage
import com.droiddeck.launcher.stores.amazon.AmazonNative
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.download.StoreDownloadTier
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
internal class EaInstaller(
private val app: Context,
private val item: CatalogItem,
private val folder: File,
) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
override fun run(handle: DownloadQueue.JobHandle): String? {
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in))
var token = EaAuth.accessToken(app) ?: throw IllegalStateException("Not signed in to EA")
val url = try {
EaApi.downloadUrl(token, item.id)
} catch (e: EaApiException) {
if (e.status != 401) throw IllegalStateException(e.message)
token = EaAuth.refreshNow(app, token) ?: throw IllegalStateException("The EA sign-in has expired")
EaApi.downloadUrl(token, item.id)
}
val game = EaLibrary.game(app, item.id)
val version = runCatching { EaApi.offers(token, listOf(item.id)).firstOrNull()?.gameVersion }.getOrNull().orEmpty()
.ifEmpty { game?.version.orEmpty() }
val cover = item.tallImageUrl ?: item.imageUrl
StoreInstalls.begin(folder, Store.EA, item.id, item.title, cover, item.imageUrl)
if (cancelled.get()) return null
val tier = StoreDownloadTier.current(app)
handle.log("ea: \"${item.title}\" tier=${tier.id} window=${tier.networkWindow}")
val request = JSONObject()
.put("url", url)
.put("gameDir", folder.absolutePath)
.put("maxWorkers", tier.networkWindow.coerceIn(1, 64))
.put("processWorkers", tier.processWorkers)
.put("caBundle", "")
val shown = AtomicLong(-1)
val downloading = AtomicBoolean(false)
val listener = object : AmazonNative.InstallListener {
override fun onStatus(text: String) { handle.stage(DownloadStage.MANIFEST, text) }
override fun onCheck(doneBytes: Long, totalBytes: Long, files: Int, filesTotal: Int) {
handle.stage(DownloadStage.MANIFEST, "Checking files…")
handle.stageProgress(doneBytes, totalBytes, files, filesTotal, bytes = true)
}
override fun onSizes(pendingBytes: Long, totalBytes: Long) { handle.diskSize(totalBytes) }
override fun onProgress(done: Long, total: Long, files: Int, filesTotal: Int) {
if (downloading.compareAndSet(false, true)) handle.stage(DownloadStage.DOWNLOAD)
while (true) {
val before = shown.get()
if (done < before) return
if (shown.compareAndSet(before, done)) break
}
handle.progress(done, total, "Downloading ($files/$filesTotal files)")
}
override fun onLog(line: String) { handle.log("ea: $line") }
override fun beforeReplace(): Boolean = try {
StoreInstalls.markUpdating(folder)
true
} catch (e: Exception) {
handle.log("ea: the install record could not be switched to updating (${e.javaClass.simpleName})")
false
}
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) = Unit
}
val outcome = EaNative.install(request, listener, cancelled)
if (outcome.cancelled || cancelled.get() && !outcome.success) return null
if (!outcome.success) throw IllegalStateException(outcome.error.ifEmpty { "The EA install failed" })
val result = outcome.result ?: JSONObject()
result.optLong("totalBytes").takeIf { it > 0 }?.let { EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply() }
handle.stage(DownloadStage.INSTALL, app.getString(R.string.stores_registering))
val sidecar = sidecarFor(app, item, folder, game, version, cover) { handle.log("ea: $it") }
StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) })
return folder.path
}
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
}
companion object {
fun locale(context: Context, available: List<String>): String {
val wanted = StoreLanguage.localeFor(null, context)
val exact = "${wanted.language}_${wanted.country}"
return available.firstOrNull { it.equals(exact, true) }
?: available.firstOrNull { it.substringBefore('_').equals(wanted.language, true) }
?: available.firstOrNull { it.equals("en_US", true) }
?: "en_US"
}
internal fun sidecarFor(app: Context, item: CatalogItem, folder: File, game: EaGame?, version: String, cover: String?, log: (String) -> Unit): StoreGameSidecar {
val manifest = EaManifest.read(folder)
if (manifest == null) log("no installerdata.xml in ${folder.name}")
val launcher = manifest?.launcher()
val candidates = listOfNotNull(launcher?.path, game?.executePath?.takeIf { it.isNotEmpty() })
var exe = candidates.firstNotNullOfOrNull { EaPaths.relativeOnDisk(folder, EaPaths.relative(it)) }.orEmpty()
if (exe.isEmpty()) exe = StoreExe.pick(folder, null, item.title)
if (exe.isEmpty()) { log("no exe found; pick one in Steam settings › Added games"); exe = "game.exe" } else log("exe=$exe")
val args = EaPaths.splitArgs(listOf(game?.parameters.orEmpty(), launcher?.parameters.orEmpty()).filter { it.isNotBlank() }.joinToString(" "))
val keys = JSONArray()
listOfNotNull(game?.installCheck, game?.executePath, launcher?.path).mapNotNull { EaPaths.registered(it) }
.distinctBy { it.key.lowercase() + "|" + it.value.lowercase() }
.forEach { keys.put(JSONArray().put(it.key).put(it.value)) }
val extra = linkedMapOf(
"contentId" to (game?.contentId ?: item.extra["contentId"].orEmpty()),
"slug" to (game?.slug ?: item.extra["slug"].orEmpty()),
"multiplayerId" to game?.multiplayerId.orEmpty(),
"masterTitleId" to game?.masterTitleId.orEmpty(),
"locale" to locale(app, game?.locales.orEmpty()),
"registry" to keys.toString(),
"touchup" to manifest?.touchupPath.orEmpty().replace('\\', '/').trimStart('/'),
"touchupArgs" to manifest?.touchupParameters.orEmpty(),
"contentIds" to manifest?.contentIds.orEmpty().joinToString(","),
"displayName" to manifest?.title(locale(app, game?.locales.orEmpty())).orEmpty(),
).filterValues { it.isNotEmpty() }
return StoreGameSidecar(
Store.EA, item.id, item.title, exe = exe, args = args,
installVersion = version.ifEmpty { manifest?.version.orEmpty() }, installedAt = System.currentTimeMillis(),
cover = cover, hero = item.imageUrl, extra = extra, cloud = false, logo = item.logoUrl,
)
}
}
}
@@ -0,0 +1,138 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import android.util.Base64
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.UUID
object EaLaunch {
private const val TAG = "EaLaunch"
const val ENV_FILE = ".droiddeck-ea-env"
private fun installed(context: Context, id: String): Pair<File, StoreGameSidecar>? =
StoreInstallRoot.gameFolders(context).firstNotNullOfOrNull { f ->
StoreGameSidecar.read(f)?.takeIf { it.store == Store.EA && it.id == id && it.isInstalled }?.let { f to it }
}
internal fun environment(
offerId: String,
contentId: String,
locale: String,
generic: String,
access: String,
opaque: String,
account: EaAuth.Account,
session: String = UUID.randomUUID().toString(),
): LinkedHashMap<String, String> = linkedMapOf(
"EAAuthCode" to "unavailable",
"EAEgsProxyIpcPort" to "0",
"EAEntitlementSource" to "EA",
"EAExternalSource" to "EA",
"EAFreeTrialGame" to "false",
"EAGameLocale" to locale,
"EAGenericAuthToken" to generic,
"EALaunchCode" to "unavailable",
"EALaunchOwner" to "EA",
"EALaunchEAID" to account.eaId.ifEmpty { account.displayName },
"EALaunchEnv" to "production",
"EALaunchOfflineMode" to "false",
"EALsxPort" to EaLsx.PORT.toString(),
"EARtPLaunchCode" to EaCrypto.rtpCode().toString(),
"EASecureLaunchTokenTemp" to account.userId,
"EASteamProxyIpcPort" to "0",
"OriginSessionKey" to session,
"ContentId" to contentId,
"EAOnErrorExitRetCode" to "1",
"EAConnectionId" to offerId,
"EALicenseToken" to offerId,
"EALaunchUserAuthToken" to opaque,
"EAAccessTokenJWS" to access,
)
/** Adds what installs made before the manifest's content ids and title were recorded are missing. */
internal fun backfill(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
if (sidecar.extra.containsKey("contentIds") || sidecar.extra.containsKey("displayName")) return sidecar
val manifest = EaManifest.read(folder) ?: return sidecar
val locale = sidecar.extra["locale"].orEmpty().ifEmpty { "en_US" }
val added = mapOf(
"contentIds" to manifest.contentIds.joinToString(","),
"displayName" to manifest.title(locale).orEmpty(),
).filterValues { it.isNotEmpty() }
if (added.isEmpty()) return sidecar
val updated = sidecar.copy(extra = sidecar.extra + added)
runCatching { updated.write(folder) }.onFailure { Log.w(TAG, "sidecar: ${it.javaClass.simpleName}") }
return updated
}
/** Answers the guest's request right before an EA game starts: session, licence and the LSX endpoint. */
fun prepare(context: Context, request: JSONObject): JSONObject {
val app = context.applicationContext
val id = request.optString("id")
val reply = JSONObject().put("ok", false)
val (folder, recorded) = installed(app, id) ?: return reply.put("reason", "not-installed")
val sidecar = backfill(folder, recorded)
runCatching { com.droiddeck.launcher.stores.StoreLaunch.refreshLauncher(folder, sidecar) }
File(folder, ENV_FILE).delete()
val account = EaAuth.load(app) ?: return reply.put("reason", "signed-out")
val access = EaAuth.accessToken(app) ?: return reply.put("reason", "sign-in-expired")
val contentId = sidecar.extra["contentId"].orEmpty()
val locale = sidecar.extra["locale"].orEmpty().ifEmpty { "en_US" }
val core = EaOoa.coreDir(folder, sidecar.exe)
val hashVersion = core?.let(EaOoa::activationVersion) ?: 1
val hardware = request.optJSONObject("hardware")?.let(EaOoa.Hardware::fromJson)?.takeIf { it.complete(hashVersion) }
val legacy = EaOoa.legacyCore(core)
val lifetime = if (legacy) 14400 else 550
val opaque = EaAuth.mint(app, EaAuth.CLIENT_ID, "token", EaAuth.OPAQUE_SCOPES, "OPAQUE", lifetime).orEmpty()
val known = (sidecar.extra["contentIds"].orEmpty().split(',') + sidecar.extra["multiplayerId"].orEmpty()).map { it.trim() }.filter { it.isNotEmpty() }.toSet()
val lsx = EaLsx.start(app, EaLsx.Game(id, contentId, sidecar.title, sidecar.installVersion, locale, hardware, hashVersion, opaque,
System.currentTimeMillis() + (lifetime - 30) * 1000L, known))
val env = environment(id, contentId, locale, if (legacy && opaque.isNotEmpty()) opaque else access, access, opaque, EaAuth.load(app) ?: account)
val tmp = File(folder, "$ENV_FILE.tmp")
tmp.writeText(env.entries.joinToString("\r\n", postfix = "\r\n") { "${it.key}=${it.value}" })
tmp.renameTo(File(folder, ENV_FILE))
val licenses = JSONArray()
val notes = ArrayList<String>()
if (core != null) {
val ids = (listOf(contentId) + sidecar.extra["contentIds"].orEmpty().split(',')).map { it.trim() }.filter { it.isNotEmpty() }.distinct()
if (hardware == null) notes += "no-hardware" else {
val hash = EaOoa.machineHash(hardware, hashVersion)
for (content in ids) {
notes += try {
for ((path, data) in EaOoa.files(core, EaOoa.request(access, content, hash))) {
licenses.put(JSONObject().put("path", path).put("data", Base64.encodeToString(data, Base64.NO_WRAP)))
}
"$content:ok"
} catch (e: Exception) {
Log.w(TAG, "licence $content for $id: ${e.message}")
com.droiddeck.launcher.stores.StoresState.logLine("ea licence $content for $id v$hashVersion legacy=$legacy: ${e.message}")
"$content:refused"
}
}
}
}
if (core != null && notes.isNotEmpty() && notes.none { it.endsWith(":ok") }) runCatching {
val rows = EaApi.legacyEntitlements(access).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
}
}
@@ -0,0 +1,293 @@
package com.droiddeck.launcher.stores.ea
import android.content.Context
import android.content.SharedPreferences
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoreShelves
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.mergeItems
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
object EaPrefs {
const val FILE = "stores.ea"
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
}
data class EaGame(
val offerId: String,
val contentId: String,
val title: String,
val slug: String,
val tall: String = "",
val wide: String = "",
val logo: String = "",
val version: String = "",
val executePath: String = "",
val installCheck: String = "",
val parameters: String = "",
val multiplayerId: String = "",
val masterTitleId: String = "",
val cloudSaves: String = "",
val locales: List<String> = emptyList(),
) {
fun toJson(): JSONObject = JSONObject()
.put("offerId", offerId).put("contentId", contentId).put("title", title).put("slug", slug)
.put("tall", tall).put("wide", wide).put("logo", logo).put("version", version)
.put("executePath", executePath).put("installCheck", installCheck).put("parameters", parameters)
.put("multiplayerId", multiplayerId).put("masterTitleId", masterTitleId).put("cloudSaves", cloudSaves)
.put("locales", JSONArray(locales))
companion object {
fun fromJson(o: JSONObject): EaGame? {
val locales = o.optJSONArray("locales")
return EaGame(
offerId = o.optString("offerId").ifEmpty { return null },
contentId = o.optString("contentId"),
title = o.optString("title"),
slug = o.optString("slug"),
tall = o.optString("tall"),
wide = o.optString("wide"),
logo = o.optString("logo"),
version = o.optString("version"),
executePath = o.optString("executePath"),
installCheck = o.optString("installCheck"),
parameters = o.optString("parameters"),
multiplayerId = o.optString("multiplayerId"),
masterTitleId = o.optString("masterTitleId"),
cloudSaves = o.optString("cloudSaves"),
locales = (0 until (locales?.length() ?: 0)).map { locales!!.optString(it) },
)
}
}
}
object EaLibrary {
private const val KEY_CACHE = "library_cache"
private const val KEY_SYNCED_AT = "library_synced_at"
private const val THROTTLE_MS = 6 * 60 * 60 * 1000L
private const val ART_TTL_MS = 7 * 24 * 60 * 60 * 1000L
private const val ART_THREADS = 4
const val STORE_URL = "https://www.ea.com/ea-app/library"
private val gate = SyncGate()
private val generation = java.util.concurrent.atomic.AtomicInteger()
/** Drops the cache and makes any sync already running drop its results, in one step. */
fun reset(context: Context) {
synchronized(cacheLock) {
generation.incrementAndGet()
EaPrefs.get(context).edit().remove(KEY_CACHE).remove(KEY_SYNCED_AT).commit()
}
}
internal fun snapshot(app: Context): Pair<Int, List<EaGame>> = synchronized(cacheLock) { generation.get() to cached(app) }
internal fun storeCache(app: Context, games: List<EaGame>, gen: Int): Boolean = synchronized(cacheLock) {
gen == generation.get() &&
EaPrefs.get(app).edit().putString(KEY_CACHE, JSONArray(games.map { it.toJson() }).toString()).putLong(KEY_SYNCED_AT, System.currentTimeMillis()).commit()
}
private val cacheLock = Any()
private val store = Store.EA
fun cached(context: Context): List<EaGame> {
val text = EaPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList()
val arr = runCatching { JSONArray(text) }.getOrNull() ?: return emptyList()
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.let(EaGame::fromJson) }
}
fun game(context: Context, offerId: String): EaGame? = cached(context).firstOrNull { it.offerId == offerId }
fun toCatalogItem(context: Context, g: EaGame): CatalogItem {
val prefs = EaPrefs.get(context)
val tall = g.tall.ifEmpty { prefs.getString("poster_${g.offerId}", null).orEmpty() }
return CatalogItem(
store = store,
id = g.offerId,
title = g.title.ifBlank { g.slug },
imageUrl = g.wide.ifEmpty { tall }.ifEmpty { null },
tallImageUrl = tall.ifEmpty { g.wide }.ifEmpty { null },
publisher = "Electronic Arts",
owned = true,
sizeBytes = prefs.getLong("size_${g.offerId}", 0L),
storeUrl = STORE_URL,
extra = mapOf("contentId" to g.contentId, "slug" to g.slug, "version" to g.version),
logoUrl = g.logo.ifEmpty { null },
)
}
/** One entry per game: owned editions of the same base game collapse onto the base offer. */
internal fun pick(owned: List<EaApi.OwnedProduct>, offers: Map<String, EaApi.Offer>): List<Pair<EaApi.OwnedProduct, EaApi.Offer>> {
val usable = owned.filter { o ->
val offer = offers[o.offerId] ?: return@filter false
!o.trial && o.downloadable && offer.downloadable &&
(offer.displayType.isEmpty() || offer.displayType.equals("FullGame", true)) &&
(o.gameType.isEmpty() || o.gameType.equals("BASE_GAME", true))
}
return usable.groupBy { it.baseSlug.ifEmpty { it.slug }.ifEmpty { it.offerId } }.values.map { group ->
val chosen = group.firstOrNull { it.baseSlug.isEmpty() } ?: group.first()
chosen to offers.getValue(chosen.offerId)
}.sortedBy { it.first.name.lowercase() }
}
private fun art(app: Context, token: String, slugs: Collection<String>, gen: Int): Map<String, EaApi.Images> {
val prefs = EaPrefs.get(app)
val now = System.currentTimeMillis()
val out = java.util.concurrent.ConcurrentHashMap<String, EaApi.Images>()
val pool = Executors.newFixedThreadPool(ART_THREADS)
try {
for (slug in slugs.filter { it.isNotEmpty() }) {
if (gen != generation.get()) break
val cached = prefs.getString("art_$slug", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
if (cached != null && now - cached.optLong("at") < ART_TTL_MS) {
out[slug] = EaApi.Images(cached.optString("tall").ifEmpty { null }, cached.optString("wide").ifEmpty { null }, cached.optString("logo").ifEmpty { null })
continue
}
pool.execute {
if (gen != generation.get()) return@execute
val images = runCatching { EaApi.images(token, slug) }.getOrNull() ?: return@execute
out[slug] = images
prefs.edit().putString(
"art_$slug",
JSONObject().put("at", now).put("tall", images.tall.orEmpty()).put("wide", images.wide.orEmpty()).put("logo", images.logo.orEmpty()).toString(),
).apply()
}
}
} finally {
pool.shutdown()
pool.awaitTermination(2, TimeUnit.MINUTES)
}
return out
}
private fun posters(app: Context, games: List<EaGame>, gen: Int) {
val prefs = EaPrefs.get(app)
val todo = games.filter { it.tall.isEmpty() && !prefs.contains("poster_${it.offerId}") }.take(40)
if (todo.isEmpty()) return
val pool = Executors.newFixedThreadPool(ART_THREADS)
try {
for (g in todo) pool.execute {
if (gen != generation.get()) return@execute
prefs.edit().putString("poster_${g.offerId}", runCatching { StoreNet.sgdbPoster(g.title) }.getOrDefault("")).apply()
}
} finally {
pool.shutdown()
pool.awaitTermination(5, TimeUnit.MINUTES)
}
}
private fun publish(app: Context, games: List<EaGame>, gen: Int = generation.get()) {
if (gen != generation.get()) return
val items = games.map { toCatalogItem(app, it) }
StoresState.post {
if (gen != generation.get()) return@post
StoresState.library[store] = mergeItems(StoresState.library[store], items)
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty())
}
}
private inline fun <T> withToken(app: Context, token: String, call: (String) -> T): T = try {
call(token)
} catch (e: EaApiException) {
if (e.status != 401) throw e
call(EaAuth.refreshNow(app, token) ?: throw e)
}
fun sync(context: Context, force: Boolean) {
if (!gate.enter()) return
var forced = force
do {
runCatching { syncOnce(context.applicationContext, forced) }
forced = true
} while (gate.leave())
}
private fun syncOnce(app: Context, force: Boolean) {
val (gen, cache) = snapshot(app)
val prefs = EaPrefs.get(app)
val quiet = cache.isNotEmpty() && !force
fun status(id: Int) { if (!quiet) StoresState.post { StoresState.status[store] = app.getString(id) } }
fun problem(text: String?) {
StoresState.post {
StoresState.status.remove(store)
if (text == null) StoresState.problems.remove(store) else StoresState.problems[store] = text
}
}
try {
if (cache.isNotEmpty()) publish(app, cache, gen)
if (!force && cache.isNotEmpty() && System.currentTimeMillis() - prefs.getLong(KEY_SYNCED_AT, 0L) in 0 until THROTTLE_MS) {
return problem(null)
}
status(R.string.stores_sync_sign_in)
val token = EaAuth.accessToken(app) ?: return problem(StoresState.notSignedInLine(store))
status(R.string.stores_sync_game_list)
val owned = withToken(app, token) { EaApi.owned(it) }
val offers = withToken(app, token) { EaApi.offers(it, owned.map { o -> o.offerId }) }.associateBy { it.offerId }
val picked = pick(owned, offers)
StoresState.logLine("ea: ${owned.size} owned products, ${picked.size} games")
for ((o, offer) in picked) StoresState.logLine(
"ea: owned ${o.offerId} content=${offer.contentId} master=${offer.masterTitleId} mp=${offer.multiplayerId} via=${o.ownership.joinToString("+")} type=${o.gameType}")
if (picked.isEmpty()) return problem(if (cache.isEmpty()) app.getString(R.string.stores_sync_no_games, store.label) else null)
if (gen != generation.get()) return
status(R.string.stores_sync_cover_art)
val images = art(app, token, picked.map { it.first.slug }, gen)
val games = picked.map { (o, offer) ->
val img = images[o.slug]
EaGame(
offerId = o.offerId, contentId = offer.contentId, title = o.name.ifBlank { offer.displayName }, slug = o.slug,
tall = img?.tall.orEmpty(), wide = img?.wide.orEmpty(), logo = img?.logo.orEmpty(), version = offer.gameVersion,
executePath = offer.executePathOverride, installCheck = offer.installCheckOverride, parameters = offer.executeParameters,
multiplayerId = offer.multiplayerId, masterTitleId = offer.masterTitleId, cloudSaves = offer.cloudSaves, locales = offer.locales,
)
}
if (!storeCache(app, games, gen)) return
posters(app, games, gen)
if (gen != generation.get()) return
publish(app, games, gen)
problem(null)
} catch (e: EaApiException) {
StoresState.logLine("ea: library sync failed: ${e.message}")
if (e.status == 401) StoresState.markSignInExpired(store)
problem(if (cache.isEmpty()) e.message else null)
} catch (e: Exception) {
StoresState.logLine("ea: library sync failed: ${e.javaClass.simpleName} ${e.message.orEmpty()}")
problem(if (cache.isEmpty()) e.message ?: app.getString(R.string.stores_sync_failed) else null)
}
}
}
/** One sync at a time; a request made meanwhile makes the running one go round again rather than being lost. */
internal class SyncGate {
private var busy = false
private var again = false
/** True when the caller should run; false when a running sync will pick the request up. */
@Synchronized
fun enter(): Boolean {
if (busy) {
again = true
return false
}
busy = true
return true
}
/** True when another round was requested while running; the caller stays the runner. */
@Synchronized
fun leave(): Boolean {
if (again) {
again = false
return true
}
busy = false
return false
}
}
@@ -0,0 +1,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<String> = emptySet(),
) {
val validToken: String? get() = token.takeIf { it.isNotEmpty() && System.currentTimeMillis() < tokenExpiresAt }
}
/** The parts of a request a reply depends on. */
class Message(val recipient: String, val id: String, val name: String, val attrs: Map<String, String>, val children: List<Pair<String, String>>)
interface Account {
val userId: String
val personaId: String
val persona: String
fun authCode(clientId: String, scope: String?): String?
fun license(contentId: String, requestTicket: String, ticketEngine: String): String?
fun entitlements(group: String): List<Map<String, String>>
fun token(): String?
}
@Volatile var game: Game? = null
@Volatile private var account: Account? = null
/** The one session of the current launch that may act for the account. */
/** Bumped whenever the launch changes; a connection accepted under an older one is not admitted. */
@Volatile internal var generation = 0
private val holder = java.util.concurrent.atomic.AtomicReference<Any?>(null)
private var server: ServerSocket? = null
private val lock = Any()
private const val MAX_CLIENTS = 8
private val WHITESPACE = Regex("[ \t]+")
fun start(context: Context, game: Game): Boolean {
synchronized(lock) {
closeSessions()
this.game = game
account = AppAccount(context.applicationContext)
runCatching { server?.close() }
return try {
val socket = ServerSocket().apply {
reuseAddress = true
bind(java.net.InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT), 8)
}
server = socket
Thread({ accept(socket) }, "ea-lsx").apply { isDaemon = true }.start()
Log.i(TAG, "listening on $PORT")
true
} catch (e: Exception) {
Log.w(TAG, "could not listen on $PORT: ${e.javaClass.simpleName}")
false
}
}
}
/** Ends the endpoint; with [offerId], only if it still serves that game, so a late exit cannot stop a newer launch. */
fun stop(offerId: String? = null) {
synchronized(lock) {
if (offerId != null && game?.offerId != offerId) return
runCatching { server?.close() }
server = null
game = null
account = null
closeSessions()
}
}
private fun closeSessions() {
generation++
holder.set(null)
synchronized(sessions) { sessions.forEach { it.trusted = false } }
for (socket in open) runCatching { socket.close() }
open.clear()
}
private val open = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<Socket, Boolean>())
private const val HANDSHAKE_TIMEOUT_MS = 15_000
private const val UNTRUSTED_IDLE_MS = 10 * 60 * 1000
private fun accept(socket: ServerSocket) {
while (!socket.isClosed) {
val client = runCatching { socket.accept() }.getOrNull() ?: continue
val (owner, unreadable) = ownerUid(client)
val verified = owner?.let { it == android.os.Process.myUid() }
if (verified == false) {
Log.w(TAG, "refused a connection from another app")
runCatching { client.close() }
continue
}
if (unreadable != null) note("peer cannot be identified: $unreadable")
val session = runCatching { Session(client, client.getOutputStream(), verified == true, unreadable != null) }.getOrNull()
val live = session != null && synchronized(lock) { server === socket && admit(session).also { if (it) open += client } }
if (!live) {
runCatching { client.close() }
continue
}
Thread({ try { serve(client, session!!) } finally { synchronized(sessions) { sessions -= session!! }; open -= client } }, "ea-lsx-client").apply { isDaemon = true }.start()
}
}
/**
* The uid owning the connecting socket, or null where SELinux hides /proc/net. The guest runs
* under this app's uid, so any other uid is another app; an unknown owner never gets credentials.
*/
/** The connecting socket's uid, or why Android hid socket ownership from this app (some builds deny /proc/net). */
private fun ownerUid(client: Socket): Pair<Int?, String?> {
val port = client.port
var denied: String? = null
var read = false
for (path in listOf("/proc/net/tcp6", "/proc/net/tcp")) {
val lines = runCatching { java.io.File(path).readLines() }.onFailure { denied = "$path ${it.javaClass.simpleName}" }.getOrNull() ?: continue
read = true
owner(lines, port)?.let { return it to null }
}
return null to if (read) null else denied ?: "no socket table"
}
internal fun owner(lines: List<String>, port: Int): Int? = lines.drop(1).firstNotNullOfOrNull { line ->
val f = line.trim().split(WHITESPACE)
val local = f.getOrNull(1)?.substringAfterLast(':')?.toIntOrNull(16)
val remote = f.getOrNull(2)?.substringAfterLast(':')?.toIntOrNull(16)
if (local == port && remote == PORT) f.getOrNull(7)?.toIntOrNull() else null
}
/**
* Adds [session] unless the cap is reached by sessions that cannot be replaced; otherwise the
* oldest session that never claimed the prepared game (then the oldest untrusted one) makes room.
* Counting and eviction share one lock, so the live total never passes the cap.
*/
internal fun admit(session: Session): Boolean = synchronized(sessions) {
if (sessions.count { !it.evicted } >= MAX_CLIENTS) {
val candidates = sessions.filter { !it.trusted && !it.evicted }
val victim = candidates.firstOrNull { !it.claimed } ?: candidates.firstOrNull() ?: return false
victim.evicted = true
runCatching { victim.socket.close() }
}
sessions += session
true
}
internal val sessions = LinkedHashSet<Session>()
/** Trust and eviction exclude each other: an evicted session never becomes trusted, a trusted one is never evicted. */
internal fun promote(session: Session, matches: Boolean) {
synchronized(sessions) { session.trusted = matches && !session.evicted && session.born == generation && holder.compareAndSet(null, session) }
}
internal class Session(val socket: Socket, val output: OutputStream, val verified: Boolean, val unverifiable: Boolean = false) {
val born = generation
val connectedAt = System.currentTimeMillis()
@Volatile var evicted = false
@Volatile var claimed = false
var key: ByteArray? = null
@Volatile var trusted = false
var pending: ByteArray? = null
/** A peer Android names as another app never gets account data; one Android cannot name is judged by its claim alone. */
val mayTrust get() = (verified || unverifiable) && claimed
@Synchronized
fun send(xml: String) {
val key = this.key
val text = if (key == null) xml else EaCrypto.hex(EaCrypto.ecbEncrypt(xml.toByteArray(), key))
output.write(text.toByteArray())
output.write(0)
output.flush()
}
}
private fun note(line: String) = com.droiddeck.launcher.stores.StoresState.logLine("ea lsx: $line")
private fun serve(client: Socket, session: Session) {
client.tcpNoDelay = true
client.soTimeout = HANDSHAKE_TIMEOUT_MS
val seen = LinkedHashSet<String>()
var why = "closed by the game"
note("game connected")
try {
session.send("<LSX><Event sender=\"$SENDER\"><Challenge build=\"release\" key=\"$CHALLENGE_KEY\" version=\"$CHALLENGE_VERSION\"/></Event></LSX>")
val input = client.getInputStream()
val pending = ByteArrayOutputStream()
val chunk = ByteArray(16 * 1024)
while (true) {
if (session.key == null) {
val left = session.connectedAt + HANDSHAKE_TIMEOUT_MS - System.currentTimeMillis()
if (left <= 0) throw java.net.SocketTimeoutException()
client.soTimeout = left.toInt()
}
val n = input.read(chunk)
if (n < 0) break
pending.write(chunk, 0, n)
for (frame in frames(pending, session.key)) {
for (one in Regex("<LSX>.*?</LSX>", RegexOption.DOT_MATCHES_ALL).findAll(frame)) {
val message = parse(one.value) ?: continue
if (seen.add(message.name)) note("request ${message.name}")
val reply = runCatching { answer(session, message) }
.onFailure { note("${message.name} failed: ${it.javaClass.simpleName}") }.getOrNull()
if (reply != null) session.send("<LSX><Response sender=\"${esc(message.recipient)}\" id=\"${esc(message.id)}\">$reply</Response></LSX>")
session.pending?.let { session.key = it; session.pending = null }
if (session.key != null) {
val idle = if (session.trusted) 0 else UNTRUSTED_IDLE_MS
if (client.soTimeout != idle) client.soTimeout = idle
}
}
}
if (pending.size() > 4 * 1024 * 1024) { why = "message too large"; break }
}
} catch (e: java.net.SocketTimeoutException) {
why = "no trusted handshake in time"
} catch (e: Exception) {
why = e.javaClass.simpleName
} finally {
holder.compareAndSet(session, null)
note("game disconnected ($why)")
runCatching { client.close() }
}
}
/**
* Complete messages in what has arrived so far. Messages normally end in a NUL; one that does
* not counts once it holds a whole `<LSX>` element, plain or decrypted.
*/
private fun frames(pending: ByteArrayOutputStream, key: ByteArray?): List<String> {
val bytes = pending.toByteArray()
val out = ArrayList<String>()
var start = 0
for (i in bytes.indices) {
if (bytes[i].toInt() != 0) continue
if (i > start) decode(String(bytes, start, i - start, Charsets.UTF_8), key)?.let(out::add)
start = i + 1
}
var rest = String(bytes, start, bytes.size - start, Charsets.UTF_8)
if (rest.isNotBlank()) decode(rest, key)?.takeIf { it.contains("</LSX>") }?.let { out += it; rest = "" }
pending.reset()
pending.write(rest.toByteArray(Charsets.UTF_8))
return out
}
private fun decode(frame: String, key: ByteArray?): String? {
val text = frame.trim()
if (text.isEmpty()) return null
if (text.startsWith("<")) return text
val k = key ?: return null
return runCatching { String(EaCrypto.ecbDecrypt(EaCrypto.unhex(text), k)) }.getOrNull()
}
internal fun parse(xml: String): Message? = runCatching {
val parser = XmlPullParserFactory.newInstance().newPullParser()
parser.setInput(StringReader(xml))
var recipient = ""
var id = ""
var name = ""
val attrs = HashMap<String, String>()
val children = ArrayList<Pair<String, String>>()
var depth = 0
var child = ""
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> {
depth++
when (depth) {
2 -> if (parser.name == "Request") {
recipient = parser.getAttributeValue(null, "recipient").orEmpty()
id = parser.getAttributeValue(null, "id").orEmpty()
} else return@runCatching null
3 -> {
name = parser.name
for (i in 0 until parser.attributeCount) attrs[parser.getAttributeName(i)] = parser.getAttributeValue(i)
}
4 -> child = parser.name
}
}
XmlPullParser.TEXT -> if (depth == 4) children += child to parser.text.trim()
XmlPullParser.END_TAG -> depth--
}
parser.next()
}
if (name.isEmpty()) null else Message(recipient, id, name, attrs, children)
}.getOrNull()
private fun esc(s: String) = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
private fun tag(name: String, vararg attrs: Pair<String, Any?>, body: String? = null): String {
val a = attrs.filter { it.second != null }.joinToString("") { " ${it.first}=\"${esc(it.second.toString())}\"" }
return if (body == null) "<$name$a/>" else "<$name$a>$body</$name>"
}
private fun now() = LocalDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
private fun answer(session: Session, m: Message): String? {
val g = game
val who = account
val person = who?.takeIf { session.trusted }
val locale = g?.locale ?: "en_US"
return when (m.name) {
"ChallengeResponse" -> {
val answer = EaCrypto.challengeAnswer(m.attrs["key"].orEmpty())
val version = m.attrs["version"].orEmpty()
val seed = EaCrypto.challengeSeed(version, answer) ?: 0
val content = m.children.firstOrNull { it.first == "ContentId" }?.second.orEmpty()
session.claimed = g != null && content.isNotEmpty() && (content == g.contentId || content == g.offerId || content in g.contentIds)
val matches = session.mayTrust
promote(session, matches)
note("challenge version '$version' content '$content' ${when { session.trusted -> "matches the prepared game"; matches -> "matches, but another session holds this launch"; !session.verified && !session.unverifiable -> "comes from an unverified peer"; else -> "does not match the prepared game" }}")
session.pending = EaCrypto.lsxKey(seed)
Log.i(TAG, "game connected: ${m.children.firstOrNull { it.first == "Title" }?.second.orEmpty()}")
tag("ChallengeAccepted", "response" to answer)
}
"GetConfig" -> tag("GetConfigResponse", body = SERVICES.joinToString("") { tag("Service", "Name" to it.first, "Facility" to it.second) })
"GetProfile" -> tag(
"GetProfileResponse",
"Persona" to person?.persona.orEmpty(), "SubscriberLevel" to 0, "CommerceCurrency" to "USD", "IsTrialSubscriber" to false,
"Country" to "US", "UserId" to person?.userId.orEmpty(), "GeoCountry" to "US", "AvatarId" to "", "IsSubscriber" to false,
"IsSteamSubscriber" to false, "PersonaId" to person?.personaId.orEmpty(), "IsUnderAge" to false, "UserIndex" to 0,
)
"GetSetting" -> tag("GetSettingResponse", "Setting" to when (m.attrs["SettingId"]) {
"ENVIRONMENT" -> "production"
"LANGUAGE" -> locale
else -> "false"
})
"GetGameInfo" -> tag("GetGameInfoResponse", "GameInfo" to when (m.attrs["GameInfoId"]) {
"FREETRIAL" -> "false"
"LANGUAGES" -> LANGUAGES
"INSTALLED_LANGUAGE" -> locale
"INSTALLED_VERSION", "AVAILABLE_VERSION" -> g?.version.orEmpty()
else -> ""
})
"GetAllGameInfo" -> tag(
"GetAllGameInfoResponse",
"FullGamePurchased" to true, "FullGameReleased" to true, "InstalledVersion" to (g?.version ?: "0"), "MaxGroupSize" to 16,
"Languages" to LANGUAGES, "Expiration" to "0000-00-00T00:00:00", "UpToDate" to true, "HasExpiration" to false,
"EntitlementSource" to "EA", "AvailableVersion" to (g?.version ?: "0"), "DisplayName" to g?.title.orEmpty(),
"FreeTrial" to false, "InstalledLanguage" to locale, "FullGameReleaseDate" to "2000-01-01T00:00:00", "SystemTime" to now(),
)
"GetInternetConnectedState" -> tag("InternetConnectedState", "connected" to 1)
"IsProgressiveInstallationAvailable" -> tag("IsProgressiveInstallationAvailableResponse", "ItemId" to m.attrs["ItemId"].orEmpty(), "Available" to false)
"AreChunksInstalled" -> tag(
"AreChunksInstalledResponse", "ItemId" to m.attrs["ItemId"].orEmpty(), "Installed" to true,
body = m.children.joinToString("") { tag(it.first, body = esc(it.second)) },
)
"GetAuthCode" -> tag("AuthCode", "value" to (who?.takeIf { session.trusted }?.authCode(m.attrs["ClientId"].orEmpty(), m.attrs["Scope"])?.takeIf { session.trusted } ?: "invalid"))
"GetAuthToken" -> tag("AuthToken", "value" to if (!session.trusted) "" else (g?.validToken ?: who?.token().orEmpty()))
"RequestLicense" -> {
val token = who?.takeIf { session.trusted }?.license(g?.contentId.orEmpty(), m.attrs["RequestTicket"].orEmpty(), m.attrs["TicketEngine"].orEmpty())?.takeIf { session.trusted }.orEmpty()
tag("RequestLicenseResponse", "License" to token)
}
"QueryEntitlements" -> {
val group = m.attrs["Group"].orEmpty()
val rows = who?.takeIf { session.trusted }?.entitlements(group).orEmpty()
tag("QueryEntitlementsResponse", body = rows.joinToString("") { e ->
tag(
"Entitlement",
"LastModifiedDate" to "0000-00-00T00:00:00", "EntitlementId" to e["id"], "UseCount" to (e["useCount"] ?: "0"),
"Version" to (e["version"] ?: "0"), "ItemId" to e["productId"], "ResourceId" to "", "GrantDate" to e["grantDate"],
"Group" to group, "EntitlementTag" to e["entitlementTag"], "Type" to e["entitlementType"],
"Expiration" to (e["terminationDate"]?.takeIf { it.isNotEmpty() && it != "null" } ?: "0000-00-00T00:00:00"), "Source" to "",
)
})
}
"GetPresence" -> tag("GetPresenceResponse", "UserId" to person?.userId.orEmpty(), "Presence" to "INGAME")
"QueryPresence" -> tag("QueryPresenceResponse", body = "")
"QueryFriends" -> tag("QueryFriendsResponse", body = "")
"GetBlockList" -> tag("GetBlockListResponse", "Return" to "Success", body = "")
"QueryOffers" -> tag("QueryOffersResponse", body = "")
"QueryImage" -> tag("QueryImageResponse", "Result" to 0, body = "")
"GetVoipStatus" -> tag("GetVoipStatusResponse", "Available" to false, "Active" to false)
"ShowIGOWindow", "SetDownloaderUtilization" -> null
else -> tag("ErrorSuccess", "Code" to 0, "Description" to "")
}
}
private class AppAccount(private val app: Context) : Account {
private val saved get() = EaAuth.load(app)
override val userId: String get() = saved?.userId.orEmpty()
override val personaId: String get() = saved?.personaId.orEmpty()
override val persona: String get() = saved?.eaId?.ifEmpty { null } ?: saved?.displayName.orEmpty()
override fun authCode(clientId: String, scope: String?): String? =
EaAuth.mint(app, clientId, "code", scopes = scope?.split(' ')?.filter { it.isNotBlank() }.orEmpty())
override fun license(contentId: String, requestTicket: String, ticketEngine: String): String? {
val g = game ?: return null
val hw = g.hardware ?: return null
val token = EaAuth.accessToken(app) ?: return null
return runCatching {
EaOoa.request(token, contentId, EaOoa.machineHash(hw, g.hashVersion), requestTicket.ifEmpty { null }, ticketEngine.ifEmpty { null }).gameToken
}.onFailure { Log.w(TAG, "licence: ${it.message}") }.getOrNull()
}
override fun token(): String? = EaAuth.mint(app, EaAuth.CLIENT_ID, "token", EaAuth.OPAQUE_SCOPES, "OPAQUE", 14400)
override fun entitlements(group: String): List<Map<String, String>> {
val token = EaAuth.accessToken(app) ?: return emptyList()
val arr = runCatching { EaApi.entitlements(token, group) }.getOrNull() ?: return emptyList()
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map { o ->
o.keys().asSequence().associateWith { k -> o.optString(k) }
}
}
}
}
@@ -0,0 +1,134 @@
package com.droiddeck.launcher.stores.ea
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.File
import java.io.StringReader
/** What `__Installer/installerdata.xml` in an EA build says about running and finishing the install. */
class EaManifest(
val version: String,
val launchers: List<Launcher>,
val touchupPath: String,
val touchupParameters: String,
val contentIds: List<String> = emptyList(),
val titles: Map<String, String> = emptyMap(),
) {
fun title(locale: String): String? = titles[locale] ?: titles["en_US"] ?: titles.values.firstOrNull()
class Launcher(val path: String, val parameters: String, val trial: Boolean)
fun launcher(): Launcher? = launchers.firstOrNull { !it.trial } ?: launchers.firstOrNull()
companion object {
const val RELATIVE = "__Installer/installerdata.xml"
fun decode(bytes: ByteArray): String {
val utf16 = bytes.size >= 2 && ((bytes[0] == 0xFF.toByte() && bytes[1] == 0xFE.toByte()) || (bytes[0] == 0xFE.toByte() && bytes[1] == 0xFF.toByte()))
val text = if (utf16) String(bytes, Charsets.UTF_16) else String(bytes, Charsets.UTF_8)
return text.trimStart('')
}
fun read(folder: File): EaManifest? {
val file = File(folder, RELATIVE).takeIf { it.isFile } ?: EaPaths.find(folder, RELATIVE) ?: return null
return runCatching { parse(decode(file.readBytes())) }.getOrNull()
}
fun parse(xml: String): EaManifest {
val parser = XmlPullParserFactory.newInstance().newPullParser()
parser.setInput(StringReader(xml.replace(Regex("^<\\?xml[^>]*\\?>"), "")))
val stack = ArrayList<String>()
var version = ""
val launchers = ArrayList<Launcher>()
var touchupPath = ""
var touchupParams = ""
var path = ""
var params = ""
var trial = false
val contentIds = ArrayList<String>()
val titles = LinkedHashMap<String, String>()
var locale = ""
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> {
val name = parser.name
stack += name
if (name.equals("gameVersion", true)) {
version = parser.getAttributeValue(null, "version") ?: version
}
if (name.equals("launcher", true)) { path = ""; params = ""; trial = false }
if (name.equals("localeInfo", true)) locale = parser.getAttributeValue(null, "locale").orEmpty()
}
XmlPullParser.TEXT -> {
val text = parser.text.trim()
val tag = stack.lastOrNull().orEmpty()
val parent = stack.getOrNull(stack.size - 2).orEmpty()
if (text.isNotEmpty()) when {
parent.equals("launcher", true) && tag.equals("filePath", true) -> path = text
parent.equals("launcher", true) && tag.equals("parameters", true) -> params = text
parent.equals("launcher", true) && tag.equals("trial", true) -> trial = text.equals("true", true) || text == "1"
(parent.equals("touchup", true) || parent.equals("executable", true)) && tag.equals("filePath", true) -> touchupPath = text
(parent.equals("touchup", true) || parent.equals("executable", true)) && tag.equals("parameters", true) -> touchupParams = text
parent.equals("contentIDs", true) && tag.equals("contentID", true) -> contentIds += text
parent.equals("localeInfo", true) && tag.equals("title", true) && locale.isNotEmpty() -> titles[locale] = text
}
}
XmlPullParser.END_TAG -> {
if (parser.name.equals("launcher", true) && path.isNotEmpty()) launchers += Launcher(path, params, trial)
stack.removeLastOrNull()
}
}
parser.next()
}
return EaManifest(version, launchers, touchupPath, touchupParams, contentIds, titles)
}
}
}
object EaPaths {
private val KEY = Regex("^\\[([^\\]]+)\\](.*)$")
/** `[HKEY_LOCAL_MACHINE\SOFTWARE\Publisher\Game\Install Dir]bin\game.exe` splits into the registry key, value and relative path. */
class Registered(val key: String, val value: String, val rest: String)
fun registered(path: String): Registered? {
val m = KEY.find(path.trim()) ?: return null
val full = m.groupValues[1]
val cut = full.lastIndexOf('\\').takeIf { it > 0 } ?: return null
return Registered(full.substring(0, cut), full.substring(cut + 1), m.groupValues[2])
}
fun relative(path: String): String {
val rest = registered(path)?.rest ?: path.replace("{installLocation}", "", ignoreCase = true)
return rest.replace('\\', '/').trimStart('/').split('/').filter { it.isNotEmpty() && it != "." }.joinToString("/")
}
/** The real file for a path the manifest may spell in a different case. */
fun find(folder: File, relative: String): File? {
var cur = folder
for (part in relative.replace('\\', '/').split('/').filter { it.isNotEmpty() }) {
val names = cur.listFiles() ?: return null
cur = names.firstOrNull { it.name == part } ?: names.firstOrNull { it.name.equals(part, true) } ?: return null
}
return cur.takeIf { it.exists() && it != folder }
}
fun relativeOnDisk(folder: File, relative: String): String? =
find(folder, relative)?.relativeTo(folder)?.path?.replace(File.separatorChar, '/')
fun splitArgs(text: String): List<String> {
val out = ArrayList<String>()
val cur = StringBuilder()
var quoted = false
var any = false
for (c in text) {
when {
c == '"' -> { quoted = !quoted; any = true }
c.isWhitespace() && !quoted -> { if (any || cur.isNotEmpty()) out += cur.toString(); cur.clear(); any = false }
else -> cur.append(c)
}
}
if (any || cur.isNotEmpty()) out += cur.toString()
return out
}
}
@@ -0,0 +1,57 @@
package com.droiddeck.launcher.stores.ea
import android.util.Log
import com.droiddeck.launcher.stores.StoresNative
import com.droiddeck.launcher.stores.amazon.AmazonNative
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
object EaNative {
private const val TAG = "EaNative"
const val ENGINE_MISSING = "EA: the store engine is not available"
fun ready(): Boolean = StoresNative.ensureLoaded()
fun call(op: String, request: JSONObject): JSONObject? {
if (!ready()) return null
val text = try { nativeCall(op, request.toString()) } catch (t: Throwable) {
Log.w(TAG, "$op: ${t.javaClass.simpleName}")
null
} ?: return null
return runCatching { JSONObject(text) }.getOrNull()
}
fun install(request: JSONObject, events: AmazonNative.InstallListener, cancelled: AtomicBoolean): AmazonNative.InstallOutcome {
if (!ready()) return AmazonNative.InstallOutcome(false, false, ENGINE_MISSING, null)
val done = CountDownLatch(1)
val holder = arrayOfNulls<AmazonNative.InstallOutcome>(1)
val bridge = object : AmazonNative.InstallListener by events {
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) {
holder[0] = AmazonNative.InstallOutcome(success, cancelled, error, result.takeIf { it.isNotEmpty() }?.let { runCatching { JSONObject(it) }.getOrNull() })
done.countDown()
}
}
val handle = try { nativeInstall(request.toString(), bridge) } catch (t: Throwable) {
return AmazonNative.InstallOutcome(false, false, "The EA install could not start: ${t.javaClass.simpleName}", null)
}
if (handle == 0L) return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The EA install could not start", null)
try {
var forwarded = false
while (true) {
val finished = try { done.await(250, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) { cancelled.set(true); false }
if (finished) break
if (!forwarded && cancelled.get()) { nativeCancel(handle); forwarded = true }
}
} finally {
nativeRelease(handle)
}
return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The EA install ended without a result", null)
}
@JvmStatic private external fun nativeCall(op: String, request: String): String?
@JvmStatic private external fun nativeInstall(request: String, listener: AmazonNative.InstallListener): Long
@JvmStatic private external fun nativeCancel(handle: Long)
@JvmStatic private external fun nativeRelease(handle: Long)
}
@@ -0,0 +1,196 @@
package com.droiddeck.launcher.stores.ea
import org.json.JSONObject
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.File
import java.io.StringReader
import java.net.URLEncoder
import java.security.SecureRandom
/** Origin Online Activation: the per-machine licence an EA game's activation layer checks before it runs. */
object EaOoa {
private const val LICENSES = "https://proxy.novafusion.ea.com/licenses"
const val LICENSE_DIR = "ProgramData/Electronic Arts/EA Services/License"
private const val SIGNATURE_SLOT = 65
/**
* What the game sees of the machine inside its Wine prefix: firmware and OS values from Wine's
* WMI, CPU values from CPUID executed as x86 code, since an emulated game sees the emulator's
* CPUID rather than the host CPU WMI describes.
*/
class Hardware(
val boardManufacturer: String,
val boardSerial: String,
val biosManufacturer: String,
val biosSerial: String,
val osInstallDate: String,
val osSerial: String,
val volumeSerial: String,
val gpuPnpId: String,
val cpuVendor: String,
val cpuEdx: String,
val cpuEax: String,
val cpuEcx: String,
val brand: String,
val hostname: String,
) {
/** Whether the probe reported everything the hash of this activation version is built from. */
fun complete(version: Int): Boolean {
val needed = arrayListOf(boardManufacturer, biosManufacturer)
when {
version <= 1 -> needed += listOf(hostname, osInstallDate, osSerial)
version == 2 -> needed += listOf(osInstallDate, osSerial, volumeSerial, cpuVendor, cpuEdx, cpuEcx)
version == 3 -> needed += listOf(volumeSerial, cpuVendor, cpuEdx, cpuEcx)
else -> needed += listOf(volumeSerial, cpuVendor, cpuEdx, cpuEax)
}
return needed.all { it.isNotEmpty() }
}
companion object {
fun fromJson(o: JSONObject): Hardware {
fun s(k: String) = o.optString(k).trim()
return Hardware(
boardManufacturer = s("boardManufacturer"),
boardSerial = s("boardSerial").ifEmpty { "None" },
biosManufacturer = s("biosManufacturer"),
biosSerial = s("biosSerial").ifEmpty { "None" },
osInstallDate = s("osInstallDate"),
osSerial = s("osSerial"),
volumeSerial = s("volumeSerial").replace("-", "").lowercase().let { if (it.isEmpty()) it else it.padStart(8, '0') },
gpuPnpId = s("gpuPnpId").ifEmpty { "None" },
cpuVendor = s("cpuVendor"),
cpuEdx = s("cpuEdx").uppercase(),
cpuEax = s("cpuEax").uppercase(),
cpuEcx = s("cpuEcx").lowercase(),
brand = o.optString("cpuBrand"),
hostname = s("hostname"),
)
}
}
}
fun machineHash(hw: Hardware, version: Int): String {
val edxEax = hw.cpuEdx + hw.cpuEax
val edx = hw.cpuEdx.lowercase()
val shortGpu = hw.gpuPnpId.substringBeforeLast('\\', hw.gpuPnpId)
val parts = arrayListOf(hw.boardManufacturer, hw.boardSerial)
when {
version <= 1 -> parts += listOf(hw.hostname, hw.biosManufacturer, hw.biosSerial, hw.osInstallDate, hw.osSerial)
version == 2 -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.osInstallDate, hw.osSerial, hw.volumeSerial, shortGpu, hw.cpuVendor, edx, hw.cpuEcx)
version == 3 -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.volumeSerial, shortGpu, hw.cpuVendor, edx, hw.cpuEcx)
else -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.volumeSerial, hw.gpuPnpId, hw.cpuVendor, edxEax)
}
val text = StringBuilder(parts.joinToString(";")).append(';')
if (version >= 2) text.append(hw.brand.take(47).padEnd(47, '\u0000')).append(';')
val digest = EaCrypto.sha1(text.toString().toByteArray(Charsets.ISO_8859_1))
return if (version < 4) digest.joinToString("") { Integer.toHexString(it.toInt() and 0xFF) } else EaCrypto.hex(digest)
}
/** Version of the game's activation layer, which decides how the machine hash is built. */
fun activationVersion(core: File): Int {
val dll = listOf("activation.dll", "awc.dll").firstNotNullOfOrNull { EaPaths.find(core, it) } ?: return 1
val (major, minor, patch) = productVersion(dll.readBytes()) ?: return 1
return when {
major >= 5 -> 4
major == 4 && (minor > 7 || (minor == 7 && patch >= 6)) -> 3
major == 4 && minor == 7 -> 2
major == 4 -> 1
else -> 0
}
}
/** The activation layer's folder: `Core` beside the game's exe, where EA ships it, or at the game root. */
fun coreDir(game: File, exe: String): File? {
val exeDir = exe.replace('\\', '/').substringBeforeLast('/', "")
val candidates = listOfNotNull(exeDir.takeIf { it.isNotEmpty() }?.let { "$it/Core" }, "Core")
return candidates.firstNotNullOfOrNull { EaPaths.find(game, it)?.takeIf { f -> f.isDirectory } }
}
/** Games built on the 2012-era EA Core client, which want an opaque token rather than a JWS one. */
fun legacyCore(core: File?): Boolean = core != null && EaPaths.find(core, "EACore.dll") != null
private fun encodedSignature(core: File): Boolean = EaPaths.find(core, "activation.exe") != null
internal fun productVersion(pe: ByteArray): Triple<Int, Int, Int>? {
val magic = byteArrayOf(0xBD.toByte(), 0x04, 0xEF.toByte(), 0xFE.toByte())
var i = 0
while (i + 24 <= pe.size) {
if (pe[i] == magic[0] && pe[i + 1] == magic[1] && pe[i + 2] == magic[2] && pe[i + 3] == magic[3]) {
fun u32(at: Int) = (pe[at].toLong() and 0xFF) or ((pe[at + 1].toLong() and 0xFF) shl 8) or ((pe[at + 2].toLong() and 0xFF) shl 16) or ((pe[at + 3].toLong() and 0xFF) shl 24)
val ms = u32(i + 16)
val ls = u32(i + 20)
return Triple((ms ushr 16).toInt(), (ms and 0xFFFF).toInt(), (ls ushr 16).toInt())
}
i++
}
return null
}
class License(val fields: LinkedHashMap<String, String>, val namespace: String, val signature: String) {
val gameToken: String? get() = fields["GameToken"]?.takeIf { it.isNotEmpty() }
val contentId: String get() = fields["ContentId"].orEmpty()
}
internal fun parseLicense(xml: String, signature: String): License {
val parser = XmlPullParserFactory.newInstance().apply { isNamespaceAware = false }.newPullParser()
parser.setInput(StringReader(xml.replace(Regex("^<\\?xml[^>]*\\?>"), "").trim()))
val fields = LinkedHashMap<String, String>()
var ns = ""
var depth = 0
var tag = ""
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> {
depth++
if (depth == 1) ns = parser.getAttributeValue(null, "xmlns").orEmpty() else tag = parser.name
if (depth == 2) fields[tag] = ""
}
XmlPullParser.TEXT -> if (depth == 2) fields[tag] = fields[tag].orEmpty() + parser.text
XmlPullParser.END_TAG -> depth--
}
parser.next()
}
return License(fields, ns, signature)
}
private fun esc(s: String) = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
fun licenseXml(l: License): String {
val order = listOf("CipherKey", "MachineHash", "ContentId", "UserId", "GameToken", "GrantTime", "StartTime", "Nonce")
val body = StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>")
body.append("<License xmlns=\"").append(esc(l.namespace)).append("\">")
for (k in order) {
val v = l.fields[k] ?: continue
if (k == "GameToken" && v.isEmpty()) continue
body.append('<').append(k).append('>').append(esc(v)).append("</").append(k).append('>')
}
return body.append("</License>").toString()
}
fun licenseBlob(l: License, encoded: Boolean): ByteArray {
val signature = if (encoded) l.signature.toByteArray() else java.util.Base64.getDecoder().decode(l.signature)
val head = ByteArray(SIGNATURE_SLOT)
System.arraycopy(signature, 0, head, 0, signature.size.coerceAtMost(SIGNATURE_SLOT))
return head + EaCrypto.licenseEncrypt(licenseXml(l).toByteArray())
}
fun request(token: String, contentId: String, machineHash: String, requestToken: String? = null, requestType: String? = null): License {
val nonce = EaCrypto.hex(ByteArray(4).also(SecureRandom()::nextBytes))
val params = mutableListOf("contentId" to contentId, "machineHash" to machineHash, "nonce" to nonce, "ea_eadmtoken" to token)
if (requestToken != null) params += "requestToken" to requestToken
if (requestType != null) params += "requestType" to requestType
val url = LICENSES + "?" + params.joinToString("&") { (k, v) -> k + "=" + URLEncoder.encode(v, "UTF-8") }
val reply = EaHttp.request(url, headers = mapOf("X-Requester-Id" to "Origin Online Activation", "User-Agent" to "EACTransaction"))
if (!reply.ok) throw IllegalStateException("EA refused the licence (${reply.status}): ${reply.text.replace(Regex("(eadmToken value=\")[^\"]*"), "$1<redacted>\"").take(600)}")
val signature = reply.header("x-signature") ?: throw IllegalStateException("EA's licence had no signature")
return parseLicense(String(EaCrypto.licenseDecrypt(reply.body), Charsets.UTF_8), signature)
}
/** Licence files for a game about to start, named as the activation layer looks for them. */
fun files(core: File, license: License): List<Pair<String, ByteArray>> {
val blob = licenseBlob(license, encodedSignature(core))
val id = license.contentId
return listOf("$LICENSE_DIR/$id.dlf" to blob, "$LICENSE_DIR/${id}_cached.dlf" to blob)
}
}
@@ -64,6 +64,8 @@ object GogBackend : StoreBackend {
}, "gog-sync").start()
}
override fun search(context: Context, query: String): List<CatalogItem> = GogStoreCatalog.search(query)
override fun loadShelves(context: Context, force: Boolean) {
val app = context.applicationContext
GogStoreCatalog.cachedFeatured(app)?.let { StoresState.post { StoresState.shelves[store] = it } }
@@ -16,6 +16,7 @@ internal object GogContent {
fun token(context: Context) = GogAuth.validToken(context) ?: error("Sign in to GOG and retry")
@JvmStatic
fun builds(id: String, token: String): JSONObject {
val result = json("$SYSTEM/products/$id/os/windows/builds?generation=2", token)
if (result.optJSONArray("items")?.length() != 0) return result
@@ -24,6 +25,36 @@ internal object GogContent {
fun product(id: String, token: String): JSONObject = json("https://api.gog.com/products/$id?expand=downloads", token)
@JvmStatic
fun ownedProductIds(token: String): Set<String>? = runCatching {
val body = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token) ?: return@runCatching null
JSONObject(body).optJSONArray("owned")?.strings()?.toSet() ?: emptySet()
}.getOrNull()
@JvmStatic
fun depotProducts(depot: JSONObject, baseProductId: String): List<String> =
depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId").ifEmpty { baseProductId })
@JvmStatic
fun clientCredentials(manifest: JSONObject): Pair<String, String>? {
val clientId = manifest.optString("clientId")
val clientSecret = manifest.optString("clientSecret")
return clientId.takeIf { it.isNotBlank() }?.let { id -> clientSecret.takeIf { it.isNotBlank() }?.let { id to it } }
}
@JvmStatic
fun tempExecutable(manifest: JSONObject): String? = manifest.optJSONArray("products")?.optJSONObject(0)
?.optString("temp_executable")?.takeIf { it.isNotBlank() }
@JvmStatic
fun installProducts(baseProductId: String, depots: JSONArray, installed: List<String>, token: String,
ownership: (String) -> Set<String>? = { ownedProductIds(it) }): Set<String> {
val listed = depots.objects().flatMap { depotProducts(it, baseProductId) }.distinct()
val owned = if (listed.any { it != baseProductId } || installed.any { it != baseProductId }) ownership(token) else emptySet()
check(installed.all { it == baseProductId || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" }
return (listOf(baseProductId) + installed + listed.filter { it == baseProductId || owned?.contains(it) == true }).toSet()
}
fun downloadFilename(url: String): String = java.net.URLDecoder.decode(URL(url).path.substringAfterLast('/').replace("+", "%2B"), "UTF-8")
fun resolveDownload(download: GogOfflineDownload, token: String): GogOfflineDownload = download.copy(parts = download.parts.map { part ->
@@ -42,6 +73,7 @@ internal object GogContent {
} finally { connection.disconnect() }
})
@JvmStatic
fun json(url: String, token: String? = null): JSONObject {
require(URL(url).protocol == "https") { "GOG metadata requires HTTPS" }
val connection = URL(url).openConnection() as HttpURLConnection
@@ -61,21 +93,21 @@ internal object GogContent {
}
fun snapshot(old: GogInstallRecord, build: GogBuild, token: String,
metadata: (String, String?) -> JSONObject = ::json): GogInstallRecord {
ownership: (String) -> Set<String>? = { ownedProductIds(it) },
metadata: (String, String?) -> JSONObject = ::json,
onManifest: (JSONObject) -> Unit = {}): GogInstallRecord {
check(build.link.isNotBlank()) { "GOG build manifest is unavailable" }
val manifest = metadata(build.link, token)
onManifest(manifest)
val files = JSONArray()
val depots = if (build.generation == 1) manifest.optJSONObject("product")?.optJSONArray("depots") ?: manifest.optJSONArray("depot") else manifest.optJSONArray("depots")
check(depots != null) { "GOG build has no depot list" }
val resolvedLanguage = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), old.language)
val selectedBitness = if (depots.objects().any { it.optJSONArray("osBitness")?.strings()?.contains("64") == true }) "64" else "32"
val owned = if (old.products.any { it != old.id }) StoreNet.get("https://embed.gog.com/user/data/games", bearer = token)?.let { JSONObject(it).optJSONArray("owned")?.strings()?.toSet() } else emptySet()
val products = old.products.filter { it == old.id || owned?.contains(it) == true }
check(old.products.all { it == old.id || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" }
val products = installProducts(old.id, depots, old.products, token, ownership)
for (depot in depots.objects()) {
if (depot.has("redist")) continue
val ids = depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId", old.id))
val product = ids.firstOrNull { it in products } ?: continue
val product = depotProducts(depot, old.id).firstOrNull { it in products } ?: continue
val languages = depot.optJSONArray("languages")?.strings().orEmpty()
if (!languageSelected(languages, resolvedLanguage)) continue
val bitness = depot.optJSONArray("osBitness")?.strings().orEmpty()
@@ -108,7 +140,7 @@ internal object GogContent {
}
check(files.length() > 0) { "GOG build contains no matching files" }
return old.copy(source = "depot", generation = build.generation, build = build.id, version = build.version,
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products, verified = true)
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products.toList(), verified = true)
}
fun languageSelected(list: List<String>, selected: String): Boolean = list.any { it.equals("Neutral", true) } || StoreLanguage.depotMatchesResolved(list.toTypedArray(), selected)
@@ -125,211 +125,22 @@ public final class GogDownloadManager {
private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled, String language) {
Gen2Outcome out = new Gen2Outcome();
try {
cb.onProgress("Fetching manifest…", 5);
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, cb);
if (gm == null) { out.error = "gen2 manifest unavailable"; return out; }
JSONObject manifest = gm.manifest;
JSONArray depots = manifest.optJSONArray("depots");
if (depots == null) { out.error = "no depots in manifest"; return out; }
JSONArray products = manifest.optJSONArray("products");
String tempExe = null;
if (products != null && products.length() > 0) {
tempExe = products.getJSONObject(0).optString("temp_executable", null);
if (tempExe != null && tempExe.isEmpty()) tempExe = null;
}
String baseProductId = manifest.optString("baseProductId", null);
if ((baseProductId == null || baseProductId.isEmpty()) && products != null && products.length() > 0)
baseProductId = products.getJSONObject(0).optString("productId", null);
if (baseProductId == null || baseProductId.isEmpty()) baseProductId = game.gameId;
JSONArray deps = manifest.optJSONArray("dependencies");
List<String> dependencies = new ArrayList<>();
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) dependencies.add(d); }
if (!dependencies.isEmpty()) cb.onLog("gog: build lists dependencies " + deps);
cb.onProgress("Reading depot manifests…", 10);
List<DepotFile> files = new ArrayList<>();
List<String> depotJsons = new ArrayList<>();
JSONArray owned = new JSONArray();
List<JSONObject> ownedDepots = new ArrayList<>();
for (int i = 0; i < depots.length(); i++) {
JSONObject depot = depots.getJSONObject(i);
String depotPid = depot.optString("productId", "");
if (!depotPid.isEmpty() && !depotPid.equals(baseProductId)) continue;
owned.put(depot);
ownedDepots.add(depot);
}
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(owned), language);
cb.onLog("gog: language " + resolved);
for (int i = 0; i < ownedDepots.size(); i++) {
if (cancelled.get()) return out;
JSONObject depot = ownedDepots.get(i);
if (!depotSelected(depot, resolved)) continue;
String manifestHash = depot.optString("manifest");
if (manifestHash == null || manifestHash.isEmpty()) continue;
byte[] dmRaw = fetchBytes("https://gog-cdn-fastly.gog.com/content-system/v2/meta/" + buildCdnPath(manifestHash), null);
if (dmRaw == null) { cb.onLog("gog: depot " + i + " meta fetch failed"); continue; }
String dmStr = decompressBytes(dmRaw);
if (dmStr == null) { cb.onLog("gog: depot " + i + " decompress failed"); continue; }
parseDepotManifest(dmStr, files);
depotJsons.add(dmStr);
}
if (files.isEmpty()) { out.error = "no depot files collected"; return out; }
cb.onProgress("Fetching CDN link…", 15);
String secureLinkUrl = "https://content-system.gog.com/products/" + baseProductId + "/secure_link?_version=2&generation=2&path=/";
String cdnBase = parseCdnUrl(httpGet(secureLinkUrl, token));
if (cdnBase == null) { out.error = "secure link refused"; return out; }
installPath.mkdirs();
long plannedBytes = 0;
for (DepotFile df : files) plannedBytes += df.totalSize;
long remainingBytes = remainingInstallBytes(files, installPath);
if (remainingBytes > 0) {
long usable = com.droiddeck.launcher.stores.StoreInstallRoot.availableBytes(installPath);
if (com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(remainingBytes, usable)) {
out.hardStop = true;
out.error = "Not enough free space: need " + formatBytes(remainingBytes) + ", only " + formatBytes(usable) + " free";
return out;
}
}
Collections.sort(files, (a, b) -> Long.compare(b.totalSize, a.totalSize));
final int total = files.size();
final long planned = plannedBytes;
final AtomicInteger doneCount = new AtomicInteger(0);
final AtomicLong totalBytes = new AtomicLong(0);
final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
final AtomicLong lastSpeedB = new AtomicLong(0);
final AtomicLong speedBps = new AtomicLong(0);
final AtomicBoolean anyFailed = new AtomicBoolean(false);
final AtomicReference<String> cdnBaseRef = new AtomicReference<>(cdnBase);
final AtomicInteger cdnRefreshCount = new AtomicInteger(0);
final int MAX_CDN_REFRESH = 5;
cb.onSizes(planned, planned);
cb.onBytes(0, planned, 0);
cb.onLog("gog: " + total + " files, " + formatBytes(planned) + ", base=" + baseProductId);
int threads = downloadThreads();
if (GogNative.isAvailable()) {
runNativeEngine(ctx, GogNative.KIND_GEN2_CHUNKS, "Verified…", depotJsons, installPath, cdnBaseRef, secureLinkUrl, token,
cdnRefreshCount, MAX_CDN_REFRESH, threads, threads, true, "gog base=" + baseProductId,
cancelled, anyFailed, doneCount, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
} else {
cb.onLog("gog: engine=built-in (" + threads + " threads)");
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
List<Future<Void>> futures = new ArrayList<>();
for (DepotFile df : files) {
futures.add(pool.submit((Callable<Void>) () -> {
if (cancelled.get() || anyFailed.get()) return null;
File outFile = new File(installPath, df.relativePath);
outFile.getParentFile().mkdirs();
if (fileVerified(outFile, df.totalSize, df.md5)) {
int done = doneCount.incrementAndGet();
long tb = totalBytes.addAndGet(df.totalSize);
cb.onProgress("Verified…", 15 + (int) ((done / (float) total) * 80));
cb.onBytes(tb, planned, speedBps.get());
return null;
}
boolean ok = assembleDepotFile(df, outFile, cdnBaseRef, secureLinkUrl, token, cdnRefreshCount, MAX_CDN_REFRESH, cancelled, cb);
if (cancelled.get()) return null;
if (!ok) { cb.onLog("FAIL file=" + df.relativePath); anyFailed.set(true); return null; }
int done = doneCount.incrementAndGet();
long tb = totalBytes.addAndGet(df.totalSize);
sampleSpeed(tb, lastSpeedMs, lastSpeedB, speedBps);
cb.onProgress("Downloading: " + baseName(df.relativePath) + speedSuffix(speedBps.get()), 15 + (int) ((done / (float) total) * 80));
cb.onBytes(tb, planned, speedBps.get());
return null;
}));
}
pool.shutdown();
try {
for (Future<Void> f : futures) f.get();
} catch (Exception e) {
pool.shutdownNow();
out.error = "download pool error: " + e.getClass().getSimpleName();
return out;
}
}
if (cancelled.get()) return out;
if (anyFailed.get()) { out.error = "one or more chunks failed to download"; return out; }
cb.onProgress("Finishing…", 96);
try {
GogMaintenance.captureDepot(game.gameId, installPath, gm.buildId, buildsJson, manifest, new JSONArray(depotJsons), resolved);
} catch (Exception e) {
cb.onLog("gog: maintenance inventory not recorded: " + e.getMessage());
}
writeFile(new File(installPath, MARKER), ("{\"gameId\":\"" + game.gameId + "\",\"buildId\":\"" + gm.buildId + "\"}").getBytes("UTF-8"));
deleteCounted(new File(installPath, ".gog_chunks"), cb);
String clientId = manifest.optString("clientId", null);
if (clientId != null && !clientId.isEmpty()) GogPrefs.get(ctx).edit().putString("client_id_" + game.gameId, clientId).apply();
String clientSecret = manifest.optString("clientSecret", null);
if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply();
GameTask task = pickTask(installPath, game.gameId, tempExe, game.title);
out.result = new Result(task == null ? "" : task.path, gm.buildId, planned);
if (task != null) out.result.workDir = task.workDir;
out.result.dependencies = dependencies;
out.result.dependenciesRead = true;
out.result.language = resolved;
return out;
out.result = GogMaintenance.installContent(ctx, game, installPath, new JSONObject(buildsJson), language, cb, cancelled);
if (out.result == null && !cancelled.get()) out.error = "no usable public Windows build";
} catch (Exception e) {
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
return out;
}
}
private static final class Gen2Manifest {
final JSONObject manifest; final String buildId;
Gen2Manifest(JSONObject manifest, String buildId) { this.manifest = manifest; this.buildId = buildId; }
}
private static Gen2Manifest resolveGen2Manifest(String buildsJson, String token, Callback cb) {
try {
JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
if (items == null || items.length() == 0) { cb.onLog("gog: no gen2 builds"); return null; }
String buildId = null, manifestUrl = null;
for (int i = 0; i < items.length(); i++) {
JSONObject item = items.getJSONObject(i);
if ("windows".equals(item.optString("os")) && (item.isNull("branch") || item.optString("branch").isEmpty())) {
buildId = item.optString("build_id");
manifestUrl = item.optString("link");
if (manifestUrl == null || manifestUrl.isEmpty()) manifestUrl = item.optString("meta_url");
break;
}
}
if (buildId == null || manifestUrl == null || manifestUrl.isEmpty()) { cb.onLog("gog: no windows build"); return null; }
byte[] raw = fetchBytes(manifestUrl, token);
if (raw == null) { cb.onLog("gog: manifest fetch failed"); return null; }
String str = decompressBytes(raw);
if (str == null) { cb.onLog("gog: manifest decompress failed"); return null; }
return new Gen2Manifest(new JSONObject(str), buildId);
} catch (Exception e) {
cb.onLog("gog: manifest " + e.getClass().getSimpleName());
return null;
}
}
private static boolean depotSelected(JSONObject depot, String resolved) {
JSONArray languages = depot.optJSONArray("languages");
if (languages == null || languages.length() == 0) return true;
String[] listed = new String[languages.length()];
for (int i = 0; i < languages.length(); i++) listed[i] = languages.optString(i, "");
return StoreLanguage.depotMatchesResolved(listed, resolved);
return out;
}
public static List<String> listLanguages(Context ctx, String gameId) {
try {
String token = GogAuth.validToken(ctx);
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
String buildsJson = httpGet(buildsUrl, null);
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
if (buildsJson == null) return Collections.emptyList();
Callback silent = new Callback() {
@Override public void onProgress(String message, int pct) {}
};
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, silent);
if (gm == null) return Collections.emptyList();
return StoreLanguage.codesFromDepots(gm.manifest.optJSONArray("depots"));
JSONObject builds = GogContent.builds(gameId, token);
GogBuild build = GogBuild.Companion.select(builds, "", "");
if (build == null || build.getGeneration() != 2) return Collections.emptyList();
return StoreLanguage.codesFromDepots(GogContent.json(build.getLink(), token).optJSONArray("depots"));
} catch (Exception e) {
return Collections.emptyList();
}
@@ -398,7 +209,7 @@ public final class GogDownloadManager {
int threads = downloadThreads();
if (GogNative.isAvailable()) {
runNativeEngine(ctx, GogNative.KIND_GEN1_RANGES, "Resuming…", Collections.singletonList(manifestStr), installPath,
new AtomicReference<>(""), null, null, new AtomicInteger(0), 0, threads, threads, false, "gog gen1=" + game.gameId,
threads, threads, false, "gog gen1=" + game.gameId,
cancelled, anyFailed, done, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
} else {
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
@@ -659,8 +470,7 @@ public final class GogDownloadManager {
private static void runNativeEngine(Context ctx, int kind, String verifiedMsg, List<String> depotJsons, File installPath,
AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
AtomicInteger cdnRefreshCount, int maxCdnRefresh, int maxWorkers, int processWorkers,
int maxWorkers, int processWorkers,
boolean sortLargestFirst, String label, AtomicBoolean cancelled, AtomicBoolean anyFailed,
AtomicInteger doneCount, int total, long planned, AtomicLong totalBytes,
AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps, Callback cb) {
@@ -673,10 +483,8 @@ public final class GogDownloadManager {
int run = 0;
while (!cancelled.get()) {
run++;
final String base = cdnBaseRef.get();
final CountDownLatch latch = new CountDownLatch(1);
final AtomicBoolean okRef = new AtomicBoolean(false);
final AtomicBoolean linkExpiryRef = new AtomicBoolean(false);
final AtomicReference<String> errRef = new AtomicReference<>("");
final long runStart = totalBytes.get();
final AtomicLong verifiedInRun = new AtomicLong(0);
@@ -702,12 +510,10 @@ public final class GogDownloadManager {
@Override public void onLog(String line) { cb.onLog(line); }
@Override public void onMaintenance(long networkBytes, long reusedBytes, long appliedBytes) {}
@Override public void onComplete(boolean success, boolean wasCancelled, boolean linkExpiry, String error, long bytesWritten, int filesDone) {
okRef.set(success); linkExpiryRef.set(linkExpiry); errRef.set(error == null ? "" : error); latch.countDown();
okRef.set(success); errRef.set(error == null ? "" : error); latch.countDown();
}
};
String[] bases = (base == null || base.isEmpty()) ? new String[0] : base.split("\n");
if (run == 1) cb.onLog("gog: " + bases.length + " CDN host(s) offered");
long handle = GogNative.start(kind, manifests, bases, installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
long handle = GogNative.start(kind, manifests, new String[0], installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
workers, process, sortLargestFirst, label + " run=" + run, listener);
if (handle == 0L) { cb.onLog("gog: native start failed"); anyFailed.set(true); return; }
boolean interrupted = false;
@@ -721,85 +527,13 @@ public final class GogDownloadManager {
}
if (interrupted) { Thread.currentThread().interrupt(); anyFailed.set(true); return; }
if (cancelled.get() || okRef.get()) return;
String err = errRef.get();
if (linkExpiryRef.get() && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) {
cb.onLog("gog: secure link expired (" + err + "); refreshed, running again for the remaining files");
continue;
}
cb.onLog("gog: engine failed: " + err);
cb.onLog("gog: engine failed: " + errRef.get());
anyFailed.set(true);
return;
}
}
private static void parseDepotManifest(String json, List<DepotFile> out) {
try {
JSONObject depotObj = new JSONObject(json).optJSONObject("depot");
if (depotObj == null) return;
JSONArray depot = depotObj.optJSONArray("items");
if (depot == null) return;
for (int i = 0; i < depot.length(); i++) {
JSONObject entry = depot.getJSONObject(i);
String path = entry.optString("path", "").replace("\\", "/");
if (path.startsWith("/")) path = path.substring(1);
JSONArray chunks = entry.optJSONArray("chunks");
if (path.isEmpty() || chunks == null || chunks.length() == 0) continue;
DepotFile df = new DepotFile(path);
String fileMd5 = entry.optString("md5", null);
if (fileMd5 != null && !fileMd5.isEmpty()) df.md5 = fileMd5;
long fileTotal = 0;
for (int c = 0; c < chunks.length(); c++) {
JSONObject chunk = chunks.getJSONObject(c);
String compressedMd5 = chunk.optString("compressedMd5"), decMd5 = chunk.optString("md5");
String hash = (compressedMd5 != null && !compressedMd5.isEmpty()) ? compressedMd5 : decMd5;
if (hash == null || hash.isEmpty()) continue;
long decSize = chunk.optLong("size", 0);
df.chunks.add(new DepotFile.ChunkRef(hash, compressedMd5, decMd5, chunk.optLong("compressedSize", 0), decSize));
fileTotal += decSize;
}
df.totalSize = fileTotal;
if (!df.chunks.isEmpty()) out.add(df);
}
} catch (Exception e) {
Log.w(TAG, "depot manifest parse: " + e.getClass().getSimpleName());
}
}
static String buildCdnPath(String hash) { return hash.substring(0, 2) + "/" + hash.substring(2, 4) + "/" + hash; }
private static boolean assembleDepotFile(DepotFile df, File outFile, AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
File tmpFile = new File(outFile.getAbsolutePath() + ".bhtmp");
File parent = outFile.getParentFile();
if (parent != null) parent.mkdirs();
tmpFile.delete();
boolean ok = true;
try (FileOutputStream fos = new FileOutputStream(tmpFile)) {
for (DepotFile.ChunkRef chunk : df.chunks) {
if (cancelled.get()) { ok = false; break; }
byte[] inflated = fetchChunkVerified(cdnBaseRef, buildCdnPath(chunk.hash), chunk, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cancelled, cb);
if (inflated == null) { ok = false; break; }
fos.write(inflated);
}
} catch (Exception e) {
ok = false;
}
if (!ok || cancelled.get()) { tmpFile.delete(); return false; }
if (df.totalSize > 0 && tmpFile.length() != df.totalSize) { cb.onLog("size mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
if (df.md5 != null && !df.md5.isEmpty()) {
String actual = md5HexFile(tmpFile);
if (actual == null || !actual.equalsIgnoreCase(df.md5)) { cb.onLog("md5 mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
}
if (outFile.exists()) outFile.delete();
return tmpFile.renameTo(outFile);
}
private static String parseCdnUrl(String json) {
java.util.List<String> all = parseCdnUrls(json);
return all.isEmpty() ? null : String.join("\n", all);
}
static java.util.List<String> parseCdnUrls(String json) {
java.util.List<String> out = new ArrayList<>();
if (json == null) return out;
@@ -824,18 +558,6 @@ public final class GogDownloadManager {
return out;
}
private static final AtomicInteger CDN_TURN = new AtomicInteger();
static String pickCdn(String set) {
String[] all = set.split("\n");
return all[Math.floorMod(CDN_TURN.getAndIncrement(), all.length)];
}
static String firstCdn(String set) {
int nl = set.indexOf('\n');
return nl < 0 ? set : set.substring(0, nl);
}
private static boolean downloadRange(String url, long offset, long size, File out) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
@@ -951,15 +673,6 @@ public final class GogDownloadManager {
}
}
private static String buildChunkUrl(String base, String chunkPath) {
int q = base.indexOf('?');
return q >= 0 ? base.substring(0, q) + "/" + chunkPath + base.substring(q) : base + "/" + chunkPath;
}
private static String md5Hex(byte[] data) {
try { return toHex(java.security.MessageDigest.getInstance("MD5").digest(data)); } catch (Exception e) { return null; }
}
private static String md5HexFile(File f) {
try (FileInputStream fis = new FileInputStream(f)) {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
@@ -988,58 +701,6 @@ public final class GogDownloadManager {
return true;
}
private static void sleepBackoff(int attempt) {
try { Thread.sleep(Math.min(1000L << Math.max(0, attempt - 1), 8000L)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
}
private static boolean tryRefreshCdn(AtomicReference<String> cdnBaseRef, String staleBase, String secureLinkUrl, String token,
AtomicInteger refreshCount, int cap, Callback cb) {
if (secureLinkUrl == null) return false;
synchronized (cdnBaseRef) {
String current = cdnBaseRef.get();
if (staleBase == null || !staleBase.equals(current)) return true;
if (refreshCount.get() >= cap) { cb.onLog("gog: CDN refresh cap reached (" + cap + ")"); return false; }
String neu = parseCdnUrl(httpGet(secureLinkUrl, token));
if (neu == null || neu.isEmpty()) { cb.onLog("gog: CDN refresh failed"); return false; }
cdnBaseRef.set(neu);
cb.onLog("gog: secure link refreshed #" + refreshCount.incrementAndGet());
return true;
}
}
private static byte[] fetchChunkVerified(AtomicReference<String> cdnBaseRef, String chunkPath, DepotFile.ChunkRef chunk, String secureLinkUrl, String token,
AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
int hardFail = 0, guard = 0;
while (guard++ < 8) {
if (cancelled.get()) return null;
String base = cdnBaseRef.get();
HttpResult res = fetchBytesEx(buildChunkUrl(pickCdn(base), chunkPath), null);
if (res.body == null) {
int code = res.status;
if ((code == 401 || code == 403 || code == 404 || code == 500) && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) continue;
cb.onLog("chunk http fail code=" + code);
if (++hardFail >= 3) return null;
sleepBackoff(hardFail);
continue;
}
byte[] raw = res.body;
if (chunk.compressedSize > 0 && raw.length != chunk.compressedSize) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
if (chunk.compressedMd5 != null && !chunk.compressedMd5.isEmpty()) {
String actual = md5Hex(raw);
if (actual == null || !actual.equalsIgnoreCase(chunk.compressedMd5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
}
byte[] inflated = inflateZlib(raw);
if (inflated == null) inflated = raw;
if (chunk.size > 0 && inflated.length != chunk.size) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
if (chunk.md5 != null && !chunk.md5.isEmpty()) {
String actual = md5Hex(inflated);
if (actual == null || !actual.equalsIgnoreCase(chunk.md5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
}
return inflated;
}
return null;
}
private static void sampleSpeed(long totalBytes, AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps) {
long nowMs = System.currentTimeMillis(), prevMs = lastSpeedMs.get();
if (nowMs - prevMs >= 500 && lastSpeedMs.compareAndSet(prevMs, nowMs)) {
@@ -1289,30 +950,6 @@ public final class GogDownloadManager {
for (File f : files) if (f.isDirectory()) collectExeRecursive(f, out);
}
static long remainingInstallBytes(List<DepotFile> files, File installPath) {
long remaining = 0;
for (DepotFile file : files) {
File output = new File(installPath, file.relativePath);
if (!fileVerified(output, file.totalSize, file.md5)) remaining += file.totalSize;
}
return remaining;
}
static final class DepotFile {
final String relativePath;
final List<ChunkRef> chunks = new ArrayList<>();
String md5;
long totalSize;
DepotFile(String relativePath) { this.relativePath = relativePath; }
static final class ChunkRef {
final String hash, compressedMd5, md5;
final long compressedSize, size;
ChunkRef(String hash, String compressedMd5, String md5, long compressedSize, long size) {
this.hash = hash; this.compressedMd5 = compressedMd5; this.md5 = md5; this.compressedSize = compressedSize; this.size = size;
}
}
}
private static final class Gen1File {
final String path, url; final long offset, size;
Gen1File(String path, String url, long offset, long size) { this.path = path; this.url = url; this.offset = offset; this.size = size; }
@@ -1382,7 +1019,11 @@ public final class GogDownloadManager {
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), StoreLanguage.preferredCode());
for (int i = 0; i < depots.length(); i++) {
JSONObject depot = depots.getJSONObject(i);
if (depotSelected(depot, resolved)) total += depot.optLong("size", 0);
JSONArray languages = depot.optJSONArray("languages");
String[] listed = new String[languages == null ? 0 : languages.length()];
for (int j = 0; j < listed.length; j++) listed[j] = languages.optString(j, "");
if (listed.length == 0 || StoreLanguage.depotMatchesResolved(listed, resolved))
total += depot.optLong("size", 0);
}
return total > 0 ? total : -1;
} catch (Exception e) {
@@ -58,16 +58,17 @@ object GogLibrary {
val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn
onStatus(app.getString(R.string.stores_sync_game_list))
val listing = ownedListing(token)
val account = ownedIds(token)
val listed: List<GogGame> = listing?.games
?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
?: account?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") }
?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable))
val named = listing != null
StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids")
val ownedSet = listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }
val cachedList = cached(app)
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
val ownedSet = ownedSet(listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }, account, cachedIds)
StoresState.logLine(if (named) "GOG: listing ${listed.size} games, ${ownedSet.size} owned ids" else "GOG: listing unreadable, ${listed.size} owned ids")
val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS
val heavy = force || cachedList.isEmpty() || stale
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty())
val idsToFetch = if (heavy) ownedSet.toList() else ownedSet.filter { it !in cachedIds && it !in skipped }
@@ -133,6 +134,9 @@ object GogLibrary {
return all.ifEmpty { null }?.let { Listing(it, owned) }
}
fun ownedSet(listed: Set<String>, account: List<String>?, cached: Set<String> = emptySet()): Set<String> =
if (account == null) listed + cached else listed + account
private fun ownedIds(token: String): List<String>? {
val json = StoreNet.get(OWNED_URL, bearer = token, userAgent = GogAuth.GALAXY_UA) ?: return null
val owned = runCatching { JSONObject(json).optJSONArray("owned") }.getOrNull() ?: return null
@@ -377,16 +377,24 @@ internal object GogMaintenance {
val build = GogBuild.select(builds) ?: return null
folder.mkdirs()
val old = GogInstallRecord(game.gameId, "depot", language = language)
val target = GogContent.snapshot(old, build, GogContent.token(context))
var credentials: Pair<String, String>? = null
val target = GogContent.snapshot(old, build, GogContent.token(context), onManifest = { manifest ->
credentials = GogContent.clientCredentials(manifest)
})
GogFiles.validate(target.files, folder)
val bytes = target.files.objects().sumOf { it.optJSONArray("chunks")?.objects()?.sumOf { c -> c.optLong("compressedSize") }?.takeIf { n -> n > 0 } ?: it.optLong("size") }
val disk = target.files.objects().sumOf { it.optLong("size") }
check(!StoreInstallRoot.insufficientSpace(disk, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" }
val remaining = GogEstimate.calculate(folder, old, target, JSONArray(), reuseChunks = GogNative.isAvailable()).required
check(!StoreInstallRoot.insufficientSpace(remaining, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" }
callback.onSizes(bytes, disk)
transfer(context, Proposal(folder, old, target, GogOperation.UPDATE, bytes, disk), null, cancelled, callback)
if (cancelled.get()) return null
target.write(folder)
val task = GogDownloadManager.pickTask(folder, game.gameId, null, game.title)
credentials?.let { (clientId, clientSecret) ->
GogPrefs.get(context).edit().putString("client_id_${game.gameId}", clientId)
.putString("client_secret_${game.gameId}", clientSecret).apply()
}
val task = GogDownloadManager.pickTask(folder, game.gameId, GogContent.tempExecutable(target.manifest), game.title)
val result = GogDownloadManager.Result(task?.path ?: "", build.id, disk)
result.workDir = task?.workDir
result.language = target.language
@@ -395,20 +403,6 @@ internal object GogMaintenance {
return result
}
@JvmStatic
fun captureDepot(id: String, folder: File, build: String, buildsJson: String, manifest: JSONObject, depots: JSONArray, language: String) {
val selected = GogBuild.select(JSONObject(buildsJson), pinned = build)
val files = JSONArray()
for (depot in depots.strings()) for (item in JSONObject(depot).getJSONObject("depot").getJSONArray("items").objects()) {
val chunks = item.optJSONArray("chunks") ?: continue
files.put(JSONObject().put("path", item.getString("path").replace('\\', '/')).put("size", chunks.objects().sumOf { it.optLong("size") })
.put("md5", item.optString("md5")).put("chunks", chunks).put("product", id))
}
GogFiles.validate(files, folder)
GogInstallRecord(id, "depot", 2, build, selected?.version.orEmpty(), selected?.branch.orEmpty(), language,
manifest = GogContent.sanitize(manifest), files = files).write(folder)
}
@JvmStatic
fun captureOffline(id: String, folder: File, downloads: JSONObject) {
val selected = GogOfflineDownload.parse(downloads, "installers").firstOrNull() ?: return
@@ -6,8 +6,6 @@ import com.droiddeck.launcher.stores.StoresNative
object GogNative {
private const val TAG = "GogNative"
const val KIND_GEN2_CHUNKS = 0
const val KIND_GEN1_RANGES = 1
interface Listener {
@@ -0,0 +1,74 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.stores.StoresState
import org.json.JSONObject
import java.io.File
/**
* Answers Denuvo activations for a running Ubisoft game. droiddeck-upc.exe writes the challenge
* the game's aux DLL sends to `bridge-<product>/denuvo-<id>.req`; the answer goes to `.rsp`.
*/
object UbisoftActivation {
private const val POLL_MS = 250L
private const val IDLE_MS = 60_000L
private val watching = HashMap<Long, Long>()
fun bridge(context: Context, product: Long) = File(LinuxRuntime.rootDir(context), "root/.cache/droiddeck/ubisoft/bridge-$product")
/** Starts answering the product's activations; stale requests are cleared before this returns. */
fun watch(app: Context, product: Long) {
val dir = bridge(app, product)
synchronized(watching) {
val running = watching.containsKey(product)
watching[product] = System.currentTimeMillis()
if (running) return
dir.mkdirs()
dir.listFiles()?.forEach { it.delete() }
}
Thread({
var idleSince = 0L
while (true) {
runCatching { dir.listFiles { f -> f.name.startsWith("denuvo-") && f.name.endsWith(".req") }?.forEach { answer(app, product, it) } }
.onFailure { StoresState.logLine("Ubisoft: activation for $product: ${it.javaClass.simpleName}") }
val now = System.currentTimeMillis()
if (SessionState.running) idleSince = 0L
else if (idleSince == 0L) idleSince = now
else if (now - idleSince > IDLE_MS) synchronized(watching) {
if (now - (watching[product] ?: 0L) > IDLE_MS) { watching.remove(product); return@Thread }
}
runCatching { Thread.sleep(POLL_MS) }
}
}, "ubisoft-activation-$product").start()
}
private fun answer(app: Context, product: Long, request: File) {
val challenge = runCatching { request.readBytes() }.getOrNull() ?: return
request.delete()
val reply = File(request.parentFile, request.name.removeSuffix(".req") + ".rsp")
val token = activate(app, product, challenge)
val part = File(reply.path + ".part")
part.writeBytes(token ?: ByteArray(0))
part.renameTo(reply)
}
private fun activate(app: Context, product: Long, challenge: ByteArray): ByteArray? {
val s = UbisoftAuth.current(app) ?: return null.also { StoresState.logLine("Ubisoft: activation for $product: signed out") }
val answer = UbisoftNative.call("denuvo", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId)
.put("productId", product).put("challenge", hex(challenge)))
answer?.optString("error")?.takeIf { it.isNotEmpty() }?.let {
StoresState.logLine("Ubisoft: activation for $product: $it")
return null
}
val token = answer?.optString("token")?.takeIf { it.isNotEmpty() }?.let(::unhex)
StoresState.logLine("Ubisoft: activation for $product: ${token?.size ?: 0} B game token")
return token
}
internal fun hex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) }
internal fun unhex(s: String): ByteArray? =
if (s.length % 2 != 0) null else runCatching { ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() } }.getOrNull()
}
@@ -0,0 +1,175 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAccounts
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/** UbiServices sessions: a ticket from Ubisoft's own web sign-in, exchanged for a launcher session that remember-me renews. */
object UbisoftAuth {
private const val TAG = "UbisoftAuth"
const val SESSIONS = "https://public-ubiservices.ubi.com/v3/profiles/sessions"
/** The web app and genome connect.ubisoft.com's sign-in page accepts. */
private const val WEB_APP_ID = "f35adcb5-1911-440c-b1c9-48fdc1701c68"
private const val WEB_GENOME_ID = "5b36b900-65d8-47f3-93c8-86bdaa48ab50"
/** The Ubisoft Connect PC app id; its sessions are accepted by the library GraphQL and Demux. */
const val APP_ID = "f68a4bb5-608a-4ff2-8123-be8ef797e0a6"
private const val REFRESH_MARGIN_MS = 10 * 60 * 1000L
private val lock = Any()
class Session(
val ticket: String,
val sessionId: String,
val profileId: String,
val userId: String,
val name: String,
val expiresAt: Long,
val rememberMe: String,
)
class Reply(val status: Int, val body: String, val headers: Map<String, String>) {
val ok get() = status in 200..299
}
private val loggedIn = "https://connect.ubisoft.com/logged-in.html?appId=$WEB_APP_ID&lang=en-US&genomeId=$WEB_GENOME_ID"
val loginUrl = "https://connect.ubisoft.com/login?appId=$WEB_APP_ID&genomeId=$WEB_GENOME_ID&lang=en-US&nextUrl=" + URLEncoder.encode(loggedIn, "UTF-8")
val SIGN_IN_URLS = listOf("https://connect.ubisoft.com", "https://account.ubisoft.com", "https://ubisoft.com")
fun isLoggedInPage(url: String) = url.startsWith("https://connect.ubisoft.com/logged-in.html")
/** Reads connect.ubisoft.com's stored sign-in; the WebAuth SDK keeps it under keys holding `loginData` and `rememberMe`. */
const val PAGE_SCRIPT = "(function(){try{var o={};for(var i=0;i<localStorage.length;i++){var k=localStorage.key(i);" +
"if(/loginData|rememberMe|lastProfile/i.test(k))o[k]=localStorage.getItem(k);}return JSON.stringify(o);}catch(e){return '';}})()"
internal fun request(url: String, method: String = "GET", authorization: String? = null, sessionId: String? = null, body: String? = null, extra: Map<String, String> = emptyMap()): Reply {
val c = URL(url).openConnection() as HttpURLConnection
c.requestMethod = method
c.connectTimeout = 15_000
c.readTimeout = 25_000
c.setRequestProperty("Ubi-AppId", APP_ID)
c.setRequestProperty("Ubi-RequestedPlatformType", "uplay")
c.setRequestProperty("Content-Type", "application/json")
c.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
authorization?.let { c.setRequestProperty("Authorization", it) }
sessionId?.let { c.setRequestProperty("Ubi-SessionId", it) }
for ((k, v) in extra) c.setRequestProperty(k, v)
try {
if (body != null) {
c.doOutput = true
c.outputStream.use { it.write(body.toByteArray()) }
}
val status = c.responseCode
val text = (if (status in 200..299) c.inputStream else c.errorStream)?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty()
val headers = c.headerFields.filterKeys { it != null }.mapKeys { it.key.lowercase() }.mapValues { it.value.joinToString(",") }
return Reply(status, text, headers)
} finally {
c.disconnect()
}
}
/** POSTs a session request, replaying it once when UbiServices answers with a challenge. */
private fun session(authorization: String): Reply {
val body = JSONObject().put("rememberMe", true).toString()
val first = request(SESSIONS, "POST", authorization, body = body)
val challenge = first.headers["ubi-challenge"] ?: return first
return request(SESSIONS, "POST", authorization, body = body, extra = mapOf("Ubi-Challenge" to challenge))
}
internal fun parseSession(body: String, rememberFallback: String = ""): Session? {
val o = runCatching { JSONObject(body) }.getOrNull() ?: return null
val ticket = o.optString("ticket").takeIf { it.isNotEmpty() && it != "null" } ?: return null
return Session(
ticket = ticket,
sessionId = o.optString("sessionId"),
profileId = o.optString("profileId"),
userId = o.optString("userId"),
name = o.optString("nameOnPlatform"),
expiresAt = parseTime(o.optString("expiration")),
rememberMe = o.optString("rememberMeTicket").takeIf { it.isNotEmpty() && it != "null" } ?: rememberFallback,
)
}
internal fun parseTime(s: String): Long = runCatching { java.time.OffsetDateTime.parse(s).toInstant().toEpochMilli() }
.recoverCatching { java.time.Instant.parse(s).toEpochMilli() }.getOrDefault(System.currentTimeMillis() + 60 * 60 * 1000L)
/** Values the web sign-in stored, found wherever the SDK nests them. */
internal fun webValues(dump: String): Map<String, String> {
val out = HashMap<String, String>()
fun walk(v: Any?) {
when (v) {
is JSONObject -> v.keys().forEach { k ->
val x = v.opt(k)
if (x is String && x.isNotEmpty() && k !in out) {
val nested = if (x.startsWith("{") || x.startsWith("[")) runCatching { org.json.JSONTokener(x).nextValue() }.getOrNull() else null
if (nested != null) walk(nested) else out[k] = x
} else walk(x)
}
is JSONArray -> for (i in 0 until v.length()) walk(v.opt(i))
}
}
val top = runCatching { org.json.JSONTokener(dump).nextValue() }.getOrNull()
walk(if (top is String) runCatching { JSONObject(top) }.getOrNull() else top)
return out
}
/** Turns the page's stored sign-in into a launcher session; returns null on success or a reason. */
fun completeWebSignIn(context: Context, dump: String): String? {
val web = webValues(dump)
val ticket = web["ticket"]
val remember = web["rememberMeTicket"].orEmpty()
val fresh = when {
ticket != null -> session("ubi_v1 t=$ticket").let { r ->
parseSession(r.body, remember) ?: run { Log.w(TAG, "ticket exchange: ${r.status} ${r.body.take(200)}"); null }
}
else -> null
} ?: remember.takeIf { it.isNotEmpty() }?.let { rm -> parseSession(session("rm_v1 t=$rm").body, rm) }
?: return "no ticket"
save(context, fresh)
return null
}
private fun save(context: Context, s: Session) {
val name = s.name.ifEmpty { profileName(s) }
StoreAccounts.write(context.applicationContext, Store.UBISOFT, JSONObject()
.put("access_token", s.ticket)
.put("session_id", s.sessionId)
.put("profile_id", s.profileId)
.put("user_id", s.userId)
.put("display_name", name)
.put("expires_at", s.expiresAt)
.put("remember_me", s.rememberMe))
}
private fun profileName(s: Session): String = runCatching {
JSONObject(request("https://public-ubiservices.ubi.com/v3/profiles/me", authorization = "ubi_v1 t=${s.ticket}", sessionId = s.sessionId).body).optString("nameOnPlatform")
}.getOrDefault("")
fun load(context: Context): Session? {
val o = StoreAccounts.read(context.applicationContext, Store.UBISOFT) ?: return null
val ticket = o.optString("access_token").takeIf { it.isNotEmpty() } ?: return null
return Session(ticket, o.optString("session_id"), o.optString("profile_id"), o.optString("user_id"), o.optString("display_name"), o.optLong("expires_at"), o.optString("remember_me"))
}
/** A live session, renewed through remember-me shortly before it expires. */
fun current(context: Context): Session? = synchronized(lock) {
val s = load(context) ?: return null
if (s.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || s.rememberMe.isEmpty()) return s
val renewed = runCatching { session("rm_v1 t=${s.rememberMe}") }.getOrNull()?.let { parseSession(it.body, s.rememberMe) }
if (renewed == null) {
Log.w(TAG, "remember-me renewal failed")
return if (s.expiresAt > System.currentTimeMillis()) s else null
}
save(context, Session(renewed.ticket, renewed.sessionId, renewed.profileId.ifEmpty { s.profileId }, renewed.userId.ifEmpty { s.userId },
renewed.name.ifEmpty { s.name }, renewed.expiresAt, renewed.rememberMe))
load(context)
}
fun signOut(context: Context) = synchronized(lock) { StoreAccounts.clear(context.applicationContext, Store.UBISOFT) }
}
@@ -0,0 +1,246 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import android.content.Intent
import android.util.Log
import android.webkit.WebView
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.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.WebCookies
import org.json.JSONObject
object UbisoftBackend : StoreBackend {
override val store: Store = Store.UBISOFT
init {
LoginFlows.ubisoftFlow = { ua -> UbisoftLogin(ua) }
}
override fun signIn(context: Context) {
context.startActivity(
Intent(context, StoreLoginActivity::class.java)
.putExtra(StoreLoginActivity.EXTRA_STORE, store.id)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
override fun signOut(context: Context) {
UbisoftAuth.signOut(context.applicationContext)
UbisoftLibrary.reset()
runCatching { WebCookies.expire(UbisoftAuth.SIGN_IN_URLS, ".ubisoft.com", listOf("/")) {} }
}
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
Thread({ UbisoftLibrary.sync(app, force) }, "ubisoft-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {}
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
val app = context.applicationContext
item.extra[STEAM_APP]?.takeIf { item.extra["productId"] == null }?.let { steam ->
StoresState.logLine("Ubisoft: ${item.title} is owned on Steam; installing Steam app $steam")
installInSteam(app, steam)
return
}
if (!UbisoftNative.ready()) {
StoresState.logLine(UbisoftNative.ENGINE_MISSING)
StoresState.post { StoresState.problems[store] = UbisoftNative.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) { UbisoftInstaller(app, item, folder) }
}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
const val STEAM_APP = "steamAppId"
/** Steam asks to install a game it does not have when told to run it. */
private fun installInSteam(app: Context, steamAppId: String) {
if (com.droiddeck.launcher.session.SessionState.running) {
if (!com.droiddeck.launcher.session.SessionService.launchGame(app, steamAppId)) StoresState.logLine("Ubisoft: Steam is busy; install Steam app $steamAppId there")
return
}
app.startActivity(Intent(app, com.droiddeck.launcher.SessionActivity::class.java)
.putExtra(com.droiddeck.launcher.session.SessionService.EXTRA_STEAM_URL, com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(steamAppId))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
internal class UbisoftLogin(override val userAgent: String) : StoreLoginActivity.Flow {
override val startUrl: String get() = UbisoftAuth.loginUrl
override val readsPage: Boolean get() = true
override val pageScript: String get() = UbisoftAuth.PAGE_SCRIPT
override fun pageReady(result: String): Boolean = result.contains("ticket", ignoreCase = true)
override fun isRedirect(url: String): Boolean = UbisoftAuth.isLoggedInPage(url)
override fun forgetSignIn(done: () -> Unit) {
runCatching { WebCookies.expire(UbisoftAuth.SIGN_IN_URLS, ".ubisoft.com", listOf("/")) { done() } }.onFailure { done() }
}
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? = try {
UbisoftAuth.completeWebSignIn(activity, url)?.let {
Log.w("UbisoftLogin", "sign-in incomplete: $it")
activity.getString(R.string.stores_login_error_verification)
}
} catch (e: Exception) {
Log.w("UbisoftLogin", "sign-in failed: ${e.javaClass.simpleName}")
activity.getString(R.string.stores_login_error_generic)
}
}
object UbisoftLibrary {
private const val GRAPHQL = "https://public-ubiservices.ubi.com/v1/profiles/me/uplay/graphql"
private const val PAGE = 50
private const val QUERY = "query OwnedGames(\$limit: Int, \$offset: Int) { viewer { games(limit: \$limit, offset: \$offset) { totalCount nodes { id spaceId name coverUrl backgroundUrl bannerUrl releaseDate } } } }"
private val generation = java.util.concurrent.atomic.AtomicInteger()
private val running = java.util.concurrent.atomic.AtomicReference<Int?>(null)
/** Forgets the signed-out account: its sync results are dropped and a new account may sync at once. */
fun reset() {
generation.incrementAndGet()
StoresState.post { StoresState.shelves.remove(Store.UBISOFT) }
}
private fun post(gen: Int, block: () -> Unit) = StoresState.post { if (generation.get() == gen) block() }
internal fun parsePage(body: String): Pair<List<CatalogItem>, Int> {
val o = JSONObject(body)
o.optJSONArray("errors")?.optJSONObject(0)?.let { throw IllegalStateException(it.optString("message")) }
val games = o.optJSONObject("data")?.optJSONObject("viewer")?.optJSONObject("games") ?: return emptyList<CatalogItem>() to 0
val nodes = games.optJSONArray("nodes")
val items = (0 until (nodes?.length() ?: 0)).mapNotNull { nodes!!.optJSONObject(it) }.mapNotNull { n ->
val space = n.optString("spaceId").takeIf { it.isNotEmpty() } ?: return@mapNotNull null
fun s(k: String) = n.optString(k).takeIf { it.isNotEmpty() && it != "null" }
CatalogItem(
Store.UBISOFT, space, n.optString("name").trim(), s("backgroundUrl") ?: s("bannerUrl"),
tallImageUrl = s("coverUrl"), releaseDate = s("releaseDate")?.take(10).orEmpty(), owned = true,
extra = mapOf("spaceId" to space, "gameId" to n.optString("id")),
)
}
return items to games.optInt("totalCount", items.size)
}
/** Base games with a manifest, by UbiServices space; add-ons and bundles share spaces with their game. */
internal fun ownedBySpace(owned: JSONObject?): Map<String, JSONObject> {
val list = owned?.optJSONArray("games") ?: return emptyMap()
val games = (0 until list.length()).mapNotNull { list.optJSONObject(it) }
.filter { it.optString("manifest").isNotEmpty() && it.optInt("productType") == 0 && it.optBoolean("owned", true) }
val out = LinkedHashMap<String, JSONObject>()
for (key in listOf("spaceId")) for (g in games) {
val space = g.optString(key).takeIf { it.isNotEmpty() } ?: continue
out.putIfAbsent(space, g)
}
return out
}
private val STEAM_ID = Regex("""^\s*steam_app_id:\s*['"]?(\d+)""", RegexOption.MULTILINE)
private val THIRD_PARTY = Regex("""^\s*third_party_platform:\s*\r?\n\s+name:\s*['"]?Steam\b""", setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))
/** The Steam app of a product Ubisoft says is owned and installed through Steam. */
internal fun steamApp(configuration: String): String? =
if (THIRD_PARTY.containsMatchIn(configuration)) STEAM_ID.find(configuration)?.groupValues?.get(1) else null
/** Owned games Ubisoft delivers through Steam, by UbiServices space. */
internal fun steamBySpace(owned: JSONObject?): Map<String, String> {
val list = owned?.optJSONArray("games") ?: return emptyMap()
return (0 until list.length()).mapNotNull { list.optJSONObject(it) }
.filter { it.optInt("productType") == 0 && it.optBoolean("owned", true) && it.optString("spaceId").isNotEmpty() }
.mapNotNull { o -> steamApp(o.optString("configuration"))?.let { o.optString("spaceId") to it } }.toMap()
}
private val RELATIVE = Regex("""relative:\s*['"]?([^'"\r\n]+?)['"]?\s*$""", RegexOption.MULTILINE)
private val NAME = Regex("""^\s{1,4}name:\s*['"]?(.+?)['"]?\s*$""", RegexOption.MULTILINE)
/** The product's name from its configuration, following a localisation key such as `l1` to its default text. */
internal fun title(configuration: String): String? {
val raw = NAME.find(configuration)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
if (!Regex("""l\d+""").matches(raw)) return raw
val local = configuration.indexOf("localizations:").takeIf { it >= 0 } ?: return raw
return Regex("""^\s+${raw}:\s*['"]?(.+?)['"]?\s*$""", RegexOption.MULTILINE).find(configuration, local)?.groupValues?.get(1)?.trim() ?: raw
}
/** The first launch path under `start_game` in a product's configuration. */
internal fun executable(configuration: String): String? {
val start = configuration.indexOf("start_game").takeIf { it >= 0 } ?: return null
return RELATIVE.find(configuration, start)?.groupValues?.get(1)?.trim()?.takeIf { it.endsWith(".exe", true) }
}
fun sync(app: Context, force: Boolean) {
val gen = generation.get()
if (running.getAndSet(gen) == gen) return
try {
val s = UbisoftAuth.current(app)
if (s == null) {
post(gen) { StoresState.status.remove(Store.UBISOFT); StoresState.problems[Store.UBISOFT] = app.getString(R.string.stores_sync_sign_in) }
return
}
if (StoresState.library[Store.UBISOFT].isNullOrEmpty()) post(gen) { StoresState.status[Store.UBISOFT] = app.getString(R.string.stores_sync_game_list) }
val all = ArrayList<CatalogItem>()
var offset = 0
while (true) {
val body = JSONObject().put("operationName", "OwnedGames").put("query", QUERY)
.put("variables", JSONObject().put("limit", PAGE).put("offset", offset)).toString()
val reply = UbisoftAuth.request(GRAPHQL, "POST", "Ubi_v1 t=${s.ticket}", s.sessionId, body)
if (reply.status == 401) StoresState.markSignInExpired(Store.UBISOFT)
if (!reply.ok) throw IllegalStateException("library ${reply.status}")
val (items, total) = parsePage(reply.body)
all += items
offset += PAGE
if (items.isEmpty() || offset >= total) break
}
val owned = UbisoftNative.call("owned", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId))
owned?.optString("error")?.takeIf { it.isNotEmpty() }?.let { StoresState.logLine("Ubisoft: ownership: $it") }
val bySpace = ownedBySpace(owned)
owned?.takeIf { it.optInt("manifestsAsked") > 0 }?.let {
StoresState.logLine("Ubisoft: latest manifests: ${it.optInt("manifestsFound")} of ${it.optInt("manifestsAsked")} ${it.optString("manifestError")}".trim())
}
fun download(o: JSONObject) = listOfNotNull(
"productId" to o.optLong("productId").toString(),
o.optString("manifest").takeIf { it.isNotEmpty() }?.let { "manifest" to it },
o.optString("appId").takeIf { it.isNotEmpty() }?.let { "appId" to it },
executable(o.optString("configuration"))?.let { "exe" to it },
)
val listed = all.distinctBy { it.id }
val onSteam = steamBySpace(owned)
val matched = listed.map { g -> bySpace[g.id]?.let { g.copy(extra = g.extra + download(it)) } ?: onSteam[g.id]?.let { g.copy(extra = g.extra + (UbisoftBackend.STEAM_APP to it)) } ?: g }
.let { games -> games.filter { g -> g.extra["productId"] == null || games.first { it.extra["productId"] == g.extra["productId"] } === g } }
.let { games -> games.filter { g -> g.extra["productId"] != null || games.none { it !== g && it.title == g.title && it.extra["productId"] != null } } }
val products = matched.mapNotNull { it.extra["productId"] }.toSet()
val unlisted = bySpace.filterKeys { space -> listed.none { it.id == space } }.filterValues { it.optLong("productId").toString() !in products }.mapNotNull { (space, o) ->
val title = title(o.optString("configuration")) ?: return@mapNotNull null
CatalogItem(Store.UBISOFT, space, title, null, owned = true, extra = mapOf("spaceId" to space) + download(o))
}
val games = (matched + unlisted).sortedBy { it.title.lowercase() }
StoresState.logLine("Ubisoft: ${bySpace.size} downloadable products; " +
games.filter { it.extra.containsKey("productId") }.joinToString(", ") { "${it.title}=${it.extra["productId"]}" })
post(gen) {
StoresState.status.remove(Store.UBISOFT)
StoresState.problems.remove(Store.UBISOFT)
StoresState.library[Store.UBISOFT] = games
StoresState.shelves[Store.UBISOFT] = com.droiddeck.launcher.stores.StoreShelves(
trending = games.sortedByDescending { it.extra.containsKey("productId") }, art = games.mapNotNull { it.imageUrl })
}
StoresState.logLine("Ubisoft: ${games.size} owned games; on Steam: " + games.filter { it.extra.containsKey(UbisoftBackend.STEAM_APP) }.joinToString(", ") { "${it.title}=${it.extra[UbisoftBackend.STEAM_APP]}" })
} catch (e: Exception) {
StoresState.logLine("Ubisoft: library sync failed: ${e.javaClass.simpleName} ${e.message.orEmpty().take(200)}")
post(gen) { StoresState.status.remove(Store.UBISOFT); StoresState.problems[Store.UBISOFT] = app.getString(R.string.stores_sync_failed) }
} finally {
running.compareAndSet(gen, null)
}
}
}
@@ -0,0 +1,18 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/** The one prefix every Ubisoft game runs in, so Ubisoft Connect is installed and signed in once. */
object UbisoftConnect {
const val APP_ID = 2929927072L
fun prefix(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/compatdata/$APP_ID")
fun installed(context: Context) = File(prefix(context), "pfx/drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher/upc.exe").isFile
fun launchOptions(context: Context): String =
AddedGames.guestPath(context, prefix(context))?.let { "STEAM_COMPAT_DATA_PATH=$it %command%" }.orEmpty()
}
@@ -0,0 +1,104 @@
package com.droiddeck.launcher.stores.ubisoft
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 org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
internal class UbisoftInstaller(
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))
val s = UbisoftAuth.current(app) ?: throw IllegalStateException("Not signed in to Ubisoft")
val product = item.extra["productId"]?.toLongOrNull() ?: throw IllegalStateException("Ubisoft lists no download for ${item.title} on this account")
val cover = item.tallImageUrl ?: item.imageUrl
StoreInstalls.begin(folder, Store.UBISOFT, item.id, item.title, cover, item.imageUrl)
if (cancelled.get()) return null
val locale = StoreLanguage.localeFor(null, app)
val request = JSONObject()
.put("ticket", s.ticket)
.put("sessionId", s.sessionId)
.put("productId", product)
.put("manifest", item.extra["manifest"].orEmpty())
.put("folder", folder.absolutePath)
.put("language", "${locale.language}-${locale.country}")
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.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(line) }
override fun beforeReplace(): Boolean = true
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) = Unit
}
val outcome = UbisoftNative.install(request, listener, cancelled)
if (outcome.cancelled || cancelled.get() && !outcome.success) return null
if (!outcome.success) throw IllegalStateException(outcome.error.ifEmpty { "The Ubisoft install failed" })
handle.stage(DownloadStage.INSTALL, app.getString(R.string.stores_registering))
val configured = resolve(folder, item.extra["exe"].orEmpty())
val exe = configured ?: StoreExe.pick(folder, null, item.title).ifEmpty { "game.exe" }
handle.log("ubisoft: exe=$exe")
val sidecar = StoreGameSidecar(
Store.UBISOFT, item.id, item.title, exe = exe,
installVersion = item.extra["manifest"].orEmpty(), installedAt = System.currentTimeMillis(),
cover = cover, hero = item.imageUrl, cloud = false,
extra = item.extra.filterKeys { it in KEPT } + listOfNotNull(
outcome.result?.optJSONArray("chunks")?.let { UbisoftLaunch.CHUNKS to it.toString() },
outcome.result?.optJSONObject("setup")?.let { UbisoftLaunch.SETUP to it.toString() },
),
)
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())
}
internal companion object {
val KEPT = setOf("productId", "spaceId", "manifest", "appId")
/** A configured exe the way Windows finds it: any letter case, else the same file name anywhere in the install. */
fun resolve(folder: File, configured: String): String? {
val rel = configured.replace('\\', '/').trimStart('/').takeIf { it.isNotEmpty() } ?: return null
var at = folder
for (part in rel.split('/')) {
at = at.listFiles()?.firstOrNull { it.name.equals(part, ignoreCase = true) } ?: return byName(folder, rel.substringAfterLast('/'))
}
return if (at.isFile) at.relativeTo(folder).invariantSeparatorsPath else byName(folder, rel.substringAfterLast('/'))
}
private fun byName(folder: File, name: String): String? =
folder.walkTopDown().maxDepth(5).firstOrNull { it.isFile && it.name.equals(name, ignoreCase = true) }?.relativeTo(folder)?.invariantSeparatorsPath
}
}
@@ -0,0 +1,86 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoresState
import org.json.JSONArray
import org.json.JSONObject
/** What the launcher service hands a starting Ubisoft game: the account, its products and a fresh game ticket. */
object UbisoftLaunch {
private const val ENTITLEMENTS = "https://public-ubiservices.ubi.com/v1/profiles/me/global/ubiconnect/entitlement/api/entitlements"
private const val ME = "https://public-ubiservices.ubi.com/v3/users/me"
fun prepare(app: Context, id: String): JSONObject {
val folder = StoreInstallRoot.gameFolders(app).firstOrNull { f ->
StoreGameSidecar.read(f)?.let { it.store == Store.UBISOFT && it.id == id } == true
} ?: return failure("not-installed")
val sidecar = StoreGameSidecar.read(folder) ?: return failure("not-installed")
val product = sidecar.extra["productId"]?.toLongOrNull() ?: return failure("no-product")
val s = UbisoftAuth.current(app) ?: return failure("signed-out")
val native = UbisoftNative.call("launch", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId).put("productId", product))
?: return failure("engine-missing")
native.optString("error").takeIf { it.isNotEmpty() }?.let {
StoresState.logLine("Ubisoft: launch ticket for $product: $it")
return failure("ticket: $it")
}
val auth = "Ubi_v1 t=${s.ticket}"
val me = runCatching { UbisoftAuth.request(ME, "GET", auth, s.sessionId) }.getOrNull()?.takeIf { it.ok }
?.let { runCatching { JSONObject(it.body) }.getOrNull() }
val account = JSONObject()
.put("userId", s.userId)
.put("username", me?.optString("username")?.takeIf { it.isNotEmpty() } ?: s.name)
.put("nameOnPlatform", me?.optString("nameOnPlatform")?.takeIf { it.isNotEmpty() } ?: s.name)
.put("email", me?.optString("email").orEmpty())
.put("country", me?.optString("country")?.takeIf { it.isNotEmpty() } ?: "US")
val appId = runCatching { UbisoftAuth.request(ENTITLEMENTS, "GET", auth, s.sessionId) }.getOrNull()?.takeIf { it.ok }
?.let { applicationId(it.body, product) } ?: sidecar.extra["appId"].orEmpty()
StoresState.logLine("Ubisoft: launch ticket for $product ready (${native.optJSONArray("owned")?.length() ?: 0} products, appId ${if (appId.isEmpty()) "unknown" else "found"})")
val language = com.droiddeck.launcher.stores.StoreLanguage.localeFor(null, app).let { "${it.language}-${it.country}" }
val cached = sidecar.extra[CHUNKS]?.let { runCatching { JSONArray(it) }.getOrNull() }
var setup = sidecar.extra[SETUP]?.let { runCatching { JSONObject(it) }.getOrNull() }
val chunks = if (cached != null && setup != null) cached else
UbisoftNative.call("chunks", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId).put("productId", product)
.put("manifest", sidecar.extra["manifest"] ?: sidecar.installVersion).put("folder", folder.path).put("language", language))
?.let { r ->
r.optString("error").takeIf { it.isNotEmpty() }?.let { StoresState.logLine("Ubisoft: manifest for $product: $it") }
setup = r.optJSONObject("setup")
r.optJSONArray("chunks")
}?.also { c ->
runCatching {
StoreGameSidecar.read(folder)?.let { sc ->
sc.copy(extra = sc.extra + (CHUNKS to c.toString()) + listOfNotNull(setup?.let { SETUP to it.toString() })).write(folder)
}
com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, Store.UBISOFT, id)
?.let { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(app, it) }
}
} ?: cached
setup?.let { StoresState.logLine("Ubisoft: setup for $product: ${it.toString().take(1500)}") }
UbisoftActivation.watch(app, product)
return JSONObject().put("ok", true)
.put("productId", product)
.put("ticket", native.optString("ticket"))
.put("owned", native.optJSONArray("owned") ?: JSONArray())
.put("products", native.optJSONArray("products") ?: JSONArray())
.put("account", account)
.put("appId", appId)
.put("language", language)
.put("chunks", chunks ?: JSONArray())
.put("registry", setup?.optJSONArray("registry") ?: JSONArray())
}
const val CHUNKS = "chunks"
const val SETUP = "setup"
/** The entitlement's `applicationId` for the product; the list sits under one of a few keys depending on the API version. */
internal fun applicationId(body: String, product: Long): String? {
val root = runCatching { JSONObject(body) }.getOrNull() ?: return null
val list = sequenceOf("items", "entitlements", "products", "games", "results").firstNotNullOfOrNull { root.optJSONArray(it) } ?: return null
return (0 until list.length()).mapNotNull { list.optJSONObject(it) }
.firstOrNull { it.optLong("productId") == product }?.optString("applicationId")?.takeIf { it.isNotEmpty() }
}
private fun failure(reason: String) = JSONObject().put("ok", false).put("reason", reason)
}
@@ -0,0 +1,57 @@
package com.droiddeck.launcher.stores.ubisoft
import android.util.Log
import com.droiddeck.launcher.stores.StoresNative
import com.droiddeck.launcher.stores.amazon.AmazonNative
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
object UbisoftNative {
private const val TAG = "UbisoftNative"
const val ENGINE_MISSING = "Ubisoft: the store engine is not available"
fun ready(): Boolean = StoresNative.ensureLoaded()
fun call(op: String, request: JSONObject): JSONObject? {
if (!ready()) return null
val text = try { nativeCall(op, request.toString()) } catch (t: Throwable) {
Log.w(TAG, "$op: ${t.javaClass.simpleName}")
null
} ?: return null
return runCatching { JSONObject(text) }.getOrNull()
}
fun install(request: JSONObject, events: AmazonNative.InstallListener, cancelled: AtomicBoolean): AmazonNative.InstallOutcome {
if (!ready()) return AmazonNative.InstallOutcome(false, false, ENGINE_MISSING, null)
val done = CountDownLatch(1)
val holder = arrayOfNulls<AmazonNative.InstallOutcome>(1)
val bridge = object : AmazonNative.InstallListener by events {
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) {
holder[0] = AmazonNative.InstallOutcome(success, cancelled, error, result.takeIf { it.isNotEmpty() }?.let { runCatching { JSONObject(it) }.getOrNull() })
done.countDown()
}
}
val handle = try { nativeInstall(request.toString(), bridge) } catch (t: Throwable) {
return AmazonNative.InstallOutcome(false, false, "The Ubisoft install could not start: ${t.javaClass.simpleName}", null)
}
if (handle == 0L) return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The Ubisoft install could not start", null)
try {
var forwarded = false
while (true) {
val finished = try { done.await(250, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) { cancelled.set(true); false }
if (finished) break
if (!forwarded && cancelled.get()) { nativeCancel(handle); forwarded = true }
}
} finally {
nativeRelease(handle)
}
return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The Ubisoft install ended without a result", null)
}
@JvmStatic private external fun nativeCall(op: String, request: String): String?
@JvmStatic private external fun nativeInstall(request: String, listener: AmazonNative.InstallListener): Long
@JvmStatic private external fun nativeCancel(handle: Long)
@JvmStatic private external fun nativeRelease(handle: Long)
}
@@ -1,116 +0,0 @@
package com.droiddeck.launcher.ui
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.BottomNotifications
import kotlinx.coroutines.delay
internal fun notificationAccessIntent(context: android.content.Context): Intent =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).putExtra(
Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
android.content.ComponentName(context, com.droiddeck.launcher.session.BottomNotificationListener::class.java).flattenToString(),
)
} else Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
@Composable
fun BottomNotificationsPage(openSettings: ((Intent, () -> Boolean) -> Unit)?) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var granted by remember { mutableStateOf(BottomNotifications.granted(context)) }
LaunchedEffect(Unit) {
while (true) {
granted = BottomNotifications.granted(context)
delay(2_000L)
}
}
if (!granted) {
Column(Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)) {
Text(stringResource(R.string.bottom_notifications_why), fontSize = 15.sp, color = colors.onBackground, textAlign = TextAlign.Center)
BottomPillButton(stringResource(R.string.bottom_notifications_allow), "bottom-notifications-allow") {
val intent = notificationAccessIntent(context)
if (openSettings != null) openSettings(intent) { BottomNotifications.granted(context) }
else runCatching { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
}
return
}
val shown = BottomNotifications.items
if (shown.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.bottom_notifications_none), fontSize = 15.sp, color = colors.onSurfaceVariant)
}
return
}
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.bottom_notifications_count, shown.size), fontSize = 13.sp, color = colors.onSurfaceVariant,
modifier = Modifier.weight(1f))
BottomPillButton(stringResource(R.string.bottom_notifications_clear), "bottom-notifications-clear") { BottomNotifications.clear() }
}
LazyColumn(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(shown.toList(), key = { it.key }) { n ->
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(pal.surface).padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(n.app, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1,
overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Text(timeFormat.format(java.util.Date(n.postedAt)), fontSize = 12.sp, color = colors.onSurfaceVariant)
}
if (n.title.isNotBlank()) Text(n.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (n.text.isNotBlank()) Text(n.text, fontSize = 14.sp, color = colors.onBackground, maxLines = 4, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@Composable
internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
val pal = LocalPalette.current
Box(
Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
.clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
}
}
@@ -1,17 +1,31 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
class BottomPageActions(
val takeScreenshot: ((onDone: (Boolean) -> Unit) -> Unit)? = null,
val fps: () -> Float? = { null },
val openSettings: ((intent: android.content.Intent, done: () -> Boolean) -> Unit)? = null,
val runningGame: () -> String? = { null },
)
@@ -20,7 +34,18 @@ internal const val BOTTOM_PAGE_TELEMETRY = "telemetry"
fun sessionBottomPages(actions: BottomPageActions): List<BottomPage> = listOf(
BottomPage(BOTTOM_PAGE_TELEMETRY, R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) },
BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() },
BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage(actions.openSettings) },
BottomPage("screenshots", R.string.bottom_page_screenshots, Icons.Outlined.PhotoLibrary) { BottomScreenshotsPage(actions.takeScreenshot) },
BottomPage("chat", R.string.bottom_page_chat, Icons.Outlined.Forum) { BottomChatPage() },
)
@Composable
internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
val pal = LocalPalette.current
Box(
Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
.clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
}
}
@@ -304,7 +304,8 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
val now = System.nanoTime()
val live = frames.live
val stats = frames.stats
val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) }
val graph = FloatArray(FrameStats.GRAPH_FRAMES)
val graphEnd = if (live) stats.graph(graph) else 0L
val hz = (frames.refreshMilliHz + 499) / 1000
val data = HudData(
fps = fpsNow, avgFps = if (live) stats.averageFps() else null, lows = if (live) stats.lows(now) else FrameStats.Lows.EMPTY,
@@ -317,6 +318,7 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
title = title, cover = cover, played = playedSeconds?.let(::playedText), clock = clock,
fpsHistory = fpsHistory.snapshot(), cpuHistory = cpuHistory.snapshot(), gpuHistory = gpuHistory.snapshot(),
historySeconds = maxOf(fpsHistory.spanSeconds(), cpuHistory.spanSeconds(), gpuHistory.spanSeconds()),
graphEnd = graphEnd,
coreGroups = coreGroups, gameCores = gameCores, fahrenheit = tempDisplay.fahrenheit,
tempColor = { sensor, c ->
thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN)
@@ -0,0 +1,17 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.ea.EaUpdates
@Composable
internal fun EaUpdateButton(installed: InstalledStoreGame, enabled: Boolean) {
val ctx = LocalContext.current
LaunchedEffect(installed.key, installed.sidecar.installVersion) { EaUpdates.check(ctx, installed) }
if (EaUpdates.available[installed.key] == null) return
SecondaryButton(stringResource(R.string.common_update), enabled = enabled) { EaUpdates.start(ctx, installed) }
}
@@ -430,6 +430,7 @@ internal fun GameActions(
openUrl(ctx, item?.storeUrl.orEmpty().ifBlank { GameJoltApi.pageUrl("game", installed.sidecar.id) })
}
}
if (store == Store.EA && installed != null) EaUpdateButton(installed, download == null && StoresState.uninstalling[installed.key] == null)
if (store == Store.GOG && installed != null) GogMaintenanceActions(installed)
if (store == Store.XBOX && id != null) XboxInstalledStream(id, game?.name ?: item?.title.orEmpty(), item)
if (store == Store.XBOX && installed != null && id != null) {
@@ -915,7 +916,7 @@ private fun AddonRow(name: String, target: WowAddons.Target, a: FrontEndActions)
@Composable
private fun CloudRows(g: Library.SteamGame) {
val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON } ?: return
val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON && it != Store.EA } ?: return
val id = g.storeId ?: return
val context = LocalContext.current.applicationContext
var busy by remember(g.gameId) { mutableStateOf<String?>(null) }
@@ -742,7 +742,7 @@ internal fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?, showDownlo
}
}
private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available || it in GameJoltUpdates.available } == true ||
private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available || it in GameJoltUpdates.available || it in com.droiddeck.launcher.stores.ea.EaUpdates.available } == true ||
e.source == Store.GOG.id && (e.item?.id ?: e.game?.storeId)?.let { GogMaintenance.checks[it]?.state } == GogMaintenance.State.AVAILABLE
@Composable
@@ -224,6 +224,8 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
ChoiceRow(host, "picture-position", stringResource(R.string.display_picture_position), null,
listOf("top" to stringResource(R.string.display_position_top),
"bottom" to stringResource(R.string.display_position_bottom),
"left" to stringResource(R.string.display_position_left),
"right" to stringResource(R.string.display_position_right),
"center" to stringResource(R.string.display_position_center)),
position, onPick = a.onPicturePosition)
}
@@ -98,6 +98,7 @@ internal class HudData(
val tempColor: (HudMetrics.TempSensor, Int) -> Color,
val redC: (HudMetrics.TempSensor) -> Int?,
val historySeconds: Int = 0,
val graphEnd: Long = 0L,
) {
val frametimeMs: Float? get() = fps?.takeIf { it > 0f }?.let { 1000f / it }
val hz: Int get() = if (refreshHz > 0) refreshHz else 60
@@ -268,18 +269,26 @@ private fun lowsText(d: HudData, size: TextUnit, accent: Color, deep: Boolean =
private val GRAPH_MIN_GAP = 3.dp
@Composable
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier) {
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier, end: Long = 0L) {
Canvas(modifier) {
if (values.size < 2 || hi <= lo) return@Canvas
val stride = ceil(GRAPH_MIN_GAP.toPx() * (values.size - 1) / size.width).toInt().coerceAtLeast(1)
val points = if (stride == 1) values else values.chunked(stride) { it.average().toFloat() }
if (points.size < 2) return@Canvas
// Buckets start at multiples of stride counted from the first sample ever drawn, so a sample keeps
// its bucket as the window scrolls; counted from the window's start, every new frame re-paired them.
val skip = Math.floorMod(values.size.toLong() - end, stride.toLong()).toInt()
val path = Path()
points.forEachIndexed { i, v ->
val x = i / (points.size - 1f) * size.width
val y = size.height - ((v - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
var drawn = 0
var start = skip
while (start < values.size) {
val stop = minOf(start + stride, values.size)
var sum = 0f
for (i in start until stop) sum += values[i]
val x = (start + stop - 1) / 2f / (values.size - 1f) * size.width
val y = size.height - ((sum / (stop - start) - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
if (drawn++ == 0) path.moveTo(x, y) else path.lineTo(x, y)
start = stop
}
if (drawn < 2) return@Canvas
drawPath(path, color.copy(alpha = 0.22f), style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
drawPath(path, color, style = Stroke(width = 2.2f.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
}
@@ -287,7 +296,7 @@ private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modif
@Composable
private fun FrameGraph(d: HudData, accent: Color, height: Dp) {
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height))
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height), d.graphEnd)
}
@Composable
@@ -589,7 +598,7 @@ private fun ColumnScope.Pacing(d: HudData, accent: Color) {
drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx(), pathEffect = dash)
}
}
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize())
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize(), d.graphEnd)
for ((ms, color) in listOf(target to HudInk.dim, target * 2 to HudInk.amber)) {
Small("%.1f ms · %d fps".format(ms, (1000f / ms).roundToInt()), 11.sp, color,
Modifier.align(Alignment.TopEnd).offset(y = maxHeight * (1f - ms / FrameStats.GRAPH_MAX_MS) - 17.dp))
@@ -33,6 +33,8 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00))
Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF))
Store.UBISOFT.id -> SourceColours(Color(0xFF0B1E44), Color(0xFFA8C6FF), Color(0xFF2E7DFF))
Store.EA.id -> SourceColours(Color(0xFF3A0E12), Color(0xFFFF9A9A), Color(0xFFFF4747))
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
@@ -48,6 +50,8 @@ internal fun sourceLabel(source: String): String = when (source) {
Store.ITCH.id -> Store.ITCH.label
Store.GAMEJOLT.id -> Store.GAMEJOLT.label
Store.BATTLENET.id -> Store.BATTLENET.label
Store.UBISOFT.id -> Store.UBISOFT.shortLabel
Store.EA.id -> Store.EA.label
else -> stringResource(R.string.source_added)
}
@@ -1157,6 +1157,8 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00)
Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3)
Store.UBISOFT -> Color(0xFF2E7DFF) to Color(0xFF1A5BD6)
Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030)
null -> return base
}
val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White
@@ -58,6 +58,7 @@ object Themes {
const val RGB_SPEEDS = 10
const val DEFAULT_RGB_SPEED = 3
const val RGB_HUE_STEP = 3f
const val WHITE_RED = "white_red"
var rgbSpeed by mutableStateOf(DEFAULT_RGB_SPEED)
private set
@@ -127,6 +128,13 @@ object Themes {
primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF),
good = Color(0xFF4CD37F),
),
Palette(
WHITE_RED, R.string.theme_white_red, R.string.theme_white_red_detail,
background = Color(0xFFF8F8F8), surface = Color(0xFFFFFFFF), surfaceVariant = Color(0xFFF0F0F0), line = Color(0xFFDDDDDD), line2 = Color(0xFFC8C8C8),
onBackground = Color(0xFF191919), onSurfaceVariant = Color(0xFF555555),
primary = Color(0xFFD62828), primary2 = Color(0xFFB71C1C), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFFD62828),
good = Color(0xFF248548),
),
innerGamer(0f),
ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail),
)
@@ -56,7 +56,7 @@ import java.io.File
private fun installable(name: String, all: Map<String, WinComponents.Component>): String = WinComponents.installable(name, all)
@Composable
private fun originName(origin: String): String = if (origin == WinCompSources.Origin.XBOX.name) "Xbox" else stringResource(
private fun originName(origin: String): String = if (origin == WinCompSources.Origin.XBOX.name) "Xbox" else if (origin == WinCompSources.Origin.UBISOFT.name) "Ubisoft Connect" else stringResource(
when (origin) {
WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog
WinCompSources.Origin.EPIC.name -> R.string.wincomp_src_epic
@@ -71,6 +71,7 @@ private fun autoReason(r: WinComponents.AutoReason): String = when (r.kind) {
WinCompSources.Origin.GOG.name -> stringResource(R.string.wincomp_auto_gog)
WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic)
WinCompSources.Origin.AMAZON.name -> stringResource(R.string.wincomp_auto_amazon)
WinCompSources.Origin.UBISOFT.name -> "From Ubisoft's install steps for this game"
WinCompSources.Origin.XBOX.name -> "Xbox games install it with the game (${r.args.getOrElse(0) { "VC14" }})"
else -> ""
}
+5 -7
View File
@@ -1873,13 +1873,6 @@
<string name="bottom_downloads_shader">جارٍ تنزيل ذاكرة المظلِّلات المؤقتة</string>
<string name="bottom_downloads_paused">متوقف مؤقتًا</string>
<string name="bottom_downloads_queued">بانتظار البدء</string>
<string name="bottom_page_notifications">الإشعارات</string>
<string name="bottom_notifications_listener">الشاشة السفلية لـ DroidDeck</string>
<string name="bottom_notifications_why">اعرض الرسائل والإشعارات الأخرى هنا أثناء اللعب. تبقى على هذه الشاشة فقط ولا تُحفظ. يطلب Android الإذن مرة واحدة: تُفتح صفحة الوصول إلى الإشعارات على هذه الشاشة؛ فعِّل DroidDeck هناك وستعود هذه الصفحة تلقائيًا.</string>
<string name="bottom_notifications_allow">السماح بالوصول إلى الإشعارات</string>
<string name="bottom_notifications_none">لا توجد إشعارات.</string>
<string name="bottom_notifications_count">الإشعارات: %1$d</string>
<string name="bottom_notifications_clear">مسح</string>
<string name="bottom_page_screenshots">لقطات الشاشة</string>
<string name="stats_fps_needs_hud">يتطلب FPS تشغيل واجهة الأداء</string>
<string name="stats_speed">السرعة</string>
@@ -2983,6 +2976,8 @@
<string name="display_position_top">أعلى</string>
<string name="display_position_bottom">أسفل</string>
<string name="display_position_center">الوسط (افتراضي)</string>
<string name="display_position_left">يسار</string>
<string name="display_position_right">يمين</string>
<string name="setup_tab_storage">التخزين</string>
<string name="storage_measuring">جارٍ قياس التخزين…</string>
<string name="storage_measuring_hint">يستغرق القياس الأول بضع ثوانٍ.</string>
@@ -3816,4 +3811,7 @@
<string name="input_reset_positions">إعادة تعيين المواضع</string>
<string name="input_show_label">إظهار التسمية</string>
<string name="input_layout_import_failed">تعذّر استيراد هذا التخطيط</string>
<string name="theme_white_red">ثلج قرمزي</string>
</resources>
+4 -7
View File
@@ -1865,13 +1865,6 @@
<string name="bottom_downloads_shader">Descargando caché de shaders</string>
<string name="bottom_downloads_paused">En pausa</string>
<string name="bottom_downloads_queued">Esperando para empezar</string>
<string name="bottom_page_notifications">Notificaciones</string>
<string name="bottom_notifications_listener">DroidDeck – pantalla inferior</string>
<string name="bottom_notifications_why">Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola.</string>
<string name="bottom_notifications_allow">Permitir acceso a notificaciones</string>
<string name="bottom_notifications_none">No hay notificaciones.</string>
<string name="bottom_notifications_count">%1$d notificaciones</string>
<string name="bottom_notifications_clear">Borrar</string>
<string name="bottom_page_screenshots">Capturas de pantalla</string>
<string name="stats_fps_needs_hud">Los FPS requieren el HUD activado</string>
<string name="stats_speed">Velocidad</string>
@@ -2835,6 +2828,8 @@
<string name="display_position_top">Arriba</string>
<string name="display_position_bottom">Abajo</string>
<string name="display_position_center">Centro (predeterminado)</string>
<string name="display_position_left">Izquierda</string>
<string name="display_position_right">Derecha</string>
<string name="setup_tab_storage">Almacenamiento</string>
<string name="storage_measuring">Midiendo el almacenamiento…</string>
<string name="storage_measuring_hint">La primera vez tarda unos segundos.</string>
@@ -3672,4 +3667,6 @@
<string name="input_reset_positions">Restablecer posiciones</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">No se pudo importar esta disposición</string>
<string name="theme_white_red">Nieve escarlata</string>
<string name="theme_white_red_detail">Superficies blancas con acentos rojos.</string>
</resources>
+5 -7
View File
@@ -1777,13 +1777,6 @@
<string name="bottom_downloads_shader">Изтегляне на кеша на шейдърите</string>
<string name="bottom_downloads_paused">На пауза</string>
<string name="bottom_downloads_queued">Изчаква стартиране</string>
<string name="bottom_page_notifications">Известия</string>
<string name="bottom_notifications_listener">Долен екран на DroidDeck</string>
<string name="bottom_notifications_why">Показвайте тук съобщения и други известия, докато играете. Те остават само на този екран и не се запазват. Android пита веднъж: страницата за достъп до известията се отваря на този екран; включете DroidDeck там и тази страница ще се върне сама.</string>
<string name="bottom_notifications_allow">Разрешаване на достъпа до известията</string>
<string name="bottom_notifications_none">Няма известия.</string>
<string name="bottom_notifications_count">Известия: %1$d</string>
<string name="bottom_notifications_clear">Изчисти</string>
<string name="bottom_page_screenshots">Екранни снимки</string>
<string name="stats_fps_needs_hud">За FPS трябва HUD да е включен</string>
<string name="stats_speed">Скорост</string>
@@ -2867,6 +2860,8 @@
<string name="display_position_top">Горе</string>
<string name="display_position_bottom">Долу</string>
<string name="display_position_center">В средата (по подразбиране)</string>
<string name="display_position_left">Наляво</string>
<string name="display_position_right">Надясно</string>
<string name="setup_tab_storage">Съхранение</string>
<string name="storage_measuring">Измерване на съхранението…</string>
<string name="storage_measuring_hint">Първият път отнема няколко секунди.</string>
@@ -3660,4 +3655,7 @@
<string name="input_reset_positions">Нулиране на позициите</string>
<string name="input_show_label">Показване на етикета</string>
<string name="input_layout_import_failed">Това разположение не можа да бъде импортирано</string>
<string name="theme_white_red">Ален сняг</string>
</resources>
+5 -7
View File
@@ -1825,13 +1825,6 @@
<string name="bottom_downloads_shader">Stahování mezipaměti shaderů</string>
<string name="bottom_downloads_paused">Pozastaveno</string>
<string name="bottom_downloads_queued">Čeká na spuštění</string>
<string name="bottom_page_notifications">Oznámení</string>
<string name="bottom_notifications_listener">Spodní obrazovka DroidDeck</string>
<string name="bottom_notifications_why">Zobrazujte zde zprávy a další oznámení během hraní. Zůstávají jen na této obrazovce a neukládají se. Android se zeptá jednou: na této obrazovce se otevře jeho stránka přístupu k oznámením; zapněte tam DroidDeck a tato stránka se sama vrátí.</string>
<string name="bottom_notifications_allow">Povolit přístup k oznámením</string>
<string name="bottom_notifications_none">Žádná oznámení.</string>
<string name="bottom_notifications_count">Oznámení: %1$d</string>
<string name="bottom_notifications_clear">Vymazat</string>
<string name="bottom_page_screenshots">Snímky obrazovky</string>
<string name="stats_fps_needs_hud">FPS vyžaduje zapnutý HUD</string>
<string name="stats_speed">Rychlost</string>
@@ -2925,6 +2918,8 @@
<string name="display_position_top">Nahoře</string>
<string name="display_position_bottom">Dole</string>
<string name="display_position_center">Uprostřed (výchozí)</string>
<string name="display_position_left">Vlevo</string>
<string name="display_position_right">Vpravo</string>
<string name="setup_tab_storage">Úložiště</string>
<string name="storage_measuring">Měření úložiště…</string>
<string name="storage_measuring_hint">Poprvé to trvá pár sekund.</string>
@@ -3738,4 +3733,7 @@
<string name="input_reset_positions">Obnovit pozice</string>
<string name="input_show_label">Zobrazit popisek</string>
<string name="input_layout_import_failed">Toto rozložení nelze importovat</string>
<string name="theme_white_red">Šarlatový sníh</string>
</resources>
+5 -7
View File
@@ -1777,13 +1777,6 @@
<string name="bottom_downloads_shader">Downloader shader-cache</string>
<string name="bottom_downloads_paused">På pause</string>
<string name="bottom_downloads_queued">Venter på at starte</string>
<string name="bottom_page_notifications">Notifikationer</string>
<string name="bottom_notifications_listener">DroidDecks nederste skærm</string>
<string name="bottom_notifications_why">Vis beskeder og andre notifikationer her, mens du spiller. De bliver kun på denne skærm og gemmes ikke. Android spørger én gang: siden for notifikationsadgang åbnes på denne skærm; slå DroidDeck til der, så vender denne side selv tilbage.</string>
<string name="bottom_notifications_allow">Tillad notifikationsadgang</string>
<string name="bottom_notifications_none">Ingen notifikationer.</string>
<string name="bottom_notifications_count">%1$d notifikationer</string>
<string name="bottom_notifications_clear">Ryd</string>
<string name="bottom_page_screenshots">Skærmbilleder</string>
<string name="stats_fps_needs_hud">FPS kræver, at HUD er slået til</string>
<string name="stats_speed">Hastighed</string>
@@ -2867,6 +2860,8 @@
<string name="display_position_top">Øverst</string>
<string name="display_position_bottom">Nederst</string>
<string name="display_position_center">Midten (standard)</string>
<string name="display_position_left">Venstre</string>
<string name="display_position_right">Højre</string>
<string name="setup_tab_storage">Lager</string>
<string name="storage_measuring">Måler lager…</string>
<string name="storage_measuring_hint">Første gang tager et par sekunder.</string>
@@ -3660,4 +3655,7 @@
<string name="input_reset_positions">Nulstil placeringer</string>
<string name="input_show_label">Vis etiket</string>
<string name="input_layout_import_failed">Dette layout kunne ikke importeres</string>
<string name="theme_white_red">Skarlagensne</string>
</resources>
+5 -7
View File
@@ -1769,13 +1769,6 @@
<string name="bottom_downloads_shader">Shader-Cache wird heruntergeladen</string>
<string name="bottom_downloads_paused">Pausiert</string>
<string name="bottom_downloads_queued">Wartet auf Start</string>
<string name="bottom_page_notifications">Benachrichtigungen</string>
<string name="bottom_notifications_listener">DroidDeck – unterer Bildschirm</string>
<string name="bottom_notifications_why">Zeigt Nachrichten und andere Benachrichtigungen hier an, während du spielst. Sie bleiben nur auf diesem Bildschirm und werden nicht gespeichert. Android fragt einmal: Die Seite für den Benachrichtigungszugriff öffnet sich auf diesem Bildschirm; aktiviere dort DroidDeck, dann kehrt diese Seite von selbst zurück.</string>
<string name="bottom_notifications_allow">Benachrichtigungszugriff erlauben</string>
<string name="bottom_notifications_none">Keine Benachrichtigungen.</string>
<string name="bottom_notifications_count">%1$d Benachrichtigungen</string>
<string name="bottom_notifications_clear">Löschen</string>
<string name="bottom_page_screenshots">Screenshots</string>
<string name="stats_fps_needs_hud">FPS benötigt das aktive HUD</string>
<string name="stats_speed">Geschwindigkeit</string>
@@ -2867,6 +2860,8 @@
<string name="display_position_top">Oben</string>
<string name="display_position_bottom">Unten</string>
<string name="display_position_center">Mitte (Standard)</string>
<string name="display_position_left">Links</string>
<string name="display_position_right">Rechts</string>
<string name="setup_tab_storage">Speicher</string>
<string name="storage_measuring">Speicher wird erfasst…</string>
<string name="storage_measuring_hint">Die erste Erfassung dauert ein paar Sekunden.</string>
@@ -3660,4 +3655,7 @@
<string name="input_reset_positions">Positionen zurücksetzen</string>
<string name="input_show_label">Beschriftung anzeigen</string>
<string name="input_layout_import_failed">Dieses Layout konnte nicht importiert werden</string>
<string name="theme_white_red">Scharlachroter Schnee</string>
</resources>
+5 -7
View File
@@ -1777,13 +1777,6 @@
<string name="bottom_downloads_shader">Λήψη cache shader</string>
<string name="bottom_downloads_paused">Σε παύση</string>
<string name="bottom_downloads_queued">Αναμονή για έναρξη</string>
<string name="bottom_page_notifications">Ειδοποιήσεις</string>
<string name="bottom_notifications_listener">DroidDeck – κάτω οθόνη</string>
<string name="bottom_notifications_why">Εμφάνιση μηνυμάτων και άλλων ειδοποιήσεων εδώ ενώ παίζετε. Παραμένουν μόνο σε αυτή την οθόνη και δεν αποθηκεύονται. Το Android ρωτά μία φορά: η σελίδα πρόσβασης ειδοποιήσεων ανοίγει σε αυτή την οθόνη· ενεργοποιήστε εκεί το DroidDeck και αυτή η σελίδα επιστρέφει μόνη της.</string>
<string name="bottom_notifications_allow">Αποδοχή πρόσβασης ειδοποιήσεων</string>
<string name="bottom_notifications_none">Δεν υπάρχουν ειδοποιήσεις.</string>
<string name="bottom_notifications_count">%1$d ειδοποιήσεις</string>
<string name="bottom_notifications_clear">Απαλοιφή</string>
<string name="bottom_page_screenshots">Στιγμιότυπα οθόνης</string>
<string name="stats_fps_needs_hud">Τα FPS απαιτούν ενεργό HUD</string>
<string name="stats_speed">Ταχύτητα</string>
@@ -2867,6 +2860,8 @@
<string name="display_position_top">Πάνω</string>
<string name="display_position_bottom">Κάτω</string>
<string name="display_position_center">Κέντρο (προεπιλογή)</string>
<string name="display_position_left">Αριστερά</string>
<string name="display_position_right">Δεξιά</string>
<string name="setup_tab_storage">Αποθήκευση</string>
<string name="storage_measuring">Μέτρηση αποθήκευσης…</string>
<string name="storage_measuring_hint">Την πρώτη φορά παίρνει λίγα δευτερόλεπτα.</string>
@@ -3660,4 +3655,7 @@
<string name="input_reset_positions">Επαναφορά θέσεων</string>
<string name="input_show_label">Εμφάνιση ετικέτας</string>
<string name="input_layout_import_failed">Δεν ήταν δυνατή η εισαγωγή αυτής της διάταξης</string>
<string name="theme_white_red">Κατακόκκινο χιόνι</string>
</resources>
+5 -7
View File
@@ -1801,13 +1801,6 @@
<string name="bottom_downloads_shader">Descargando caché de shaders</string>
<string name="bottom_downloads_paused">En pausa</string>
<string name="bottom_downloads_queued">Esperando para empezar</string>
<string name="bottom_page_notifications">Notificaciones</string>
<string name="bottom_notifications_listener">DroidDeck – pantalla inferior</string>
<string name="bottom_notifications_why">Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola.</string>
<string name="bottom_notifications_allow">Permitir acceso a notificaciones</string>
<string name="bottom_notifications_none">No hay notificaciones.</string>
<string name="bottom_notifications_count">%1$d notificaciones</string>
<string name="bottom_notifications_clear">Borrar</string>
<string name="bottom_page_screenshots">Capturas de pantalla</string>
<string name="stats_fps_needs_hud">Los FPS requieren el HUD activado</string>
<string name="stats_speed">Velocidad</string>
@@ -2896,6 +2889,8 @@
<string name="display_position_top">Arriba</string>
<string name="display_position_bottom">Abajo</string>
<string name="display_position_center">Centro (predeterminado)</string>
<string name="display_position_left">Izquierda</string>
<string name="display_position_right">Derecha</string>
<string name="setup_tab_storage">Almacenamiento</string>
<string name="storage_measuring">Midiendo el almacenamiento…</string>
<string name="storage_measuring_hint">La primera vez tarda unos segundos.</string>
@@ -3699,4 +3694,7 @@
<string name="input_reset_positions">Restablecer posiciones</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">No se ha podido importar esta disposición</string>
<string name="theme_white_red">Nieve escarlata</string>
</resources>
+5 -7
View File
@@ -1777,13 +1777,6 @@
<string name="bottom_downloads_shader">Ladataan sävytinvälimuistia</string>
<string name="bottom_downloads_paused">Keskeytetty</string>
<string name="bottom_downloads_queued">Odottaa alkamista</string>
<string name="bottom_page_notifications">Ilmoitukset</string>
<string name="bottom_notifications_listener">DroidDeckin alanäyttö</string>
<string name="bottom_notifications_why">Näytä viestit ja muut ilmoitukset täällä pelatessasi. Ne näkyvät vain tällä näytöllä, eikä niitä tallenneta. Android kysyy kerran: sen ilmoitusten käyttöoikeussivu avautuu tälle näytölle; kytke DroidDeck siellä päälle, niin tämä sivu palaa itsestään.</string>
<string name="bottom_notifications_allow">Salli ilmoitusten käyttö</string>
<string name="bottom_notifications_none">Ei ilmoituksia.</string>
<string name="bottom_notifications_count">Ilmoituksia: %1$d</string>
<string name="bottom_notifications_clear">Tyhjennä</string>
<string name="bottom_page_screenshots">Kuvakaappaukset</string>
<string name="stats_fps_needs_hud">FPS vaatii HUD:n päälle</string>
<string name="stats_speed">Nopeus</string>
@@ -2867,6 +2860,8 @@
<string name="display_position_top">Ylhäällä</string>
<string name="display_position_bottom">Alhaalla</string>
<string name="display_position_center">Keskellä (oletus)</string>
<string name="display_position_left">Vasen</string>
<string name="display_position_right">Oikea</string>
<string name="setup_tab_storage">Tallennustila</string>
<string name="storage_measuring">Mitataan tallennustilaa…</string>
<string name="storage_measuring_hint">Ensimmäisellä kerralla kestää muutaman sekunnin.</string>
@@ -3660,4 +3655,7 @@
<string name="input_reset_positions">Palauta sijainnit</string>
<string name="input_show_label">Näytä nimi</string>
<string name="input_layout_import_failed">Tätä asettelua ei voitu tuoda</string>
<string name="theme_white_red">Helakanpunainen lumi</string>
</resources>
+5 -7
View File
@@ -1801,13 +1801,6 @@
<string name="bottom_downloads_shader">Téléchargement du cache de shaders</string>
<string name="bottom_downloads_paused">En pause</string>
<string name="bottom_downloads_queued">En attente de démarrage</string>
<string name="bottom_page_notifications">Notifications</string>
<string name="bottom_notifications_listener">Écran inférieur DroidDeck</string>
<string name="bottom_notifications_why">Affichez ici les messages et autres notifications pendant que vous jouez. Ils restent uniquement sur cet écran et ne sont pas enregistrés. Android ne le demande qu\'une fois : sa page d\'accès aux notifications s\'ouvre sur cet écran ; activez-y DroidDeck et cette page reviendra d\'elle-même.</string>
<string name="bottom_notifications_allow">Autoriser l\'accès aux notifications</string>
<string name="bottom_notifications_none">Aucune notification.</string>
<string name="bottom_notifications_count">%1$d notifications</string>
<string name="bottom_notifications_clear">Effacer</string>
<string name="bottom_page_screenshots">Captures d\'écran</string>
<string name="stats_fps_needs_hud">Les FPS nécessitent d\'activer le HUD</string>
<string name="stats_speed">Vitesse</string>
@@ -2896,6 +2889,8 @@
<string name="display_position_top">Haut</string>
<string name="display_position_bottom">Bas</string>
<string name="display_position_center">Centre (par défaut)</string>
<string name="display_position_left">Gauche</string>
<string name="display_position_right">Droite</string>
<string name="setup_tab_storage">Stockage</string>
<string name="storage_measuring">Mesure du stockage…</string>
<string name="storage_measuring_hint">Cela prend quelques secondes la première fois.</string>
@@ -3699,4 +3694,7 @@
<string name="input_reset_positions">Réinitialiser les positions</string>
<string name="input_show_label">Afficher le libellé</string>
<string name="input_layout_import_failed">Impossible d’importer cette disposition</string>
<string name="theme_white_red">Neige écarlate</string>
</resources>
+5 -7
View File
@@ -1777,13 +1777,6 @@
<string name="bottom_downloads_shader">शेडर कैश डाउनलोड हो रहा है</string>
<string name="bottom_downloads_paused">रुका हुआ</string>
<string name="bottom_downloads_queued">शुरू होने का इंतज़ार</string>
<string name="bottom_page_notifications">नोटिफ़िकेशन</string>
<string name="bottom_notifications_listener">DroidDeck नीचे की स्क्रीन</string>
<string name="bottom_notifications_why">खेलते समय मैसेज और अन्य नोटिफ़िकेशन यहां दिखाएं। वे सिर्फ़ इसी स्क्रीन पर रहते हैं और सेव नहीं होते। Android एक बार पूछता है: उसका नोटिफ़िकेशन ऐक्सेस पेज इसी स्क्रीन पर खुलता है; वहां DroidDeck चालू करें और यह पेज अपने-आप वापस आ जाएगा।</string>
<string name="bottom_notifications_allow">नोटिफ़िकेशन ऐक्सेस की अनुमति दें</string>
<string name="bottom_notifications_none">कोई नोटिफ़िकेशन नहीं।</string>
<string name="bottom_notifications_count">%1$d नोटिफ़िकेशन</string>
<string name="bottom_notifications_clear">साफ़ करें</string>
<string name="bottom_page_screenshots">स्क्रीनशॉट</string>
<string name="stats_fps_needs_hud">FPS के लिए HUD चालू होना चाहिए</string>
<string name="stats_speed">स्पीड</string>
@@ -2867,6 +2860,8 @@
<string name="display_position_top">ऊपर</string>
<string name="display_position_bottom">नीचे</string>
<string name="display_position_center">बीच में (डिफ़ॉल्ट)</string>
<string name="display_position_left">बाएँ</string>
<string name="display_position_right">दाएँ</string>
<string name="setup_tab_storage">स्टोरेज</string>
<string name="storage_measuring">स्टोरेज मापा जा रहा है…</string>
<string name="storage_measuring_hint">पहली बार कुछ सेकंड लगते हैं।</string>
@@ -3660,4 +3655,7 @@
<string name="input_reset_positions">स्थितियाँ रीसेट करें</string>
<string name="input_show_label">लेबल दिखाएँ</string>
<string name="input_layout_import_failed">यह लेआउट इम्पोर्ट नहीं किया जा सका</string>
<string name="theme_white_red">किरमिज़ी बर्फ</string>
</resources>
+5 -7
View File
@@ -1777,13 +1777,6 @@
<string name="bottom_downloads_shader">Shader-gyorsítótár letöltése</string>
<string name="bottom_downloads_paused">Szüneteltetve</string>
<string name="bottom_downloads_queued">Indításra vár</string>
<string name="bottom_page_notifications">Értesítések</string>
<string name="bottom_notifications_listener">DroidDeck alsó képernyő</string>
<string name="bottom_notifications_why">Itt jelenítheti meg az üzeneteket és más értesítéseket játék közben. Csak ezen a képernyőn láthatók, és nem kerülnek mentésre. Az Android egyszer kérdez rá: az értesítési hozzáférés oldala ezen a képernyőn nyílik meg; kapcsolja be ott a DroidDecket, és ez az oldal magától visszatér.</string>
<string name="bottom_notifications_allow">Értesítési hozzáférés engedélyezése</string>
<string name="bottom_notifications_none">Nincsenek értesítések.</string>
<string name="bottom_notifications_count">%1$d értesítés</string>
<string name="bottom_notifications_clear">Törlés</string>
<string name="bottom_page_screenshots">Képernyőképek</string>
<string name="stats_fps_needs_hud">Az FPS-hez be kell kapcsolni a HUD-ot</string>
<string name="stats_speed">Sebesség</string>
@@ -2867,6 +2860,8 @@
<string name="display_position_top">Fent</string>
<string name="display_position_bottom">Lent</string>
<string name="display_position_center">Középen (alapértelmezett)</string>
<string name="display_position_left">Bal</string>
<string name="display_position_right">Jobb</string>
<string name="setup_tab_storage">Tárhely</string>
<string name="storage_measuring">Tárhely mérése…</string>
<string name="storage_measuring_hint">Első alkalommal pár másodpercig tart.</string>
@@ -3660,4 +3655,7 @@
<string name="input_reset_positions">Pozíciók visszaállítása</string>
<string name="input_show_label">Címke megjelenítése</string>
<string name="input_layout_import_failed">Ez az elrendezés nem importálható</string>
<string name="theme_white_red">Skarlátvörös hó</string>
</resources>
+5 -7
View File
@@ -1753,13 +1753,6 @@
<string name="bottom_downloads_shader">Mengunduh cache shader</string>
<string name="bottom_downloads_paused">Dijeda</string>
<string name="bottom_downloads_queued">Menunggu dimulai</string>
<string name="bottom_page_notifications">Notifikasi</string>
<string name="bottom_notifications_listener">Layar bawah DroidDeck</string>
<string name="bottom_notifications_why">Tampilkan pesan dan notifikasi lain di sini saat Anda bermain. Notifikasi hanya muncul di layar ini dan tidak disimpan. Android hanya meminta sekali: halaman akses notifikasinya terbuka di layar ini; aktifkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya.</string>
<string name="bottom_notifications_allow">Izinkan akses notifikasi</string>
<string name="bottom_notifications_none">Tidak ada notifikasi.</string>
<string name="bottom_notifications_count">%1$d notifikasi</string>
<string name="bottom_notifications_clear">Hapus</string>
<string name="bottom_page_screenshots">Tangkapan layar</string>
<string name="stats_fps_needs_hud">FPS memerlukan HUD aktif</string>
<string name="stats_speed">Kecepatan</string>
@@ -2838,6 +2831,8 @@
<string name="display_position_top">Atas</string>
<string name="display_position_bottom">Bawah</string>
<string name="display_position_center">Tengah (bawaan)</string>
<string name="display_position_left">Kiri</string>
<string name="display_position_right">Kanan</string>
<string name="setup_tab_storage">Penyimpanan</string>
<string name="storage_measuring">Mengukur penyimpanan…</string>
<string name="storage_measuring_hint">Butuh beberapa detik pada kali pertama.</string>
@@ -3630,4 +3625,7 @@
<string name="input_reset_positions">Atur ulang posisi</string>
<string name="input_show_label">Tampilkan label</string>
<string name="input_layout_import_failed">Tata letak ini tidak dapat diimpor</string>
<string name="theme_white_red">Salju Kirmizi</string>
</resources>
+5 -7
View File
@@ -1793,13 +1793,6 @@
<string name="bottom_downloads_shader">Download della cache degli shader</string>
<string name="bottom_downloads_paused">In pausa</string>
<string name="bottom_downloads_queued">In attesa di avvio</string>
<string name="bottom_page_notifications">Notifiche</string>
<string name="bottom_notifications_listener">Schermo inferiore DroidDeck</string>
<string name="bottom_notifications_why">Mostra qui messaggi e altre notifiche mentre giochi. Restano solo su questo schermo e non vengono salvate. Android lo chiede una sola volta: la sua pagina di accesso alle notifiche si apre su questo schermo; attiva DroidDeck lì e questa pagina torna da sola.</string>
<string name="bottom_notifications_allow">Consenti accesso alle notifiche</string>
<string name="bottom_notifications_none">Nessuna notifica.</string>
<string name="bottom_notifications_count">%1$d notifiche</string>
<string name="bottom_notifications_clear">Cancella</string>
<string name="bottom_page_screenshots">Screenshot</string>
<string name="stats_fps_needs_hud">Gli FPS richiedono l\'HUD attivo</string>
<string name="stats_speed">Velocità</string>
@@ -2896,6 +2889,8 @@
<string name="display_position_top">In alto</string>
<string name="display_position_bottom">In basso</string>
<string name="display_position_center">Centro (predefinito)</string>
<string name="display_position_left">Sinistra</string>
<string name="display_position_right">Destra</string>
<string name="setup_tab_storage">Archiviazione</string>
<string name="storage_measuring">Misurazione dell\'archiviazione…</string>
<string name="storage_measuring_hint">La prima volta richiede qualche secondo.</string>
@@ -3699,4 +3694,7 @@
<string name="input_reset_positions">Ripristina posizioni</string>
<string name="input_show_label">Mostra etichetta</string>
<string name="input_layout_import_failed">Impossibile importare questo layout</string>
<string name="theme_white_red">Neve scarlatta</string>
</resources>
+5 -7
View File
@@ -1753,13 +1753,6 @@
<string name="bottom_downloads_shader">シェーダーキャッシュをダウンロード中</string>
<string name="bottom_downloads_paused">一時停止中</string>
<string name="bottom_downloads_queued">開始待ち</string>
<string name="bottom_page_notifications">通知</string>
<string name="bottom_notifications_listener">DroidDeck下部画面</string>
<string name="bottom_notifications_why">プレイ中、メッセージなどの通知をここに表示します。通知はこの画面にのみ表示され、保存されません。Androidによる確認は一度だけです。この画面に通知へのアクセスのページが開くので、そこでDroidDeckをオンにすると、このページに自動的に戻ります。</string>
<string name="bottom_notifications_allow">通知へのアクセスを許可</string>
<string name="bottom_notifications_none">通知はありません。</string>
<string name="bottom_notifications_count">%1$d件の通知</string>
<string name="bottom_notifications_clear">クリア</string>
<string name="bottom_page_screenshots">スクリーンショット</string>
<string name="stats_fps_needs_hud">FPSの表示にはHUDをオンにする必要があります</string>
<string name="stats_speed">速度</string>
@@ -2838,6 +2831,8 @@
<string name="display_position_top">上</string>
<string name="display_position_bottom">下</string>
<string name="display_position_center">中央(既定)</string>
<string name="display_position_left">左</string>
<string name="display_position_right">右</string>
<string name="setup_tab_storage">ストレージ</string>
<string name="storage_measuring">ストレージを集計しています…</string>
<string name="storage_measuring_hint">初回の集計には数秒かかります。</string>
@@ -3630,4 +3625,7 @@
<string name="input_reset_positions">位置をリセット</string>
<string name="input_show_label">ラベルを表示</string>
<string name="input_layout_import_failed">このレイアウトをインポートできませんでした</string>
<string name="theme_white_red">緋色の雪</string>
</resources>
+5 -7
View File
@@ -1753,13 +1753,6 @@
<string name="bottom_downloads_shader">셰이더 캐시 다운로드 중</string>
<string name="bottom_downloads_paused">일시 중지됨</string>
<string name="bottom_downloads_queued">시작 대기 중</string>
<string name="bottom_page_notifications">알림</string>
<string name="bottom_notifications_listener">DroidDeck 하단 화면</string>
<string name="bottom_notifications_why">플레이하는 동안 메시지와 기타 알림을 여기에 표시합니다. 알림은 이 화면에만 표시되며 저장되지 않습니다. Android에서 한 번만 묻습니다. 이 화면에 알림 접근 권한 페이지가 열리면 DroidDeck을 켜세요. 그러면 이 페이지로 자동으로 돌아옵니다.</string>
<string name="bottom_notifications_allow">알림 접근 허용</string>
<string name="bottom_notifications_none">알림이 없습니다.</string>
<string name="bottom_notifications_count">알림 %1$d개</string>
<string name="bottom_notifications_clear">지우기</string>
<string name="bottom_page_screenshots">스크린샷</string>
<string name="stats_fps_needs_hud">FPS를 보려면 HUD를 켜야 합니다</string>
<string name="stats_speed">속도</string>
@@ -2838,6 +2831,8 @@
<string name="display_position_top">위</string>
<string name="display_position_bottom">아래</string>
<string name="display_position_center">가운데(기본)</string>
<string name="display_position_left">왼쪽</string>
<string name="display_position_right">오른쪽</string>
<string name="setup_tab_storage">저장 공간</string>
<string name="storage_measuring">저장 공간 계산 중…</string>
<string name="storage_measuring_hint">처음 계산할 때는 몇 초 걸립니다.</string>
@@ -3630,4 +3625,7 @@
<string name="input_reset_positions">위치 초기화</string>
<string name="input_show_label">라벨 표시</string>
<string name="input_layout_import_failed">이 레이아웃을 가져올 수 없습니다</string>
<string name="theme_white_red">진홍빛 눈</string>
</resources>
+5 -7
View File
@@ -1753,13 +1753,6 @@
<string name="bottom_downloads_shader">Memuat turun cache shader</string>
<string name="bottom_downloads_paused">Dijeda</string>
<string name="bottom_downloads_queued">Menunggu untuk bermula</string>
<string name="bottom_page_notifications">Pemberitahuan</string>
<string name="bottom_notifications_listener">Skrin bawah DroidDeck</string>
<string name="bottom_notifications_why">Paparkan mesej dan pemberitahuan lain di sini semasa anda bermain. Ia hanya kekal pada skrin ini dan tidak disimpan. Android bertanya sekali sahaja: halaman akses pemberitahuannya dibuka pada skrin ini; hidupkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya.</string>
<string name="bottom_notifications_allow">Benarkan akses pemberitahuan</string>
<string name="bottom_notifications_none">Tiada pemberitahuan.</string>
<string name="bottom_notifications_count">%1$d pemberitahuan</string>
<string name="bottom_notifications_clear">Kosongkan</string>
<string name="bottom_page_screenshots">Tangkapan skrin</string>
<string name="stats_fps_needs_hud">FPS memerlukan HUD dihidupkan</string>
<string name="stats_speed">Kelajuan</string>
@@ -2838,6 +2831,8 @@
<string name="display_position_top">Atas</string>
<string name="display_position_bottom">Bawah</string>
<string name="display_position_center">Tengah (lalai)</string>
<string name="display_position_left">Kiri</string>
<string name="display_position_right">Kanan</string>
<string name="setup_tab_storage">Storan</string>
<string name="storage_measuring">Mengukur storan…</string>
<string name="storage_measuring_hint">Mengambil beberapa saat pada kali pertama.</string>
@@ -3630,4 +3625,7 @@
<string name="input_reset_positions">Tetapkan semula kedudukan</string>
<string name="input_show_label">Tunjukkan label</string>
<string name="input_layout_import_failed">Susun atur ini tidak dapat diimport</string>
<string name="theme_white_red">Salji Merah Skarlet</string>
</resources>
+5 -7
View File
@@ -1777,13 +1777,6 @@
<string name="bottom_downloads_shader">Laster ned shaderbuffer</string>
<string name="bottom_downloads_paused">Pauset</string>
<string name="bottom_downloads_queued">Venter på å starte</string>
<string name="bottom_page_notifications">Varslinger</string>
<string name="bottom_notifications_listener">DroidDeck nedre skjerm</string>
<string name="bottom_notifications_why">Vis meldinger og andre varslinger her mens du spiller. De vises bare på denne skjermen og lagres ikke. Android spør én gang: siden for varseltilgang åpnes på denne skjermen; slå på DroidDeck der, så kommer denne siden tilbake av seg selv.</string>
<string name="bottom_notifications_allow">Tillat varseltilgang</string>
<string name="bottom_notifications_none">Ingen varslinger.</string>
<string name="bottom_notifications_count">%1$d varslinger</string>
<string name="bottom_notifications_clear">Fjern</string>
<string name="bottom_page_screenshots">Skjermbilder</string>
<string name="stats_fps_needs_hud">FPS krever at HUD-en er på</string>
<string name="stats_speed">Fart</string>
@@ -2867,6 +2860,8 @@
<string name="display_position_top">Øverst</string>
<string name="display_position_bottom">Nederst</string>
<string name="display_position_center">Midten (standard)</string>
<string name="display_position_left">Venstre</string>
<string name="display_position_right">Høyre</string>
<string name="setup_tab_storage">Lagring</string>
<string name="storage_measuring">Måler lagring…</string>
<string name="storage_measuring_hint">Første gang tar noen sekunder.</string>
@@ -3660,4 +3655,7 @@
<string name="input_reset_positions">Tilbakestill plasseringer</string>
<string name="input_show_label">Vis etikett</string>
<string name="input_layout_import_failed">Kunne ikke importere dette oppsettet</string>
<string name="theme_white_red">Skarlagenssnø</string>
</resources>
+5 -7
View File
@@ -1777,13 +1777,6 @@
<string name="bottom_downloads_shader">Shadercache downloaden</string>
<string name="bottom_downloads_paused">Gepauzeerd</string>
<string name="bottom_downloads_queued">Wacht om te starten</string>
<string name="bottom_page_notifications">Meldingen</string>
<string name="bottom_notifications_listener">DroidDeck onderste scherm</string>
<string name="bottom_notifications_why">Toon berichten en andere meldingen hier terwijl je speelt. Ze blijven alleen op dit scherm en worden niet opgeslagen. Android vraagt dit één keer: de pagina voor meldingstoegang opent op dit scherm; zet DroidDeck daar aan en deze pagina komt vanzelf terug.</string>
<string name="bottom_notifications_allow">Meldingstoegang toestaan</string>
<string name="bottom_notifications_none">Geen meldingen.</string>
<string name="bottom_notifications_count">%1$d meldingen</string>
<string name="bottom_notifications_clear">Wissen</string>
<string name="bottom_page_screenshots">Screenshots</string>
<string name="stats_fps_needs_hud">Voor FPS moet de HUD aan staan</string>
<string name="stats_speed">Snelheid</string>
@@ -2867,6 +2860,8 @@
<string name="display_position_top">Boven</string>
<string name="display_position_bottom">Onder</string>
<string name="display_position_center">Midden (standaard)</string>
<string name="display_position_left">Links</string>
<string name="display_position_right">Rechts</string>
<string name="setup_tab_storage">Opslag</string>
<string name="storage_measuring">Opslag meten…</string>
<string name="storage_measuring_hint">De eerste keer duurt een paar seconden.</string>
@@ -3660,4 +3655,7 @@
<string name="input_reset_positions">Posities herstellen</string>
<string name="input_show_label">Label tonen</string>
<string name="input_layout_import_failed">Deze indeling kan niet worden geïmporteerd</string>
<string name="theme_white_red">Scharlakenrode sneeuw</string>
</resources>
+5 -7
View File
@@ -1825,13 +1825,6 @@
<string name="bottom_downloads_shader">Pobieranie pamięci podręcznej shaderów</string>
<string name="bottom_downloads_paused">Wstrzymano</string>
<string name="bottom_downloads_queued">Oczekuje na rozpoczęcie</string>
<string name="bottom_page_notifications">Powiadomienia</string>
<string name="bottom_notifications_listener">Dolny ekran DroidDeck</string>
<string name="bottom_notifications_why">Wyświetlaj tu wiadomości i inne powiadomienia podczas gry. Pozostają tylko na tym ekranie i nie są zapisywane. Android zapyta raz: na tym ekranie otworzy się jego strona dostępu do powiadomień; włącz tam DroidDeck, a ta strona wróci sama.</string>
<string name="bottom_notifications_allow">Zezwól na dostęp do powiadomień</string>
<string name="bottom_notifications_none">Brak powiadomień.</string>
<string name="bottom_notifications_count">Powiadomienia: %1$d</string>
<string name="bottom_notifications_clear">Wyczyść</string>
<string name="bottom_page_screenshots">Zrzuty ekranu</string>
<string name="stats_fps_needs_hud">FPS wymaga włączonego HUD</string>
<string name="stats_speed">Prędkość</string>
@@ -2925,6 +2918,8 @@
<string name="display_position_top">Góra</string>
<string name="display_position_bottom">Dół</string>
<string name="display_position_center">Środek (domyślnie)</string>
<string name="display_position_left">Lewo</string>
<string name="display_position_right">Prawo</string>
<string name="setup_tab_storage">Pamięć</string>
<string name="storage_measuring">Mierzenie pamięci…</string>
<string name="storage_measuring_hint">Pierwszy raz trwa kilka sekund.</string>
@@ -3738,4 +3733,7 @@
<string name="input_reset_positions">Resetuj położenie</string>
<string name="input_show_label">Pokaż etykietę</string>
<string name="input_layout_import_failed">Nie można zaimportować tego układu</string>
<string name="theme_white_red">Szkarłatny śnieg</string>
</resources>
+5 -7
View File
@@ -1793,13 +1793,6 @@
<string name="bottom_downloads_shader">A transferir a cache de shaders</string>
<string name="bottom_downloads_paused">Em pausa</string>
<string name="bottom_downloads_queued">A aguardar início</string>
<string name="bottom_page_notifications">Notificações</string>
<string name="bottom_notifications_listener">Ecrã inferior do DroidDeck</string>
<string name="bottom_notifications_why">Mostre aqui mensagens e outras notificações enquanto joga. Ficam apenas neste ecrã e não são guardadas. O Android pergunta uma vez: a página de acesso às notificações abre neste ecrã; ative lá o DroidDeck e esta página volta automaticamente.</string>
<string name="bottom_notifications_allow">Permitir acesso às notificações</string>
<string name="bottom_notifications_none">Sem notificações.</string>
<string name="bottom_notifications_count">%1$d notificações</string>
<string name="bottom_notifications_clear">Limpar</string>
<string name="bottom_page_screenshots">Capturas de ecrã</string>
<string name="stats_fps_needs_hud">O FPS requer o HUD ativado</string>
<string name="stats_speed">Velocidade</string>
@@ -2894,6 +2887,8 @@
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
<string name="display_picture_position">Posição da imagem</string>
<string name="display_position_top">Topo</string>
<string name="display_position_left">Esquerda</string>
<string name="display_position_right">Direita</string>
<string name="display_position_bottom">Fundo</string>
<string name="display_position_center">Centro (predefinido)</string>
<string name="setup_tab_storage">Armazenamento</string>
@@ -3699,4 +3694,7 @@
<string name="input_reset_positions">Repor posições</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">Não foi possível importar este esquema</string>
<string name="theme_white_red">Neve Escarlate</string>
</resources>
+5 -7
View File
@@ -1793,13 +1793,6 @@
<string name="bottom_downloads_shader">Baixando o cache de shaders</string>
<string name="bottom_downloads_paused">Pausado</string>
<string name="bottom_downloads_queued">Aguardando para começar</string>
<string name="bottom_page_notifications">Notificações</string>
<string name="bottom_notifications_listener">Tela inferior do DroidDeck</string>
<string name="bottom_notifications_why">Mostre aqui mensagens e outras notificações enquanto você joga. Elas ficam só nesta tela e não são salvas. O Android pergunta uma vez: a página de acesso a notificações dele abre nesta tela; ative o DroidDeck lá e esta página volta sozinha.</string>
<string name="bottom_notifications_allow">Permitir acesso a notificações</string>
<string name="bottom_notifications_none">Nenhuma notificação.</string>
<string name="bottom_notifications_count">%1$d notificações</string>
<string name="bottom_notifications_clear">Limpar</string>
<string name="bottom_page_screenshots">Capturas de tela</string>
<string name="stats_fps_needs_hud">O FPS precisa do HUD ativado</string>
<string name="stats_speed">Velocidade</string>
@@ -2894,6 +2887,8 @@
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
<string name="display_picture_position">Posição da imagem</string>
<string name="display_position_top">Topo</string>
<string name="display_position_left">Esquerda</string>
<string name="display_position_right">Direita</string>
<string name="display_position_bottom">Base</string>
<string name="display_position_center">Centro (padrão)</string>
<string name="setup_tab_storage">Armazenamento</string>
@@ -3699,4 +3694,7 @@
<string name="input_reset_positions">Redefinir posições</string>
<string name="input_show_label">Mostrar rótulo</string>
<string name="input_layout_import_failed">Não foi possível importar este layout</string>
<string name="theme_white_red">Neve Escarlate</string>
</resources>
+5 -7
View File
@@ -1801,13 +1801,6 @@
<string name="bottom_downloads_shader">Se descarcă cache-ul de shadere</string>
<string name="bottom_downloads_paused">Întrerupt</string>
<string name="bottom_downloads_queued">Așteaptă să înceapă</string>
<string name="bottom_page_notifications">Notificări</string>
<string name="bottom_notifications_listener">Ecranul de jos DroidDeck</string>
<string name="bottom_notifications_why">Afișează aici mesajele și alte notificări în timp ce te joci. Rămân doar pe acest ecran și nu sunt salvate. Android întreabă o singură dată: pagina sa de acces la notificări se deschide pe acest ecran; activează DroidDeck acolo, iar această pagină revine singură.</string>
<string name="bottom_notifications_allow">Permite accesul la notificări</string>
<string name="bottom_notifications_none">Nicio notificare.</string>
<string name="bottom_notifications_count">Notificări: %1$d</string>
<string name="bottom_notifications_clear">Șterge</string>
<string name="bottom_page_screenshots">Capturi de ecran</string>
<string name="stats_fps_needs_hud">FPS necesită HUD-ul activat</string>
<string name="stats_speed">Viteză</string>
@@ -2896,6 +2889,8 @@
<string name="display_position_top">Sus</string>
<string name="display_position_bottom">Jos</string>
<string name="display_position_center">Centru (implicit)</string>
<string name="display_position_left">Stânga</string>
<string name="display_position_right">Dreapta</string>
<string name="setup_tab_storage">Stocare</string>
<string name="storage_measuring">Se măsoară stocarea…</string>
<string name="storage_measuring_hint">Prima dată durează câteva secunde.</string>
@@ -3699,4 +3694,7 @@
<string name="input_reset_positions">Resetează pozițiile</string>
<string name="input_show_label">Afișează eticheta</string>
<string name="input_layout_import_failed">Acest aranjament nu a putut fi importat</string>
<string name="theme_white_red">Zăpadă stacojie</string>
</resources>
+5 -7
View File
@@ -1888,13 +1888,6 @@
<string name="bottom_downloads_shader">Загрузка кэша шейдеров</string>
<string name="bottom_downloads_paused">Приостановлено</string>
<string name="bottom_downloads_queued">Ожидает начала</string>
<string name="bottom_page_notifications">Уведомления</string>
<string name="bottom_notifications_listener">Нижний экран DroidDeck</string>
<string name="bottom_notifications_why">Показывает сообщения и другие уведомления здесь, пока вы играете. Они отображаются только на этом экране и не сохраняются. Android спросит один раз: на этом экране откроется страница доступа к уведомлениям; включите там DroidDeck, и эта страница вернется сама.</string>
<string name="bottom_notifications_allow">Разрешить доступ к уведомлениям</string>
<string name="bottom_notifications_none">Нет уведомлений.</string>
<string name="bottom_notifications_count">Уведомлений: %1$d</string>
<string name="bottom_notifications_clear">Очистить</string>
<string name="bottom_page_screenshots">Скриншоты</string>
<string name="stats_fps_needs_hud">Для FPS нужно включить HUD</string>
<string name="stats_speed">Скорость</string>
@@ -3008,6 +3001,8 @@
<string name="display_position_top">Сверху</string>
<string name="display_position_bottom">Снизу</string>
<string name="display_position_center">По центру (по умолчанию)</string>
<string name="display_position_left">Слева</string>
<string name="display_position_right">Справа</string>
<string name="setup_tab_storage">Хранилище</string>
<string name="storage_measuring">Измерение хранилища…</string>
<string name="storage_measuring_hint">В первый раз занимает несколько секунд.</string>
@@ -3821,4 +3816,7 @@
<string name="input_reset_positions">Сбросить положение</string>
<string name="input_show_label">Показывать подпись</string>
<string name="input_layout_import_failed">Не удалось импортировать эту раскладку</string>
<string name="theme_white_red">Алый снег</string>
</resources>
+5 -7
View File
@@ -1777,13 +1777,6 @@
<string name="bottom_downloads_shader">Laddar ned shadercache</string>
<string name="bottom_downloads_paused">Pausad</string>
<string name="bottom_downloads_queued">Väntar på att starta</string>
<string name="bottom_page_notifications">Aviseringar</string>
<string name="bottom_notifications_listener">DroidDecks nedre skärm</string>
<string name="bottom_notifications_why">Visa meddelanden och andra aviseringar här medan du spelar. De visas bara på den här skärmen och sparas inte. Android frågar en gång: sidan för aviseringsåtkomst öppnas på den här skärmen; slå på DroidDeck där så kommer den här sidan tillbaka av sig själv.</string>
<string name="bottom_notifications_allow">Tillåt aviseringsåtkomst</string>
<string name="bottom_notifications_none">Inga aviseringar.</string>
<string name="bottom_notifications_count">%1$d aviseringar</string>
<string name="bottom_notifications_clear">Rensa</string>
<string name="bottom_page_screenshots">Skärmbilder</string>
<string name="stats_fps_needs_hud">FPS kräver att HUD:en är på</string>
<string name="stats_speed">Hastighet</string>
@@ -2867,6 +2860,8 @@
<string name="display_position_top">Överst</string>
<string name="display_position_bottom">Nederst</string>
<string name="display_position_center">Mitten (standard)</string>
<string name="display_position_left">Vänster</string>
<string name="display_position_right">Höger</string>
<string name="setup_tab_storage">Lagring</string>
<string name="storage_measuring">Mäter lagring…</string>
<string name="storage_measuring_hint">Första gången tar några sekunder.</string>
@@ -3660,4 +3655,7 @@
<string name="input_reset_positions">Återställ positioner</string>
<string name="input_show_label">Visa etikett</string>
<string name="input_layout_import_failed">Det gick inte att importera layouten</string>
<string name="theme_white_red">Scharlakansröd snö</string>
</resources>
+5 -7
View File
@@ -1753,13 +1753,6 @@
<string name="bottom_downloads_shader">กำลังดาวน์โหลดแคชเชเดอร์</string>
<string name="bottom_downloads_paused">หยุดชั่วคราว</string>
<string name="bottom_downloads_queued">กำลังรอเริ่ม</string>
<string name="bottom_page_notifications">การแจ้งเตือน</string>
<string name="bottom_notifications_listener">หน้าจอล่างของ DroidDeck</string>
<string name="bottom_notifications_why">แสดงข้อความและการแจ้งเตือนอื่นๆ ที่นี่ขณะที่คุณเล่น การแจ้งเตือนจะแสดงบนหน้าจอนี้เท่านั้นและจะไม่ถูกบันทึก Android จะถามเพียงครั้งเดียว: หน้าการเข้าถึงการแจ้งเตือนจะเปิดขึ้นบนหน้าจอนี้ เปิด DroidDeck ที่นั่น แล้วหน้านี้จะกลับมาเอง</string>
<string name="bottom_notifications_allow">อนุญาตการเข้าถึงการแจ้งเตือน</string>
<string name="bottom_notifications_none">ไม่มีการแจ้งเตือน</string>
<string name="bottom_notifications_count">การแจ้งเตือน %1$d รายการ</string>
<string name="bottom_notifications_clear">ล้าง</string>
<string name="bottom_page_screenshots">ภาพหน้าจอ</string>
<string name="stats_fps_needs_hud">ต้องเปิด HUD เพื่อแสดง FPS</string>
<string name="stats_speed">ความเร็ว</string>
@@ -2838,6 +2831,8 @@
<string name="display_position_top">บน</string>
<string name="display_position_bottom">ล่าง</string>
<string name="display_position_center">กลาง (ค่าเริ่มต้น)</string>
<string name="display_position_left">ซ้าย</string>
<string name="display_position_right">ขวา</string>
<string name="setup_tab_storage">พื้นที่จัดเก็บ</string>
<string name="storage_measuring">กำลังวัดพื้นที่…</string>
<string name="storage_measuring_hint">ครั้งแรกใช้เวลาสองสามวินาที</string>
@@ -3630,4 +3625,7 @@
<string name="input_reset_positions">รีเซ็ตตำแหน่ง</string>
<string name="input_show_label">แสดงป้ายกำกับ</string>
<string name="input_layout_import_failed">ไม่สามารถนำเข้าการจัดวางนี้ได้</string>
<string name="theme_white_red">หิมะสีแดงชาด</string>
</resources>
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more