mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-10 10:01:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ed02f2cf | ||
|
|
9bf638a3c4 | ||
|
|
40242fa082 | ||
|
|
98ab71a814 | ||
|
|
cdff94e72c | ||
|
|
aa1984bb94 | ||
|
|
634cb390c9 | ||
|
|
13c8edb3d5 | ||
|
|
1ec2b696b7 | ||
|
|
621fd8d43e | ||
|
|
17936530aa | ||
|
|
ae3e9509f4 | ||
|
|
6bcd431ca6 | ||
|
|
836340f3a2 | ||
|
|
1b19837fc6 | ||
|
|
c85b84a964 | ||
|
|
75a0230304 | ||
|
|
7c89c14a84 | ||
|
|
b837611efe | ||
|
|
bf434b2652 | ||
|
|
35941bb9e7 | ||
|
|
96a12465e0 | ||
|
|
88d6b70881 | ||
|
|
29823bb1bf | ||
|
|
92a7124589 | ||
|
|
bf95a40e3e | ||
|
|
1355cb3a2b | ||
|
|
51407155fc | ||
|
|
b4a953c659 | ||
|
|
5e438e9248 | ||
|
|
6b110581ac | ||
|
|
92742f37c1 | ||
|
|
ecd916d43f | ||
|
|
a7c10e3f51 | ||
|
|
1760f008ae | ||
|
|
3bb279f635 | ||
|
|
3fef1f3fa1 | ||
|
|
a4f26799ab | ||
|
|
08a6a3cba8 | ||
|
|
005fec2125 | ||
|
|
2e70b8ed73 | ||
|
|
74c0f2127a |
No files matched your search
@@ -10,3 +10,5 @@ tools/mangoapp/mangoapp text eol=lf
|
||||
app/src/main/assets/desktop-removal/*.json text eol=lf
|
||||
app/src/main/assets/desktop-removal/*.txt text eol=lf
|
||||
*.pdf binary
|
||||
|
||||
app/src/main/resources/ea/graphql/*.gql -text
|
||||
@@ -883,6 +883,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'
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 2
|
||||
outputs:
|
||||
labels: ${{ steps.runner.outputs.labels || '"ubuntu-24.04"' }}
|
||||
labels: ${{ steps.runner.outputs.labels || steps.fork.outputs.labels || '"ubuntu-24.04"' }}
|
||||
steps:
|
||||
- name: Pick the compile runner
|
||||
id: runner
|
||||
@@ -36,3 +36,25 @@ jobs:
|
||||
fi
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Pick the fork runner
|
||||
id: fork
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
labels='"ubuntu-24.04"'
|
||||
gist=https://api.github.com/gists/0c7ed7ad6d6493de84f5123689c64ae6
|
||||
body=$(curl -fsS --max-time 15 -H "Authorization: Bearer $GH_TOKEN" "$gist" 2>/dev/null || curl -fsS --max-time 15 "$gist" 2>/dev/null || echo '{}')
|
||||
IDLE_AT=$(jq -r 'select(.owner.login == "maxjivi05") | .files["idle-at"].content // "0"' <<< "$body" 2>/dev/null || echo 0)
|
||||
echo "Heartbeat: ${IDLE_AT:-none}"
|
||||
age=-1
|
||||
if [[ "${IDLE_AT:-0}" =~ ^[0-9]+$ ]] && [ "${IDLE_AT:-0}" -gt 0 ]; then
|
||||
age=$(( $(date +%s) - IDLE_AT ))
|
||||
fi
|
||||
if [ "$age" -ge 0 ] && [ "$age" -lt 180 ]; then
|
||||
labels='"droiddeck-fork"'
|
||||
fi
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
echo "Compile runner: \`$labels\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -79,7 +79,8 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- droiddeck-ntsync follows the [Linux](https://github.com/torvalds/linux) ntsync semantics in `Documentation/userspace-api/ntsync.rst` (GPL-2.0).
|
||||
- The proot fast path orders bindings like [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later).
|
||||
- DiRT 3 and DiRT Showdown keep their telemetry socket the way RoccoC's DiRTTelemetryErrorFix (MIT) does, based on the fix by Carsten Gottschling.
|
||||
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `TU_DEBUG=sysmem`.
|
||||
- Zink on PowerVR keeps colour textures in colour with Icenowy Zheng's upstream [Mesa](https://gitlab.freedesktop.org/mesa/mesa) fix d55418dae7 (MIT), carried as a runtime patch.
|
||||
- Games and AppImage, Flatpak and Snap programs such as emulators use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client and Chromium-based apps keep `TU_DEBUG=sysmem`.
|
||||
- Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose `311210.py` and `570940.py` fail under ARM64 Proton.
|
||||
- [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans uses the launch option from its README and D3D12 feature level 12_1.
|
||||
- The arm64 client's sign in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest).
|
||||
@@ -92,6 +93,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- Epic Games and GOG build on [Phobos](https://github.com/phobos665)'s adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl). GOG install script registry entries are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher) (GPL-3.0) reads them.
|
||||
- Epic's official Linux launcher preview runs through FEX. That setup is outside Epic's listed Linux support.
|
||||
- Battle.net is by [@kayakyakr](https://github.com/kayakyakr). It runs Blizzard's own client in Proton (downloaded from Blizzard, not included) and shows key art from the [Battle.net Shop](https://shop.battle.net). Without Steam, its games see the pad as an Xbox 360 controller via `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`.
|
||||
- The EA store follows the EA app's protocols as documented by [ArmchairDevelopers](https://github.com/ArmchairDevelopers)' [Maxima](https://github.com/ArmchairDevelopers/Maxima) (GPL-3.0): sign-in, the GraphQL queries (included verbatim, since EA matches them by hash), build archives, online activation licences and the LSX game endpoint. The code is DroidDeck's own.
|
||||
- Battle.net installs drive Blizzard's Agent the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) does, with requests from [wowdev.wiki](https://wowdev.wiki/Agent). Read without taking code: [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader.
|
||||
- World of Warcraft addon imports replace an addon's folder from a temporary copy, as [WowUp](https://github.com/WowUp/WowUp) does, and read flavour folders the way [WoWDeck](https://github.com/sigboe/wowdeck) does. No code from either is used.
|
||||
- The World of Warcraft addon Download window searches [WoWInterface](https://www.wowinterface.com) through its MMOUI API. [CurseForge](https://www.curseforge.com/wow) opens in the user's browser, with no API and no key.
|
||||
|
||||
+2
-2
@@ -131,8 +131,8 @@ android {
|
||||
applicationId "com.droiddeck.launcher"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 28
|
||||
versionCode 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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,8 @@ class App : Application() {
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
|
||||
com.droiddeck.launcher.sync.Syncthing.init(this)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend)
|
||||
com.droiddeck.launcher.stores.ea.EaBackend.app = this
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend)
|
||||
}
|
||||
|
||||
private fun prefetchAddonPacks() {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,8 +40,10 @@ object SessionFiles {
|
||||
WinComponents.publish(context)
|
||||
val files = arrayOf(
|
||||
"usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env",
|
||||
"usr/local/bin/droiddeck-game-gpu" to "usr/local/bin/droiddeck-game-gpu",
|
||||
"usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory",
|
||||
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
|
||||
"usr/local/bin/droiddeck-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",
|
||||
|
||||
@@ -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 -> ""
|
||||
} ?: return null
|
||||
p.edit()
|
||||
.putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE })
|
||||
@@ -148,7 +148,7 @@ object CloudSaves {
|
||||
val dir = when (store) {
|
||||
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
|
||||
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> null
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null
|
||||
}
|
||||
return dir to (if (dir == null) "unresolved" else "ok")
|
||||
}
|
||||
@@ -173,7 +173,7 @@ object CloudSaves {
|
||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
|
||||
}
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> null
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null
|
||||
}
|
||||
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
|
||||
|
||||
@@ -13,11 +13,13 @@ internal object LoginFlows {
|
||||
Store.EPIC -> epicFlow()
|
||||
Store.AMAZON -> amazonFlow()
|
||||
Store.GAMEJOLT -> gameJoltFlow(browserUserAgent)
|
||||
Store.EA -> eaFlow(browserUserAgent)
|
||||
Store.ITCH, Store.XBOX, Store.BATTLENET -> null
|
||||
}
|
||||
|
||||
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
@Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
@Volatile var eaFlow: (String) -> StoreLoginActivity.Flow? = { null }
|
||||
@Volatile var gameJoltFlow: (String) -> StoreLoginActivity.Flow? = { null }
|
||||
|
||||
private class GogFlow(private val state: String, override val userAgent: String) : StoreLoginActivity.Flow {
|
||||
|
||||
@@ -7,7 +7,8 @@ enum class Store(val id: String, val label: String, val shortLabel: String) {
|
||||
XBOX("xbox", "Xbox", "Xbox"),
|
||||
ITCH("itch", "Itch.io", "Itch.io"),
|
||||
GAMEJOLT("gamejolt", "Game Jolt", "Game Jolt"),
|
||||
BATTLENET("battlenet", "Battle.net", "Battle.net");
|
||||
BATTLENET("battlenet", "Battle.net", "Battle.net"),
|
||||
EA("ea", "EA", "EA");
|
||||
|
||||
val folder: String get() = if (this == ITCH) "itch" else shortLabel
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ object StoreLaunch {
|
||||
const val LAUNCHER = ".droiddeck-launch.bat"
|
||||
const val EPIC_CODE = ".droiddeck-epic-code"
|
||||
const val EPIC_ARGS = ".droiddeck-epic-args"
|
||||
const val EA_ENV = ".droiddeck-ea-env"
|
||||
/** A sleeping stand-in that the launch hook installs; older EA SDKs require a running Origin process. */
|
||||
const val EA_ORIGIN = "C:\\Program Files (x86)\\Origin\\Origin.exe"
|
||||
|
||||
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
|
||||
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC
|
||||
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC || sidecar.store == Store.EA
|
||||
val file = File(folder, LAUNCHER)
|
||||
if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
|
||||
file.writeText(launcherText(sidecar))
|
||||
@@ -49,6 +52,14 @@ object StoreLaunch {
|
||||
lines += "del \"%~dp0$EPIC_ARGS\""
|
||||
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
|
||||
lines += "set \"$CLIENT_VAR=\" & \"%~dp0$exe\" %$CLIENT_VAR%"
|
||||
} else if (sidecar.store == Store.EA) {
|
||||
lines += "if not exist \"%~dp0$EA_ENV\" goto run"
|
||||
lines += "for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0$EA_ENV\") do set \"%%a=%%b\""
|
||||
lines += "del \"%~dp0$EA_ENV\""
|
||||
lines += ":run"
|
||||
lines += "if exist \"$EA_ORIGIN\" start \"\" \"$EA_ORIGIN\""
|
||||
lines += run
|
||||
lines += "taskkill /f /im Origin.exe >nul 2>&1"
|
||||
} else {
|
||||
lines += run
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ object StoreLaunchRequests {
|
||||
|
||||
fun stop() {
|
||||
com.droiddeck.launcher.stores.xbox.XboxRuntime.stop()
|
||||
com.droiddeck.launcher.stores.ea.EaLsx.stop()
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
@@ -58,6 +59,12 @@ object StoreLaunchRequests {
|
||||
"xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease")))
|
||||
"xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
|
||||
"ea-prepare" -> runCatching { lastLaunched = Store.EA to request.optString("id"); com.droiddeck.launcher.stores.ea.EaLaunch.prepare(app, request) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("reason", "error-" + it.javaClass.simpleName) }
|
||||
"ea-exited" -> {
|
||||
com.droiddeck.launcher.stores.ea.EaLsx.stop(request.optString("id"))
|
||||
JSONObject().put("ok", true)
|
||||
}
|
||||
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") }
|
||||
"epic-code" -> {
|
||||
|
||||
@@ -55,6 +55,7 @@ object StoreSizes {
|
||||
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.ITCH, Store.GAMEJOLT -> {}
|
||||
Store.EA -> com.droiddeck.launcher.stores.ea.EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.GOG, Store.XBOX, Store.BATTLENET -> {}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +112,7 @@ object StoreSizes {
|
||||
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
|
||||
AmazonManifest.parse(bytes).totalInstallSize
|
||||
}
|
||||
Store.EA -> com.droiddeck.launcher.stores.ea.EaSizes.size(context, item)
|
||||
Store.GOG, Store.XBOX, Store.BATTLENET -> 0L
|
||||
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
|
||||
Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item)
|
||||
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
|
||||
private fun tag(name: String, vararg attrs: Pair<String, Any?>, body: String? = null): String {
|
||||
val a = attrs.filter { it.second != null }.joinToString("") { " ${it.first}=\"${esc(it.second.toString())}\"" }
|
||||
return if (body == null) "<$name$a/>" else "<$name$a>$body</$name>"
|
||||
}
|
||||
|
||||
private fun now() = LocalDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
|
||||
|
||||
private fun answer(session: Session, m: Message): String? {
|
||||
val g = game
|
||||
val who = account
|
||||
val person = who?.takeIf { session.trusted }
|
||||
val locale = g?.locale ?: "en_US"
|
||||
return when (m.name) {
|
||||
"ChallengeResponse" -> {
|
||||
val answer = EaCrypto.challengeAnswer(m.attrs["key"].orEmpty())
|
||||
val version = m.attrs["version"].orEmpty()
|
||||
val seed = EaCrypto.challengeSeed(version, answer) ?: 0
|
||||
val content = m.children.firstOrNull { it.first == "ContentId" }?.second.orEmpty()
|
||||
session.claimed = g != null && content.isNotEmpty() && (content == g.contentId || content == 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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
|
||||
fun licenseXml(l: License): String {
|
||||
val order = listOf("CipherKey", "MachineHash", "ContentId", "UserId", "GameToken", "GrantTime", "StartTime", "Nonce")
|
||||
val body = StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>")
|
||||
body.append("<License xmlns=\"").append(esc(l.namespace)).append("\">")
|
||||
for (k in order) {
|
||||
val v = l.fields[k] ?: continue
|
||||
if (k == "GameToken" && v.isEmpty()) continue
|
||||
body.append('<').append(k).append('>').append(esc(v)).append("</").append(k).append('>')
|
||||
}
|
||||
return body.append("</License>").toString()
|
||||
}
|
||||
|
||||
fun licenseBlob(l: License, encoded: Boolean): ByteArray {
|
||||
val signature = if (encoded) l.signature.toByteArray() else java.util.Base64.getDecoder().decode(l.signature)
|
||||
val head = ByteArray(SIGNATURE_SLOT)
|
||||
System.arraycopy(signature, 0, head, 0, signature.size.coerceAtMost(SIGNATURE_SLOT))
|
||||
return head + EaCrypto.licenseEncrypt(licenseXml(l).toByteArray())
|
||||
}
|
||||
|
||||
fun request(token: String, contentId: String, machineHash: String, requestToken: String? = null, requestType: String? = null): License {
|
||||
val nonce = EaCrypto.hex(ByteArray(4).also(SecureRandom()::nextBytes))
|
||||
val params = mutableListOf("contentId" to contentId, "machineHash" to machineHash, "nonce" to nonce, "ea_eadmtoken" to token)
|
||||
if (requestToken != null) params += "requestToken" to requestToken
|
||||
if (requestType != null) params += "requestType" to requestType
|
||||
val url = LICENSES + "?" + params.joinToString("&") { (k, v) -> k + "=" + URLEncoder.encode(v, "UTF-8") }
|
||||
val reply = EaHttp.request(url, headers = mapOf("X-Requester-Id" to "Origin Online Activation", "User-Agent" to "EACTransaction"))
|
||||
if (!reply.ok) throw IllegalStateException("EA refused the licence (${reply.status}): ${reply.text.replace(Regex("(eadmToken value=\")[^\"]*"), "$1<redacted>\"").take(600)}")
|
||||
val signature = reply.header("x-signature") ?: throw IllegalStateException("EA's licence had no signature")
|
||||
return parseLicense(String(EaCrypto.licenseDecrypt(reply.body), Charsets.UTF_8), signature)
|
||||
}
|
||||
|
||||
/** Licence files for a game about to start, named as the activation layer looks for them. */
|
||||
fun files(core: File, license: License): List<Pair<String, ByteArray>> {
|
||||
val blob = licenseBlob(license, encodedSignature(core))
|
||||
val id = license.contentId
|
||||
return listOf("$LICENSE_DIR/$id.dlf" to blob, "$LICENSE_DIR/${id}_cached.dlf" to blob)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@ object GogBackend : StoreBackend {
|
||||
}, "gog-sync").start()
|
||||
}
|
||||
|
||||
override fun search(context: Context, query: String): List<CatalogItem> = GogStoreCatalog.search(query)
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
GogStoreCatalog.cachedFeatured(app)?.let { StoresState.post { StoresState.shelves[store] = it } }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
|
||||
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
|
||||
Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00))
|
||||
Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF))
|
||||
Store.EA.id -> SourceColours(Color(0xFF3A0E12), Color(0xFFFF9A9A), Color(0xFFFF4747))
|
||||
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
|
||||
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
|
||||
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
|
||||
@@ -48,6 +49,7 @@ internal fun sourceLabel(source: String): String = when (source) {
|
||||
Store.ITCH.id -> Store.ITCH.label
|
||||
Store.GAMEJOLT.id -> Store.GAMEJOLT.label
|
||||
Store.BATTLENET.id -> Store.BATTLENET.label
|
||||
Store.EA.id -> Store.EA.label
|
||||
else -> stringResource(R.string.source_added)
|
||||
}
|
||||
|
||||
|
||||
@@ -1157,6 +1157,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
|
||||
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
|
||||
Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00)
|
||||
Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3)
|
||||
Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030)
|
||||
null -> return base
|
||||
}
|
||||
val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1769,13 +1769,6 @@
|
||||
<string name="bottom_downloads_shader">Gölgelendirici önbelleği indiriliyor</string>
|
||||
<string name="bottom_downloads_paused">Duraklatıldı</string>
|
||||
<string name="bottom_downloads_queued">Başlamayı bekliyor</string>
|
||||
<string name="bottom_page_notifications">Bildirimler</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck alt ekranı</string>
|
||||
<string name="bottom_notifications_why">Oynarken mesajları ve diğer bildirimleri burada görün. Yalnızca bu ekranda kalırlar ve kaydedilmezler. Android bunu bir kez sorar: bildirim erişimi sayfası bu ekranda açılır; orada DroidDeck\'i açın, bu sayfa kendiliğinden geri gelir.</string>
|
||||
<string name="bottom_notifications_allow">Bildirim erişimine izin ver</string>
|
||||
<string name="bottom_notifications_none">Bildirim yok.</string>
|
||||
<string name="bottom_notifications_count">%1$d bildirim</string>
|
||||
<string name="bottom_notifications_clear">Temizle</string>
|
||||
<string name="bottom_page_screenshots">Ekran görüntüleri</string>
|
||||
<string name="stats_fps_needs_hud">FPS için HUD açık olmalı</string>
|
||||
<string name="stats_speed">Hız</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Üst</string>
|
||||
<string name="display_position_bottom">Alt</string>
|
||||
<string name="display_position_center">Orta (varsayılan)</string>
|
||||
<string name="display_position_left">Sol</string>
|
||||
<string name="display_position_right">Sağ</string>
|
||||
<string name="setup_tab_storage">Depolama</string>
|
||||
<string name="storage_measuring">Depolama ölçülüyor…</string>
|
||||
<string name="storage_measuring_hint">İlk seferde birkaç saniye sürer.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Konumları sıfırla</string>
|
||||
<string name="input_show_label">Etiketi göster</string>
|
||||
<string name="input_layout_import_failed">Bu düzen içe aktarılamadı</string>
|
||||
|
||||
<string name="theme_white_red">Kızıl Kar</string>
|
||||
|
||||
</resources>
|
||||
@@ -1825,13 +1825,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>
|
||||
@@ -2925,6 +2918,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>
|
||||
@@ -3738,4 +3733,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>
|
||||
@@ -1753,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">Đang tải bộ nhớ đệm shader</string>
|
||||
<string name="bottom_downloads_paused">Đã tạm dừng</string>
|
||||
<string name="bottom_downloads_queued">Đang chờ bắt đầu</string>
|
||||
<string name="bottom_page_notifications">Thông báo</string>
|
||||
<string name="bottom_notifications_listener">Màn hình dưới DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Hiển thị tin nhắn và các thông báo khác ở đây khi bạn chơi. Chúng chỉ ở trên màn hình này và không được lưu. Android chỉ hỏi một lần: trang quyền truy cập thông báo sẽ mở trên màn hình này; hãy bật DroidDeck ở đó và trang này sẽ tự quay lại.</string>
|
||||
<string name="bottom_notifications_allow">Cho phép truy cập thông báo</string>
|
||||
<string name="bottom_notifications_none">Không có thông báo.</string>
|
||||
<string name="bottom_notifications_count">%1$d thông báo</string>
|
||||
<string name="bottom_notifications_clear">Xóa</string>
|
||||
<string name="bottom_page_screenshots">Ảnh chụp màn hình</string>
|
||||
<string name="stats_fps_needs_hud">Cần bật HUD để xem FPS</string>
|
||||
<string name="stats_speed">Tốc độ</string>
|
||||
@@ -2838,6 +2831,8 @@
|
||||
<string name="display_position_top">Trên</string>
|
||||
<string name="display_position_bottom">Dưới</string>
|
||||
<string name="display_position_center">Giữa (mặc định)</string>
|
||||
<string name="display_position_left">Trái</string>
|
||||
<string name="display_position_right">Phải</string>
|
||||
<string name="setup_tab_storage">Lưu trữ</string>
|
||||
<string name="storage_measuring">Đang đo dung lượng…</string>
|
||||
<string name="storage_measuring_hint">Lần đầu mất vài giây.</string>
|
||||
@@ -3630,4 +3625,7 @@
|
||||
<string name="input_reset_positions">Đặt lại vị trí</string>
|
||||
<string name="input_show_label">Hiện nhãn</string>
|
||||
<string name="input_layout_import_failed">Không thể nhập bố cục này</string>
|
||||
|
||||
<string name="theme_white_red">Tuyết đỏ thẫm</string>
|
||||
|
||||
</resources>
|
||||
@@ -1831,13 +1831,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>
|
||||
@@ -2842,6 +2835,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>
|
||||
@@ -3634,4 +3629,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>
|
||||
@@ -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>
|
||||
@@ -3633,4 +3628,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>
|
||||
@@ -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>
|
||||
@@ -3633,4 +3628,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>
|
||||
@@ -1965,13 +1965,6 @@
|
||||
<string name="bottom_downloads_shader">Downloading shader cache</string>
|
||||
<string name="bottom_downloads_paused">Paused</string>
|
||||
<string name="bottom_downloads_queued">Waiting to start</string>
|
||||
<string name="bottom_page_notifications">Notifications</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck bottom screen</string>
|
||||
<string name="bottom_notifications_why">Show messages and other notifications here while you play. They stay on this screen only and are not saved. Android asks once: its notification access page opens on this screen; turn DroidDeck on there and this page comes back by itself.</string>
|
||||
<string name="bottom_notifications_allow">Allow notification access</string>
|
||||
<string name="bottom_notifications_none">No notifications.</string>
|
||||
<string name="bottom_notifications_count">%1$d notifications</string>
|
||||
<string name="bottom_notifications_clear">Clear</string>
|
||||
<string name="bottom_page_screenshots">Screenshots</string>
|
||||
<string name="stats_fps_needs_hud">FPS needs the HUD on</string>
|
||||
<string name="stats_speed">Speed</string>
|
||||
@@ -3113,6 +3106,8 @@
|
||||
<string name="glasses_pro2_experimental">Experimental Pro 2 head tracking. Brightness, volume, and display switching are not supported yet.</string>
|
||||
<string name="display_picture_position">Picture position</string>
|
||||
<string name="display_position_top">Top</string>
|
||||
<string name="display_position_left">Left</string>
|
||||
<string name="display_position_right">Right</string>
|
||||
<string name="display_position_bottom">Bottom</string>
|
||||
<string name="display_position_center">Center (default)</string>
|
||||
<string name="glasses_6dof">6DoF virtual screen</string>
|
||||
@@ -3821,4 +3816,7 @@
|
||||
<string name="input_reset_positions">Reset positions</string>
|
||||
<string name="input_show_label">Show label</string>
|
||||
<string name="input_layout_import_failed">Could not import this layout</string>
|
||||
|
||||
<string name="theme_white_red">Scarlet Snow</string>
|
||||
<string name="theme_white_red_detail">White surfaces with red accents.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,69 @@
|
||||
query GameImages($gameSlug: String!, $locale: Locale!) {
|
||||
game(slug: $gameSlug, locale: $locale) {
|
||||
id
|
||||
slug
|
||||
title
|
||||
keyArt {
|
||||
...imageRenditionData
|
||||
__typename
|
||||
}
|
||||
packArt {
|
||||
...PackArt
|
||||
__typename
|
||||
}
|
||||
...PrimaryLogo
|
||||
__typename
|
||||
}
|
||||
}
|
||||
|
||||
fragment imageRenditionData on ImageRendition {
|
||||
path
|
||||
aspect9x16Image {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
aspect16x9Image {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
aspect1x1Image {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
largestImage {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
rawImages {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
|
||||
fragment PrimaryLogo on GameV2 {
|
||||
primaryLogo(uiClientContexts: ["juno-web-game-tile"]) {
|
||||
path
|
||||
title
|
||||
largestImage {
|
||||
path
|
||||
width
|
||||
height
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
|
||||
fragment PackArt on ImageRendition {
|
||||
aspect2x1Image {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
aspect9x16Image {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
query GetUserPlayer {
|
||||
me {
|
||||
id: pd
|
||||
pd
|
||||
player {
|
||||
...Player
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
|
||||
fragment Player on Player {
|
||||
id: pd
|
||||
pd
|
||||
psd
|
||||
displayName
|
||||
uniqueName
|
||||
nickname
|
||||
avatar {
|
||||
...Avatar
|
||||
__typename
|
||||
}
|
||||
relationship
|
||||
__typename
|
||||
}
|
||||
|
||||
fragment Avatar on AvatarList {
|
||||
large {
|
||||
...image
|
||||
__typename
|
||||
}
|
||||
medium {
|
||||
...image
|
||||
__typename
|
||||
}
|
||||
small {
|
||||
...image
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
|
||||
fragment image on Image {
|
||||
height
|
||||
width
|
||||
path
|
||||
__typename
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
query availableBuilds($offerId: String!) {
|
||||
availableBuilds(offerId: $offerId) {
|
||||
buildId
|
||||
downloadType
|
||||
gameVersion
|
||||
buildLiveDate
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
query downloadUrl($offerId: String!, $buildId: String) {
|
||||
downloadUrl(offerId: $offerId, buildId: $buildId) {
|
||||
url
|
||||
syncUrl
|
||||
__typename
|
||||
}
|
||||
}
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user