mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-17 04:37:03 -04:00
Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d78aa812 | ||
|
|
522e755b37 | ||
|
|
9c9486400b | ||
|
|
62192203e9 | ||
|
|
d8df18df82 | ||
|
|
953c11ed48 | ||
|
|
c796c7cd48 | ||
|
|
89a0e4d25f | ||
|
|
59a5afa21d | ||
|
|
66529d9026 | ||
|
|
abd89f9954 | ||
|
|
f7ffab1345 | ||
|
|
8886304894 | ||
|
|
0b8d55d434 | ||
|
|
99381a8f3e | ||
|
|
a422698f92 | ||
|
|
dce7e7ece6 | ||
|
|
e3fe63aee6 | ||
|
|
22c85c0cb4 | ||
|
|
a548d381b8 | ||
|
|
9abb21e389 | ||
|
|
4e4cc9d5e3 | ||
|
|
a353b15364 | ||
|
|
86b92c5d0e | ||
|
|
e24e07d0ce | ||
|
|
71da1c90c5 | ||
|
|
cd4a26b5f1 | ||
|
|
66ec776b0b | ||
|
|
2847910f22 | ||
|
|
d21466be6b | ||
|
|
21a78a1eb2 | ||
|
|
5aa245cf39 | ||
|
|
507ddac39d | ||
|
|
ef16de6280 | ||
|
|
d301148881 | ||
|
|
787cec2548 | ||
|
|
0a72690631 | ||
|
|
b539cc1111 | ||
|
|
acf66b2b0e | ||
|
|
892f400145 | ||
|
|
47e4f25346 | ||
|
|
e1ca869d8e | ||
|
|
9eb509b9e1 | ||
|
|
c39a521a28 | ||
|
|
58ab106da8 | ||
|
|
6e49c0c287 | ||
|
|
322040123d | ||
|
|
36a9cbfc13 | ||
|
|
a2486b2afc | ||
|
|
1bd3c64d01 | ||
|
|
7fc61b4c3d | ||
|
|
21e5fdfc63 | ||
|
|
f819988b0d | ||
|
|
39c8a0fe8c | ||
|
|
44331558cc | ||
|
|
d2bab8970d | ||
|
|
eb22557702 | ||
|
|
38d560cef1 | ||
|
|
45a808c80d | ||
|
|
929e049761 | ||
|
|
684f7407de | ||
|
|
00a106e2fa | ||
|
|
91dabdc1e7 | ||
|
|
f635e92498 | ||
|
|
eda1e9b9c6 | ||
|
|
fa9a9446bb | ||
|
|
3e3c4d69e4 | ||
|
|
913806f169 | ||
|
|
7b018e6c42 | ||
|
|
e45d6f7192 | ||
|
|
b3ff93e80f | ||
|
|
c035351b4f | ||
|
|
10cdf97480 | ||
|
|
f614483d01 | ||
|
|
f5eb5e7b81 | ||
|
|
bf99dbbe73 | ||
|
|
46fac69438 | ||
|
|
82f04fb22e | ||
|
|
9796525449 |
No files matched your search
@@ -1,2 +1,4 @@
|
||||
tools/linuxfs/** text=auto eol=lf
|
||||
tools/mangoapp/** text=auto eol=lf
|
||||
tools/comet/LICENSE whitespace=-blank-at-eof
|
||||
tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu >/dev/null
|
||||
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
|
||||
D=app/src/main/assets/linuxfs
|
||||
mkdir -p "$D"
|
||||
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
|
||||
@@ -396,6 +396,30 @@ jobs:
|
||||
set -euo pipefail
|
||||
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
|
||||
|
||||
- name: Check Quest tracking IPC
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1)
|
||||
mkdir -p build/vr/include
|
||||
cp -R "$VK_INCLUDE/vulkan" build/vr/include/
|
||||
if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking
|
||||
/tmp/test-xr-tracking
|
||||
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
|
||||
/tmp/test-xr-touch
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
|
||||
/tmp/test-xr-vsync
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
|
||||
/tmp/test-xr-frames
|
||||
cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o
|
||||
cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o
|
||||
g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \
|
||||
-Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
|
||||
tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \
|
||||
-ldl -o /tmp/test-presenter-shutdown
|
||||
timeout 10 /tmp/test-presenter-shutdown
|
||||
|
||||
- name: Rust toolchain for the store engines (aarch64-linux-android)
|
||||
id: rust
|
||||
run: |
|
||||
@@ -429,7 +453,7 @@ jobs:
|
||||
path: |
|
||||
app/.cxx
|
||||
app/build/intermediates/cxx
|
||||
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**') }}
|
||||
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**', 'tools/vr/src/xr_*.h') }}
|
||||
restore-keys: |
|
||||
cxx-${{ env.NDK_VERSION }}-
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Rules
|
||||
|
||||
- Comments should be rare, factual, and explain only non-obvious external constraints or business invariants.
|
||||
@@ -17,7 +17,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier.
|
||||
|
||||
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
On Android 14 and newer, before Steam launches, you must turn on **Disable child process restrictions** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
|
||||
## Community
|
||||
|
||||
@@ -37,4 +37,4 @@ Compatibility and performance vary by device; hardware validation is limited. De
|
||||
|
||||
## Credits and licence
|
||||
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
+4
-2
@@ -150,6 +150,7 @@ android {
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
prefab true
|
||||
compose true
|
||||
buildConfig true
|
||||
}
|
||||
@@ -225,7 +226,8 @@ if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
|
||||
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
|
||||
|
||||
dependencies {
|
||||
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
|
||||
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
|
||||
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
|
||||
implementation 'org.apache.commons:commons-compress:1.27.1'
|
||||
implementation 'org.tukaani:xz:1.10'
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
@@ -244,5 +246,5 @@ dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.robolectric:robolectric:4.16.1'
|
||||
testImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.2-3@jar'
|
||||
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.7-4@jar'
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
@@ -21,6 +22,7 @@
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
@@ -36,20 +38,35 @@
|
||||
android:isGame="true">
|
||||
|
||||
<meta-data android:name="android.game_mode_config" android:resource="@xml/game_mode_config" />
|
||||
<meta-data android:name="com.oculus.supportedDevices" android:value="quest2|quest3|quest3s" />
|
||||
<activity android:name=".QuestXrActivity" android:exported="true" android:launchMode="singleTask"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:resizeableActivity="false" android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode"
|
||||
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen">
|
||||
<meta-data android:name="android.app.lib_name" android:value="droiddeckxr" />
|
||||
<intent-filter>
|
||||
<action android:name="com.droiddeck.launcher.XR" />
|
||||
<category android:name="com.oculus.intent.category.VR" />
|
||||
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation">
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
<data android:scheme="droiddeck" android:host="game" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
@@ -59,6 +76,7 @@
|
||||
<intent-filter>
|
||||
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.CAR_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
@@ -91,18 +109,21 @@
|
||||
android:name=".files.FileManagerActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".files.FilePickerActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".stores.StoreLoginActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
|
||||
Binary file not shown.
@@ -30,6 +30,7 @@ string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH)
|
||||
message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})")
|
||||
|
||||
add_subdirectory(waylandcomp)
|
||||
add_subdirectory(questxr)
|
||||
add_library(sdnative SHARED sdnative/sdnative.c)
|
||||
target_link_libraries(sdnative log)
|
||||
|
||||
|
||||
@@ -371,6 +371,17 @@ send_vibration(int strong, int weak, uint16_t duration_ms, uint16_t slot) {
|
||||
syscall(SYS_close, sock);
|
||||
}
|
||||
|
||||
// Must match RumbleComponent.HOLD_MS: play until the slot's next effect or stop.
|
||||
static constexpr uint16_t RUMBLE_HOLD_MS = 0xffff;
|
||||
|
||||
__attribute__((visibility("hidden"))) static void send_ff_effect(const struct ff_effect &effect, uint16_t slot) {
|
||||
uint16_t duration = effect.replay.length ? effect.replay.length : RUMBLE_HOLD_MS;
|
||||
if (effect.type == FF_RUMBLE)
|
||||
send_vibration(effect.u.rumble.strong_magnitude, effect.u.rumble.weak_magnitude, duration, slot);
|
||||
else if (effect.type == FF_PERIODIC)
|
||||
send_vibration(effect.u.periodic.magnitude, effect.u.periodic.magnitude, duration, slot);
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void
|
||||
check_ff_event(const struct input_event *ev, uint16_t slot) {
|
||||
if (ev->type != EV_FF)
|
||||
@@ -381,15 +392,7 @@ check_ff_event(const struct input_event *ev, uint16_t slot) {
|
||||
auto it = ff_effects().find(id);
|
||||
if (it == ff_effects().end())
|
||||
return;
|
||||
|
||||
uint16_t duration = it->second.replay.length;
|
||||
if (it->second.type == FF_RUMBLE) {
|
||||
send_vibration(it->second.u.rumble.strong_magnitude,
|
||||
it->second.u.rumble.weak_magnitude, duration, slot);
|
||||
} else if (it->second.type == FF_PERIODIC) {
|
||||
send_vibration(it->second.u.periodic.magnitude,
|
||||
it->second.u.periodic.magnitude, duration, slot);
|
||||
}
|
||||
send_ff_effect(it->second, slot);
|
||||
} else {
|
||||
send_vibration(0, 0, 0, slot);
|
||||
}
|
||||
@@ -1140,8 +1143,12 @@ struct DeckImu {
|
||||
uint16_t pressure[2];
|
||||
uint32_t controls;
|
||||
uint32_t pad_type;
|
||||
uint32_t latency;
|
||||
};
|
||||
static_assert(offsetof(DeckImu, pad_type) == 44, "DeckImu::pad_type matches FakeInputWriter");
|
||||
static_assert(offsetof(DeckImu, latency) == 48, "DeckImu::latency matches FakeInputWriter");
|
||||
static constexpr uint32_t LATENCY_CONTROLLER = 1, LATENCY_TOUCH = 2;
|
||||
static constexpr int LOW_LATENCY_REPORT_INTERVAL_US = 1000;
|
||||
static constexpr uint32_t DECK_EXTRA_L4 = 1, DECK_EXTRA_R4 = 2, DECK_EXTRA_L5 = 4,
|
||||
DECK_EXTRA_R5 = 8, DECK_EXTRA_LPAD_TOUCH = 16,
|
||||
DECK_EXTRA_RPAD_TOUCH = 32, DECK_EXTRA_LPAD_CLICK = 64,
|
||||
@@ -1303,6 +1310,27 @@ __attribute__((visibility("hidden"))) static uint32_t live_pad_type() {
|
||||
return value & PAD_TYPE_SET ? value & 0xff : session_pad_type();
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool low_latency(uint32_t which) {
|
||||
const uint8_t *blocks = ring_blocks();
|
||||
const DeckImu *imu = blocks ? reinterpret_cast<const DeckImu *>(blocks + FAKE_INPUT_RING_SIZE) : nullptr;
|
||||
return imu && (__atomic_load_n(&imu->latency, __ATOMIC_ACQUIRE) & which);
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static int controller_report_interval_us(int normal_us) {
|
||||
return low_latency(LATENCY_CONTROLLER) ? LOW_LATENCY_REPORT_INTERVAL_US : normal_us;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void sleep_us(int us) {
|
||||
struct timespec interval = {0, us * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static uint64_t monotonic_us() {
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
return static_cast<uint64_t>(now.tv_sec) * 1000000ULL + static_cast<uint64_t>(now.tv_nsec) / 1000ULL;
|
||||
}
|
||||
|
||||
static bool fake_triton_enabled() {
|
||||
static int enabled = -1;
|
||||
if (enabled < 0)
|
||||
@@ -1453,8 +1481,7 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg)
|
||||
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
struct timespec interval = {0, DECK_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US));
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
munmap(self->ring, self->mapping_size);
|
||||
@@ -1486,7 +1513,7 @@ __attribute__((visibility("hidden"))) static void put_sony_touch(uint8_t *at, So
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void
|
||||
build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint32_t buttons, const int32_t *axes,
|
||||
build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint64_t elapsed_us, uint32_t buttons, const int32_t *axes,
|
||||
const DeckExtras &extras, SonyTouch &touch) {
|
||||
memset(report, 0, SONY_REPORT_BYTES);
|
||||
report[0] = 0x01;
|
||||
@@ -1517,7 +1544,7 @@ build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint32_t butt
|
||||
p[6] = static_cast<uint8_t>(system | ((packet & 0x3f) << 2));
|
||||
p[7] = left_trigger;
|
||||
p[8] = right_trigger;
|
||||
put16(p + 9, static_cast<int>(packet * 750));
|
||||
put16(p + 9, static_cast<int>(elapsed_us * 3 / 16));
|
||||
for (int i = 0; i < 3; i++) {
|
||||
put16(p + 12 + i * 2, gyro[i]);
|
||||
put16(p + 18 + i * 2, accel[i]);
|
||||
@@ -1539,7 +1566,7 @@ build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint32_t butt
|
||||
put16(p + 15 + i * 2, gyro[i]);
|
||||
put16(p + 21 + i * 2, accel[i]);
|
||||
}
|
||||
put32(p + 27, packet * 12000);
|
||||
put32(p + 27, static_cast<uint32_t>(elapsed_us * 3));
|
||||
put_sony_touch(p + 32, touch, 0, left, extras.pads[0], extras.pads[1], SONY_TOUCH_HEIGHT_DUALSENSE);
|
||||
put_sony_touch(p + 36, touch, 1, right, extras.pads[2], extras.pads[3], SONY_TOUCH_HEIGHT_DUALSENSE);
|
||||
p[52] = 0x2a;
|
||||
@@ -1558,6 +1585,7 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg)
|
||||
int32_t axes[8] = {};
|
||||
DeckExtras extras;
|
||||
SonyTouch touch;
|
||||
const uint64_t start_us = monotonic_us();
|
||||
while (live_pad_type() == type) {
|
||||
if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) {
|
||||
buttons = snap.buttons;
|
||||
@@ -1565,12 +1593,11 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg)
|
||||
}
|
||||
read_deck_extras(*self, extras);
|
||||
uint8_t report[SONY_REPORT_BYTES];
|
||||
build_sony_report(report, self->sony, ++packet, buttons, axes, extras, touch);
|
||||
build_sony_report(report, self->sony, ++packet, monotonic_us() - start_us, buttons, axes, extras, touch);
|
||||
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
struct timespec interval = {0, DECK_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US));
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
munmap(self->ring, self->mapping_size);
|
||||
@@ -1684,8 +1711,7 @@ __attribute__((visibility("hidden"))) static void *triton_report_thread(void *ar
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
}
|
||||
struct timespec interval = {0, TRITON_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US));
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
munmap(self->ring, self->mapping_size);
|
||||
@@ -1709,7 +1735,7 @@ static constexpr int16_t TRITON_WIRED_PRODUCT = 0x1302;
|
||||
static constexpr int32_t TRITON_STICK_TOUCH = 2000;
|
||||
|
||||
__attribute__((visibility("hidden"))) static void
|
||||
build_triton_report(uint8_t *report, uint32_t packet, uint32_t buttons, const int32_t *axes,
|
||||
build_triton_report(uint8_t *report, uint32_t packet, uint64_t elapsed_us, uint32_t buttons, const int32_t *axes,
|
||||
const DeckExtras &extras) {
|
||||
memset(report, 0, TRITON_STATE_BYTES);
|
||||
report[0] = static_cast<uint8_t>(packet);
|
||||
@@ -1752,7 +1778,7 @@ build_triton_report(uint8_t *report, uint32_t packet, uint32_t buttons, const in
|
||||
put16(report + 23, extras.pads[2]);
|
||||
put16(report + 25, extras.pads[3]);
|
||||
put16(report + 27, extras.pressure[1]);
|
||||
put32(report + 29, packet * TRITON_REPORT_INTERVAL_US);
|
||||
put32(report + 29, static_cast<uint32_t>(elapsed_us));
|
||||
for (int i = 0; i < 3; i++) {
|
||||
put16(report + 33 + i * 2, extras.accel[i]);
|
||||
put16(report + 39 + i * 2, extras.gyro[i]);
|
||||
@@ -1768,6 +1794,7 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg)
|
||||
uint32_t buttons = 0;
|
||||
int32_t axes[8] = {};
|
||||
DeckExtras extras;
|
||||
const uint64_t start_us = monotonic_us();
|
||||
while (live_pad_type() == PAD_TYPE_STEAM) {
|
||||
if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) {
|
||||
buttons = snap.buttons;
|
||||
@@ -1776,12 +1803,11 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg)
|
||||
read_deck_extras(*self, extras);
|
||||
uint8_t report[TRITON_REPORT_ID_BYTES + TRITON_STATE_BYTES];
|
||||
report[0] = TRITON_STATE_WIRED;
|
||||
build_triton_report(report + 1, ++packet, buttons, axes, extras);
|
||||
build_triton_report(report + 1, ++packet, monotonic_us() - start_us, buttons, axes, extras);
|
||||
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
struct timespec interval = {0, TRITON_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US));
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
munmap(self->ring, self->mapping_size);
|
||||
@@ -2019,8 +2045,7 @@ __attribute__((visibility("hidden"))) static void *touch_report_thread(void *arg
|
||||
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
struct timespec interval = {0, TOUCH_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
sleep_us(low_latency(LATENCY_TOUCH) ? LOW_LATENCY_REPORT_INTERVAL_US : TOUCH_REPORT_INTERVAL_US);
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
touch_state([](TouchRingFile &r) { if (r.opened) r.opened--; });
|
||||
@@ -3447,16 +3472,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
if (effect->id == -1)
|
||||
effect->id = next_ff_id++;
|
||||
ff_effects()[effect->id] = *effect;
|
||||
|
||||
uint16_t duration = effect->replay.length;
|
||||
uint16_t slot = static_cast<uint16_t>(event_number);
|
||||
if (effect->type == FF_RUMBLE) {
|
||||
send_vibration(effect->u.rumble.strong_magnitude,
|
||||
effect->u.rumble.weak_magnitude, duration, slot);
|
||||
} else if (effect->type == FF_PERIODIC) {
|
||||
send_vibration(effect->u.periodic.magnitude, effect->u.periodic.magnitude,
|
||||
duration, slot);
|
||||
}
|
||||
send_ff_effect(*effect, static_cast<uint16_t>(event_number));
|
||||
return 0;
|
||||
} else if (type == 0x45 && number == 0x81) {
|
||||
int id = (intptr_t)argp;
|
||||
@@ -3545,6 +3561,7 @@ EXPORT int close(int fd) {
|
||||
}
|
||||
|
||||
static constexpr int kMaxRingWaitMs = 2;
|
||||
static constexpr int kLowLatencyRingWaitUs = 250;
|
||||
|
||||
EXPORT ssize_t read(int fd, void *buf, size_t count) {
|
||||
if (!watched(fd)) return syscall(SYS_read, fd, buf, count);
|
||||
@@ -3633,7 +3650,8 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
|
||||
int result = nanosleep(&sleep_time, nullptr);
|
||||
guard.lock();
|
||||
if (result < 0) return -1;
|
||||
if (backoff_ns < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2;
|
||||
if (low_latency(LATENCY_CONTROLLER)) backoff_ns = kLowLatencyRingWaitUs * 1000;
|
||||
else if (backoff_ns < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3815,7 +3833,7 @@ static int poll_fake(struct pollfd *fds, nfds_t nfds, int timeout,
|
||||
if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms)
|
||||
return 0;
|
||||
|
||||
if (backoff_ms < kMaxRingWaitMs)
|
||||
if (backoff_ms < kMaxRingWaitMs && !low_latency(LATENCY_CONTROLLER))
|
||||
backoff_ms *= 2;
|
||||
}
|
||||
}
|
||||
@@ -3988,7 +4006,7 @@ EXPORT int select(int nfds, fd_set *readfds, fd_set *writefds,
|
||||
if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms)
|
||||
return 0;
|
||||
|
||||
if (backoff_ms < kMaxRingWaitMs)
|
||||
if (backoff_ms < kMaxRingWaitMs && !low_latency(LATENCY_CONTROLLER))
|
||||
backoff_ms *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(droiddeckxr C CXX)
|
||||
find_package(OpenXR REQUIRED CONFIG)
|
||||
add_library(droiddeckxr_glue STATIC ${ANDROID_NDK}/sources/android/native_app_glue/android_native_app_glue.c)
|
||||
target_include_directories(droiddeckxr_glue PUBLIC ${ANDROID_NDK}/sources/android/native_app_glue)
|
||||
add_library(droiddeckxr SHARED quest_xr.cpp)
|
||||
target_compile_features(droiddeckxr PRIVATE cxx_std_17)
|
||||
target_compile_definitions(droiddeckxr PRIVATE XR_USE_PLATFORM_ANDROID XR_USE_GRAPHICS_API_OPENGL_ES XR_USE_TIMESPEC)
|
||||
target_compile_options(droiddeckxr PRIVATE -Wall -Wextra -Wno-missing-field-initializers -Werror=return-type)
|
||||
target_link_options(droiddeckxr PRIVATE -Wl,-z,max-page-size=16384 -uANativeActivity_onCreate)
|
||||
target_link_libraries(droiddeckxr PRIVATE droiddeckxr_glue OpenXR::openxr_loader EGL GLESv3 android log)
|
||||
|
||||
target_include_directories(droiddeckxr PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../tools/vr/src)
|
||||
@@ -0,0 +1,201 @@
|
||||
#pragma once
|
||||
#include <EGL/eglext.h>
|
||||
#include <GLES3/gl31.h>
|
||||
#include <GLES2/gl2ext.h>
|
||||
#include <android/hardware_buffer.h>
|
||||
#include "xr_gpu_channel.h"
|
||||
|
||||
class QuestGpuFrames {
|
||||
public:
|
||||
bool Start(const std::string &filename, EGLDisplay display) {
|
||||
const char *extensions = reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS));
|
||||
const char *eglExtensions = eglQueryString(display, EGL_EXTENSIONS);
|
||||
if (!extensions || !eglExtensions || !strstr(extensions, "GL_EXT_external_buffer")
|
||||
|| !strstr(eglExtensions, "EGL_ANDROID_get_native_client_buffer")) return false;
|
||||
storage_ = reinterpret_cast<PFNGLBUFFERSTORAGEEXTERNALEXTPROC>(eglGetProcAddress("glBufferStorageExternalEXT"));
|
||||
client_ = reinterpret_cast<PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC>(eglGetProcAddress("eglGetNativeClientBufferANDROID"));
|
||||
if (strstr(extensions, "GL_OES_texture_view")) view_ = reinterpret_cast<PFNGLTEXTUREVIEWOESPROC>(eglGetProcAddress("glTextureViewOES"));
|
||||
else if (strstr(extensions, "GL_EXT_texture_view")) view_ = reinterpret_cast<PFNGLTEXTUREVIEWOESPROC>(eglGetProcAddress("glTextureViewEXT"));
|
||||
if (!storage_ || !client_ || !view_ || filename.size() >= sizeof(sockaddr_un::sun_path)) return false;
|
||||
const char *source = "#version 310 es\nlayout(local_size_x=8,local_size_y=8)in;"
|
||||
"layout(std430,binding=0)readonly buffer Source{uint pixels[];};"
|
||||
"layout(rgba8,binding=0)writeonly uniform highp image2D target;uniform bool bgra;"
|
||||
"void main(){ivec2 p=ivec2(gl_GlobalInvocationID.xy),size=imageSize(target);"
|
||||
"if(any(greaterThanEqual(p,size)))return;vec4 value=unpackUnorm4x8(pixels[p.y*size.x+p.x]);"
|
||||
"imageStore(target,p,bgra?value.bgra:value);}";
|
||||
GLuint shader = glCreateShader(GL_COMPUTE_SHADER); glShaderSource(shader, 1, &source, nullptr); glCompileShader(shader);
|
||||
program_ = glCreateProgram(); glAttachShader(program_, shader); glLinkProgram(program_); glDeleteShader(shader);
|
||||
GLint linked{}; glGetProgramiv(program_, GL_LINK_STATUS, &linked);
|
||||
if (!linked) { Stop(); return false; }
|
||||
lock_ = open((filename + ".lock").c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0600);
|
||||
struct stat status{};
|
||||
if (lock_ < 0 || fstat(lock_, &status) || !S_ISREG(status.st_mode) || status.st_uid != getuid()
|
||||
|| flock(lock_, LOCK_EX | LOCK_NB)) { Stop(); return false; }
|
||||
if (lstat(filename.c_str(), &status) == 0) {
|
||||
if (!S_ISSOCK(status.st_mode) || status.st_uid != getuid() || unlink(filename.c_str())) { Stop(); return false; }
|
||||
} else if (errno != ENOENT) { Stop(); return false; }
|
||||
listener_ = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
|
||||
sockaddr_un address{}; address.sun_family = AF_UNIX; strcpy(address.sun_path, filename.c_str());
|
||||
if (listener_ < 0 || bind(listener_, reinterpret_cast<sockaddr *>(&address), sizeof(address))) { Stop(); return false; }
|
||||
filename_ = filename;
|
||||
if (chmod(filename.c_str(), 0600) || listen(listener_, 2)) { Stop(); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Take(DroidDeckXrFrameHeader &header, GLuint &texture) {
|
||||
if (listener_ < 0) return false;
|
||||
if (peer_ < 0) {
|
||||
peer_ = accept4(listener_, nullptr, nullptr, SOCK_CLOEXEC | SOCK_NONBLOCK);
|
||||
if (peer_ < 0) return false;
|
||||
if (!xr_frame_peer(peer_)) { Disconnect(); return false; }
|
||||
configured_ = false; deadline_ = xr_frame_now() + 2000000000LL;
|
||||
}
|
||||
pollfd ready{peer_, POLLIN, 0};
|
||||
if (poll(&ready, 1, 0) <= 0) {
|
||||
if (!configured_ && xr_frame_now() > deadline_) Disconnect();
|
||||
return false;
|
||||
}
|
||||
if (!(ready.revents & POLLIN)) { Disconnect(); return false; }
|
||||
if (!configured_) {
|
||||
XrGpuRequest request;
|
||||
if (!xr_gpu_receive(peer_, &request, sizeof(request)) || request.magic != XR_GPU_MAGIC
|
||||
|| request.version != XR_GPU_VERSION || !xr_gpu_dimensions(request.width, request.height)
|
||||
|| !Allocate(request.width, request.height)) { Disconnect(); return false; }
|
||||
XrGpuPoolInfo info; info.width = width_; info.height = height_;
|
||||
for (unsigned index = 0; index < XR_GPU_SLOTS; index++) info.sizes[index] = sizes_[index];
|
||||
configured_ = xr_gpu_send(peer_, &info, sizeof(info), fds_.data(), fds_.size());
|
||||
for (int &fd : fds_) { if (fd >= 0) close(fd); fd = -1; }
|
||||
if (!configured_) Disconnect();
|
||||
return false;
|
||||
}
|
||||
XrGpuFrame frame;
|
||||
if (!xr_gpu_receive(peer_, &frame, sizeof(frame))) { Disconnect(); return false; }
|
||||
auto state = xr_gpu_frame_state(frame, width_, height_, lastFrame_, xr_frame_now());
|
||||
if (state == XrGpuFrameState::Invalid) {
|
||||
rejected_++; Disconnect(); return false;
|
||||
}
|
||||
lastFrame_ = frame.header.frame_id;
|
||||
if (state == XrGpuFrameState::Stale) {
|
||||
// No GPU read was submitted, so the producer can safely reclaim a dropped frame.
|
||||
dropped_++;
|
||||
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
|
||||
return false;
|
||||
}
|
||||
if (!Output()) { Disconnect(); return false; }
|
||||
glMemoryBarrier(GL_ALL_BARRIER_BITS);
|
||||
glUseProgram(program_); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffers_[frame.slot]);
|
||||
glUniform1i(glGetUniformLocation(program_, "bgra"), !!(frame.flags & XR_GPU_BGRA));
|
||||
glBindImageTexture(0, raw_, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
|
||||
glDispatchCompute((width_ + 7) / 8, (height_ + 7) / 8, 1);
|
||||
glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
|
||||
// The producer may overwrite this slot only after the native GPU has finished reading it.
|
||||
glFinish();
|
||||
if (glGetError() != GL_NO_ERROR) { rejected_++; Disconnect(); return false; }
|
||||
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
|
||||
received_++;
|
||||
header = frame.header;
|
||||
texture = header.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? srgb_ : raw_;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint64_t Received() const { return received_; }
|
||||
uint64_t Rejected() const { return rejected_; }
|
||||
uint64_t Dropped() const { return dropped_; }
|
||||
void Stop() {
|
||||
Disconnect(); DropPool();
|
||||
glDeleteTextures(1, &srgb_); glDeleteTextures(1, &raw_); srgb_ = raw_ = 0;
|
||||
if (program_) glDeleteProgram(program_); program_ = 0;
|
||||
if (listener_ >= 0) close(listener_); listener_ = -1;
|
||||
if (!filename_.empty()) unlink(filename_.c_str()); filename_.clear();
|
||||
if (lock_ >= 0) close(lock_); lock_ = -1;
|
||||
}
|
||||
private:
|
||||
int listener_ = -1, peer_ = -1, lock_ = -1;
|
||||
std::string filename_;
|
||||
bool configured_ = false;
|
||||
int64_t deadline_{};
|
||||
uint32_t width_{}, height_{}, outputWidth_{}, outputHeight_{};
|
||||
uint64_t lastFrame_{}, received_{}, rejected_{}, dropped_{};
|
||||
std::array<AHardwareBuffer *, XR_GPU_SLOTS> hardware_{};
|
||||
std::array<GLuint, XR_GPU_SLOTS> buffers_{};
|
||||
std::array<int, XR_GPU_SLOTS> fds_{{-1, -1, -1}};
|
||||
std::array<uint64_t, XR_GPU_SLOTS> sizes_{};
|
||||
GLuint raw_{}, srgb_{}, program_{};
|
||||
PFNGLBUFFERSTORAGEEXTERNALEXTPROC storage_{};
|
||||
PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC client_{};
|
||||
PFNGLTEXTUREVIEWOESPROC view_{};
|
||||
|
||||
void Disconnect() { if (peer_ >= 0) close(peer_); peer_ = -1; configured_ = false; lastFrame_ = 0; }
|
||||
void DropPool() {
|
||||
if (buffers_[0]) glFinish();
|
||||
glDeleteBuffers(buffers_.size(), buffers_.data()); buffers_.fill(0);
|
||||
for (auto *&buffer : hardware_) { if (buffer) AHardwareBuffer_release(buffer); buffer = nullptr; }
|
||||
for (int &fd : fds_) { if (fd >= 0) close(fd); fd = -1; }
|
||||
}
|
||||
static int AllocationFd(AHardwareBuffer *buffer, uint64_t bytes, uint64_t &allocation) {
|
||||
int pair[2];
|
||||
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, pair)) return -1;
|
||||
if (AHardwareBuffer_sendHandleToUnixSocket(buffer, pair[0])) { close(pair[0]); close(pair[1]); return -1; }
|
||||
close(pair[0]);
|
||||
int selected = -1; unsigned candidates = 0;
|
||||
for (;;) {
|
||||
unsigned char data[1024]; iovec bytesReceived{data, sizeof(data)};
|
||||
alignas(cmsghdr) unsigned char control[CMSG_SPACE(32 * sizeof(int))]{};
|
||||
msghdr message{}; message.msg_iov = &bytesReceived; message.msg_iovlen = 1;
|
||||
message.msg_control = control; message.msg_controllen = sizeof(control);
|
||||
ssize_t result = recvmsg(pair[1], &message, MSG_CMSG_CLOEXEC);
|
||||
if (result < 0 && errno == EINTR) continue;
|
||||
if (result <= 0) break;
|
||||
for (auto *item = CMSG_FIRSTHDR(&message); item; item = CMSG_NXTHDR(&message, item)) {
|
||||
if (item->cmsg_level != SOL_SOCKET || item->cmsg_type != SCM_RIGHTS) continue;
|
||||
auto *fds = reinterpret_cast<int *>(CMSG_DATA(item));
|
||||
for (unsigned index = 0; index < (item->cmsg_len - CMSG_LEN(0)) / sizeof(int); index++) {
|
||||
struct stat status{};
|
||||
if (!fstat(fds[index], &status) && status.st_size >= static_cast<int64_t>(bytes)
|
||||
&& status.st_size <= static_cast<int64_t>(bytes + 65536)) {
|
||||
candidates++; if (selected >= 0) close(selected);
|
||||
selected = fds[index]; allocation = status.st_size;
|
||||
} else close(fds[index]);
|
||||
}
|
||||
}
|
||||
if (message.msg_flags & (MSG_TRUNC | MSG_CTRUNC)) { candidates = 0; break; }
|
||||
}
|
||||
close(pair[1]);
|
||||
if (candidates != 1) { if (selected >= 0) close(selected); return -1; }
|
||||
return selected;
|
||||
}
|
||||
bool Allocate(uint32_t width, uint32_t height) {
|
||||
DropPool(); width_ = width; height_ = height;
|
||||
const uint64_t bytes = uint64_t(width) * height * 4;
|
||||
GLint64 limit{}; glGetInteger64v(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &limit);
|
||||
if (bytes > static_cast<uint64_t>(limit)) return false;
|
||||
AHardwareBuffer_Desc description{}; description.width = bytes; description.height = description.layers = 1;
|
||||
description.format = AHARDWAREBUFFER_FORMAT_BLOB;
|
||||
description.usage = AHARDWAREBUFFER_USAGE_GPU_DATA_BUFFER;
|
||||
glGenBuffers(buffers_.size(), buffers_.data());
|
||||
for (unsigned index = 0; index < XR_GPU_SLOTS; index++) {
|
||||
if (AHardwareBuffer_allocate(&description, &hardware_[index])) { DropPool(); return false; }
|
||||
fds_[index] = AllocationFd(hardware_[index], bytes, sizes_[index]);
|
||||
if (fds_[index] < 0) { DropPool(); return false; }
|
||||
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffers_[index]);
|
||||
storage_(GL_SHADER_STORAGE_BUFFER, 0, bytes, client_(hardware_[index]), 0);
|
||||
if (glGetError() != GL_NO_ERROR) { DropPool(); return false; }
|
||||
}
|
||||
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
|
||||
return true;
|
||||
}
|
||||
bool Output() {
|
||||
if (raw_ && outputWidth_ == width_ && outputHeight_ == height_) return true;
|
||||
glDeleteTextures(1, &srgb_); glDeleteTextures(1, &raw_); srgb_ = raw_ = 0;
|
||||
glGenTextures(1, &raw_); glBindTexture(GL_TEXTURE_2D, raw_);
|
||||
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width_, height_);
|
||||
glGenTextures(1, &srgb_); view_(srgb_, GL_TEXTURE_2D, raw_, GL_SRGB8_ALPHA8, 0, 1, 0, 1);
|
||||
for (GLuint texture : {raw_, srgb_}) {
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
}
|
||||
outputWidth_ = width_; outputHeight_ = height_;
|
||||
return glGetError() == GL_NO_ERROR;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,777 @@
|
||||
#include <jni.h>
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl3.h>
|
||||
#include <android/log.h>
|
||||
#include <android_native_app_glue.h>
|
||||
#include <time.h>
|
||||
#include <openxr/openxr.h>
|
||||
#include <openxr/openxr_platform.h>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
#include <string>
|
||||
#include <stdexcept>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <chrono>
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <cerrno>
|
||||
#include <cstddef>
|
||||
#include <ctime>
|
||||
#include "xr_tracking.h"
|
||||
#include "xr_frame_channel.h"
|
||||
#include "xr_frame_math.h"
|
||||
#include "gpu_frames.h"
|
||||
|
||||
static void check(XrResult result, const char *operation) {
|
||||
if (XR_FAILED(result)) throw std::runtime_error(std::string(operation) + ": " + std::to_string(result));
|
||||
}
|
||||
|
||||
struct Eye {
|
||||
XrSwapchain swapchain = XR_NULL_HANDLE;
|
||||
int width = 0, height = 0;
|
||||
std::vector<XrSwapchainImageOpenGLESKHR> images;
|
||||
};
|
||||
|
||||
class QuestXR {
|
||||
public:
|
||||
explicit QuestXR(android_app *app) : app_(app) {}
|
||||
~QuestXR() { cleanup(); }
|
||||
|
||||
void run() {
|
||||
initialize();
|
||||
while (!app_->destroyRequested && !exit_) {
|
||||
android_poll_source *source = nullptr;
|
||||
int events = 0;
|
||||
int pollTimeout = running_ ? 0 : 100;
|
||||
for (int eventCount = 0; eventCount < 32; eventCount++) {
|
||||
if (ALooper_pollOnce(pollTimeout, nullptr, &events, reinterpret_cast<void **>(&source)) < 0) break;
|
||||
if (source) source->process(app_, source);
|
||||
if (app_->destroyRequested) break;
|
||||
pollTimeout = 0;
|
||||
}
|
||||
receiveFrame();
|
||||
pollEvents();
|
||||
if (running_ && !app_->destroyRequested) frame();
|
||||
}
|
||||
if (exit_ && !app_->destroyRequested) ANativeActivity_finish(app_->activity);
|
||||
}
|
||||
|
||||
void health(const char *error = "") {
|
||||
int steamvrState = -1;
|
||||
if (statusCallback_) {
|
||||
steamvrState = env_->CallIntMethod(app_->activity->clazz, statusCallback_);
|
||||
if (env_->ExceptionCheck()) { env_->ExceptionClear(); steamvrState = -1; }
|
||||
}
|
||||
std::string path = std::string(app_->activity->externalDataPath) + "/xr-health.json";
|
||||
std::string temp = path + ".tmp";
|
||||
FILE *file = std::fopen(temp.c_str(), "w");
|
||||
if (!file) return;
|
||||
std::fprintf(file,
|
||||
"{\"frames\":%llu,\"rendered\":%llu,\"sessionState\":%d,\"headFlags\":%llu,"
|
||||
"\"headPosition\":[%.6f,%.6f,%.6f],\"controllersActive\":[%s,%s],"
|
||||
"\"periodNs\":%lld,\"surfaceSwapchain\":%s,\"source\":\"%s\",\"guestFrames\":%lld,\"steamvrState\":%d,"
|
||||
"\"receivedFrames\":%llu,\"rejectedFrames\":%llu,\"uploadedFrames\":%llu,\"sourceFrameId\":%llu,"
|
||||
"\"gpuReceivedFrames\":%llu,\"gpuDroppedFrames\":%llu,\"gpuVerifiedFrameId\":%llu,\"gpuChecksum\":%u,\"error\":\"%s\"}\n",
|
||||
(unsigned long long)frames_, (unsigned long long)rendered_, state_,
|
||||
(unsigned long long)head_.locationFlags,
|
||||
head_.pose.position.x, head_.pose.position.y, head_.pose.position.z,
|
||||
handActive_[0] ? "true" : "false", handActive_[1] ? "true" : "false",
|
||||
(long long)period_, "false", "frames",
|
||||
0LL, steamvrState, (unsigned long long)(receiver_.Received() + gpuFrames_.Received()),
|
||||
(unsigned long long)(receiver_.Rejected() + gpuFrames_.Rejected()),
|
||||
(unsigned long long)uploadedFrames_, (unsigned long long)sourceHeader_.frame_id,
|
||||
(unsigned long long)gpuFrames_.Received(), (unsigned long long)gpuFrames_.Dropped(),
|
||||
(unsigned long long)verifiedFrame_, gpuChecksum_, error);
|
||||
std::fclose(file);
|
||||
std::rename(temp.c_str(), path.c_str());
|
||||
}
|
||||
|
||||
private:
|
||||
android_app *app_;
|
||||
JNIEnv *env_ = nullptr;
|
||||
XrInstance instance_ = XR_NULL_HANDLE;
|
||||
XrSession session_ = XR_NULL_HANDLE;
|
||||
XrSystemId system_ = XR_NULL_SYSTEM_ID;
|
||||
XrSpace world_ = XR_NULL_HANDLE, view_ = XR_NULL_HANDLE;
|
||||
XrActionSet actions_ = XR_NULL_HANDLE;
|
||||
XrAction grip_ = XR_NULL_HANDLE, trigger_ = XR_NULL_HANDLE, vibration_ = XR_NULL_HANDLE;
|
||||
XrAction squeeze_ = XR_NULL_HANDLE, stick_ = XR_NULL_HANDLE, stickClick_ = XR_NULL_HANDLE;
|
||||
XrAction primary_ = XR_NULL_HANDLE, secondary_ = XR_NULL_HANDLE, menu_ = XR_NULL_HANDLE;
|
||||
XrAction triggerTouch_ = XR_NULL_HANDLE, stickTouch_ = XR_NULL_HANDLE, primaryTouch_ = XR_NULL_HANDLE;
|
||||
XrAction secondaryTouch_ = XR_NULL_HANDLE, thumbrestTouch_ = XR_NULL_HANDLE;
|
||||
DroidDeckXrHand inputs_[2]{};
|
||||
PFN_xrConvertTimeToTimespecTimeKHR convertTime_ = nullptr;
|
||||
bool timespecSupported_ = false;
|
||||
PFN_xrPerfSettingsSetPerformanceLevelEXT setPerformance_ = nullptr;
|
||||
bool performanceSupported_ = false;
|
||||
XrPath hands_[2]{};
|
||||
XrSpace handSpaces_[2]{};
|
||||
XrSpaceLocation head_{XR_TYPE_SPACE_LOCATION};
|
||||
XrSpaceLocation handLocations_[2]{{XR_TYPE_SPACE_LOCATION}, {XR_TYPE_SPACE_LOCATION}};
|
||||
bool handActive_[2]{};
|
||||
bool running_ = false, exit_ = false, quitRequested_ = false;
|
||||
bool guestAttached_ = false, verifyPixels_ = false;
|
||||
XrFrameReceiver receiver_;
|
||||
QuestGpuFrames gpuFrames_;
|
||||
DroidDeckXrFrameHeader sourceHeader_{};
|
||||
uint64_t uploadedFrames_ = 0, verifiedFrame_ = 0;
|
||||
uint32_t gpuChecksum_ = 0;
|
||||
GLuint sourceTexture_ = 0, sourceProgram_ = 0;
|
||||
GLuint cpuTexture_ = 0;
|
||||
jmethodID frameCallback_ = nullptr;
|
||||
jmethodID activeCallback_ = nullptr;
|
||||
jmethodID statusCallback_ = nullptr;
|
||||
DroidDeckXrTracking *tracking_ = nullptr;
|
||||
DroidDeckXrHaptics *haptics_ = nullptr;
|
||||
uint64_t lastPulse_[2]{};
|
||||
XrSessionState state_ = XR_SESSION_STATE_UNKNOWN;
|
||||
uint64_t frames_ = 0, rendered_ = 0;
|
||||
XrDuration period_ = 0;
|
||||
EGLDisplay display_ = EGL_NO_DISPLAY;
|
||||
EGLContext context_ = EGL_NO_CONTEXT;
|
||||
EGLSurface surface_ = EGL_NO_SURFACE;
|
||||
std::array<Eye, 2> eyes_;
|
||||
GLuint framebuffer_ = 0, vertexArray_ = 0;
|
||||
GLint sourceEncoding_ = 0, eyeEncoding_ = 0;
|
||||
|
||||
std::string captureRequest() {
|
||||
if (frames_ % 9) return {};
|
||||
std::string file = std::string(app_->activity->externalDataPath) + "/xr-capture.request";
|
||||
FILE *request = std::fopen(file.c_str(), "r");
|
||||
if (!request) return {};
|
||||
char token[34]{};
|
||||
size_t size = std::fread(token, 1, sizeof(token) - 1, request);
|
||||
std::fclose(request);
|
||||
unlink(file.c_str());
|
||||
if (size != 32 || std::strspn(token, "0123456789abcdef") != 32) return {};
|
||||
unlink((std::string(app_->activity->externalDataPath) + "/xr-capture.json").c_str());
|
||||
return token;
|
||||
}
|
||||
|
||||
bool captureTexture(GLuint texture, int width, int height, const char *name) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
|
||||
GLint encoding = 0;
|
||||
glGetFramebufferAttachmentParameteriv(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
|
||||
GL_FRAMEBUFFER_ATTACHMENT_COLOR_ENCODING, &encoding);
|
||||
if (!std::strcmp(name, "source")) sourceEncoding_ = encoding;
|
||||
else eyeEncoding_ = encoding;
|
||||
std::vector<unsigned char> pixels((size_t)width * height * 4);
|
||||
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
|
||||
bool ok = glGetError() == GL_NO_ERROR;
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
if (!ok) return false;
|
||||
std::string file = std::string(app_->activity->externalDataPath) +
|
||||
"/xr-capture-" + name + ".ppm";
|
||||
std::string temporary = file + ".tmp";
|
||||
FILE *output = std::fopen(temporary.c_str(), "wb");
|
||||
if (!output) return false;
|
||||
ok = std::fprintf(output, "P6\n%d %d\n255\n", width, height) > 0;
|
||||
std::vector<unsigned char> row((size_t)width * 3);
|
||||
for (int y = height - 1; y >= 0 && ok; y--) {
|
||||
for (int x = 0; x < width; x++)
|
||||
std::memcpy(row.data() + x * 3, pixels.data() + ((size_t)y * width + x) * 4, 3);
|
||||
ok = std::fwrite(row.data(), 1, row.size(), output) == row.size();
|
||||
}
|
||||
if (std::fclose(output) != 0) ok = false;
|
||||
if (!ok) { unlink(temporary.c_str()); return false; }
|
||||
return std::rename(temporary.c_str(), file.c_str()) == 0;
|
||||
}
|
||||
|
||||
void captureResult(const std::string &token, bool ok, bool valid,
|
||||
const XrCompositionLayerProjectionView (&views)[2], XrTime displayTime) {
|
||||
std::string file = std::string(app_->activity->externalDataPath) + "/xr-capture.json";
|
||||
std::string temporary = file + ".tmp";
|
||||
FILE *output = std::fopen(temporary.c_str(), "w");
|
||||
if (!output) return;
|
||||
std::fprintf(output, "{\"id\":\"%s\",\"ok\":%s,\"validLayer\":%s,\"frame\":%llu,"
|
||||
"\"sourceFrameId\":%llu,\"displayTime\":%lld,\"sessionState\":%d,\"eyes\":[",
|
||||
token.c_str(), ok ? "true" : "false", valid ? "true" : "false",
|
||||
(unsigned long long)frames_, (unsigned long long)sourceHeader_.frame_id, (long long)displayTime, state_);
|
||||
for (int eye = 0; eye < 2; eye++) {
|
||||
const auto &pose = views[eye].pose;
|
||||
const auto &fov = views[eye].fov;
|
||||
std::fprintf(output, "%s{\"width\":%d,\"height\":%d,\"position\":[%g,%g,%g],"
|
||||
"\"orientation\":[%g,%g,%g,%g],\"fov\":[%g,%g,%g,%g]}",
|
||||
eye ? "," : "", eyes_[eye].width, eyes_[eye].height,
|
||||
pose.position.x, pose.position.y, pose.position.z,
|
||||
pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w,
|
||||
fov.angleLeft, fov.angleRight, fov.angleUp, fov.angleDown);
|
||||
}
|
||||
const char *extensions = reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS));
|
||||
const bool srgbControl = extensions && std::strstr(extensions, "GL_EXT_sRGB_write_control");
|
||||
std::fprintf(output, "],\"sourceImage\":true,\"sourceColorSpace\":\"%s\",\"sourceEncoding\":%d,\"eyeEncoding\":%d,"
|
||||
"\"srgbWriteControl\":%s,\"srgbWriteEnabled\":%s}\n",
|
||||
sourceHeader_.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? "srgb" : "linear", sourceEncoding_, eyeEncoding_,
|
||||
srgbControl ? "true" : "false", srgbControl && glIsEnabled(0x8DB9) ? "true" : "false");
|
||||
if (std::fclose(output) == 0) std::rename(temporary.c_str(), file.c_str());
|
||||
}
|
||||
|
||||
XrPath path(const char *text) {
|
||||
XrPath result{};
|
||||
check(xrStringToPath(instance_, text, &result), "xrStringToPath");
|
||||
return result;
|
||||
}
|
||||
|
||||
void initialize() {
|
||||
app_->userData = this;
|
||||
app_->onInputEvent = [](android_app *app, AInputEvent *event) -> int32_t {
|
||||
if (AInputEvent_getType(event) != AINPUT_EVENT_TYPE_KEY || AKeyEvent_getKeyCode(event) != AKEYCODE_BACK
|
||||
|| AKeyEvent_getAction(event) != AKEY_EVENT_ACTION_UP) return 0;
|
||||
auto *runtime = static_cast<QuestXR *>(app->userData);
|
||||
if (runtime->running_) {
|
||||
XrResult result = xrRequestExitSession(runtime->session_);
|
||||
if (XR_SUCCEEDED(result)) runtime->quitRequested_ = true;
|
||||
else __android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "exit request failed: %d", result);
|
||||
} else ANativeActivity_finish(app->activity);
|
||||
return 1;
|
||||
};
|
||||
app_->activity->vm->AttachCurrentThread(&env_, nullptr);
|
||||
jclass activityClass = env_->GetObjectClass(app_->activity->clazz);
|
||||
jmethodID getIntent = env_->GetMethodID(activityClass, "getIntent", "()Landroid/content/Intent;");
|
||||
jobject intent = env_->CallObjectMethod(app_->activity->clazz, getIntent);
|
||||
jclass intentClass = env_->GetObjectClass(intent);
|
||||
jmethodID getBoolean = env_->GetMethodID(intentClass, "getBooleanExtra", "(Ljava/lang/String;Z)Z");
|
||||
jstring extra = env_->NewStringUTF("frame_verify");
|
||||
verifyPixels_ = env_->CallBooleanMethod(intent, getBoolean, extra, JNI_FALSE);
|
||||
env_->DeleteLocalRef(extra);
|
||||
activeCallback_ = env_->GetMethodID(activityClass, "onXrActive", "(Z)V");
|
||||
frameCallback_ = env_->GetMethodID(activityClass, "onXrFrame", "(J)V");
|
||||
statusCallback_ = env_->GetMethodID(activityClass, "onXrStatus", "()I");
|
||||
if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("XR activity bridge methods unavailable"); }
|
||||
env_->DeleteLocalRef(intentClass); env_->DeleteLocalRef(intent); env_->DeleteLocalRef(activityClass);
|
||||
PFN_xrInitializeLoaderKHR initializeLoader = nullptr;
|
||||
check(xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR",
|
||||
reinterpret_cast<PFN_xrVoidFunction *>(&initializeLoader)), "loader init entrypoint");
|
||||
XrLoaderInitInfoAndroidKHR loader{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
|
||||
loader.applicationVM = app_->activity->vm;
|
||||
loader.applicationContext = app_->activity->clazz;
|
||||
check(initializeLoader(reinterpret_cast<XrLoaderInitInfoBaseHeaderKHR *>(&loader)), "xrInitializeLoaderKHR");
|
||||
uint32_t count = 0;
|
||||
check(xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr), "extensions count");
|
||||
std::vector<XrExtensionProperties> extensions(count, {XR_TYPE_EXTENSION_PROPERTIES});
|
||||
check(xrEnumerateInstanceExtensionProperties(nullptr, count, &count, extensions.data()), "extensions");
|
||||
for (const auto &extension : extensions) {
|
||||
if (!std::strcmp(extension.extensionName, XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME))
|
||||
timespecSupported_ = true;
|
||||
if (!std::strcmp(extension.extensionName, XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME))
|
||||
performanceSupported_ = true;
|
||||
}
|
||||
std::vector<const char *> enabled{XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME};
|
||||
if (timespecSupported_) enabled.push_back(XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME);
|
||||
if (performanceSupported_) enabled.push_back(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME);
|
||||
XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
|
||||
androidInfo.applicationVM = app_->activity->vm;
|
||||
androidInfo.applicationActivity = app_->activity->clazz;
|
||||
XrInstanceCreateInfo create{XR_TYPE_INSTANCE_CREATE_INFO};
|
||||
create.next = &androidInfo;
|
||||
std::strcpy(create.applicationInfo.applicationName, "DroidDeck XR");
|
||||
create.applicationInfo.applicationVersion = 1;
|
||||
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
|
||||
create.enabledExtensionCount = enabled.size();
|
||||
create.enabledExtensionNames = enabled.data();
|
||||
check(xrCreateInstance(&create, &instance_), "xrCreateInstance");
|
||||
if (performanceSupported_) {
|
||||
XrResult result = xrGetInstanceProcAddr(instance_, "xrPerfSettingsSetPerformanceLevelEXT",
|
||||
reinterpret_cast<PFN_xrVoidFunction *>(&setPerformance_));
|
||||
if (XR_FAILED(result)) setPerformance_ = nullptr;
|
||||
}
|
||||
if (timespecSupported_) check(xrGetInstanceProcAddr(instance_, "xrConvertTimeToTimespecTimeKHR",
|
||||
reinterpret_cast<PFN_xrVoidFunction *>(&convertTime_)), "time conversion entrypoint");
|
||||
XrSystemGetInfo systemInfo{XR_TYPE_SYSTEM_GET_INFO};
|
||||
systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
|
||||
check(xrGetSystem(instance_, &systemInfo, &system_), "xrGetSystem");
|
||||
PFN_xrGetOpenGLESGraphicsRequirementsKHR requirementsFunction = nullptr;
|
||||
check(xrGetInstanceProcAddr(instance_, "xrGetOpenGLESGraphicsRequirementsKHR",
|
||||
reinterpret_cast<PFN_xrVoidFunction *>(&requirementsFunction)), "GLES requirements entrypoint");
|
||||
XrGraphicsRequirementsOpenGLESKHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
|
||||
check(requirementsFunction(instance_, system_, &requirements), "GLES requirements");
|
||||
if (requirements.minApiVersionSupported > XR_MAKE_VERSION(3, 0, 0)
|
||||
|| requirements.maxApiVersionSupported < XR_MAKE_VERSION(3, 0, 0))
|
||||
throw std::runtime_error("Runtime does not support GLES 3.0");
|
||||
display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
if (!eglInitialize(display_, nullptr, nullptr)) throw std::runtime_error("eglInitialize failed");
|
||||
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT | EGL_WINDOW_BIT,
|
||||
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 24, EGL_NONE};
|
||||
EGLConfig config{};
|
||||
EGLint found = 0;
|
||||
if (!eglChooseConfig(display_, attributes, &config, 1, &found) || found != 1)
|
||||
throw std::runtime_error("EGL config unavailable");
|
||||
const EGLint contextAttributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
|
||||
context_ = eglCreateContext(display_, config, EGL_NO_CONTEXT, contextAttributes);
|
||||
const EGLint surfaceAttributes[] = {EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE};
|
||||
surface_ = eglCreatePbufferSurface(display_, config, surfaceAttributes);
|
||||
if (!eglMakeCurrent(display_, surface_, surface_, context_)) throw std::runtime_error("eglMakeCurrent failed");
|
||||
XrGraphicsBindingOpenGLESAndroidKHR binding{XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR};
|
||||
binding.display = display_; binding.config = config; binding.context = context_;
|
||||
XrSessionCreateInfo sessionInfo{XR_TYPE_SESSION_CREATE_INFO};
|
||||
sessionInfo.next = &binding; sessionInfo.systemId = system_;
|
||||
check(xrCreateSession(instance_, &sessionInfo, &session_), "xrCreateSession");
|
||||
XrReferenceSpaceCreateInfo reference{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||
reference.poseInReferenceSpace.orientation.w = 1;
|
||||
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_STAGE;
|
||||
XrResult referenceResult = xrCreateReferenceSpace(session_, &reference, &world_);
|
||||
if (referenceResult == XR_ERROR_REFERENCE_SPACE_UNSUPPORTED) {
|
||||
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
|
||||
reference.poseInReferenceSpace.position.y = -1.6f;
|
||||
check(xrCreateReferenceSpace(session_, &reference, &world_), "local standing space");
|
||||
} else check(referenceResult, "stage space");
|
||||
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
|
||||
reference.poseInReferenceSpace.position.y = 0;
|
||||
check(xrCreateReferenceSpace(session_, &reference, &view_), "view space");
|
||||
initializeActions();
|
||||
initializeImages();
|
||||
initializeGraphics();
|
||||
initializeTracking();
|
||||
{
|
||||
std::string filename = std::string(app_->activity->internalDataPath) + "/.wayland-rt/" DROIDDECK_XR_FRAME_SOCKET;
|
||||
if (!receiver_.Start(filename)) throw std::runtime_error("XR frame receiver unavailable");
|
||||
bool gpu = !verifyPixels_ && gpuFrames_.Start(std::string(app_->activity->internalDataPath) + "/.wayland-rt/" DROIDDECK_XR_GPU_SOCKET, display_);
|
||||
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "GPU frame channel available=%d", gpu);
|
||||
}
|
||||
health();
|
||||
}
|
||||
|
||||
void initializeActions() {
|
||||
hands_[0] = path("/user/hand/left"); hands_[1] = path("/user/hand/right");
|
||||
XrActionSetCreateInfo setInfo{XR_TYPE_ACTION_SET_CREATE_INFO};
|
||||
std::strcpy(setInfo.actionSetName, "quest"); std::strcpy(setInfo.localizedActionSetName, "Quest controllers");
|
||||
check(xrCreateActionSet(instance_, &setInfo, &actions_), "action set");
|
||||
auto action = [&](const char *name, XrActionType type, XrAction *handle) {
|
||||
XrActionCreateInfo info{XR_TYPE_ACTION_CREATE_INFO};
|
||||
std::strcpy(info.actionName, name); std::strcpy(info.localizedActionName, name);
|
||||
info.actionType = type; info.countSubactionPaths = 2; info.subactionPaths = hands_;
|
||||
check(xrCreateAction(actions_, &info, handle), "create action");
|
||||
};
|
||||
action("grip_pose", XR_ACTION_TYPE_POSE_INPUT, &grip_);
|
||||
action("trigger", XR_ACTION_TYPE_FLOAT_INPUT, &trigger_);
|
||||
action("squeeze", XR_ACTION_TYPE_FLOAT_INPUT, &squeeze_);
|
||||
action("stick", XR_ACTION_TYPE_VECTOR2F_INPUT, &stick_);
|
||||
action("stick_click", XR_ACTION_TYPE_BOOLEAN_INPUT, &stickClick_);
|
||||
action("primary", XR_ACTION_TYPE_BOOLEAN_INPUT, &primary_);
|
||||
action("secondary", XR_ACTION_TYPE_BOOLEAN_INPUT, &secondary_);
|
||||
action("menu", XR_ACTION_TYPE_BOOLEAN_INPUT, &menu_);
|
||||
action("trigger_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &triggerTouch_);
|
||||
action("stick_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &stickTouch_);
|
||||
action("primary_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &primaryTouch_);
|
||||
action("secondary_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &secondaryTouch_);
|
||||
action("thumbrest_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &thumbrestTouch_);
|
||||
action("vibration", XR_ACTION_TYPE_VIBRATION_OUTPUT, &vibration_);
|
||||
std::vector<XrActionSuggestedBinding> bindings;
|
||||
for (const char *hand : {"left", "right"}) {
|
||||
std::string root = std::string("/user/hand/") + hand;
|
||||
bindings.push_back({grip_, path((root + "/input/grip/pose").c_str())});
|
||||
bindings.push_back({trigger_, path((root + "/input/trigger/value").c_str())});
|
||||
bindings.push_back({triggerTouch_, path((root + "/input/trigger/touch").c_str())});
|
||||
bindings.push_back({squeeze_, path((root + "/input/squeeze/value").c_str())});
|
||||
bindings.push_back({stick_, path((root + "/input/thumbstick").c_str())});
|
||||
bindings.push_back({stickClick_, path((root + "/input/thumbstick/click").c_str())});
|
||||
bindings.push_back({stickTouch_, path((root + "/input/thumbstick/touch").c_str())});
|
||||
bindings.push_back({thumbrestTouch_, path((root + "/input/thumbrest/touch").c_str())});
|
||||
bindings.push_back({primary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/x/click" : "/input/a/click")).c_str())});
|
||||
bindings.push_back({secondary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/y/click" : "/input/b/click")).c_str())});
|
||||
bindings.push_back({primaryTouch_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/x/touch" : "/input/a/touch")).c_str())});
|
||||
bindings.push_back({secondaryTouch_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/y/touch" : "/input/b/touch")).c_str())});
|
||||
bindings.push_back({vibration_, path((root + "/output/haptic").c_str())});
|
||||
}
|
||||
bindings.push_back({menu_, path("/user/hand/left/input/menu/click")});
|
||||
XrInteractionProfileSuggestedBinding suggestion{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
|
||||
suggestion.interactionProfile = path("/interaction_profiles/oculus/touch_controller");
|
||||
suggestion.countSuggestedBindings = bindings.size(); suggestion.suggestedBindings = bindings.data();
|
||||
check(xrSuggestInteractionProfileBindings(instance_, &suggestion), "Touch bindings");
|
||||
XrSessionActionSetsAttachInfo attach{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
|
||||
attach.countActionSets = 1; attach.actionSets = &actions_;
|
||||
check(xrAttachSessionActionSets(session_, &attach), "attach actions");
|
||||
for (int hand = 0; hand < 2; hand++) {
|
||||
XrActionSpaceCreateInfo space{XR_TYPE_ACTION_SPACE_CREATE_INFO};
|
||||
space.action = grip_; space.subactionPath = hands_[hand]; space.poseInActionSpace.orientation.w = 1;
|
||||
check(xrCreateActionSpace(session_, &space, &handSpaces_[hand]), "controller space");
|
||||
}
|
||||
}
|
||||
|
||||
void initializeImages() {
|
||||
uint32_t count = 0;
|
||||
check(xrEnumerateViewConfigurationViews(instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
|
||||
0, &count, nullptr), "view count");
|
||||
if (count != 2) throw std::runtime_error("Expected two stereo views");
|
||||
std::vector<XrViewConfigurationView> views(count, {XR_TYPE_VIEW_CONFIGURATION_VIEW});
|
||||
check(xrEnumerateViewConfigurationViews(instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
|
||||
count, &count, views.data()), "view configuration");
|
||||
check(xrEnumerateSwapchainFormats(session_, 0, &count, nullptr), "format count");
|
||||
std::vector<int64_t> formats(count);
|
||||
check(xrEnumerateSwapchainFormats(session_, count, &count, formats.data()), "formats");
|
||||
int64_t format = 0;
|
||||
for (int64_t candidate : formats) if (candidate == GL_SRGB8_ALPHA8) format = candidate;
|
||||
if (!format) throw std::runtime_error("sRGB swapchain unavailable");
|
||||
for (int eye = 0; eye < 2; eye++) {
|
||||
Eye &target = eyes_[eye];
|
||||
target.width = views[eye].recommendedImageRectWidth;
|
||||
target.height = views[eye].recommendedImageRectHeight;
|
||||
XrSwapchainCreateInfo create{XR_TYPE_SWAPCHAIN_CREATE_INFO};
|
||||
create.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT;
|
||||
create.format = format; create.sampleCount = 1;
|
||||
create.width = target.width; create.height = target.height;
|
||||
create.faceCount = 1; create.arraySize = 1; create.mipCount = 1;
|
||||
check(xrCreateSwapchain(session_, &create, &target.swapchain), "swapchain");
|
||||
check(xrEnumerateSwapchainImages(target.swapchain, 0, &count, nullptr), "image count");
|
||||
target.images.resize(count, {XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR});
|
||||
check(xrEnumerateSwapchainImages(target.swapchain, count, &count,
|
||||
reinterpret_cast<XrSwapchainImageBaseHeader *>(target.images.data())), "images");
|
||||
}
|
||||
}
|
||||
|
||||
void initializeTracking() {
|
||||
std::string directory = std::string(app_->activity->internalDataPath) + "/.wayland-rt";
|
||||
if (mkdir(directory.c_str(), 0700) != 0 && errno != EEXIST)
|
||||
throw std::runtime_error("XR tracking directory unavailable");
|
||||
auto mapFile = [&](const char *name, size_t size) {
|
||||
std::string filename = directory + "/" + name;
|
||||
int descriptor = open(filename.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0600);
|
||||
if (descriptor < 0) throw std::runtime_error("XR IPC file unavailable");
|
||||
if (ftruncate(descriptor, size) != 0) {
|
||||
close(descriptor); throw std::runtime_error("XR IPC file size failed");
|
||||
}
|
||||
void *mapping = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, descriptor, 0);
|
||||
close(descriptor);
|
||||
if (mapping == MAP_FAILED) throw std::runtime_error("XR IPC map failed");
|
||||
return mapping;
|
||||
};
|
||||
tracking_ = static_cast<DroidDeckXrTracking *>(mapFile(DROIDDECK_XR_FILE, sizeof(DroidDeckXrTracking)));
|
||||
haptics_ = static_cast<DroidDeckXrHaptics *>(mapFile(DROIDDECK_XR_HAPTIC_FILE, sizeof(DroidDeckXrHaptics)));
|
||||
haptics_->magic = DROIDDECK_XR_HAPTIC_MAGIC; haptics_->version = DROIDDECK_XR_VERSION;
|
||||
for (int hand = 0; hand < 2; hand++) lastPulse_[hand] = haptics_->hands[hand].id;
|
||||
}
|
||||
|
||||
static DroidDeckXrPose posePacket(const XrPosef &pose, XrSpaceLocationFlags flags) {
|
||||
return {{pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w},
|
||||
{pose.position.x, pose.position.y, pose.position.z}, (uint32_t)flags};
|
||||
}
|
||||
|
||||
void publishTracking(const XrView views[2], XrTime predicted) {
|
||||
if (!tracking_) return;
|
||||
timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
DroidDeckXrTracking packet{};
|
||||
packet.magic = DROIDDECK_XR_MAGIC; packet.version = DROIDDECK_XR_VERSION;
|
||||
packet.active = state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED;
|
||||
packet.sampled_ns = (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec;
|
||||
if (convertTime_) {
|
||||
timespec displayTime{};
|
||||
check(convertTime_(instance_, predicted, &displayTime), "display time conversion");
|
||||
packet.predicted_ns = (int64_t)displayTime.tv_sec * 1000000000LL + displayTime.tv_nsec;
|
||||
}
|
||||
packet.period_ns = period_;
|
||||
packet.head = posePacket(head_.pose, head_.locationFlags);
|
||||
for (int eye = 0; eye < 2; eye++) {
|
||||
packet.eyes[eye].pose = posePacket(views[eye].pose, head_.locationFlags);
|
||||
packet.eyes[eye].fov[0] = views[eye].fov.angleLeft;
|
||||
packet.eyes[eye].fov[1] = views[eye].fov.angleRight;
|
||||
packet.eyes[eye].fov[2] = views[eye].fov.angleUp;
|
||||
packet.eyes[eye].fov[3] = views[eye].fov.angleDown;
|
||||
packet.eyes[eye].width = eyes_[eye].width; packet.eyes[eye].height = eyes_[eye].height;
|
||||
packet.hands[eye] = inputs_[eye];
|
||||
packet.hands[eye].pose = posePacket(handLocations_[eye].pose, handLocations_[eye].locationFlags);
|
||||
packet.hands[eye].active = handActive_[eye];
|
||||
}
|
||||
uint32_t sequence = __atomic_load_n(&tracking_->seq, __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&tracking_->seq, sequence | 1u, __ATOMIC_SEQ_CST);
|
||||
__atomic_thread_fence(__ATOMIC_SEQ_CST);
|
||||
std::memcpy(tracking_, &packet, offsetof(DroidDeckXrTracking, seq));
|
||||
std::memcpy(reinterpret_cast<char *>(tracking_) + offsetof(DroidDeckXrTracking, active),
|
||||
reinterpret_cast<const char *>(&packet) + offsetof(DroidDeckXrTracking, active),
|
||||
sizeof(packet) - offsetof(DroidDeckXrTracking, active));
|
||||
__atomic_store_n(&tracking_->seq, (sequence | 1u) + 1u, __ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
void attachGuest(bool attach) {
|
||||
if (attach == guestAttached_) return;
|
||||
env_->CallVoidMethod(app_->activity->clazz, activeCallback_, static_cast<jboolean>(attach));
|
||||
if (env_->ExceptionCheck()) {
|
||||
env_->ExceptionDescribe(); env_->ExceptionClear(); throw std::runtime_error("Guest XR handshake failed");
|
||||
}
|
||||
guestAttached_ = attach;
|
||||
}
|
||||
|
||||
void pollHaptics() {
|
||||
if (!haptics_) return;
|
||||
DroidDeckXrHaptics packet{};
|
||||
uint32_t sequence = __atomic_load_n(&haptics_->seq, __ATOMIC_ACQUIRE);
|
||||
if (sequence & 1u) return;
|
||||
std::memcpy(&packet, haptics_, sizeof(packet));
|
||||
__atomic_thread_fence(__ATOMIC_ACQUIRE);
|
||||
if (__atomic_load_n(&haptics_->seq, __ATOMIC_RELAXED) != sequence
|
||||
|| packet.magic != DROIDDECK_XR_HAPTIC_MAGIC || packet.version != DROIDDECK_XR_VERSION) return;
|
||||
for (int hand = 0; hand < 2; hand++) {
|
||||
const auto &pulse = packet.hands[hand];
|
||||
if (pulse.id == lastPulse_[hand]) continue;
|
||||
lastPulse_[hand] = pulse.id;
|
||||
if (state_ != XR_SESSION_STATE_FOCUSED || !handActive_[hand]
|
||||
|| pulse.duration_ns < 1000000 || pulse.duration_ns > 1000000000
|
||||
|| !std::isfinite(pulse.amplitude) || pulse.amplitude < 0 || pulse.amplitude > 1
|
||||
|| !std::isfinite(pulse.frequency) || pulse.frequency < 0) continue;
|
||||
XrHapticActionInfo info{XR_TYPE_HAPTIC_ACTION_INFO}; info.action = vibration_; info.subactionPath = hands_[hand];
|
||||
XrHapticVibration vibration{XR_TYPE_HAPTIC_VIBRATION};
|
||||
vibration.duration = pulse.duration_ns; vibration.amplitude = pulse.amplitude; vibration.frequency = pulse.frequency;
|
||||
check(xrApplyHapticFeedback(session_, &info, reinterpret_cast<XrHapticBaseHeader *>(&vibration)), "guest haptics");
|
||||
}
|
||||
}
|
||||
|
||||
GLuint shader(GLenum type, const char *text) {
|
||||
GLuint handle = glCreateShader(type);
|
||||
glShaderSource(handle, 1, &text, nullptr); glCompileShader(handle);
|
||||
GLint success = 0; glGetShaderiv(handle, GL_COMPILE_STATUS, &success);
|
||||
if (!success) { glDeleteShader(handle); throw std::runtime_error("Shader compile failed"); }
|
||||
return handle;
|
||||
}
|
||||
|
||||
void initializeGraphics() {
|
||||
glGenFramebuffers(1, &framebuffer_);
|
||||
glGenVertexArrays(1, &vertexArray_);
|
||||
GLuint vertex = shader(GL_VERTEX_SHADER,
|
||||
"#version 300 es\nout vec2 uv; uniform float eye; void main(){vec2 coordinate=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));uv=vec2(coordinate.x*0.5+eye*0.5,1.0-coordinate.y);gl_Position=vec4(coordinate*2.0-1.0,0.0,1.0);}");
|
||||
GLuint fragment = shader(GL_FRAGMENT_SHADER,
|
||||
"#version 300 es\nprecision highp float; in vec2 uv; uniform sampler2D source; uniform float eye; out vec4 outputColor; void main(){float edge=0.5/float(textureSize(source,0).x);vec2 coordinate=vec2(clamp(uv.x,eye*0.5+edge,(eye+1.0)*0.5-edge),uv.y);outputColor=texture(source,coordinate);}");
|
||||
sourceProgram_ = glCreateProgram(); glAttachShader(sourceProgram_, vertex); glAttachShader(sourceProgram_, fragment);
|
||||
glLinkProgram(sourceProgram_); glDeleteShader(vertex); glDeleteShader(fragment);
|
||||
GLint linked = 0; glGetProgramiv(sourceProgram_, GL_LINK_STATUS, &linked);
|
||||
if (!linked) throw std::runtime_error("Frame shader link failed");
|
||||
glGenTextures(1, &sourceTexture_); glBindTexture(GL_TEXTURE_2D, sourceTexture_);
|
||||
cpuTexture_ = sourceTexture_;
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
}
|
||||
|
||||
void receiveFrame() {
|
||||
if (gpuFrames_.Take(sourceHeader_, sourceTexture_)) {
|
||||
uploadedFrames_++;
|
||||
if (uploadedFrames_ % 72 == 1) health();
|
||||
return;
|
||||
}
|
||||
DroidDeckXrPixels frame;
|
||||
if (!receiver_.Take(&frame)) return;
|
||||
GLint maximum = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximum);
|
||||
if (frame.header.width > static_cast<uint32_t>(maximum) || frame.header.height > static_cast<uint32_t>(maximum))
|
||||
throw std::runtime_error("XR source exceeds GPU texture limit");
|
||||
sourceTexture_ = cpuTexture_;
|
||||
glBindTexture(GL_TEXTURE_2D, sourceTexture_);
|
||||
GLenum encoding = frame.header.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? GL_SRGB8_ALPHA8 : GL_RGBA8;
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, encoding, frame.header.width, frame.header.height, 0,
|
||||
GL_RGBA, GL_UNSIGNED_BYTE, frame.pixels.data());
|
||||
if (verifyPixels_) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, 0);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, sourceTexture_, 0);
|
||||
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
|
||||
throw std::runtime_error("Frame readback framebuffer incomplete");
|
||||
std::vector<unsigned char> readback(frame.header.bytes);
|
||||
glReadPixels(0, 0, frame.header.width, frame.header.height, GL_RGBA, GL_UNSIGNED_BYTE, readback.data());
|
||||
if (readback != frame.pixels) throw std::runtime_error("Frame GPU readback does not match owned pixels");
|
||||
gpuChecksum_ = 2166136261u;
|
||||
for (unsigned char value : readback) gpuChecksum_ = (gpuChecksum_ ^ value) * 16777619u;
|
||||
verifiedFrame_ = frame.header.frame_id;
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
if (glGetError() != GL_NO_ERROR) throw std::runtime_error("Frame texture upload failed");
|
||||
sourceHeader_ = frame.header;
|
||||
receiver_.Recycle(std::move(frame.pixels));
|
||||
uploadedFrames_++;
|
||||
if (verifyPixels_ || uploadedFrames_ % 72 == 1) health();
|
||||
}
|
||||
|
||||
void render(Eye &eye, uint32_t image) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, eye.images[image].image, 0);
|
||||
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
|
||||
throw std::runtime_error("Incomplete eye framebuffer");
|
||||
{
|
||||
glViewport(0, 0, eye.width, eye.height); glDisable(GL_DEPTH_TEST);
|
||||
glUseProgram(sourceProgram_); glBindVertexArray(vertexArray_);
|
||||
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, sourceTexture_);
|
||||
glUniform1i(glGetUniformLocation(sourceProgram_, "source"), 0);
|
||||
glUniform1f(glGetUniformLocation(sourceProgram_, "eye"), &eye == &eyes_[1] ? 1.f : 0.f);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
}
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
if (glGetError() != GL_NO_ERROR) throw std::runtime_error("OpenGL render error");
|
||||
glFlush();
|
||||
}
|
||||
|
||||
void pollEvents() {
|
||||
XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER};
|
||||
XrResult result;
|
||||
while ((result = xrPollEvent(instance_, &event)) == XR_SUCCESS) {
|
||||
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
|
||||
state_ = reinterpret_cast<XrEventDataSessionStateChanged *>(&event)->state;
|
||||
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "session state=%d", state_);
|
||||
attachGuest(state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED);
|
||||
if (state_ == XR_SESSION_STATE_READY) {
|
||||
XrSessionBeginInfo begin{XR_TYPE_SESSION_BEGIN_INFO};
|
||||
begin.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
|
||||
check(xrBeginSession(session_, &begin), "xrBeginSession"); running_ = true;
|
||||
if (setPerformance_) {
|
||||
for (auto domain : {XR_PERF_SETTINGS_DOMAIN_CPU_EXT, XR_PERF_SETTINGS_DOMAIN_GPU_EXT}) {
|
||||
XrResult request = setPerformance_(session_, domain, XR_PERF_SETTINGS_LEVEL_SUSTAINED_HIGH_EXT);
|
||||
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "sustained performance domain=%d result=%d", domain, request);
|
||||
}
|
||||
}
|
||||
} else if (state_ == XR_SESSION_STATE_STOPPING) {
|
||||
check(xrEndSession(session_), "xrEndSession"); running_ = false;
|
||||
if (quitRequested_) exit_ = true;
|
||||
} else if (state_ == XR_SESSION_STATE_EXITING || state_ == XR_SESSION_STATE_LOSS_PENDING) exit_ = true;
|
||||
health();
|
||||
} else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) exit_ = true;
|
||||
else if (event.type == XR_TYPE_EVENT_DATA_PERF_SETTINGS_EXT) {
|
||||
const auto &performance = *reinterpret_cast<XrEventDataPerfSettingsEXT *>(&event);
|
||||
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "performance domain=%d subdomain=%d level=%d->%d",
|
||||
performance.domain, performance.subDomain, performance.fromLevel, performance.toLevel);
|
||||
}
|
||||
event = {XR_TYPE_EVENT_DATA_BUFFER};
|
||||
}
|
||||
check(result, "xrPollEvent");
|
||||
}
|
||||
|
||||
void frame() {
|
||||
XrFrameWaitInfo wait{XR_TYPE_FRAME_WAIT_INFO};
|
||||
XrFrameState frameState{XR_TYPE_FRAME_STATE};
|
||||
check(xrWaitFrame(session_, &wait, &frameState), "xrWaitFrame");
|
||||
period_ = frameState.predictedDisplayPeriod;
|
||||
XrFrameBeginInfo begin{XR_TYPE_FRAME_BEGIN_INFO}; check(xrBeginFrame(session_, &begin), "xrBeginFrame");
|
||||
check(xrLocateSpace(view_, world_, frameState.predictedDisplayTime, &head_), "head pose");
|
||||
if (state_ == XR_SESSION_STATE_FOCUSED) {
|
||||
XrActiveActionSet active{actions_, XR_NULL_PATH};
|
||||
XrActionsSyncInfo sync{XR_TYPE_ACTIONS_SYNC_INFO}; sync.countActiveActionSets = 1; sync.activeActionSets = &active;
|
||||
check(xrSyncActions(session_, &sync), "xrSyncActions");
|
||||
for (int hand = 0; hand < 2; hand++) {
|
||||
XrActionStateGetInfo get{XR_TYPE_ACTION_STATE_GET_INFO}; get.action = grip_; get.subactionPath = hands_[hand];
|
||||
XrActionStatePose pose{XR_TYPE_ACTION_STATE_POSE}; check(xrGetActionStatePose(session_, &get, &pose), "grip state");
|
||||
handActive_[hand] = pose.isActive;
|
||||
check(xrLocateSpace(handSpaces_[hand], world_, frameState.predictedDisplayTime, &handLocations_[hand]), "hand pose");
|
||||
get.action = trigger_;
|
||||
XrActionStateFloat trigger{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &trigger), "trigger state");
|
||||
inputs_[hand].trigger = trigger.isActive ? trigger.currentState : 0;
|
||||
get.action = squeeze_;
|
||||
XrActionStateFloat squeeze{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &squeeze), "squeeze state");
|
||||
inputs_[hand].squeeze = squeeze.isActive ? squeeze.currentState : 0;
|
||||
get.action = stick_;
|
||||
XrActionStateVector2f stick{XR_TYPE_ACTION_STATE_VECTOR2F}; check(xrGetActionStateVector2f(session_, &get, &stick), "stick state");
|
||||
inputs_[hand].stick[0] = stick.isActive ? stick.currentState.x : 0;
|
||||
inputs_[hand].stick[1] = stick.isActive ? stick.currentState.y : 0;
|
||||
inputs_[hand].buttons = 0;
|
||||
const std::pair<XrAction, uint32_t> buttons[] = {
|
||||
{stickClick_, DROIDDECK_XR_STICK_CLICK}, {primary_, DROIDDECK_XR_PRIMARY_CLICK},
|
||||
{secondary_, DROIDDECK_XR_SECONDARY_CLICK}, {menu_, DROIDDECK_XR_MENU_CLICK},
|
||||
{triggerTouch_, DROIDDECK_XR_TRIGGER_TOUCH}, {stickTouch_, DROIDDECK_XR_STICK_TOUCH},
|
||||
{primaryTouch_, DROIDDECK_XR_PRIMARY_TOUCH}, {secondaryTouch_, DROIDDECK_XR_SECONDARY_TOUCH},
|
||||
{thumbrestTouch_, DROIDDECK_XR_THUMBREST_TOUCH}};
|
||||
for (const auto &button : buttons) {
|
||||
get.action = button.first;
|
||||
XrActionStateBoolean value{XR_TYPE_ACTION_STATE_BOOLEAN}; check(xrGetActionStateBoolean(session_, &get, &value), "button state");
|
||||
if (value.isActive && value.currentState) inputs_[hand].buttons |= button.second;
|
||||
}
|
||||
}
|
||||
} else handActive_[0] = handActive_[1] = false;
|
||||
pollHaptics();
|
||||
XrViewLocateInfo locate{XR_TYPE_VIEW_LOCATE_INFO}; locate.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
|
||||
locate.displayTime = frameState.predictedDisplayTime; locate.space = world_;
|
||||
XrViewState viewState{XR_TYPE_VIEW_STATE}; uint32_t count = 0;
|
||||
XrView views[2]{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}};
|
||||
check(xrLocateViews(session_, &locate, &viewState, 2, &count, views), "xrLocateViews");
|
||||
publishTracking(views, frameState.predictedDisplayTime);
|
||||
if (guestAttached_) {
|
||||
timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
env_->CallVoidMethod(app_->activity->clazz, frameCallback_, (jlong)now.tv_sec * 1000000000LL + now.tv_nsec);
|
||||
if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("Guest frame callback failed"); }
|
||||
}
|
||||
XrCompositionLayerProjectionView projection[2]{{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}, {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}};
|
||||
bool valid = frameState.shouldRender && count == 2 &&
|
||||
(viewState.viewStateFlags & (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT))
|
||||
== (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT);
|
||||
{
|
||||
valid = valid && sourceHeader_.frame_id && xr_frame_now() - sourceHeader_.captured_ns < 500000000LL;
|
||||
if (valid) for (int eye = 0; eye < 2; eye++) {
|
||||
auto source = xr_frame_view(sourceHeader_, eye);
|
||||
views[eye].pose.orientation = {source.orientation[0], source.orientation[1], source.orientation[2], source.orientation[3]};
|
||||
views[eye].pose.position = {source.position[0], source.position[1], source.position[2]};
|
||||
views[eye].fov = {source.fov[0], source.fov[1], source.fov[2], source.fov[3]};
|
||||
}
|
||||
}
|
||||
const std::string capture = captureRequest();
|
||||
bool captured = valid;
|
||||
if (!capture.empty() && valid)
|
||||
captured = captureTexture(sourceTexture_, sourceHeader_.width, sourceHeader_.height, "source");
|
||||
if (valid) for (int eye = 0; eye < 2; eye++) {
|
||||
uint32_t image = 0;
|
||||
XrSwapchainImageAcquireInfo acquire{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
|
||||
check(xrAcquireSwapchainImage(eyes_[eye].swapchain, &acquire, &image), "acquire image");
|
||||
XrSwapchainImageWaitInfo imageWait{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; imageWait.timeout = XR_INFINITE_DURATION;
|
||||
check(xrWaitSwapchainImage(eyes_[eye].swapchain, &imageWait), "wait image");
|
||||
render(eyes_[eye], image);
|
||||
if (!capture.empty()) captured = captureTexture(eyes_[eye].images[image].image, eyes_[eye].width, eyes_[eye].height, eye ? "right" : "left") && captured;
|
||||
XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
|
||||
check(xrReleaseSwapchainImage(eyes_[eye].swapchain, &release), "release image");
|
||||
projection[eye].pose = views[eye].pose; projection[eye].fov = views[eye].fov;
|
||||
projection[eye].subImage.swapchain = eyes_[eye].swapchain;
|
||||
projection[eye].subImage.imageRect.extent = {eyes_[eye].width, eyes_[eye].height};
|
||||
}
|
||||
XrCompositionLayerProjection layer{XR_TYPE_COMPOSITION_LAYER_PROJECTION};
|
||||
layer.space = world_; layer.viewCount = 2; layer.views = projection;
|
||||
const XrCompositionLayerBaseHeader *layers[] = {reinterpret_cast<XrCompositionLayerBaseHeader *>(&layer)};
|
||||
XrFrameEndInfo end{XR_TYPE_FRAME_END_INFO}; end.displayTime = frameState.predictedDisplayTime;
|
||||
end.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; end.layerCount = valid ? 1 : 0; end.layers = layers;
|
||||
check(xrEndFrame(session_, &end), "xrEndFrame");
|
||||
if (!capture.empty()) captureResult(capture, captured, valid, projection, frameState.predictedDisplayTime);
|
||||
frames_++; if (valid) rendered_++;
|
||||
if (frames_ % 72 == 0) {
|
||||
health();
|
||||
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "frames=%llu rendered=%llu head=%llu hands=%d,%d period=%lld",
|
||||
(unsigned long long)frames_, (unsigned long long)rendered_, (unsigned long long)head_.locationFlags,
|
||||
handActive_[0], handActive_[1], (long long)period_);
|
||||
}
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
receiver_.Stop();
|
||||
gpuFrames_.Stop();
|
||||
try { attachGuest(false); } catch (const std::exception &error) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "cleanup: %s", error.what());
|
||||
}
|
||||
if (tracking_) {
|
||||
__atomic_store_n(&tracking_->active, 0u, __ATOMIC_RELEASE);
|
||||
munmap(tracking_, sizeof(DroidDeckXrTracking));
|
||||
}
|
||||
if (haptics_) munmap(haptics_, sizeof(DroidDeckXrHaptics));
|
||||
if (display_ != EGL_NO_DISPLAY && context_ != EGL_NO_CONTEXT) {
|
||||
glDeleteFramebuffers(1, &framebuffer_); glDeleteVertexArrays(1, &vertexArray_);
|
||||
glDeleteTextures(1, &cpuTexture_); glDeleteProgram(sourceProgram_);
|
||||
}
|
||||
for (auto &eye : eyes_) if (eye.swapchain) xrDestroySwapchain(eye.swapchain);
|
||||
for (auto space : handSpaces_) if (space) xrDestroySpace(space);
|
||||
if (view_) xrDestroySpace(view_); if (world_) xrDestroySpace(world_);
|
||||
if (session_) xrDestroySession(session_);
|
||||
if (actions_) xrDestroyActionSet(actions_);
|
||||
if (instance_) xrDestroyInstance(instance_);
|
||||
if (display_ != EGL_NO_DISPLAY) {
|
||||
eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||
if (surface_ != EGL_NO_SURFACE) eglDestroySurface(display_, surface_);
|
||||
if (context_ != EGL_NO_CONTEXT) eglDestroyContext(display_, context_);
|
||||
eglTerminate(display_);
|
||||
}
|
||||
if (env_) app_->activity->vm->DetachCurrentThread();
|
||||
}
|
||||
};
|
||||
|
||||
void android_main(android_app *app) {
|
||||
QuestXR runtime(app);
|
||||
try { runtime.run(); }
|
||||
catch (const std::exception &error) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "%s", error.what());
|
||||
runtime.health(error.what());
|
||||
ANativeActivity_finish(app->activity);
|
||||
}
|
||||
}
|
||||
@@ -657,6 +657,7 @@ static void take_pixel(struct surface *s, const uint8_t bgra[4], struct wl_resou
|
||||
static struct surface *g_hud_surface;
|
||||
static int64_t g_hud_last_ns, g_hud_alive_ns;
|
||||
static int g_hud_fresh;
|
||||
static uint64_t g_game_frames;
|
||||
extern void droiddeck_on_game_surface(const char *window, const char *gpu);
|
||||
extern void droiddeck_on_game_frame(void);
|
||||
extern void droiddeck_on_game_program(int pid, const char *program);
|
||||
@@ -740,10 +741,12 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
}
|
||||
if (s == g_hud_surface) {
|
||||
g_hud_alive_ns = now_ns();
|
||||
if (!repeat) { g_hud_last_ns = g_hud_alive_ns; g_hud_fresh = 1; droiddeck_on_game_frame(); }
|
||||
if (!repeat) { g_hud_last_ns = g_hud_alive_ns; g_hud_fresh = 1; g_game_frames++; droiddeck_on_game_frame(); }
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t droiddeck_game_frames(void) { return g_game_frames; }
|
||||
|
||||
struct dmabuf_buffer *droiddeck_dmabuf_from_resource(struct wl_resource *buffer) { return get_dmabuf(buffer); }
|
||||
int droiddeck_dmabuf_fd(const struct dmabuf_buffer *b) { return b && b->n_planes > 0 ? b->fd[0] : -1; }
|
||||
void droiddeck_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h) { *w = b->width; *h = b->height; }
|
||||
@@ -1871,7 +1874,12 @@ static void render_scene(void) {
|
||||
else if (rendered && how == 2) droiddeck_color_frame_shown(hs.color, DROIDDECK_HDR_TONEMAPPED, 0);
|
||||
else if (rendered) hdr_copy = "the HDR composition pass is unavailable on this driver";
|
||||
}
|
||||
if (hdr_route == 0 || hdr_route == 3) {
|
||||
if (hdr_route == 0 && vkp_quest_layer_presentation() && !framegen && !cardboard &&
|
||||
!ahb_layer_only_candidate(w, h)) {
|
||||
/* The complete SDR scene stays on one display layer. Do not hide/re-show GAME
|
||||
* on each desktop frame just because it is not a single fullscreen window. */
|
||||
rendered = vkp_render(w, h, dl.d, dl.n) == 0;
|
||||
} else if (hdr_route == 0 || hdr_route == 3) {
|
||||
const int pass_on = fx_on || framegen;
|
||||
int li = (g_zero_copy && !framegen && !cardboard) ? layer_candidate(&dl, w, h) : -1;
|
||||
int over = li >= 0 ? dl.n - 1 - li : 0;
|
||||
@@ -2194,6 +2202,7 @@ struct constraint {
|
||||
int pending_region_set;
|
||||
int hint_set, pending_hint_set;
|
||||
double hint_x, hint_y, pending_hint_x, pending_hint_y;
|
||||
int gave_keys;
|
||||
};
|
||||
static struct wl_list g_constraints;
|
||||
static struct constraint *g_active_constraint;
|
||||
@@ -2272,6 +2281,14 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh
|
||||
g_ptr_y = oy + k->hint_y;
|
||||
}
|
||||
g_raw_valid = 0;
|
||||
if (k->gave_keys) {
|
||||
k->gave_keys = 0;
|
||||
for (int i = 0; i < g_nkbs; i++) {
|
||||
if (!k->surface || g_kbs[i].focus != k->surface->resource) continue;
|
||||
wl_keyboard_send_leave(g_kbs[i].kb, wl_display_next_serial(g_display), g_kbs[i].focus);
|
||||
g_kbs[i].focus = NULL;
|
||||
}
|
||||
}
|
||||
if (tell_client) constraint_send_state(k, 0);
|
||||
constraint_describe(k, name, sizeof(name));
|
||||
droiddeck_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined",
|
||||
@@ -2290,6 +2307,12 @@ static void constraint_activate(struct constraint *k) {
|
||||
if (!k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y);
|
||||
constraint_origin(k, &ox, &oy);
|
||||
pointer_focus(k->surface->resource, wl_fixed_from_double(g_ptr_x - ox), wl_fixed_from_double(g_ptr_y - oy));
|
||||
int focused = 0;
|
||||
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus) focused = 1;
|
||||
if (k->is_lock && !focused && keyboard_for(wl_resource_get_client(k->surface->resource))) {
|
||||
keyboard_focus(k->surface->resource);
|
||||
k->gave_keys = 1;
|
||||
}
|
||||
constraint_send_state(k, 1);
|
||||
constraint_describe(k, name, sizeof(name));
|
||||
if (k->is_lock)
|
||||
@@ -2594,7 +2617,24 @@ static void pointer_delta(double dx, double dy) {
|
||||
}
|
||||
|
||||
static void pointer_button(uint32_t button, int pressed) {
|
||||
pointer_input(0, 0, 1, button, pressed);
|
||||
if (pressed) {
|
||||
pointer_input(0, 0, 1, button, pressed);
|
||||
return;
|
||||
}
|
||||
// Releases also clean up interrupted gestures. Deliver them to the existing
|
||||
// pointer focus without motion or hit-testing: native touch has independent
|
||||
// coordinates, so replaying g_ptr_x/y here can warp the game cursor.
|
||||
uint32_t t = now_ms();
|
||||
for (int i = 0; i < g_nptrs; i++) {
|
||||
struct seat_pointer *sp = &g_ptrs[i];
|
||||
if (!sp->focus) continue;
|
||||
wl_pointer_send_button(sp->ptr, wl_display_next_serial(g_display), t, button,
|
||||
WL_POINTER_BUTTON_STATE_RELEASED);
|
||||
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_FRAME_SINCE_VERSION)
|
||||
wl_pointer_send_frame(sp->ptr);
|
||||
}
|
||||
g_grab = NULL;
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
static void deliver_pointer(const struct input_msg *m) {
|
||||
@@ -2901,12 +2941,26 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
|
||||
case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break;
|
||||
case 7: deliver_touch(&m); break;
|
||||
case 8: output_resized(); break;
|
||||
case 9:
|
||||
vk_present_set_scale_mode(m.p1, m.p2);
|
||||
schedule_render();
|
||||
break;
|
||||
default: deliver_pointer(&m); break;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void droiddeck_wayland_set_scale_mode(int mode, int alignment) {
|
||||
if (g_input_pipe[1] < 0) {
|
||||
vk_present_set_scale_mode(mode, alignment);
|
||||
return;
|
||||
}
|
||||
struct input_msg m = { 9, mode, alignment, 0, 0 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
(void)n;
|
||||
}
|
||||
|
||||
void droiddeck_wayland_send_pointer(int action, int x, int y) {
|
||||
if (g_input_pipe[1] < 0) return;
|
||||
struct input_msg m = { 0, action, x, y, 0 };
|
||||
@@ -3110,6 +3164,9 @@ int droiddeck_wayland_run(void) {
|
||||
open_session_log();
|
||||
|
||||
if (vkp_ready() != 0) WLOGE("renderer unavailable; clients will connect but nothing is drawn");
|
||||
/* Presentation compatibility is not HDR capability. Set the initial layer mode before
|
||||
* clients bind, including when Desktop is the first session in this app process. */
|
||||
if (vkp_quest_layer_presentation() && sc_layer_available()) g_zero_copy = 1;
|
||||
|
||||
struct wl_display *display = wl_display_create();
|
||||
if (!display) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <android/log.h>
|
||||
|
||||
void droiddeck_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||
uint64_t droiddeck_game_frames(void);
|
||||
#define FGLOG(...) droiddeck_log("framegen", __VA_ARGS__)
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "DroidDeckWayland", __VA_ARGS__)
|
||||
|
||||
@@ -372,7 +373,11 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t n_gen = fge_plan(g_ring_n, ++g_source_frames, g_presented_rate);
|
||||
/* The pacer's source rate must be the game's, not this loop's: a loop slowed by its own
|
||||
* generations would otherwise read as a slower game and justify even more generations. */
|
||||
++g_source_frames;
|
||||
const uint64_t game_frames = droiddeck_game_frames();
|
||||
uint32_t n_gen = fge_plan(g_ring_n, game_frames ? game_frames : g_source_frames, g_presented_rate);
|
||||
if (n_gen > g_ring_n) n_gen = g_ring_n;
|
||||
g_planned = n_gen;
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#ifndef DROIDDECK_QUEST_DEVICE_H
|
||||
#define DROIDDECK_QUEST_DEVICE_H
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
|
||||
/* Presentation only; this does not select a GPU driver or expand driver support. */
|
||||
static inline int quest_device_matches(const char *model, const char *device, const char *maker) {
|
||||
return !strcasecmp(model, "Quest 2") || !strcasecmp(model, "Quest 3") ||
|
||||
!strcasecmp(model, "Quest 3S") ||
|
||||
((!strcasecmp(maker, "Oculus") || !strcasecmp(maker, "Meta")) &&
|
||||
(!strcmp(device, "hollywood") || !strcmp(device, "eureka") || !strcmp(device, "panther")));
|
||||
}
|
||||
#endif
|
||||
@@ -185,6 +185,8 @@ static int g_pending_cb;
|
||||
static unsigned g_stat_layer_frames;
|
||||
static int g_two_logged;
|
||||
static AHardwareBuffer *g_blank;
|
||||
static ASurfaceControl *g_base_sc;
|
||||
static int g_base_w, g_base_h, g_base_shown;
|
||||
|
||||
static void layers_init(void) {
|
||||
if (g_layers_ready) return;
|
||||
@@ -262,14 +264,58 @@ static AHardwareBuffer *blank_buffer(void) {
|
||||
AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY};
|
||||
if (AHardwareBuffer_allocate(&d, &g_blank) != 0 || !g_blank) { g_blank = NULL; return NULL; }
|
||||
void *p = NULL;
|
||||
if (AHardwareBuffer_lock(g_blank, AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY, -1, NULL, &p) == 0 && p) {
|
||||
AHardwareBuffer_Desc got; AHardwareBuffer_describe(g_blank, &got);
|
||||
memset(p, 0, (size_t)got.stride * 4 * 16);
|
||||
AHardwareBuffer_unlock(g_blank, NULL);
|
||||
int locked = AHardwareBuffer_lock(g_blank, AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY, -1, NULL, &p);
|
||||
if (locked != 0 || !p) {
|
||||
if (locked == 0) AHardwareBuffer_unlock(g_blank, NULL);
|
||||
AHardwareBuffer_release(g_blank); g_blank = NULL;
|
||||
return NULL;
|
||||
}
|
||||
AHardwareBuffer_Desc got; AHardwareBuffer_describe(g_blank, &got);
|
||||
memset(p, 0, (size_t)got.stride * 4 * got.height);
|
||||
if (AHardwareBuffer_unlock(g_blank, NULL) != 0) {
|
||||
AHardwareBuffer_release(g_blank); g_blank = NULL;
|
||||
return NULL;
|
||||
}
|
||||
return g_blank;
|
||||
}
|
||||
|
||||
int sc_layer_base_black(int width, int height) {
|
||||
if (width <= 0 || height <= 0 || load_api() != 0 || !vkp_window()) return -1;
|
||||
if (g_base_sc && g_base_shown && width == g_base_w && height == g_base_h) return 0;
|
||||
AHardwareBuffer *black = blank_buffer();
|
||||
if (!black) return -1;
|
||||
if (!g_base_sc) g_base_sc = api.createFromWindow(vkp_window(), "droiddeck_wayland_black");
|
||||
if (!g_base_sc) return -1;
|
||||
ASurfaceTransaction *tx = api.txCreate();
|
||||
if (!tx) return -1;
|
||||
ARect src = {0, 0, 16, 16}, dst = {0, 0, width, height};
|
||||
api.setBuffer(tx, g_base_sc, black, -1);
|
||||
api.setBufferTransparency(tx, g_base_sc, ASC_TRANSPARENCY_OPAQUE);
|
||||
api.setZOrder(tx, g_base_sc, 0); /* GAME = 1, OVERLAY = 2 */
|
||||
api.setGeometry(tx, g_base_sc, &src, &dst, 0);
|
||||
api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_SHOW);
|
||||
api.txApply(tx); api.txDelete(tx);
|
||||
g_base_w = width; g_base_h = height; g_base_shown = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void base_hide(int retire) {
|
||||
if (!g_base_sc) return;
|
||||
ASurfaceTransaction *tx = api.txCreate();
|
||||
if (tx) {
|
||||
api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_HIDE);
|
||||
if (retire) api.reparent(tx, g_base_sc, NULL);
|
||||
api.txApply(tx); api.txDelete(tx);
|
||||
g_base_shown = 0;
|
||||
}
|
||||
if (retire) {
|
||||
api.release(g_base_sc); g_base_sc = NULL;
|
||||
g_base_w = g_base_h = g_base_shown = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void sc_layer_hide_base(void) { base_hide(0); }
|
||||
|
||||
static void replace_with_blank(ASurfaceTransaction *tx, struct layer *l) {
|
||||
AHardwareBuffer *b = blank_buffer();
|
||||
if (b) api.setBuffer(tx, l->sc, b, -1);
|
||||
@@ -927,6 +973,7 @@ void sc_layer_hide_overlay(void) {
|
||||
}
|
||||
|
||||
void sc_layer_window_gone(void) {
|
||||
base_hide(1); /* the backdrop belongs to the old window, just like GAME and OVERLAY */
|
||||
if (api.state != 1) return;
|
||||
layers_init();
|
||||
for (int i = SC_LAYER_COUNT - 1; i >= 0; i--) retire_sc(&g_layers[i]);
|
||||
|
||||
@@ -36,6 +36,8 @@ int sc_layer_overlay_affordable(void);
|
||||
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent);
|
||||
|
||||
void sc_layer_hide(void);
|
||||
int sc_layer_base_black(int width, int height);
|
||||
void sc_layer_hide_base(void);
|
||||
void sc_layer_hide_overlay(void);
|
||||
|
||||
void sc_layer_window_gone(void);
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
#include "hdr_compose.h"
|
||||
#include "blend_pass.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "quest_device.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/system_properties.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
@@ -64,6 +66,21 @@ static VkExtent2D g_extent;
|
||||
static int g_first_frame_done;
|
||||
|
||||
static int g_base_black;
|
||||
|
||||
int vkp_quest_layer_presentation(void) {
|
||||
/* Compositor thread only; this is a process-wide device property, not a session setting.
|
||||
* Quest 2 still needs its separate A6xx driver: this gate only chooses presentation. */
|
||||
static int enabled = -1;
|
||||
if (enabled < 0) {
|
||||
char model[PROP_VALUE_MAX] = {0}, device[PROP_VALUE_MAX] = {0}, maker[PROP_VALUE_MAX] = {0};
|
||||
__system_property_get("ro.product.model", model);
|
||||
__system_property_get("ro.product.device", device);
|
||||
__system_property_get("ro.product.manufacturer", maker);
|
||||
enabled = quest_device_matches(model, device, maker);
|
||||
if (enabled) droiddeck_log("gpu", "Quest presentation: display layers for Steam and desktop, CPU-initialized black backdrop; independent of HDR");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
static VkExtent2D g_caps_extent;
|
||||
static int64_t g_surface_checked_ns;
|
||||
|
||||
@@ -121,6 +138,9 @@ static pthread_mutex_t g_req_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static ANativeWindow *g_req_window;
|
||||
static int g_req_pending;
|
||||
static int g_req_detached;
|
||||
static uint64_t g_req_generation, g_req_applied;
|
||||
static pthread_cond_t g_req_condition = PTHREAD_COND_INITIALIZER;
|
||||
|
||||
|
||||
static volatile int g_mode = VKP_MODE_STRETCH, g_align = VKP_ALIGN_CENTER;
|
||||
|
||||
@@ -130,6 +150,9 @@ static struct {
|
||||
int rx, ry, rw, rh;
|
||||
int valid;
|
||||
} 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 int64_t g_swap_retry_at_ns;
|
||||
static int g_swap_fail_logged;
|
||||
@@ -198,26 +221,53 @@ void vk_present_set_window(ANativeWindow *window) {
|
||||
if (g_req_pending && g_req_window && g_req_window != window) superseded = g_req_window;
|
||||
g_req_window = window;
|
||||
g_req_pending = 1;
|
||||
g_req_generation++;
|
||||
if (!window) g_req_detached = 1;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
if (superseded) ANativeWindow_release(superseded);
|
||||
}
|
||||
|
||||
static void window_request_applied(uint64_t generation) {
|
||||
pthread_mutex_lock(&g_req_lock);
|
||||
g_req_applied = generation;
|
||||
pthread_cond_broadcast(&g_req_condition);
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
}
|
||||
|
||||
int vkp_wait_window_request(int timeout_ms) {
|
||||
if (timeout_ms <= 0 || timeout_ms > 10000) return 0;
|
||||
struct timespec deadline;
|
||||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||||
deadline.tv_sec += timeout_ms / 1000;
|
||||
deadline.tv_nsec += (timeout_ms % 1000) * 1000000L;
|
||||
if (deadline.tv_nsec >= 1000000000L) { deadline.tv_sec++; deadline.tv_nsec -= 1000000000L; }
|
||||
pthread_mutex_lock(&g_req_lock);
|
||||
const uint64_t generation = g_req_generation;
|
||||
int error = 0;
|
||||
while (g_req_applied < generation && error == 0)
|
||||
error = pthread_cond_timedwait(&g_req_condition, &g_req_lock, &deadline);
|
||||
int applied = g_req_applied >= generation;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
return applied;
|
||||
}
|
||||
|
||||
int vkp_apply_window_request(void) {
|
||||
ANativeWindow *w;
|
||||
pthread_mutex_lock(&g_req_lock);
|
||||
if (!g_req_pending) { pthread_mutex_unlock(&g_req_lock); return 0; }
|
||||
w = g_req_window;
|
||||
const int detached = g_req_detached;
|
||||
const uint64_t generation = g_req_generation;
|
||||
g_req_window = NULL;
|
||||
g_req_pending = 0;
|
||||
g_req_detached = 0;
|
||||
pthread_mutex_unlock(&g_req_lock);
|
||||
if (w == g_window) {
|
||||
if (w) ANativeWindow_release(w);
|
||||
if (!w || !detached) return 0;
|
||||
if (!w || !detached) { window_request_applied(generation); return 0; }
|
||||
sc_layer_window_gone();
|
||||
destroy_swapchain();
|
||||
window_request_applied(generation);
|
||||
droiddeck_log("gpu", "screen surface rebound: rebuilding the swapchain at its new size");
|
||||
return 1;
|
||||
}
|
||||
@@ -228,6 +278,7 @@ int vkp_apply_window_request(void) {
|
||||
g_window = w;
|
||||
g_swap_retry_at_ns = 0;
|
||||
g_swap_fail_logged = 0;
|
||||
window_request_applied(generation);
|
||||
droiddeck_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
|
||||
return 1;
|
||||
}
|
||||
@@ -244,7 +295,7 @@ int vkp_device_lost(void) { return g_dev_state == -2; }
|
||||
|
||||
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_BOTTOM) alignment = VKP_ALIGN_CENTER;
|
||||
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_BOTTOM) alignment = VKP_ALIGN_CENTER;
|
||||
g_mode = mode;
|
||||
g_align = alignment;
|
||||
}
|
||||
@@ -263,6 +314,8 @@ static const char *align_name(int a) {
|
||||
switch (a) {
|
||||
case VKP_ALIGN_TOP: return "top";
|
||||
case VKP_ALIGN_BOTTOM: return "bottom";
|
||||
case VKP_ALIGN_PICTURE_TOP: return "picture top";
|
||||
case VKP_ALIGN_PICTURE_BOTTOM: return "picture bottom";
|
||||
default: return "center";
|
||||
}
|
||||
}
|
||||
@@ -298,13 +351,30 @@ static void update_map(int scene_w, int scene_h) {
|
||||
g_map.off_x = (float)(g_map.rx + (int)((g_map.rw - scene_w * aspect) * 0.5f));
|
||||
g_map.off_y = (float)(g_map.ry + (int)((g_map.rh - scene_h * aspect) * 0.5f));
|
||||
}
|
||||
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;
|
||||
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_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;
|
||||
g_game_picture_rect[3] = g_map.off_y + scene_h * g_map.ky;
|
||||
pthread_mutex_unlock(&g_game_picture_lock);
|
||||
droiddeck_log("screen", "%s, %s: %dx%d scene shown %dx%d at %d,%d on the %dx%d output",
|
||||
mode_name(mode), align_name(align), scene_w, scene_h,
|
||||
(int)(scene_w * g_map.kx + 0.5f), (int)(scene_h * g_map.ky + 0.5f),
|
||||
(int)g_map.off_x, (int)g_map.off_y, W, H);
|
||||
}
|
||||
|
||||
int vkp_game_picture_rect(float rect[4]) {
|
||||
pthread_mutex_lock(&g_game_picture_lock);
|
||||
int active = g_game_picture_top;
|
||||
if (active) memcpy(rect, g_game_picture_rect, sizeof(g_game_picture_rect));
|
||||
pthread_mutex_unlock(&g_game_picture_lock);
|
||||
return active;
|
||||
}
|
||||
|
||||
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy) {
|
||||
if (!g_map.valid || g_map.kx <= 0 || g_map.ky <= 0) return 0;
|
||||
*sx = (ox - g_map.off_x) / g_map.kx;
|
||||
@@ -1313,6 +1383,10 @@ static void check_surface_changed(void) {
|
||||
|
||||
int vkp_base_black(int scene_w, int scene_h) {
|
||||
vkp_apply_window_request();
|
||||
if (vkp_quest_layer_presentation()) {
|
||||
if (vkp_update_map(scene_w, scene_h) != 0) return -1;
|
||||
return sc_layer_base_black((int)g_extent.width, (int)g_extent.height);
|
||||
}
|
||||
check_surface_changed();
|
||||
if (g_base_black && g_swapchain && g_window && g_dev_state == 1) {
|
||||
g_perf.base_kept++;
|
||||
@@ -1391,6 +1465,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
const struct vkp_hdr_frame *hf, int *how) {
|
||||
if (how) *how = 0;
|
||||
vkp_apply_window_request();
|
||||
sc_layer_hide_base();
|
||||
check_surface_changed();
|
||||
if (g_dev_state == -2) return -1;
|
||||
if (dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
@@ -1648,6 +1723,26 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
}
|
||||
|
||||
int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) {
|
||||
if (vkp_quest_layer_presentation() && !vkp_framegen_active() && !vkp_cardboard_active()) {
|
||||
/* The desktop (multiple windows), overlays and an empty scene must also avoid WSI.
|
||||
* Merely enabling zero-copy handles only a single fullscreen window and leaves both
|
||||
* the letterbox and every fallback frame going through the corrupted swapchain. */
|
||||
vkp_apply_window_request();
|
||||
sc_layer_hide_overlay();
|
||||
if (n > 0) {
|
||||
if (sc_layer_present_pass(draws, n, scene_w, scene_h) != 0) {
|
||||
static int said;
|
||||
if (!said) { said = 1; droiddeck_log("error", "Quest scene layer unavailable; retaining the previous frame rather than using the corrupt Android swapchain"); }
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
sc_layer_hide();
|
||||
}
|
||||
/* sc_layer_present_pass signals only after submitting client content.
|
||||
* A backdrop alone, or a successful no-op while a layer slot is unavailable,
|
||||
* must not dismiss the app's loading UI or mark Steam's window as shown. */
|
||||
return vkp_base_black(scene_w, scene_h);
|
||||
}
|
||||
return render_impl(scene_w, scene_h, draws, n, NULL, NULL);
|
||||
}
|
||||
|
||||
@@ -1681,6 +1776,15 @@ void vkp_signal_first_frame(void) {
|
||||
|
||||
int vkp_update_map(int scene_w, int scene_h) {
|
||||
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
if (vkp_quest_layer_presentation()) {
|
||||
/* Layer geometry needs the window size, not a Vulkan swapchain. In particular, do
|
||||
* not create/present a swapchain just to paint black under an otherwise clean layer. */
|
||||
int w = ANativeWindow_getWidth(g_window), h = ANativeWindow_getHeight(g_window);
|
||||
if (w <= 0 || h <= 0) return -1;
|
||||
g_extent = (VkExtent2D){(uint32_t)w, (uint32_t)h};
|
||||
update_map(scene_w, scene_h);
|
||||
return g_map.valid ? 0 : -1;
|
||||
}
|
||||
check_surface_changed();
|
||||
if (!g_swapchain && swap_init() != 0) return -1;
|
||||
update_map(scene_w, scene_h);
|
||||
@@ -1889,11 +1993,7 @@ int vkp_pass_begin_hdr(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draws, int n,
|
||||
const struct vkp_hdr_frame *hf, int *rw, int *rh) {
|
||||
if (g_pass.active) vkp_pass_abort();
|
||||
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
check_surface_changed();
|
||||
if (!g_swapchain && swap_init() != 0) return -1;
|
||||
update_map(scene_w, scene_h);
|
||||
if (!g_map.valid) return -1;
|
||||
if (vkp_update_map(scene_w, scene_h) != 0) return -1;
|
||||
if (hf) {
|
||||
if (ensure_img(&g_hdrscene, scene_w, scene_h, HDR_FMT,
|
||||
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT |
|
||||
|
||||
@@ -8,10 +8,13 @@ void vk_present_set_driver(const char *driver_path, const char *library_name,
|
||||
void vk_present_set_window(ANativeWindow *window);
|
||||
int vkp_apply_window_request(void);
|
||||
|
||||
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 };
|
||||
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 };
|
||||
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);
|
||||
void vkp_output_size(int *w, int *h);
|
||||
|
||||
@@ -48,6 +51,7 @@ const char *vkp_gpu_name(void);
|
||||
|
||||
int vkp_ready(void);
|
||||
int vkp_has_window(void);
|
||||
int vkp_wait_window_request(int timeout_ms);
|
||||
int vkp_device_lost(void);
|
||||
|
||||
int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "droiddeck_color.h"
|
||||
|
||||
extern int droiddeck_wayland_run(void);
|
||||
extern void droiddeck_wayland_set_scale_mode(int mode, int alignment);
|
||||
extern void droiddeck_wayland_send_pointer(int action, int x, int y);
|
||||
extern void droiddeck_wayland_send_touch(int action, int pointer_id, int x, int y);
|
||||
extern int droiddeck_cursor_snapshot(int *out, int cap);
|
||||
@@ -354,11 +355,20 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeResizeOutput(JNIEnv
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScaleMode(JNIEnv *env, jclass clazz, jint mode, jint alignment) {
|
||||
vk_present_set_scale_mode(mode, alignment);
|
||||
droiddeck_wayland_set_scale_mode(mode, alignment);
|
||||
__android_log_print(ANDROID_LOG_INFO, TAG, "scale mode %d alignment %d", mode, alignment);
|
||||
}
|
||||
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeGamePictureRect(JNIEnv *env, jclass clazz, jfloatArray out) {
|
||||
if (!out || (*env)->GetArrayLength(env, out) < 4) return JNI_FALSE;
|
||||
float rect[4];
|
||||
if (!vkp_game_picture_rect(rect)) return JNI_FALSE;
|
||||
(*env)->SetFloatArrayRegion(env, out, 0, 4, rect);
|
||||
return JNI_TRUE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetUpscaler(JNIEnv *env, jclass clazz, jint mode) {
|
||||
vkp_effects_set_scaling(mode);
|
||||
@@ -582,6 +592,12 @@ static char *dup_bytes(JNIEnv *env, jbyteArray arr, int *len) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeWaitSurface(
|
||||
JNIEnv *env, jclass clazz, jint timeout_ms) {
|
||||
return vkp_wait_window_request(timeout_ms) ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) {
|
||||
int len;
|
||||
|
||||
@@ -20,6 +20,7 @@ class App : Application() {
|
||||
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
com.droiddeck.launcher.core.DownloadSource.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
BwrapSpawner.start(this)
|
||||
com.droiddeck.launcher.frontend.GameFileSync.start(this)
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import com.droiddeck.launcher.gpu.AutoDriverTrial
|
||||
import com.droiddeck.launcher.gpu.DriverBundle
|
||||
import com.droiddeck.launcher.gpu.DriverPairs
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
@@ -20,7 +21,13 @@ import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.gpu.TurnipReleases
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.QuestExperimental
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.ui.DriverRow
|
||||
import com.droiddeck.launcher.ui.DriverSlot
|
||||
import com.droiddeck.launcher.ui.GPU_MODE_AUTO
|
||||
import com.droiddeck.launcher.ui.GPU_MODE_MIX
|
||||
import com.droiddeck.launcher.ui.GPU_MODE_PAIR
|
||||
import com.droiddeck.launcher.ui.GpuDriversState
|
||||
import com.droiddeck.launcher.ui.PairRow
|
||||
import com.droiddeck.launcher.wayland.CompositorHost
|
||||
@@ -41,7 +48,18 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
var pairPercent by mutableIntStateOf(-1)
|
||||
var autoStatus by mutableStateOf("")
|
||||
var activeBundle by mutableStateOf<String?>(null)
|
||||
private var autoCheckedThisProcess = false
|
||||
private var lastAutoCheck = 0L
|
||||
private var pairModeOpen by mutableStateOf(false)
|
||||
var restartPending by mutableStateOf(false)
|
||||
private set
|
||||
private var runtimePending by mutableStateOf(false)
|
||||
private var sessionRunning by mutableStateOf(false)
|
||||
|
||||
val uiMode: String get() = when (mode) {
|
||||
SessionPrefs.GPU_DRIVERS_AUTO -> GPU_MODE_AUTO
|
||||
SessionPrefs.GPU_DRIVERS_MIX -> GPU_MODE_MIX
|
||||
else -> if (pairModeOpen || pairRows.any { it.active }) GPU_MODE_PAIR else GPU_MODE_MIX
|
||||
}
|
||||
|
||||
fun state() = GpuDriversState(
|
||||
gpuName = gpu.displayName(activity), gpuFamily = gpu.family.label(activity), soc = gpu.soc, supportText = gpu.supportText(activity),
|
||||
@@ -51,7 +69,56 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
|
||||
activeBundle = activeBundle,
|
||||
mode = uiMode, display = displaySlot(), runtime = runtimeSlot(),
|
||||
canRestart = !sessionRunning,
|
||||
checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L,
|
||||
)
|
||||
|
||||
private fun slotSource(chosenDefault: Boolean, defaultLabel: Int): String = activity.getString(when {
|
||||
uiMode == GPU_MODE_AUTO -> R.string.gpu_src_auto
|
||||
uiMode == GPU_MODE_PAIR && pairRows.any { it.active } -> R.string.gpu_src_pair
|
||||
activeBundle != null -> R.string.gpu_src_bundle
|
||||
chosenDefault -> defaultLabel
|
||||
else -> R.string.gpu_src_chosen
|
||||
})
|
||||
|
||||
private fun displaySlot(): DriverSlot {
|
||||
val row = androidRows.firstOrNull { it.id == androidSelected }
|
||||
val live = androidRows.firstOrNull { it.id == CompositorHost.displayChoice }?.name
|
||||
return DriverSlot(
|
||||
name = row?.name ?: activity.getString(R.string.gpu_auto_by_gpu),
|
||||
source = slotSource(androidSelected == TurnipDriver.AUTO, R.string.gpu_src_by_gpu),
|
||||
pending = restartPending,
|
||||
status = when {
|
||||
restartPending -> activity.getString(R.string.gpu_display_pending, live ?: activity.getString(R.string.gpu_previous_driver))
|
||||
CompositorHost.isStarted -> activity.getString(R.string.gpu_display_live)
|
||||
else -> activity.getString(R.string.gpu_display_next)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun runtimeSlot(): DriverSlot {
|
||||
val row = linuxRows.firstOrNull { it.id == linuxSelected }
|
||||
val live = SessionState.runtimeDriver?.let { id -> linuxRows.firstOrNull { it.id == id }?.name }
|
||||
return DriverSlot(
|
||||
name = row?.name ?: activity.getString(R.string.gpu_runtime_default),
|
||||
source = slotSource(linuxSelected.isEmpty(), R.string.gpu_src_default),
|
||||
pending = runtimePending,
|
||||
status = when {
|
||||
runtimePending -> activity.getString(R.string.gpu_runtime_pending, live ?: activity.getString(R.string.gpu_previous_driver))
|
||||
sessionRunning -> activity.getString(R.string.gpu_runtime_live)
|
||||
else -> activity.getString(R.string.gpu_runtime_hint)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun restartApp() {
|
||||
if (SessionState.running) return
|
||||
val launch = activity.packageManager.getLaunchIntentForPackage(activity.packageName) ?: return
|
||||
activity.startActivity(android.content.Intent.makeRestartActivityTask(launch.component)
|
||||
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "components"))
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
var androidSelected by mutableStateOf("")
|
||||
|
||||
@@ -89,21 +156,30 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
canRestoreBundled = td.hiddenBundled().isNotEmpty()
|
||||
androidSelected = SessionPrefs.androidDriver(activity)
|
||||
activeBundle = DriverBundle.active(activity)?.label
|
||||
restartPending = CompositorHost.isStarted && CompositorHost.displayChoice.let { it != null && it != androidSelected }
|
||||
sessionRunning = SessionState.running
|
||||
runtimePending = sessionRunning && SessionState.runtimeDriver.let { it != null && it != linuxSelected }
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
}
|
||||
|
||||
fun summary(): String {
|
||||
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
|
||||
val label = activity.getString(if (mode == SessionPrefs.GPU_DRIVERS_AUTO) R.string.common_auto else R.string.drivers_manual)
|
||||
return active?.let { activity.getString(R.string.drivers_summary, label, it) } ?: label
|
||||
val display = displaySlot().name
|
||||
val base = when {
|
||||
uiMode == GPU_MODE_AUTO -> activity.getString(R.string.drivers_summary, activity.getString(R.string.common_auto), active ?: display)
|
||||
uiMode == GPU_MODE_PAIR && active != null -> activity.getString(R.string.drivers_summary, activity.getString(R.string.gpu_mode_pair_short), active)
|
||||
else -> activity.getString(R.string.drivers_summary, activity.getString(R.string.gpu_mode_mix_short),
|
||||
runtimeSlot().name.let { if (it == display) it else activity.getString(R.string.gpu_summary_mix, display, it) })
|
||||
}
|
||||
return if (restartPending) activity.getString(R.string.gpu_summary_restart, base) else base
|
||||
}
|
||||
|
||||
fun refreshPairs() {
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
|
||||
val recommended = DriverPairs.recommendedKey(gpu, pairs)
|
||||
val recommended = if (QuestExperimental.isSupported()) null else DriverPairs.recommendedKey(gpu, pairs)
|
||||
pairRows = pairs.map { p ->
|
||||
val bundle = p.bundle?.let { installedBundle(it) }
|
||||
val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) }
|
||||
@@ -120,18 +196,56 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
}.sortedByDescending { it.recommended }
|
||||
}
|
||||
|
||||
fun setMode(auto: Boolean) {
|
||||
mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL
|
||||
fun chooseMode(next: String) {
|
||||
pairModeOpen = next == GPU_MODE_PAIR
|
||||
setMode(auto = next == GPU_MODE_AUTO, mix = next == GPU_MODE_MIX)
|
||||
}
|
||||
|
||||
fun setMode(auto: Boolean, mix: Boolean = false) {
|
||||
mode = when {
|
||||
auto -> SessionPrefs.GPU_DRIVERS_AUTO
|
||||
mix -> SessionPrefs.GPU_DRIVERS_MIX
|
||||
else -> SessionPrefs.GPU_DRIVERS_MANUAL
|
||||
}
|
||||
SessionPrefs.setGpuDriverMode(activity, mode)
|
||||
autoStatus = ""
|
||||
if (auto) ensureAuto(force = false)
|
||||
}
|
||||
|
||||
fun checkOnOpen() {
|
||||
if (SessionPrefs.gpuDriverMode(activity) == SessionPrefs.GPU_DRIVERS_AUTO) ensureAuto(force = false)
|
||||
else if (!releaseChecking && releasesStale() && com.droiddeck.launcher.frontend.AutoComponents.isOnline(activity)) checkLatestTurnip()
|
||||
}
|
||||
|
||||
private fun releasesStale(): Boolean {
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
return cached == null || System.currentTimeMillis() - cached.checkedAt > RELEASE_CHECK_INTERVAL_MS ||
|
||||
cached.assets.none { it.pair.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun ensureAuto(force: Boolean) {
|
||||
mode = SessionPrefs.gpuDriverMode(activity)
|
||||
if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return
|
||||
if (!force && autoCheckedThisProcess) return
|
||||
autoCheckedThisProcess = true
|
||||
if (SessionState.running) {
|
||||
if (force) autoStatus = activity.getString(R.string.drivers_auto_waits_for_session)
|
||||
return
|
||||
}
|
||||
AutoDriverTrial.rollbackDue(activity)?.let { trial ->
|
||||
AutoDriverTrial.rollBack(activity, trial)
|
||||
autoStatus = activity.getString(R.string.drivers_rolled_back, trial.name)
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
if (!force && System.currentTimeMillis() - lastAutoCheck < RELEASE_CHECK_INTERVAL_MS) return
|
||||
lastAutoCheck = System.currentTimeMillis()
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != QuestExperimental.defaultAndroidDriver()
|
||||
if (QuestExperimental.applyAutoDrivers(activity)) {
|
||||
autoStatus = activity.getString(
|
||||
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_quest_restart else R.string.drivers_using_quest,
|
||||
)
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
|
||||
autoStatus = activity.getString(R.string.drivers_none_to_set, gpu.supportText(activity).lowercase())
|
||||
return
|
||||
@@ -139,10 +253,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
releaseChecking = true
|
||||
autoStatus = activity.getString(R.string.drivers_checking_latest)
|
||||
Thread({
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
|
||||
cached.assets.none { it.pair.isNotEmpty() }
|
||||
val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
|
||||
val problem = if (force || releasesStale()) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
|
||||
val holdBack = !force && com.droiddeck.launcher.frontend.AutoComponents.constrained(activity)
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
@@ -156,6 +268,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
?: activity.getString(R.string.upd_current_status)
|
||||
pair == null || !pair.complete ->
|
||||
autoStatus = problem?.let { activity.getString(R.string.upd_couldnt_check, it) } ?: activity.getString(R.string.drivers_recommended_unpublished)
|
||||
AutoDriverTrial.release(pair) in AutoDriverTrial.bad(activity) ->
|
||||
autoStatus = activity.getString(R.string.drivers_skipping_bad, pair.name, pair.version)
|
||||
SessionState.running -> autoStatus = activity.getString(R.string.drivers_auto_waits_for_session)
|
||||
holdBack && row?.installed != true -> autoStatus = activity.getString(R.string.drivers_auto_waits_for_network, pair.name, pair.version)
|
||||
else -> installPair(pair, auto = true)
|
||||
}
|
||||
}
|
||||
@@ -165,6 +281,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun selectPair(key: String) {
|
||||
if (pairBusy != null) return
|
||||
val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return
|
||||
pairModeOpen = true
|
||||
if (mode != SessionPrefs.GPU_DRIVERS_MANUAL) setMode(auto = false)
|
||||
installPair(pair, auto = false)
|
||||
}
|
||||
|
||||
@@ -174,8 +292,6 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
pairPercent = 0
|
||||
if (auto) autoStatus = activity.getString(R.string.drivers_downloading_pair, pair.name, pair.version)
|
||||
Thread({
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
var downloaded = false
|
||||
val result = runCatching {
|
||||
val zips = pair.assets
|
||||
@@ -189,22 +305,27 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
pairBusy = null
|
||||
pairPercent = -1
|
||||
result.onSuccess { (displayId, linuxId) ->
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
|
||||
if (auto && SessionState.running) {
|
||||
lastAutoCheck = 0L
|
||||
autoStatus = activity.getString(R.string.drivers_auto_waits_for_session)
|
||||
return@onSuccess
|
||||
}
|
||||
val previousDisplay = SessionPrefs.androidDriver(activity)
|
||||
val previousLinux = SessionPrefs.linuxDriver(activity)
|
||||
val displayChanged = previousDisplay != displayId
|
||||
SessionPrefs.setAndroidDriver(activity, displayId)
|
||||
SessionPrefs.setLinuxDriver(activity, linuxId)
|
||||
val restart = displayChanged && CompositorHost.isStarted
|
||||
if (auto) {
|
||||
val previous = SessionPrefs.gpuAutoInstalled(activity)
|
||||
for (id in previous - setOf(displayId, linuxId)) {
|
||||
val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id))
|
||||
when {
|
||||
bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) }
|
||||
lm.isInstalled(id) -> lm.removeDriver(id)
|
||||
else -> td.remove(id)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
|
||||
val trial = AutoDriverTrial.current(activity)
|
||||
val lastGood = trial?.let { setOf(it.previousDisplay, it.previousLinux) } ?: setOf(previousDisplay, previousLinux)
|
||||
val changed = previousDisplay != displayId || previousLinux != linuxId
|
||||
SessionPrefs.setGpuAutoInstalled(activity, SessionPrefs.gpuAutoInstalled(activity) + setOf(displayId, linuxId))
|
||||
if (changed) AutoDriverTrial.begin(
|
||||
activity, AutoDriverTrial.release(pair), "${pair.name} ${pair.version}", displayId, linuxId,
|
||||
trial?.previousDisplay ?: previousDisplay, trial?.previousLinux ?: previousLinux,
|
||||
)
|
||||
if (!SessionState.running) AutoDriverTrial.prune(activity, lastGood + setOf(displayId, linuxId))
|
||||
autoStatus = activity.getString(
|
||||
if (downloaded) { if (restart) R.string.drivers_updated_to_restart else R.string.drivers_updated_to }
|
||||
else { if (restart) R.string.drivers_switched_to_restart else R.string.drivers_switched_to },
|
||||
@@ -295,14 +416,15 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
linux -> SessionPrefs.setLinuxDriver(activity, id)
|
||||
else -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
}
|
||||
setMode(false)
|
||||
pairModeOpen = false
|
||||
setMode(auto = false, mix = true)
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
private fun useBundle(bundle: DriverBundle.Bundle): String {
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
|
||||
DriverBundle.select(activity, bundle)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(auto = false, mix = true)
|
||||
return activity.getString(
|
||||
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_bundle_restart else R.string.drivers_using_bundle,
|
||||
bundle.label,
|
||||
@@ -411,6 +533,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
|
||||
private companion object {
|
||||
const val TAG = "MainActivity"
|
||||
const val RELEASE_CHECK_INTERVAL_MS = 3_600_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.core.WirelessAdbFix
|
||||
@@ -109,6 +110,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private var showRemove by mutableStateOf(false)
|
||||
private var showResetSettings by mutableStateOf(false)
|
||||
private var lowSpace by mutableStateOf<Long?>(null)
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
@@ -150,15 +152,21 @@ class MainActivity : ComponentActivity() {
|
||||
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var picturePosition by mutableStateOf("center")
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
|
||||
private var downloadMirror by mutableStateOf("")
|
||||
private var downloadPrefix by mutableStateOf("")
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var gameStoresEnabled by mutableStateOf(true)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
|
||||
private var navRequest by mutableStateOf<String?>(null)
|
||||
private var storesShowMature by mutableStateOf(false)
|
||||
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
|
||||
private var storesView by mutableStateOf(SessionPrefs.STORES_VIEWS.first())
|
||||
private var mic by mutableStateOf(false)
|
||||
private var wifiDiscovery by mutableStateOf(false)
|
||||
private var wifiDiscoveryPermission by mutableStateOf(false)
|
||||
@@ -201,6 +209,9 @@ class MainActivity : ComponentActivity() {
|
||||
private val pickDeckyPluginZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { decky.importPluginZip(it) }
|
||||
}
|
||||
private val pickBootMovie = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importBootMovie(it) }
|
||||
}
|
||||
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
|
||||
}
|
||||
@@ -245,6 +256,38 @@ class MainActivity : ComponentActivity() {
|
||||
}, "lossless-sync").start()
|
||||
}
|
||||
|
||||
private fun importBootMovie(file: File) {
|
||||
bootMovieImporting = true
|
||||
bootMovieProgress = -1
|
||||
Thread({
|
||||
val result = com.droiddeck.launcher.session.BootMovie.import(
|
||||
this, file,
|
||||
onStart = { ui.post { android.widget.Toast.makeText(this, getString(R.string.boot_movie_converting_started, file.name), android.widget.Toast.LENGTH_LONG).show() } },
|
||||
onProgress = { percent -> ui.post { bootMovieProgress = percent } },
|
||||
)
|
||||
val message = when (result) {
|
||||
is com.droiddeck.launcher.session.BootMovie.Import.Done -> getString(
|
||||
if (result.audioDropped) R.string.boot_movie_imported_silent else R.string.boot_movie_imported, result.name)
|
||||
com.droiddeck.launcher.session.BootMovie.Import.TooLong ->
|
||||
getString(R.string.boot_movie_too_long, com.droiddeck.launcher.session.BootMovie.MAX_CONVERT_SECONDS)
|
||||
is com.droiddeck.launcher.session.BootMovie.Import.Unsupported -> getString(R.string.boot_movie_unsupported)
|
||||
com.droiddeck.launcher.session.BootMovie.Import.Failed -> getString(R.string.boot_movie_import_failed)
|
||||
}
|
||||
ui.post {
|
||||
bootMovieImporting = false
|
||||
bootMovieProgress = -1
|
||||
loadBootMovie()
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "boot-movie-import").start()
|
||||
}
|
||||
|
||||
private fun loadBootMovie() {
|
||||
bootMovie = com.droiddeck.launcher.session.BootMovie.choice(this)
|
||||
bootMovieSound = SessionPrefs.bootMovieSound(this)
|
||||
bootMovieCustomName = com.droiddeck.launcher.session.BootMovie.customName(this)
|
||||
}
|
||||
|
||||
private fun importLossless(dll: File) {
|
||||
Thread({
|
||||
val message = Lossless.message(this, Lossless.import(this, dll))
|
||||
@@ -321,6 +364,11 @@ class MainActivity : ComponentActivity() {
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var forceSsbs by mutableStateOf(false)
|
||||
private var runSteamAtStartup by mutableStateOf(false)
|
||||
private var bootMovie by mutableStateOf(com.droiddeck.launcher.session.BootMovie.DROIDDECK_30)
|
||||
private var bootMovieSound by mutableStateOf(true)
|
||||
private var bootMovieCustomName by mutableStateOf<String?>(null)
|
||||
private var bootMovieImporting by mutableStateOf(false)
|
||||
private var bootMovieProgress by mutableStateOf(-1)
|
||||
private var theme by mutableStateOf("graphite")
|
||||
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
@@ -511,11 +559,17 @@ class MainActivity : ComponentActivity() {
|
||||
orientation = SessionPrefs.orientation(this)
|
||||
requestedOrientation = orientation
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
downloadSource = DownloadSource.mode(this)
|
||||
downloadMirror = DownloadSource.mirror(this)
|
||||
downloadPrefix = DownloadSource.custom(this)
|
||||
DownloadSource.probe()
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
|
||||
storesOpenTab = SessionPrefs.storesOpenTab(this)
|
||||
storesShowMature = SessionPrefs.storesShowMature(this)
|
||||
storesSort = SessionPrefs.storesSort(this)
|
||||
storesView = SessionPrefs.storesView(this)
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
@@ -565,6 +619,9 @@ class MainActivity : ComponentActivity() {
|
||||
showPhantomGate = showPhantomGate,
|
||||
orientation = orientation,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
downloadSource = downloadSource,
|
||||
downloadMirror = downloadMirror,
|
||||
downloadPrefix = downloadPrefix,
|
||||
animationsEnabled = animationsEnabled,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
@@ -573,10 +630,17 @@ class MainActivity : ComponentActivity() {
|
||||
addedGamesArt = addedGamesArt,
|
||||
sgdbUserKey = sgdbUserKey,
|
||||
storesShowMature = storesShowMature,
|
||||
storesSort = storesSort,
|
||||
storesView = storesView,
|
||||
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
|
||||
),
|
||||
FrontEndActions(
|
||||
onPlay = { startSteamSession() },
|
||||
onPlay = { playSteam() },
|
||||
onSteamUrl = { url ->
|
||||
if (!SessionState.running) startSession(
|
||||
Intent(this, SessionActivity::class.java).putExtra(SessionService.EXTRA_STEAM_URL, url), steamSession = true,
|
||||
)
|
||||
},
|
||||
onPlayDesktopUi = {
|
||||
startSession(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
|
||||
@@ -597,6 +661,7 @@ class MainActivity : ComponentActivity() {
|
||||
val known = steamGames.mapNotNull { it.gameFiles?.path }.toSet()
|
||||
updateAddedGames(folders, then = { if (folders.any { it !in known }) fetchAddedGameArt() })
|
||||
},
|
||||
onSteamGameArtChanged = { appId -> refreshSteamGameArt(appId) },
|
||||
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
|
||||
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
|
||||
onSgdbKey = { key ->
|
||||
@@ -728,12 +793,26 @@ class MainActivity : ComponentActivity() {
|
||||
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
|
||||
onNavigated = { navRequest = null },
|
||||
onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show },
|
||||
onStoresSort = { sort -> SessionPrefs.setStoresSort(this, sort); storesSort = sort },
|
||||
onStoresView = { view -> SessionPrefs.setStoresView(this, view); storesView = view },
|
||||
onLibraryChanged = { refreshAddedGames(); refresh() },
|
||||
onAnimationsEnabled = { on ->
|
||||
SessionPrefs.setAnimationsEnabled(this, on)
|
||||
animationsEnabled = on
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
},
|
||||
onDownloadSource = { mode ->
|
||||
DownloadSource.setMode(this, mode)
|
||||
downloadSource = mode
|
||||
},
|
||||
onDownloadMirror = { host ->
|
||||
DownloadSource.setMirror(this, host)
|
||||
downloadMirror = host
|
||||
},
|
||||
onDownloadPrefix = { prefix ->
|
||||
DownloadSource.setCustom(this, prefix)
|
||||
downloadPrefix = DownloadSource.custom(this)
|
||||
},
|
||||
onHomeApp = { manageHomeApp() },
|
||||
onHomeScreen = { on ->
|
||||
HomeApp.setHomeScreenEnabled(this, on)
|
||||
@@ -841,6 +920,14 @@ class MainActivity : ComponentActivity() {
|
||||
onConfirm = { showRemove = false; removeRuntime() },
|
||||
onDismiss = { showRemove = false },
|
||||
)
|
||||
lowSpace?.let { free ->
|
||||
com.droiddeck.launcher.ui.LowSpaceDialog(
|
||||
free,
|
||||
onFreeUp = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.requestOpen() },
|
||||
onPlay = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.lowSpaceDismissed = true; startSteamSession() },
|
||||
onDismiss = { lowSpace = null },
|
||||
)
|
||||
}
|
||||
if (showResetSettings) com.droiddeck.launcher.ui.ResetSettingsDialog(
|
||||
onConfirm = { gameProfiles -> resetSettings(gameProfiles) },
|
||||
onDismiss = { showResetSettings = false },
|
||||
@@ -893,6 +980,14 @@ class MainActivity : ComponentActivity() {
|
||||
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
|
||||
}
|
||||
|
||||
private fun playSteam() {
|
||||
val storage = com.droiddeck.launcher.storage.StorageState
|
||||
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
|
||||
lowSpace = storage.freeBytes(this)
|
||||
storage.refresh(this)
|
||||
} else startSteamSession()
|
||||
}
|
||||
|
||||
private fun startSteamSession() {
|
||||
startSession(Intent(this, SessionActivity::class.java), steamSession = true)
|
||||
}
|
||||
@@ -956,7 +1051,7 @@ class MainActivity : ComponentActivity() {
|
||||
refreshPhantomStatus()
|
||||
syncLossless()
|
||||
updates.onResume()
|
||||
drivers.ensureAuto(force = false)
|
||||
drivers.checkOnOpen()
|
||||
protons.checkUpdates()
|
||||
if (!SessionState.running) Thread({
|
||||
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
|
||||
@@ -1018,7 +1113,9 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun refreshSecondScreenDisplays() {
|
||||
if (!::displayManager.isInitialized) return
|
||||
secondScreenDisplays = SecondScreenDisplays.available(displayManager)
|
||||
// The launcher can itself be shown on a non-default display (DeX, an external monitor); that one is not a "second screen".
|
||||
val ownDisplayId = if (android.os.Build.VERSION.SDK_INT >= 30) display?.displayId ?: android.view.Display.DEFAULT_DISPLAY else @Suppress("DEPRECATION") windowManager.defaultDisplay.displayId
|
||||
secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId)
|
||||
}
|
||||
|
||||
private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) {
|
||||
@@ -1152,6 +1249,8 @@ class MainActivity : ComponentActivity() {
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_android))) },
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
onMode = { m -> drivers.chooseMode(m) },
|
||||
onRestart = { drivers.restartApp() },
|
||||
),
|
||||
protons = com.droiddeck.launcher.ui.ProtonsState(
|
||||
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
|
||||
@@ -1246,6 +1345,7 @@ class MainActivity : ComponentActivity() {
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
backActionsInverted = backActionsInverted,
|
||||
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
|
||||
picturePosition = if (mode == SessionService.MODE_STEAM) picturePosition else null,
|
||||
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
|
||||
mic = if (mode == SessionService.MODE_STEAM) mic else null,
|
||||
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
|
||||
@@ -1258,6 +1358,11 @@ class MainActivity : ComponentActivity() {
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
|
||||
bootMovie = if (mode == SessionService.MODE_STEAM) bootMovie else null,
|
||||
bootMovieSound = bootMovieSound,
|
||||
bootMovieCustomName = bootMovieCustomName,
|
||||
bootMovieImporting = bootMovieImporting,
|
||||
bootMovieProgress = bootMovieProgress,
|
||||
wifiDiscovery = if (mode == SessionService.MODE_STEAM) wifiDiscovery else null,
|
||||
wifiDiscoveryPermission = wifiDiscoveryPermission,
|
||||
wifiDiscoveryLocation = wifiDiscoveryLocation,
|
||||
@@ -1306,6 +1411,7 @@ class MainActivity : ComponentActivity() {
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
|
||||
onPicturePosition = { value -> SessionPrefs.setPicturePosition(this, value); picturePosition = value },
|
||||
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
|
||||
onMic = { on ->
|
||||
SessionPrefs.setMicEnabled(this, on)
|
||||
@@ -1337,6 +1443,12 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
runSteamAtStartup = on
|
||||
},
|
||||
onBootMovie = { id -> SessionPrefs.setBootMovie(this, id); loadBootMovie() },
|
||||
onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on },
|
||||
onImportBootMovie = {
|
||||
pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick)))
|
||||
},
|
||||
onRemoveBootMovie = { com.droiddeck.launcher.session.BootMovie.removeCustom(this); loadBootMovie() },
|
||||
onDeckyInstall = { release -> decky.installDecky(release) },
|
||||
onDeckyCheck = { decky.refreshDecky() },
|
||||
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
|
||||
@@ -1407,6 +1519,20 @@ class MainActivity : ComponentActivity() {
|
||||
}, "added-games-update").start()
|
||||
}
|
||||
|
||||
private val steamArtRefreshes = HashMap<Int, Int>()
|
||||
|
||||
private fun refreshSteamGameArt(appId: Int) {
|
||||
val version = (steamArtRefreshes[appId] ?: 0) + 1
|
||||
steamArtRefreshes[appId] = version
|
||||
Thread({
|
||||
val fresh = com.droiddeck.launcher.frontend.Library.steamGames(this).firstOrNull { it.appId == appId } ?: return@Thread
|
||||
ui.post {
|
||||
if (steamArtRefreshes[appId] != version) return@post
|
||||
steamGames = steamGames.map { if (it.appId == appId && it.library != Library.ADDED) fresh else it }
|
||||
}
|
||||
}, "steam-art-update").start()
|
||||
}
|
||||
|
||||
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
|
||||
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
|
||||
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
|
||||
@@ -1454,6 +1580,7 @@ class MainActivity : ComponentActivity() {
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
loadBootMovie()
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
@@ -1465,6 +1592,7 @@ class MainActivity : ComponentActivity() {
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
directAudio = SessionPrefs.directAudio(this)
|
||||
picturePosition = SessionPrefs.picturePosition(this)
|
||||
stretch16x9 = SessionPrefs.stretch16x9(this)
|
||||
mic = SessionPrefs.micEnabled(this)
|
||||
refreshWifiDiscovery()
|
||||
@@ -1525,6 +1653,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun refresh() {
|
||||
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
|
||||
if (showComponents) drivers.refreshDrivers()
|
||||
addedGamesArt = SessionPrefs.addedGamesArt(this)
|
||||
desktopInstalled = DesktopCatalog.desktopInstalled(this)
|
||||
desktopPresent = DesktopCatalog.desktopPresent(this)
|
||||
@@ -1544,6 +1673,7 @@ class MainActivity : ComponentActivity() {
|
||||
else -> "Steam"
|
||||
} else null
|
||||
val scanGeneration = ++libraryScanGeneration
|
||||
val artRefreshesAtStart = HashMap(steamArtRefreshes)
|
||||
if (shortcutPicker) shortcutLibraryScanning = true
|
||||
Thread({
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
@@ -1572,7 +1702,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
ui.post {
|
||||
if (scanGeneration == libraryScanGeneration) {
|
||||
steamGames = all
|
||||
val current = steamGames.filter { it.library != Library.ADDED }.associateBy { it.appId }
|
||||
steamGames = all.map { g ->
|
||||
val newer = current[g.appId]
|
||||
if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon) else g
|
||||
}
|
||||
shortcutLibraryScanning = false
|
||||
handleGameLink(all)
|
||||
}
|
||||
@@ -1601,6 +1735,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
|
||||
if (busy || LinuxRuntimeInstaller.isBusy()) return false
|
||||
if (com.droiddeck.launcher.storage.StorageState.cleaning) {
|
||||
android.widget.Toast.makeText(this, R.string.storage_cleanup_busy, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
}
|
||||
refreshPhantomStatus()
|
||||
if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
|
||||
showPhantomGate = true
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.app.NativeActivity
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.droiddeck.launcher.gpu.SteamVr
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.wayland.CompositorHost
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
|
||||
class QuestXrActivity : NativeActivity() {
|
||||
private var steamVrStarted = false
|
||||
@Volatile private var closing = false
|
||||
@Volatile private var steamVrStatus = SteamVr.Status.UNAVAILABLE
|
||||
private var sessionId: String? = null
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
sessionId = SessionState.sessionId
|
||||
SessionService.setXrActivityVisible(this, true)
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
fun onXrActive(active: Boolean) {
|
||||
if (active) {
|
||||
check(CompositorHost.isStarted) { "DroidDeck compositor is not running" }
|
||||
SessionService.resume(this)
|
||||
WaylandCompositor.nativeSetCardboard(false, 1, 0.064f, 3f, 4f, 0f, 0f, 90f, 0f, 0f)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
WaylandCompositor.nativeSetZeroCopy(false)
|
||||
CompositorHost.attachXr()
|
||||
WaylandCompositor.nativeVsync(System.nanoTime())
|
||||
check(WaylandCompositor.nativeWaitSurface(3000)) { "XR frame GPU handshake timed out" }
|
||||
}
|
||||
SessionService.setXrActivityVisible(this, active)
|
||||
if (active) startSteamVr()
|
||||
}
|
||||
|
||||
private fun startSteamVr() {
|
||||
if (!steamVrStarted) {
|
||||
steamVrStarted = true
|
||||
Thread({
|
||||
try {
|
||||
val started = synchronized(steamVrLock) {
|
||||
if (closing || !sessionAlive()) false else {
|
||||
steamVrOwner = this
|
||||
SteamVr.run(this, "start")
|
||||
}
|
||||
}
|
||||
if (!started) {
|
||||
if (!closing) leaveXr("SteamVR start failed", sessionAlive())
|
||||
return@Thread
|
||||
}
|
||||
val startupDeadline = SystemClock.elapsedRealtime() + 30_000
|
||||
var lastRunning: Long? = null
|
||||
while (!closing) {
|
||||
if (!sessionAlive()) {
|
||||
leaveXr("Steam session ended", false)
|
||||
break
|
||||
}
|
||||
steamVrStatus = SteamVr.status(this)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (steamVrStatus == SteamVr.Status.RUNNING) lastRunning = now
|
||||
val deadline = lastRunning?.plus(5_000) ?: startupDeadline
|
||||
if (steamVrStatus != SteamVr.Status.RUNNING && now >= deadline) {
|
||||
leaveXr("SteamVR stopped or did not finish starting", true)
|
||||
break
|
||||
}
|
||||
Thread.sleep(1000)
|
||||
}
|
||||
} finally {
|
||||
synchronized(steamVrLock) {
|
||||
// A retiring activity must not stop a replacement's SteamVR session.
|
||||
if (steamVrOwner === this) {
|
||||
if (sessionAlive()) SteamVr.run(this, "stop")
|
||||
steamVrOwner = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}, "quest-steamvr-start").start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionAlive() = SessionState.running && !SessionState.stopRequested &&
|
||||
sessionId != null && SessionState.sessionId == sessionId
|
||||
|
||||
private fun leaveXr(reason: String, failed: Boolean) {
|
||||
Log.w("DroidDeckXR", reason)
|
||||
closing = true
|
||||
runOnUiThread {
|
||||
if (failed) Toast.makeText(this, R.string.cardboard_steamvr_failed, Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
fun onXrFrame(frameTimeNanos: Long) {
|
||||
WaylandCompositor.nativeVsync(frameTimeNanos)
|
||||
}
|
||||
|
||||
fun onXrStatus(): Int = steamVrStatus.ordinal
|
||||
|
||||
override fun onDestroy() {
|
||||
closing = true
|
||||
SessionService.setXrActivityVisible(this, false)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
SessionService.setXrActivityVisible(this, false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val steamVrLock = Any()
|
||||
private var steamVrOwner: QuestXrActivity? = null
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var pipUi by mutableStateOf(false)
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
private var unfoldedControls by mutableStateOf(false)
|
||||
private var picturePosition by mutableStateOf("center")
|
||||
|
||||
private fun applyGameAlignment() {
|
||||
if (!CompositorHost.isStarted) return
|
||||
val active = SessionState.mode == SessionService.MODE_STEAM &&
|
||||
!pipUi && !isInPictureInPictureMode && !cardboard.enabled
|
||||
val alignment = if (!active) ALIGN_CENTER else when (picturePosition) {
|
||||
"top" -> ALIGN_PICTURE_TOP
|
||||
"bottom" -> ALIGN_PICTURE_BOTTOM
|
||||
else -> ALIGN_CENTER
|
||||
}
|
||||
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, alignment)
|
||||
}
|
||||
private var unfoldedPanel: com.droiddeck.launcher.ui.DeckControlsPanel? = null
|
||||
private lateinit var sessionRoot: FrameLayout
|
||||
private val guestKeysDown = mutableSetOf<Int>()
|
||||
@@ -162,6 +175,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
private var watching = true
|
||||
private lateinit var touchpad: TouchpadGestures
|
||||
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
|
||||
private var cursorVisible by mutableStateOf(false)
|
||||
@@ -177,6 +191,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (pointerLocked == locked) return@post
|
||||
pointerLocked = locked
|
||||
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
|
||||
// Touchpad deltas are measured from the middle while locked (moveLockedPointer).
|
||||
if (locked && ::touchpad.isInitialized) {
|
||||
drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) }
|
||||
cursorVisible = false
|
||||
}
|
||||
updatePointerCapture()
|
||||
}
|
||||
}
|
||||
@@ -220,6 +239,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var leaving by mutableStateOf(false)
|
||||
private var closeAtOnce = false
|
||||
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
private var drawerDefaultProton by mutableStateOf<String?>(null)
|
||||
private val drawerComponentMenu by lazy { ComponentsMenu(this, uiHandler) }
|
||||
private var drawerGameProfileId by mutableStateOf<Long?>(null)
|
||||
private val drawerGameInventory = GameProfileInventoryCache<Library.SteamGame>()
|
||||
@@ -243,6 +263,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
private var lowLatencyController by mutableStateOf(false)
|
||||
private var lowLatencyTouch by mutableStateOf(false)
|
||||
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
private var achievementsInFront by mutableStateOf<AchievementsInFront?>(null)
|
||||
@@ -308,16 +330,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
padBridge?.syncConnectedControllers()
|
||||
updateOnScreenControls()
|
||||
refreshControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
padBridge?.onDeviceRemoved(deviceId)
|
||||
updateOnScreenControls()
|
||||
refreshControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
|
||||
override fun onInputDeviceChanged(deviceId: Int) = refreshControls()
|
||||
}
|
||||
|
||||
private var runtimeRemovalBlocked = false
|
||||
@@ -381,7 +403,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
|
||||
padBridge = bridge
|
||||
SessionState.padBridge = bridge
|
||||
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
|
||||
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); refreshControls() } }
|
||||
steamController = SteamControllerBle(this).also { it.start() }
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -397,6 +419,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
|
||||
keyboard = KeyboardHost(this).also { root.addView(it) }
|
||||
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
|
||||
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
|
||||
val noCursor = android.view.PointerIcon.getSystemIcon(this, android.view.PointerIcon.TYPE_NULL)
|
||||
root.pointerIcon = noCursor
|
||||
surfaceView.pointerIcon = noCursor
|
||||
@@ -458,6 +481,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
defaultFocusHighlightEnabled = false
|
||||
setContent {
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
|
||||
androidx.compose.runtime.LaunchedEffect(picturePosition, SessionState.running, pipUi, cardboard.enabled) { applyGameAlignment() }
|
||||
DroidDeckTheme {
|
||||
if (pipUi) {
|
||||
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
|
||||
@@ -498,8 +522,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
releaseDrawerDirection()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture) {
|
||||
while (cardboard.enabled || steamVrSwitchedPicture) {
|
||||
LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture, drawerOpen, drawerPage) {
|
||||
while (cardboard.enabled || steamVrSwitchedPicture || (drawerOpen && drawerPage == SessionDrawerPage.VR)) {
|
||||
val now = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
SteamVr.status(this@SessionActivity)
|
||||
}
|
||||
@@ -536,6 +560,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onCardboard = { c -> SessionPrefs.setCardboard(this@SessionActivity, c); applyCardboard(c) },
|
||||
onCardboardRecenter = { headTracker?.recenter() },
|
||||
steamVr = steamVrStatus,
|
||||
immersiveSteamVr = com.droiddeck.launcher.session.QuestExperimental.isSupported(),
|
||||
onSteamVr = { action -> steamVrAction(action) },
|
||||
textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy,
|
||||
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
|
||||
@@ -612,6 +637,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
lowLatencyController = lowLatencyController,
|
||||
lowLatencyTouch = lowLatencyTouch,
|
||||
onLowLatencyController = { on ->
|
||||
SessionPrefs.setLowLatencyController(this@SessionActivity, on)
|
||||
lowLatencyController = on
|
||||
applyLowLatency()
|
||||
},
|
||||
onLowLatencyTouch = { on ->
|
||||
SessionPrefs.setLowLatencyTouch(this@SessionActivity, on)
|
||||
lowLatencyTouch = on
|
||||
applyLowLatency()
|
||||
},
|
||||
onOsc = { v ->
|
||||
SessionPrefs.setOscMode(this@SessionActivity, v)
|
||||
readPrefs()
|
||||
@@ -704,6 +741,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onGameNoSteam = ::setDrawerGameNoSteam,
|
||||
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
|
||||
onGameComponent = ::setDrawerGameComponent,
|
||||
defaultProtonId = drawerDefaultProton,
|
||||
onDefaultProton = ::chooseDrawerDefaultProton,
|
||||
onGameProton = ::setDrawerGameProton,
|
||||
))
|
||||
LaunchedEffect(achievementsOpen) {
|
||||
if (achievementsOpen) {
|
||||
@@ -823,8 +863,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Thread({
|
||||
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
|
||||
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
|
||||
val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(this, s.protons.map { it.proton }) }.getOrNull() }
|
||||
uiHandler.post {
|
||||
drawerComponents = snap
|
||||
drawerDefaultProton = chosen
|
||||
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "drawer-components").start()
|
||||
@@ -968,6 +1010,46 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}, "drawer-game-component").start()
|
||||
}
|
||||
|
||||
private fun chooseDrawerDefaultProton(protonId: String) {
|
||||
val proton = drawerComponents?.protons?.firstOrNull { it.proton.id == protonId }?.proton ?: return
|
||||
drawerDefaultProton = protonId
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
when (ProtonDefault.request(this, proton)) {
|
||||
ProtonDefault.Outcome.LIVE -> getString(R.string.comp_default_live, proton.name)
|
||||
ProtonDefault.Outcome.NEXT_START -> getString(R.string.comp_default_next, proton.name)
|
||||
ProtonDefault.Outcome.NOT_RUNNABLE -> getString(R.string.comp_default_not_runnable, proton.name)
|
||||
}
|
||||
}.getOrElse { e -> getString(R.string.comp_default_failed, e.message ?: e.javaClass.simpleName) }
|
||||
uiHandler.post {
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
refreshDrawerComponents()
|
||||
refreshDrawerGameProfile()
|
||||
}
|
||||
}, "drawer-default-proton").start()
|
||||
}
|
||||
|
||||
private fun setDrawerGameProton(appId: Long, protonId: String?) {
|
||||
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
|
||||
val proton = protonId?.let { id -> drawerComponents?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return }
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
getString(
|
||||
when (ProtonDefault.requestGame(this, appId.toString(), proton)) {
|
||||
ProtonDefault.Outcome.LIVE -> R.string.comp_game_proton_live
|
||||
ProtonDefault.Outcome.NEXT_START -> R.string.comp_game_proton_next
|
||||
ProtonDefault.Outcome.NOT_RUNNABLE -> R.string.comp_game_proton_not_runnable
|
||||
},
|
||||
name,
|
||||
)
|
||||
}.getOrElse { e -> getString(R.string.session_profile_save_failed, e.message ?: e.javaClass.simpleName) }
|
||||
uiHandler.post {
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
refreshDrawerGameProfile()
|
||||
}
|
||||
}, "drawer-game-proton").start()
|
||||
}
|
||||
|
||||
private fun setDrawerGameFexPreset(appId: Long, preset: String?) {
|
||||
if (drawerGameFexPresetSaving) return
|
||||
drawerGameFexPresetSaving = true
|
||||
@@ -1051,6 +1133,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun readPrefs() {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
picturePosition = SessionPrefs.picturePosition(this)
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
@@ -1081,6 +1164,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
lowLatencyController = SessionPrefs.lowLatencyController(this)
|
||||
lowLatencyTouch = SessionPrefs.lowLatencyTouch(this)
|
||||
padType = SessionPrefs.steamController(this)
|
||||
}
|
||||
|
||||
@@ -1212,6 +1297,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
val turnip = TurnipDriver(this)
|
||||
com.droiddeck.launcher.gpu.DriverSetupLog.write(this, "surface.driver-setup")
|
||||
if (!CompositorHost.isStarted) CompositorHost.displayChoice = SessionPrefs.androidDriver(this)
|
||||
val driverId = if (CompositorHost.isStarted) null else turnip.install()
|
||||
|
||||
val previous = SessionState.outputSize
|
||||
@@ -1286,6 +1372,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
)
|
||||
}
|
||||
applyFrameGen()
|
||||
applyGameAlignment()
|
||||
}
|
||||
|
||||
private var surfaceW = 0
|
||||
@@ -1705,9 +1792,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private val pointerListener = object : PointerGestures.Listener {
|
||||
override fun onMove(x: Float, y: Float) = movePointer(x, y)
|
||||
override fun onMove(x: Float, y: Float) {
|
||||
if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y)
|
||||
}
|
||||
override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {
|
||||
movePointer(x, y)
|
||||
if (!pointerLocked) movePointer(x, y)
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
|
||||
}
|
||||
override fun onWheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
|
||||
@@ -1717,6 +1806,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveLockedPointer(x: Float, y: Float) {
|
||||
val rect = drawnRect() ?: return
|
||||
val scale = SessionState.outputSize.first / rect.width()
|
||||
WaylandCompositor.sendPointerDelta((x - rect.centerX()) * scale, (y - rect.centerY()) * scale)
|
||||
touchpad.place(rect.centerX(), rect.centerY())
|
||||
cursorVisible = false
|
||||
}
|
||||
|
||||
private fun movePointer(x: Float, y: Float) {
|
||||
val rect = drawnRect() ?: return
|
||||
val cx = x.coerceIn(rect.left, rect.right)
|
||||
@@ -1825,6 +1922,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onSteamMenu = ::tapSteamButton,
|
||||
onQam = ::tapQam,
|
||||
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
|
||||
pad = padBridge,
|
||||
)
|
||||
secondScreenPresentation = presentation
|
||||
}
|
||||
@@ -1859,11 +1957,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
private fun refreshControls() {
|
||||
applyUnfoldedControls()
|
||||
updateOnScreenControls()
|
||||
}
|
||||
|
||||
private fun applyUnfoldedControls() {
|
||||
if (!::sessionRoot.isInitialized) return
|
||||
val width = sessionRoot.width
|
||||
val height = sessionRoot.height
|
||||
val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) &&
|
||||
!PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
|
||||
SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad &&
|
||||
!pipUi && !isInPictureInPictureMode && width > 0 && height > 0 &&
|
||||
resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP
|
||||
@@ -1939,9 +2043,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop"
|
||||
}
|
||||
|
||||
private val gamePictureBounds = FloatArray(4)
|
||||
private var topPictureMapped = false
|
||||
|
||||
private fun drawnRect(): android.graphics.RectF? {
|
||||
val width = surfaceView.width.takeIf { it > 0 } ?: return null
|
||||
val height = surfaceView.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])
|
||||
val out = SessionState.outputSize
|
||||
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
|
||||
val drawnW = out.first * scale
|
||||
@@ -1951,9 +2061,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return android.graphics.RectF(left, top, left + drawnW, top + drawnH)
|
||||
}
|
||||
|
||||
private var ignoreGameTouch = false
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
|
||||
if (routedTouchMode == SessionPrefs.TOUCH_OFF) return true
|
||||
if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") {
|
||||
val wasActive = threeFingerTap.active
|
||||
if (threeFingerTap.onTouch(event)) padBridge?.triggerSteamKeyboard()
|
||||
if (threeFingerTap.active && !wasActive) {
|
||||
if (usingTouchpad()) touchpad.cancel() else WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
}
|
||||
if (threeFingerTap.active || wasActive) return true
|
||||
}
|
||||
if (usingTouchpad()) {
|
||||
val rect = drawnRect() ?: return false
|
||||
if (touchpad.bounds != rect) {
|
||||
@@ -1964,6 +2084,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return touchpad.onTouch(event)
|
||||
}
|
||||
val rect = drawnRect() ?: return false
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN)
|
||||
ignoreGameTouch = topPictureMapped && !rect.contains(event.x, event.y)
|
||||
if (ignoreGameTouch) {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN)
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false
|
||||
return true
|
||||
}
|
||||
fun sendTouch(action: Int, index: Int) {
|
||||
val x = event.getX(index).coerceIn(rect.left, rect.right)
|
||||
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
|
||||
@@ -2274,12 +2402,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (SessionState.guestPid > 1) padBridge?.syncConnectedControllers()
|
||||
onScreenControls?.reload()
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
updateOnScreenControls()
|
||||
resumed = true
|
||||
updatePointerCapture()
|
||||
if (!pipUi) sessionClipboard.start()
|
||||
updatePadMotion()
|
||||
readPrefs()
|
||||
refreshControls()
|
||||
if (::pip.isInitialized) pip.resumed()
|
||||
if (CompositorHost.isStarted) applyFrameGen()
|
||||
SessionPrefs.sessionPrefs(this).registerOnSharedPreferenceChangeListener(cardboardPrefListener)
|
||||
@@ -2304,6 +2432,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun steamVrAction(action: String) {
|
||||
if (action == "start" && com.droiddeck.launcher.session.QuestExperimental.isSupported()) {
|
||||
startActivity(android.content.Intent(this, QuestXrActivity::class.java))
|
||||
return
|
||||
}
|
||||
if (action == "start") {
|
||||
switchPictureForSteamVr()
|
||||
steamVrSeenRunning = false
|
||||
@@ -2389,8 +2521,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
super.onAttachedToWindow()
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
|
||||
val decor = window.decorView
|
||||
decor.requestUnbufferedDispatch(UNBUFFERED_SOURCES)
|
||||
decor.viewTreeObserver.addOnGlobalFocusChangeListener { _, _ -> decor.post { decor.requestUnbufferedDispatch(UNBUFFERED_SOURCES) } }
|
||||
decor.requestUnbufferedDispatch(unbufferedSources())
|
||||
decor.viewTreeObserver.addOnGlobalFocusChangeListener { _, _ -> decor.post { decor.requestUnbufferedDispatch(unbufferedSources()) } }
|
||||
}
|
||||
|
||||
private fun unbufferedSources() =
|
||||
if (lowLatencyTouch) UNBUFFERED_SOURCES or InputDevice.SOURCE_CLASS_POINTER else UNBUFFERED_SOURCES
|
||||
|
||||
private fun applyLowLatency() {
|
||||
com.droiddeck.launcher.input.FakeInputWriter.setLowLatency(lowLatencyController, lowLatencyTouch)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) window.decorView.requestUnbufferedDispatch(unbufferedSources())
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
@@ -2512,6 +2652,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private const val DRAWER_REPEAT_ACCELERATION_MS = 1_400L
|
||||
private const val SCALE_FIT = 1
|
||||
private const val ALIGN_CENTER = 0
|
||||
private const val ALIGN_PICTURE_TOP = 3
|
||||
private const val ALIGN_PICTURE_BOTTOM = 4
|
||||
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,
|
||||
|
||||
@@ -2,10 +2,15 @@ package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.PerformanceHintManager
|
||||
import android.os.PowerManager
|
||||
import android.os.StatFs
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
@@ -32,6 +37,57 @@ object DeviceReport {
|
||||
}
|
||||
}
|
||||
|
||||
/** Name of an ApplicationExitInfo reason code, so a report says "LOW_MEMORY" instead of "3". */
|
||||
internal fun exitReasonName(reason: Int): String = when (reason) {
|
||||
0 -> "UNKNOWN"
|
||||
1 -> "EXIT_SELF"
|
||||
2 -> "SIGNALED"
|
||||
3 -> "LOW_MEMORY"
|
||||
4 -> "CRASH"
|
||||
5 -> "CRASH_NATIVE"
|
||||
6 -> "ANR"
|
||||
7 -> "INITIALIZATION_FAILURE"
|
||||
8 -> "PERMISSION_CHANGE"
|
||||
9 -> "EXCESSIVE_RESOURCE_USAGE"
|
||||
10 -> "USER_REQUESTED"
|
||||
11 -> "USER_STOPPED"
|
||||
12 -> "DEPENDENCY_DIED"
|
||||
13 -> "OTHER"
|
||||
14 -> "FREEZER"
|
||||
15 -> "PACKAGE_STATE_CHANGE"
|
||||
16 -> "PACKAGE_UPDATED"
|
||||
else -> "REASON_$reason"
|
||||
}
|
||||
|
||||
internal fun thermalStatusName(status: Int): String = when (status) {
|
||||
0 -> "NONE"
|
||||
1 -> "LIGHT"
|
||||
2 -> "MODERATE"
|
||||
3 -> "SEVERE"
|
||||
4 -> "CRITICAL"
|
||||
5 -> "EMERGENCY"
|
||||
6 -> "SHUTDOWN"
|
||||
else -> "STATUS_$status"
|
||||
}
|
||||
|
||||
internal fun gameModeName(mode: Int): String = when (mode) {
|
||||
0 -> "UNSUPPORTED"
|
||||
1 -> "STANDARD"
|
||||
2 -> "PERFORMANCE"
|
||||
3 -> "BATTERY"
|
||||
4 -> "CUSTOM"
|
||||
else -> "MODE_$mode"
|
||||
}
|
||||
|
||||
internal fun refreshModesSummary(rates: List<Float>): String =
|
||||
rates.map { Math.round(it) }.distinct().sortedDescending().joinToString(", ") { "$it Hz" }
|
||||
|
||||
internal fun exitSummary(processName: String?, reason: Int, status: Int, description: String?): String =
|
||||
buildString {
|
||||
append(processName ?: "?").append(" ").append(exitReasonName(reason)).append(" status=").append(status)
|
||||
if (!description.isNullOrBlank()) append(" ").append(description.trim())
|
||||
}
|
||||
|
||||
fun build(context: Context, mode: String): String {
|
||||
val b = StringBuilder()
|
||||
fun h(title: String) {
|
||||
@@ -68,6 +124,20 @@ object DeviceReport {
|
||||
k("Fingerprint", Build.FINGERPRINT)
|
||||
k("Kernel", System.getProperty("os.version"))
|
||||
k("ABIs", Build.SUPPORTED_ABIS.joinToString(", "))
|
||||
k("Memory page size", runCatching { android.system.Os.sysconf(android.system.OsConstants._SC_PAGESIZE) }.getOrNull()?.let { "$it bytes" })
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
h("Recent process exits")
|
||||
val exits = runCatching {
|
||||
context.getSystemService(android.app.ActivityManager::class.java).getHistoricalProcessExitReasons(context.packageName, 0, 5)
|
||||
}.getOrNull().orEmpty()
|
||||
if (exits.isEmpty()) k("Exits", "none recorded") else {
|
||||
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
for (e in exits) {
|
||||
k(stamp.format(Date(e.timestamp)), exitSummary(e.processName, e.reason, e.status, e.description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h("CPU and memory")
|
||||
k("Cores", CpuCores.all.size)
|
||||
@@ -82,6 +152,19 @@ object DeviceReport {
|
||||
k("RAM total", FileUtils.sizeToString(mi.totalMem))
|
||||
k("RAM available", FileUtils.sizeToString(mi.availMem))
|
||||
}
|
||||
runCatching {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) k("Thermal status", thermalStatusName(pm.currentThermalStatus))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
k("Thermal headroom (10 s)", pm.getThermalHeadroom(10).let { if (it.isNaN()) "not reported" else String.format(Locale.US, "%.2f", it) })
|
||||
}
|
||||
k("Power saver", pm.isPowerSaveMode)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val rate = runCatching { context.getSystemService(PerformanceHintManager::class.java)?.preferredUpdateRateNanos }.getOrNull()
|
||||
k("ADPF hint sessions", if (rate == null || rate <= 0) "not supported" else "supported (preferred update ${rate / 1000} us)")
|
||||
k("Game Mode", runCatching { context.getSystemService(android.app.GameManager::class.java)?.gameMode }.getOrNull()?.let { gameModeName(it) })
|
||||
}
|
||||
runCatching {
|
||||
val fs = StatFs(context.filesDir.path)
|
||||
k("App storage free", FileUtils.sizeToString(fs.availableBlocksLong * fs.blockSizeLong))
|
||||
@@ -99,6 +182,13 @@ object DeviceReport {
|
||||
h("Display")
|
||||
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
|
||||
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
|
||||
runCatching {
|
||||
val display = context.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY)
|
||||
k("Panel refresh now", String.format(Locale.US, "%.2f Hz", display.refreshRate))
|
||||
k("Panel refresh modes", refreshModesSummary(display.supportedModes.map { it.refreshRate }))
|
||||
}
|
||||
k("peak_refresh_rate", runCatching { Settings.System.getString(context.contentResolver, "peak_refresh_rate") }.getOrNull() ?: "not set")
|
||||
k("refresh_rate_mode", runCatching { Settings.Secure.getString(context.contentResolver, "refresh_rate_mode") }.getOrNull() ?: "not set")
|
||||
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
|
||||
for ((label, mode) in listOf("Resolution (Steam)" to SessionService.MODE_STEAM, "Resolution (Desktop)" to SessionService.MODE_DESKTOP)) {
|
||||
val choice = SessionPrefs.resolutionChoice(context, mode, panelSize)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
object DownloadSource {
|
||||
const val MODE_AUTO = "auto"
|
||||
const val MODE_DIRECT = "direct"
|
||||
const val MODE_MIRROR = "mirror"
|
||||
const val MODE_CUSTOM = "custom"
|
||||
|
||||
private const val PREFS = "download-source"
|
||||
private const val KEY_MODE = "mode"
|
||||
private const val KEY_MIRROR = "mirror"
|
||||
private const val KEY_CUSTOM = "custom"
|
||||
private const val KEY_FASTEST = "fastest"
|
||||
|
||||
private const val PROBE = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/README.md"
|
||||
private const val PROBE_TIMEOUT_MS = 4_000
|
||||
private const val PROBE_COOLDOWN_MS = 30_000L
|
||||
private const val UA = "DroidDeck-Android"
|
||||
|
||||
private val MODES = listOf(MODE_AUTO, MODE_DIRECT, MODE_MIRROR, MODE_CUSTOM)
|
||||
|
||||
private val GITHUB_HOSTS = listOf(
|
||||
"github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"api.github.com",
|
||||
)
|
||||
|
||||
private val CATALOGS = listOf(
|
||||
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-CI/",
|
||||
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/",
|
||||
)
|
||||
|
||||
data class Mirror(val host: String, val api: Boolean)
|
||||
|
||||
val MIRRORS = listOf(
|
||||
Mirror("gh-proxy.com", true),
|
||||
Mirror("gh-proxy.org", true),
|
||||
Mirror("ghproxy.net", false),
|
||||
Mirror("ghfast.top", false),
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var app: Context? = null
|
||||
|
||||
@Volatile
|
||||
private var lastGood: String? = null
|
||||
|
||||
private val probing = AtomicBoolean(false)
|
||||
private val probedAt = AtomicLong(0L)
|
||||
|
||||
fun init(context: Context) {
|
||||
app = context.applicationContext
|
||||
lastGood = prefs()?.getString(KEY_FASTEST, null)
|
||||
}
|
||||
|
||||
fun probe() {
|
||||
if (app == null || prefs()?.getString(KEY_MODE, MODE_AUTO) == MODE_DIRECT) return
|
||||
if (!probing.compareAndSet(false, true)) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - probedAt.get() < PROBE_COOLDOWN_MS) {
|
||||
probing.set(false)
|
||||
return
|
||||
}
|
||||
probedAt.set(now)
|
||||
Thread {
|
||||
try {
|
||||
val times = ConcurrentHashMap<String, Long>()
|
||||
val sources = listOf<String?>(null) + MIRRORS.map { it.host }
|
||||
val latch = CountDownLatch(sources.size)
|
||||
for (mirror in sources) {
|
||||
Thread {
|
||||
try {
|
||||
val ms = latency(mirror?.let { "https://$it/$PROBE" } ?: PROBE)
|
||||
if (ms > 0) times[mirror ?: ""] = ms
|
||||
} finally {
|
||||
latch.countDown()
|
||||
}
|
||||
}.apply { isDaemon = true }.start()
|
||||
}
|
||||
latch.await(PROBE_TIMEOUT_MS * 3L, TimeUnit.MILLISECONDS)
|
||||
val best = times.minByOrNull { it.value }?.key ?: return@Thread
|
||||
lastGood = best
|
||||
prefs()?.edit()?.putString(KEY_FASTEST, best)?.apply()
|
||||
} finally {
|
||||
probing.set(false)
|
||||
}
|
||||
}.apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
private fun latency(url: String): Long {
|
||||
val started = System.nanoTime()
|
||||
var connection: HttpURLConnection? = null
|
||||
return try {
|
||||
val c = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = PROBE_TIMEOUT_MS
|
||||
readTimeout = PROBE_TIMEOUT_MS
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("Range", "bytes=0-1023")
|
||||
setRequestProperty("User-Agent", UA)
|
||||
}
|
||||
connection = c
|
||||
if (c.responseCode / 100 != 2) 0L else {
|
||||
c.inputStream.use { it.read() }
|
||||
(System.nanoTime() - started) / 1_000_000
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
} finally {
|
||||
runCatching { connection?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
fun isGithub(url: String): Boolean = GITHUB_HOSTS.any { url.startsWith("https://$it/") }
|
||||
|
||||
/** Catalogs carry the digests everything else is checked against, so a mirror must never supply one. */
|
||||
fun isCatalog(url: String): Boolean = CATALOGS.any { url.startsWith(it) }
|
||||
|
||||
fun candidates(target: String): List<String> {
|
||||
// GitHub API metadata carries executable URLs and digests; never proxy it.
|
||||
if (isCatalog(target) || target.startsWith("https://api.github.com/")) return listOf(target)
|
||||
val prefs = prefs() ?: return listOf(target)
|
||||
if (!isGithub(target)) return listOf(target)
|
||||
val api = target.startsWith("https://api.github.com/")
|
||||
val proxies = MIRRORS.filter { !api || it.api }.map { it.proxy(target) }
|
||||
val ordered = when (prefs.getString(KEY_MODE, MODE_AUTO) ?: MODE_AUTO) {
|
||||
MODE_DIRECT -> listOf(target)
|
||||
MODE_MIRROR -> listOfNotNull(picked(prefs, api)?.proxy(target)) + listOf(target) + quickest(proxies)
|
||||
MODE_CUSTOM -> listOfNotNull(customProxy(prefs, target)) + listOf(target) + quickest(proxies)
|
||||
else -> if (api) listOf(target) else listOf(target) + quickest(proxies)
|
||||
}
|
||||
return ordered.distinct()
|
||||
}
|
||||
|
||||
private fun quickest(sources: List<String>): List<String> =
|
||||
sources.sortedBy { if (host(it) == lastGood) 0 else 1 }
|
||||
|
||||
fun succeeded(source: String) {
|
||||
lastGood = host(source)
|
||||
}
|
||||
|
||||
fun failed() {
|
||||
lastGood = null
|
||||
prefs()?.edit()?.remove(KEY_FASTEST)?.apply()
|
||||
}
|
||||
|
||||
fun mode(context: Context): String = prefs(context)?.getString(KEY_MODE, MODE_AUTO) ?: MODE_AUTO
|
||||
|
||||
fun setMode(context: Context, mode: String) {
|
||||
prefs(context)?.edit()?.putString(KEY_MODE, mode.takeIf { it in MODES } ?: MODE_AUTO)?.apply()
|
||||
}
|
||||
|
||||
fun mirror(context: Context): String {
|
||||
val host = prefs(context)?.getString(KEY_MIRROR, "") ?: ""
|
||||
return MIRRORS.firstOrNull { it.host == host }?.host ?: MIRRORS.first().host
|
||||
}
|
||||
|
||||
fun setMirror(context: Context, host: String) {
|
||||
prefs(context)?.edit()?.putString(KEY_MIRROR, host.takeIf { h -> MIRRORS.any { it.host == h } } ?: "")?.apply()
|
||||
}
|
||||
|
||||
fun custom(context: Context): String = prefs(context)?.getString(KEY_CUSTOM, "") ?: ""
|
||||
|
||||
fun setCustom(context: Context, prefix: String) {
|
||||
prefs(context)?.edit()?.putString(KEY_CUSTOM, normalizePrefix(prefix))?.apply()
|
||||
}
|
||||
|
||||
fun reset(context: Context) {
|
||||
lastGood = null
|
||||
prefs(context)?.edit()?.clear()?.apply()
|
||||
}
|
||||
|
||||
private fun prefs(): SharedPreferences? = app?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private fun prefs(context: Context): SharedPreferences? =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private fun Mirror.proxy(target: String): String = "https://$host/$target"
|
||||
|
||||
private fun host(url: String): String =
|
||||
MIRRORS.firstOrNull { url.startsWith("https://${it.host}/") }?.host ?: ""
|
||||
|
||||
private fun picked(prefs: SharedPreferences, api: Boolean): Mirror? {
|
||||
val chosen = MIRRORS.firstOrNull { it.host == (prefs.getString(KEY_MIRROR, "") ?: "") } ?: MIRRORS.firstOrNull()
|
||||
return if (api && chosen != null && !chosen.api) MIRRORS.firstOrNull { it.api } else chosen
|
||||
}
|
||||
|
||||
private fun custom(prefs: SharedPreferences): String = prefs.getString(KEY_CUSTOM, "") ?: ""
|
||||
|
||||
private fun customProxy(prefs: SharedPreferences, target: String): String? =
|
||||
normalizePrefix(custom(prefs)).takeIf { it.isNotEmpty() }?.let { "$it/$target" }
|
||||
|
||||
private fun normalizePrefix(value: String): String {
|
||||
val trimmed = value.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("http://")) return ""
|
||||
return if (trimmed.startsWith("https://")) trimmed else "https://$trimmed"
|
||||
}
|
||||
}
|
||||
@@ -6,63 +6,201 @@ import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
public final class Downloader {
|
||||
private static final String TAG = "Downloader";
|
||||
private static final int TIMEOUT_MS = 30_000;
|
||||
private static final int CONNECT_TIMEOUT_MS = 10_000;
|
||||
private static final int STALL_TIMEOUT_MS = 15_000;
|
||||
private static final int HTTP_RANGE_NOT_SATISFIABLE = 416;
|
||||
|
||||
private Downloader() {}
|
||||
|
||||
public static String downloadString(String url) {
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
connection = open(url);
|
||||
if (connection.getResponseCode() / 100 != 2) {
|
||||
Log.w(TAG, url + " -> HTTP " + connection.getResponseCode());
|
||||
return null;
|
||||
List<String> sources = DownloadSource.INSTANCE.candidates(url);
|
||||
for (int i = 0; i < sources.size(); i++) {
|
||||
if (i > 0) Log.i(TAG, "retrying " + url + " via " + sources.get(i));
|
||||
Fetch fetch = fetch(sources.get(i));
|
||||
if (fetch.body != null) {
|
||||
DownloadSource.INSTANCE.succeeded(sources.get(i));
|
||||
return fetch.body;
|
||||
}
|
||||
try (InputStream in = connection.getInputStream();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
FileUtils.copy(in, out);
|
||||
return new String(out.toByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "GET " + url, e);
|
||||
return null;
|
||||
} finally {
|
||||
if (connection != null) connection.disconnect();
|
||||
if (fetch.fatal && sources.get(i).equals(url)) return null;
|
||||
}
|
||||
DownloadSource.INSTANCE.failed();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fetches without the mirror list, for a URL whose body is a digest the payload is checked against. */
|
||||
public static String downloadDirect(String url) {
|
||||
return fetch(url).body;
|
||||
}
|
||||
|
||||
public static boolean downloadFile(String url, File destination, boolean resume,
|
||||
java.util.function.Consumer<Float> progress) {
|
||||
return downloadFile(url, destination, resume, null, progress);
|
||||
}
|
||||
|
||||
/** Verify each candidate before accepting it, so a bad mirror can fall back. */
|
||||
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
|
||||
java.util.function.Consumer<Float> progress) {
|
||||
if (expectedSha256 != null && !expectedSha256.matches("(?i)[0-9a-f]{64}"))
|
||||
throw new IllegalArgumentException("Expected SHA-256 must be 64 hex characters");
|
||||
return downloadCandidates(url, DownloadSource.INSTANCE.candidates(url), destination, resume, expectedSha256, progress);
|
||||
}
|
||||
|
||||
// Package-private for the local multi-source HTTP test.
|
||||
static boolean downloadCandidates(String original, List<String> sources, File destination, boolean resume,
|
||||
String expectedSha256, java.util.function.Consumer<Float> progress) {
|
||||
Identity identity = loadIdentity(destination, resume, expectedSha256);
|
||||
for (int i = 0; i < sources.size(); i++) {
|
||||
String source = sources.get(i);
|
||||
if (i > 0) Log.i(TAG, "retrying " + original + " via " + source);
|
||||
Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, progress);
|
||||
if (outcome == Outcome.DONE) {
|
||||
if (expectedSha256 != null && !expectedSha256.equalsIgnoreCase(Hashes.sha256(destination))) {
|
||||
Log.w(TAG, "checksum mismatch from " + source + "; trying another source");
|
||||
if (!discardPartial(destination, identity)) return false;
|
||||
continue;
|
||||
}
|
||||
resumeFile(destination).delete();
|
||||
DownloadSource.INSTANCE.succeeded(source);
|
||||
if (progress != null) progress.accept(1f);
|
||||
return true;
|
||||
}
|
||||
if (outcome == Outcome.FATAL && source.equals(original)) return false;
|
||||
}
|
||||
DownloadSource.INSTANCE.failed();
|
||||
return false;
|
||||
}
|
||||
|
||||
private enum Outcome { DONE, RETRY, FATAL }
|
||||
|
||||
private static final class Identity {
|
||||
private String source;
|
||||
private String etag;
|
||||
}
|
||||
|
||||
private static File resumeFile(File dest) {
|
||||
return new File(dest.getPath() + ".resume.properties");
|
||||
}
|
||||
|
||||
private static boolean strongETag(String value) {
|
||||
return value != null && !value.isEmpty() && !value.startsWith("W/");
|
||||
}
|
||||
|
||||
/** Only reuse saved bytes when we persisted a strong validator and their source. */
|
||||
private static Identity loadIdentity(File dest, boolean resume, String expectedSha256) {
|
||||
Identity identity = new Identity();
|
||||
File marker = resumeFile(dest);
|
||||
if (!resume || !dest.isFile() || dest.length() == 0) {
|
||||
marker.delete();
|
||||
return identity;
|
||||
}
|
||||
try (FileInputStream in = new FileInputStream(marker)) {
|
||||
Properties p = new Properties();
|
||||
p.load(in);
|
||||
String source = p.getProperty("source");
|
||||
String etag = p.getProperty("etag");
|
||||
String expected = p.getProperty("sha256", "");
|
||||
if (source != null && source.startsWith("http") && strongETag(etag)
|
||||
&& expected.equals(expectedSha256 == null ? "" : expectedSha256.toLowerCase(java.util.Locale.ROOT))) {
|
||||
identity.source = source;
|
||||
identity.etag = etag;
|
||||
return identity;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Legacy partials or interrupted metadata writes have no trustworthy identity.
|
||||
}
|
||||
if (!discardPartial(dest, identity)) Log.w(TAG, "could not discard unidentified partial " + dest);
|
||||
return identity;
|
||||
}
|
||||
|
||||
private static boolean discardPartial(File dest, Identity identity) {
|
||||
identity.source = null;
|
||||
identity.etag = null;
|
||||
File marker = resumeFile(dest);
|
||||
return (!dest.exists() || dest.delete()) && (!marker.exists() || marker.delete());
|
||||
}
|
||||
|
||||
private static void saveIdentity(File dest, Identity identity, String expectedSha256) {
|
||||
File marker = resumeFile(dest);
|
||||
if (!strongETag(identity.etag)) {
|
||||
marker.delete();
|
||||
return;
|
||||
}
|
||||
Properties p = new Properties();
|
||||
p.setProperty("source", identity.source);
|
||||
p.setProperty("etag", identity.etag);
|
||||
p.setProperty("sha256", expectedSha256 == null ? "" : expectedSha256.toLowerCase(java.util.Locale.ROOT));
|
||||
try (FileOutputStream out = new FileOutputStream(marker)) {
|
||||
p.store(out, "DroidDeck partial download validator");
|
||||
} catch (IOException e) {
|
||||
marker.delete(); // No valid metadata means no future resume.
|
||||
Log.w(TAG, "cannot persist download validator", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Outcome attempt(String url, File destination, boolean resume, Identity identity,
|
||||
String expectedSha256, java.util.function.Consumer<Float> progress) {
|
||||
HttpURLConnection connection = null;
|
||||
long have = resume && destination.isFile() ? destination.length() : 0;
|
||||
try {
|
||||
connection = open(url);
|
||||
if (have > 0) connection.setRequestProperty("Range", "bytes=" + have + "-");
|
||||
if (have > 0 && (!url.equals(identity.source) || !strongETag(identity.etag))) {
|
||||
// A new source or unvalidated bytes must restart from byte zero.
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
have = 0;
|
||||
}
|
||||
connection = open(url, STALL_TIMEOUT_MS);
|
||||
if (have > 0) {
|
||||
connection.setRequestProperty("Range", "bytes=" + have + "-");
|
||||
connection.setRequestProperty("If-Range", identity.etag);
|
||||
}
|
||||
int code = connection.getResponseCode();
|
||||
boolean appending = code == HttpURLConnection.HTTP_PARTIAL;
|
||||
if (have > 0 && code == HTTP_RANGE_NOT_SATISFIABLE) {
|
||||
connection.disconnect();
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
return attempt(url, destination, false, identity, expectedSha256, progress);
|
||||
}
|
||||
if (code / 100 != 2) {
|
||||
if (have > 0) {
|
||||
connection.disconnect();
|
||||
destination.delete();
|
||||
return downloadFile(url, destination, false, progress);
|
||||
}
|
||||
Log.w(TAG, url + " -> HTTP " + code);
|
||||
return false;
|
||||
return code == HttpURLConnection.HTTP_NOT_FOUND || code == HttpURLConnection.HTTP_GONE
|
||||
? Outcome.FATAL : Outcome.RETRY;
|
||||
}
|
||||
boolean appending = code == HttpURLConnection.HTTP_PARTIAL;
|
||||
if (appending && !rangeStartsAt(connection.getHeaderField("Content-Range"), have)) {
|
||||
connection.disconnect();
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
return attempt(url, destination, false, identity, expectedSha256, progress);
|
||||
}
|
||||
String etag = connection.getHeaderField("ETag");
|
||||
if (have > 0 && appending && (!strongETag(etag) || !etag.equals(identity.etag))) {
|
||||
connection.disconnect();
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
return attempt(url, destination, false, identity, expectedSha256, progress);
|
||||
}
|
||||
if (!appending && have > 0) {
|
||||
// If-Range may legitimately return full 200 when representation changed.
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
have = 0;
|
||||
}
|
||||
if (!appending) have = 0;
|
||||
long length = connection.getContentLengthLong();
|
||||
long total = length < 0 ? -1 : length + have;
|
||||
long total = appending ? rangeTotal(connection.getHeaderField("Content-Range")) : -1;
|
||||
if (total < 0) total = length < 0 ? -1 : length + have;
|
||||
File parent = destination.getParentFile();
|
||||
if (parent != null && !parent.isDirectory())
|
||||
parent.mkdirs();
|
||||
if (parent != null && !parent.isDirectory()) parent.mkdirs();
|
||||
identity.source = url;
|
||||
identity.etag = strongETag(etag) ? etag : null;
|
||||
saveIdentity(destination, identity, expectedSha256);
|
||||
long written = have;
|
||||
try (InputStream in = new BufferedInputStream(connection.getInputStream(), 1 << 16);
|
||||
OutputStream out = new FileOutputStream(destination, appending)) {
|
||||
@@ -77,20 +215,66 @@ public final class Downloader {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (progress != null) progress.accept(1f);
|
||||
return total <= 0 || written >= total;
|
||||
if (total > 0 && written < total) return Outcome.RETRY;
|
||||
return Outcome.DONE;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "download " + url, e);
|
||||
return false;
|
||||
return Outcome.RETRY;
|
||||
} finally {
|
||||
if (connection != null) connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpURLConnection open(String url) throws java.io.IOException {
|
||||
/** True when a `Content-Range: bytes START-END/TOTAL` header starts exactly at {@code start}. */
|
||||
static boolean rangeStartsAt(String contentRange, long start) {
|
||||
if (contentRange == null) return false;
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+(\\d+)-\\d+/(?:\\d+|\\*)\\s*$").matcher(contentRange);
|
||||
return m.matches() && Long.parseLong(m.group(1)) == start;
|
||||
}
|
||||
|
||||
/** The complete length in a `Content-Range` header, or -1 when the header leaves it unknown. */
|
||||
static long rangeTotal(String contentRange) {
|
||||
if (contentRange == null) return -1;
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+\\d+-\\d+/(\\d+)\\s*$").matcher(contentRange);
|
||||
return m.matches() ? Long.parseLong(m.group(1)) : -1;
|
||||
}
|
||||
|
||||
private static final class Fetch {
|
||||
final String body;
|
||||
final boolean fatal;
|
||||
|
||||
Fetch(String body, boolean fatal) {
|
||||
this.body = body;
|
||||
this.fatal = fatal;
|
||||
}
|
||||
}
|
||||
|
||||
private static Fetch fetch(String url) {
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
connection = open(url, STALL_TIMEOUT_MS);
|
||||
int code = connection.getResponseCode();
|
||||
if (code / 100 != 2) {
|
||||
Log.w(TAG, url + " -> HTTP " + code);
|
||||
return new Fetch(null, code == HttpURLConnection.HTTP_NOT_FOUND || code == HttpURLConnection.HTTP_GONE);
|
||||
}
|
||||
try (InputStream in = connection.getInputStream();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
FileUtils.copy(in, out);
|
||||
return new Fetch(new String(out.toByteArray(), StandardCharsets.UTF_8), false);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "GET " + url, e);
|
||||
return new Fetch(null, false);
|
||||
} finally {
|
||||
if (connection != null) connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||
connection.setConnectTimeout(TIMEOUT_MS);
|
||||
connection.setReadTimeout(TIMEOUT_MS);
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(readTimeoutMs);
|
||||
connection.setInstanceFollowRedirects(true);
|
||||
connection.setRequestProperty("User-Agent", "DroidDeck-Android");
|
||||
return connection;
|
||||
|
||||
@@ -26,10 +26,12 @@ object PhantomProcessLimit {
|
||||
usesDeviceConfig(sdk) && enabled -> listOf(
|
||||
"device_config delete activity_manager max_phantom_processes",
|
||||
"device_config set_sync_disabled_for_tests none",
|
||||
"settings put global $SETTING true",
|
||||
)
|
||||
usesDeviceConfig(sdk) -> listOf(
|
||||
"device_config set_sync_disabled_for_tests persistent",
|
||||
"device_config put activity_manager max_phantom_processes $MAX_PHANTOM",
|
||||
"settings put global $SETTING false",
|
||||
)
|
||||
else -> listOf("settings put global $SETTING ${if (enabled) "true" else "false"}")
|
||||
}
|
||||
@@ -57,7 +59,12 @@ object PhantomProcessLimit {
|
||||
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
|
||||
if (usesDeviceConfig(sdk)) {
|
||||
val off = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(PREF_ANDROID_12_OFF, false)
|
||||
return if (off) PhantomProcessStatus.DISABLED else PhantomProcessStatus.UNREADABLE
|
||||
val global = try {
|
||||
Settings.Global.getString(context.contentResolver, SETTING)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
return android12Status(off, global, systemProperty(OVERRIDE_PROPERTY))
|
||||
}
|
||||
val global = try {
|
||||
Settings.Global.getString(context.contentResolver, SETTING)
|
||||
@@ -67,6 +74,10 @@ object PhantomProcessLimit {
|
||||
return status(global, systemProperty(OVERRIDE_PROPERTY))
|
||||
}
|
||||
|
||||
fun android12Status(rememberedOff: Boolean, global: String?, override: String?): PhantomProcessStatus =
|
||||
if (rememberedOff || status(global, override) == PhantomProcessStatus.DISABLED) PhantomProcessStatus.DISABLED
|
||||
else PhantomProcessStatus.UNREADABLE
|
||||
|
||||
fun status(global: String?, override: String?): PhantomProcessStatus {
|
||||
val value = global?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
|
||||
?: override?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Process
|
||||
import android.util.Log
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
object SessionLogCapture {
|
||||
private const val TAG = "SessionLogCapture"
|
||||
@@ -11,6 +12,7 @@ object SessionLogCapture {
|
||||
private var pid = -1
|
||||
private var writer: BufferedWriter? = null
|
||||
private var target: File? = null
|
||||
private var syncer: Thread? = null
|
||||
|
||||
@Synchronized
|
||||
fun start(target: File) {
|
||||
@@ -37,6 +39,7 @@ object SessionLogCapture {
|
||||
out.write("logcat could not be started; this ROM may not hand an app its own entries.\n")
|
||||
out.flush()
|
||||
}
|
||||
target.parentFile?.let { syncer = startSyncing(it) }
|
||||
Log.i(TAG, "app log -> $target (logcat pid $pid)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not start the app log", e)
|
||||
@@ -49,8 +52,19 @@ object SessionLogCapture {
|
||||
if (t.parentFile?.absolutePath == dir.absolutePath) stop()
|
||||
}
|
||||
|
||||
/** flush() only reaches the page cache, and a hard reset on f2fs lost the last minutes of every
|
||||
* log. Every 2 s the session folder's files (ours and the native parts') are synced to storage. */
|
||||
private fun startSyncing(dir: File): Thread = Thread({
|
||||
while (true) {
|
||||
try { Thread.sleep(2000) } catch (e: InterruptedException) { break }
|
||||
dir.listFiles()?.forEach { runCatching { FileOutputStream(it, true).use { s -> s.fd.sync() } } }
|
||||
}
|
||||
}, "session-log-sync").apply { isDaemon = true; start() }
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
syncer?.interrupt()
|
||||
syncer = null
|
||||
target = null
|
||||
if (pid != -1) {
|
||||
try {
|
||||
|
||||
@@ -22,7 +22,7 @@ object AddedGameArt {
|
||||
}
|
||||
|
||||
enum class Slot(val key: String, val cacheName: String, val assetType: Int?) {
|
||||
COVER("p", "p.jpg", 0), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null),
|
||||
COVER("p", "p.jpg", 0), WIDE("header", "header.jpg", 3), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null),
|
||||
}
|
||||
|
||||
const val FOLDER = "folder"
|
||||
@@ -36,10 +36,14 @@ object AddedGameArt {
|
||||
|
||||
class Option(val group: String, val thumb: String, val full: String)
|
||||
|
||||
private val CHOSEN = Regex("""chosen-(\w+)\.(\w+)""")
|
||||
class Choices(val options: List<Option>, val sgdb: SteamGridDb.Status)
|
||||
|
||||
private fun chosen(cache: File, slot: Slot): File? =
|
||||
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.firstOrNull()
|
||||
class Found<T>(val items: List<T>, val status: SteamGridDb.Status)
|
||||
|
||||
private val CHOSEN = Regex("""chosen-(p|header|hero|logo|icon)(?:-\d+)?\.(\w+)""")
|
||||
|
||||
internal fun chosen(cache: File, slot: Slot): File? =
|
||||
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.maxWithOrNull(compareBy({ it.lastModified() }, { it.name }))
|
||||
|
||||
private val IMAGE = listOf("png", "jpg", "jpeg")
|
||||
private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait")
|
||||
@@ -75,7 +79,7 @@ object AddedGameArt {
|
||||
fun cached(name: String) = File(cache, name).takeIf { it.isFile }
|
||||
return Art(
|
||||
portrait = chosen(cache, Slot.COVER) ?: own.portrait ?: cached("p.jpg") ?: exeArt(context, game, cover = true),
|
||||
header = own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
|
||||
header = chosen(cache, Slot.WIDE) ?: own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
|
||||
hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: cached("hero.jpg"),
|
||||
logo = chosen(cache, Slot.LOGO) ?: own.logo ?: cached("logo.png"),
|
||||
icon = chosen(cache, Slot.ICON) ?: own.icon ?: cached("icon-sgdb.png") ?: exeIcon(context, game),
|
||||
@@ -92,6 +96,7 @@ object AddedGameArt {
|
||||
chosen(cache, slot)?.let { return@map SlotState(slot, it, chosenSource(context, game, slot).first.ifEmpty { FILE }, true) }
|
||||
val auto: Pair<File, String>? = when (slot) {
|
||||
Slot.COVER -> own.portrait?.let { it to FOLDER } ?: cached("p.jpg") ?: exeArt(context, game, cover = true)?.let { it to EXE }
|
||||
Slot.WIDE -> own.header?.let { it to FOLDER } ?: cached("header.jpg") ?: exeArt(context, game, cover = false)?.let { it to EXE }
|
||||
Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: cached("hero.jpg")
|
||||
Slot.LOGO -> own.logo?.let { it to FOLDER } ?: cached("logo.png")
|
||||
Slot.ICON -> own.icon?.let { it to FOLDER } ?: File(cache, "icon-sgdb.png").takeIf { it.isFile }?.let { it to SGDB }
|
||||
@@ -108,42 +113,88 @@ object AddedGameArt {
|
||||
dir.listFiles { f -> f.isFile && f.extension.lowercase() in CHOOSABLE }?.sortedBy { it.name.lowercase() }.orEmpty()
|
||||
}.take(12)
|
||||
|
||||
fun options(context: Context, game: AddedGames.Game, slot: Slot): List<Option> {
|
||||
fun choices(context: Context, game: AddedGames.Game, slot: Slot): Choices {
|
||||
val out = ArrayList<Option>()
|
||||
folderImages(game.folder).forEach { out.add(Option(FOLDER, it.path, it.path)) }
|
||||
val cache = cacheDir(context, game)
|
||||
val steamId = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
|
||||
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null }
|
||||
if (steamId != null && remote != null) {
|
||||
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote)
|
||||
if (url != null) out.add(Option(STEAM, url, url))
|
||||
}
|
||||
if (steamId != null) steamOption(steamId, slot)?.let { out.add(it) }
|
||||
val (found, status) = sgdbChoices(context, File(cache, "sgdb-id"), steamId?.toIntOrNull(), game.name, slot)
|
||||
out.addAll(found)
|
||||
return Choices(out, status)
|
||||
}
|
||||
|
||||
internal fun steamOption(steamId: String, slot: Slot): Option? {
|
||||
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.WIDE -> "header.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null } ?: return null
|
||||
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote) ?: return null
|
||||
return Option(STEAM, url, url)
|
||||
}
|
||||
|
||||
internal fun sgdbChoices(context: Context, marker: File, steamId: Int?, name: String, slot: Slot): Pair<List<Option>, SteamGridDb.Status> {
|
||||
val key = SteamGridDb.key(context)
|
||||
if (key.isNotEmpty()) {
|
||||
cache.mkdirs()
|
||||
val gridId = remembered(File(cache, "sgdb-id")) {
|
||||
runCatching { SteamGridDb.gameId(key, steamId?.toIntOrNull(), game.name) }.getOrNull()?.toString()
|
||||
}?.toIntOrNull()
|
||||
if (gridId != null) runCatching { SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5) }.getOrNull()
|
||||
?.forEach { (thumb, full) -> out.add(Option(SGDB, thumb, full)) }
|
||||
if (key.isEmpty()) return emptyList<Option>() to SteamGridDb.Status.NO_KEY
|
||||
marker.parentFile?.mkdirs()
|
||||
return try {
|
||||
val gridId = remembered(marker, SteamGridDb.keyChangedAt(context), retryMiss = true) {
|
||||
SteamGridDb.gameId(key, steamId, name)?.toString()
|
||||
}?.toIntOrNull() ?: return emptyList<Option>() to SteamGridDb.Status.NO_GAME
|
||||
SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5).map { (thumb, full) -> Option(SGDB, thumb, full) } to SteamGridDb.Status.OK
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$name: SteamGridDB failed (${e.javaClass.simpleName})")
|
||||
emptyList<Option>() to ((e as? SteamGridDb.Failed)?.status ?: SteamGridDb.Status.SERVER)
|
||||
}
|
||||
}
|
||||
|
||||
fun searchGames(context: Context, term: String): Found<SteamGridDb.Game> = sgdb(context) { SteamGridDb.search(it, term) }
|
||||
|
||||
fun gameImages(context: Context, id: Int, slot: Slot): Found<Option> = sgdb(context) { key ->
|
||||
SteamGridDb.all(key, id, slot).map { (thumb, full) -> Option(SGDB, thumb, full) }
|
||||
}
|
||||
|
||||
private fun <T> sgdb(context: Context, call: (String) -> List<T>): Found<T> {
|
||||
val key = SteamGridDb.key(context)
|
||||
if (key.isEmpty()) return Found(emptyList(), SteamGridDb.Status.NO_KEY)
|
||||
return try {
|
||||
Found(call(key), SteamGridDb.Status.OK)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "SteamGridDB: ${e.javaClass.simpleName}")
|
||||
Found(emptyList(), (e as? SteamGridDb.Failed)?.status ?: SteamGridDb.Status.SERVER)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun choose(context: Context, game: AddedGames.Game, slot: Slot, option: Option): Boolean {
|
||||
val bytes = if (option.full.startsWith("https://")) get(option.full) else runCatching { File(option.full).readBytes() }.getOrNull()
|
||||
bytes ?: return false
|
||||
val ext = option.full.substringAfterLast('.', "png").substringBefore('?').lowercase().takeIf { it in CHOOSABLE + "ico" } ?: "png"
|
||||
val cache = cacheDir(context, game).apply { mkdirs() }
|
||||
chosen(cache, slot)?.delete()
|
||||
val dst = File(cache, "chosen-${slot.key}.${if (ext == "jpeg") "jpg" else ext}")
|
||||
val tmp = File(cache, dst.name + ".tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
if (!tmp.renameTo(dst)) return false
|
||||
if (!store(cacheDir(context, game), slot, option)) return false
|
||||
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, option.group + "|" + option.full)
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun store(cache: File, slot: Slot, option: Option): Boolean {
|
||||
val fresh = storeNew(cache, slot, option) ?: return false
|
||||
dropOlder(cache, slot, fresh)
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun dropOlder(cache: File, slot: Slot, keep: File) {
|
||||
cache.listFiles { f -> f != keep && CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private val lastStamp = java.util.concurrent.atomic.AtomicLong(0)
|
||||
|
||||
private fun nextStamp(): Long = lastStamp.updateAndGet { maxOf(it + 1, System.currentTimeMillis()) }
|
||||
|
||||
internal fun storeNew(cache: File, slot: Slot, option: Option): File? {
|
||||
val bytes = if (option.full.startsWith("https://")) get(option.full) else runCatching { File(option.full).readBytes() }.getOrNull()
|
||||
bytes ?: return null
|
||||
val ext = option.full.substringAfterLast('.', "png").substringBefore('?').lowercase().takeIf { it in CHOOSABLE + "ico" } ?: "png"
|
||||
cache.mkdirs()
|
||||
val suffix = if (ext == "jpeg") "jpg" else ext
|
||||
var dst = File(cache, "chosen-${slot.key}-" + "%013d".format(nextStamp()) + "." + suffix)
|
||||
while (dst.exists()) dst = File(cache, "chosen-${slot.key}-" + "%013d".format(nextStamp()) + "." + suffix)
|
||||
val tmp = File(cache, dst.name + ".tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
return if (tmp.renameTo(dst)) dst else null
|
||||
}
|
||||
|
||||
fun chosenSource(context: Context, game: AddedGames.Game, slot: Slot): Pair<String, String> {
|
||||
val value = SessionPrefs.addedGameArtSource(context, game.folder.path, slot.key)
|
||||
return value.substringBefore('|') to value.substringAfter('|', "")
|
||||
@@ -180,6 +231,8 @@ object AddedGameArt {
|
||||
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean {
|
||||
if (!SessionPrefs.addedGamesArt(context)) return false
|
||||
val sgdbKey = SteamGridDb.key(context)
|
||||
val keyChangedAt = SteamGridDb.keyChangedAt(context)
|
||||
var sgdbDown = false
|
||||
var changed = false
|
||||
for (game in games) {
|
||||
if (local(game).portrait != null) continue
|
||||
@@ -192,11 +245,24 @@ object AddedGameArt {
|
||||
val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES }
|
||||
val id = match.appId?.toString()
|
||||
if (id != null && download(id, cache)) changed = true
|
||||
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty()) continue
|
||||
val gridId = remembered(File(cache, "sgdb-id")) {
|
||||
runCatching { SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name) }.getOrNull()?.toString()
|
||||
}?.toIntOrNull() ?: continue
|
||||
if (runCatching { SteamGridDb.download(sgdbKey, gridId, cache) }.getOrDefault(false)) changed = true
|
||||
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty() || sgdbDown) continue
|
||||
val stored: String? = try {
|
||||
remembered(File(cache, "sgdb-id"), keyChangedAt) {
|
||||
SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name)?.toString()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${game.name}: SteamGridDB lookup failed (${e.javaClass.simpleName}); not asking again this pass")
|
||||
sgdbDown = true
|
||||
null
|
||||
}
|
||||
val gridId = stored?.toIntOrNull() ?: continue
|
||||
val grabbed = try {
|
||||
SteamGridDb.download(sgdbKey, gridId, cache)
|
||||
} catch (e: Exception) {
|
||||
sgdbDown = true
|
||||
false
|
||||
}
|
||||
if (grabbed) changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
@@ -207,11 +273,12 @@ object AddedGameArt {
|
||||
return match
|
||||
}
|
||||
|
||||
private fun remembered(marker: File, find: () -> String?): String? {
|
||||
internal fun remembered(marker: File, notBefore: Long = 0L, retryMiss: Boolean = false, find: () -> String?): String? {
|
||||
if (marker.isFile) {
|
||||
val value = marker.readText().trim()
|
||||
if (value != "none") return value
|
||||
if (System.currentTimeMillis() - marker.lastModified() < RETRY_AFTER_MS) return null
|
||||
val at = marker.lastModified()
|
||||
if (!retryMiss && at >= notBefore && System.currentTimeMillis() - at < RETRY_AFTER_MS) return null
|
||||
}
|
||||
val found = find()
|
||||
marker.writeText(found ?: "none")
|
||||
|
||||
@@ -36,19 +36,24 @@ object AddedGameEdits {
|
||||
|
||||
fun setArt(context: Context, folder: String, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
|
||||
val game = game(context, folder) ?: return false
|
||||
if (!AddedGameArt.choose(context, game, slot, option)) return false
|
||||
applyArt(context, game, slot)
|
||||
return true
|
||||
return synchronized(SteamGrid.lock) {
|
||||
if (!AddedGameArt.choose(context, game, slot, option)) return@synchronized false
|
||||
applyArt(context, game, slot)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun resetArt(context: Context, folder: String, slot: AddedGameArt.Slot) {
|
||||
val game = game(context, folder) ?: return
|
||||
AddedGameArt.reset(context, game, slot)
|
||||
applyArt(context, game, slot)
|
||||
synchronized(SteamGrid.lock) {
|
||||
AddedGameArt.reset(context, game, slot)
|
||||
applyArt(context, game, slot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyArt(context: Context, game: AddedGames.Game, slot: AddedGameArt.Slot) {
|
||||
val file = AddedGameArt.describe(context, game).first { it.slot == slot }.file
|
||||
if (slot != AddedGameArt.Slot.ICON) SteamGrid.write(context, SteamGrid.target(game, context), slot, AddedGameArt.chosen(AddedGameArt.cacheDir(context, game), slot))
|
||||
publish(context) {
|
||||
if (slot == AddedGameArt.Slot.ICON) SteamLiveShortcuts.setArt(context, game.appId, null, null, iconPath = file?.absolutePath.orEmpty())
|
||||
else SteamLiveShortcuts.setArt(context, game.appId, slot.assetType, file)
|
||||
|
||||
@@ -330,7 +330,7 @@ object AddedGames {
|
||||
}
|
||||
}
|
||||
|
||||
private fun accountConfig(root: File): File? {
|
||||
internal fun accountConfig(root: File): File? {
|
||||
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
|
||||
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
|
||||
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
|
||||
@@ -332,7 +332,7 @@ object AutoComponents {
|
||||
caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
private fun constrained(context: Context): Boolean {
|
||||
fun constrained(context: Context): Boolean {
|
||||
val power = context.getSystemService(PowerManager::class.java)
|
||||
if (power?.isPowerSaveMode == true) return true
|
||||
if (Build.VERSION.SDK_INT >= 29 && (power?.currentThermalStatus ?: 0) >= PowerManager.THERMAL_STATUS_MODERATE) return true
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.frontend
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -199,24 +200,62 @@ object CoverArt {
|
||||
setRequestProperty("User-Agent", "DroidDeck")
|
||||
}
|
||||
|
||||
private fun exists(url: String): Boolean = try {
|
||||
val c = open(url, "HEAD"); val ok = c.responseCode == 200; c.disconnect(); ok
|
||||
} catch (e: Exception) { false }
|
||||
private fun <T> through(url: String, attempt: (String, Boolean) -> T?): T? {
|
||||
for (source in DownloadSource.candidates(url)) {
|
||||
val result = attempt(source, source == url)
|
||||
if (result != null) {
|
||||
DownloadSource.succeeded(source)
|
||||
return result
|
||||
}
|
||||
}
|
||||
DownloadSource.failed()
|
||||
return null
|
||||
}
|
||||
|
||||
private fun get(url: String): String? = try {
|
||||
private fun exists(url: String): Boolean = through(url) { source, original -> head(source, original) } ?: false
|
||||
|
||||
private fun head(url: String, original: Boolean): Boolean? = try {
|
||||
val c = open(url, "HEAD")
|
||||
val code = c.responseCode
|
||||
c.disconnect()
|
||||
when {
|
||||
code == 200 -> true
|
||||
code == 404 || code == 410 -> if (original) false else null
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
private fun get(url: String): String? = through(url) { source, original -> text(source, original) }
|
||||
|
||||
private fun text(url: String, original: Boolean): String? = try {
|
||||
val c = open(url)
|
||||
if (c.responseCode != 200) { c.disconnect(); null } else c.inputStream.bufferedReader().use { it.readText() }
|
||||
val code = c.responseCode
|
||||
when {
|
||||
code == 200 -> c.inputStream.bufferedReader().use { it.readText() }.takeIf { it.isNotEmpty() }
|
||||
code == 404 || code == 410 -> { c.disconnect(); if (original) "" else null }
|
||||
else -> { c.disconnect(); null }
|
||||
}
|
||||
} catch (e: Exception) { Log.w(TAG, "$url: $e"); null }
|
||||
|
||||
private fun download(url: String, target: File): Boolean = try {
|
||||
private fun download(url: String, target: File): Boolean = through(url) { source, original -> fetch(source, target, original) } ?: false
|
||||
|
||||
private fun fetch(url: String, target: File, original: Boolean): Boolean? = try {
|
||||
val c = open(url)
|
||||
if (c.responseCode != 200) { c.disconnect(); false } else {
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.path + ".part")
|
||||
c.inputStream.use { input -> tmp.outputStream().use { input.copyTo(it) } }
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(tmp.path, bounds)
|
||||
if (bounds.outWidth > 0 && tmp.renameTo(target)) true else { tmp.delete(); false }
|
||||
when (val code = c.responseCode) {
|
||||
200 -> {
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.path + ".part")
|
||||
c.inputStream.use { input -> tmp.outputStream().use { input.copyTo(it) } }
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(tmp.path, bounds)
|
||||
when {
|
||||
bounds.outWidth > 0 && tmp.renameTo(target) -> true
|
||||
original -> { tmp.delete(); false }
|
||||
else -> { tmp.delete(); null }
|
||||
}
|
||||
}
|
||||
404, 410 -> { c.disconnect(); if (original) false else null }
|
||||
else -> { c.disconnect(); null }
|
||||
}
|
||||
} catch (e: Exception) { Log.w(TAG, "$url: $e"); false }
|
||||
} catch (e: Exception) { Log.w(TAG, "$url: $e"); null }
|
||||
}
|
||||
@@ -76,7 +76,7 @@ object DependencyDetector {
|
||||
|
||||
fun consider(f: File, isDir: Boolean, insideRedist: Boolean) {
|
||||
val name = f.name.lowercase(Locale.US)
|
||||
val path = f.path.replace('\\', '/').lowercase(Locale.US)
|
||||
val path = relativeReason(gameDir, f).lowercase(Locale.US)
|
||||
val kind = if (insideRedist || isDir || name.endsWith(".exe") || name.endsWith(".msi") || name.endsWith(".cab"))
|
||||
Kind.BUNDLED else Kind.SHIPPED
|
||||
for (comp in classify(path, name, isDir)) {
|
||||
|
||||
@@ -5,12 +5,14 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.content.pm.ShortcutManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
object GameShortcuts {
|
||||
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
|
||||
@@ -19,22 +21,35 @@ object GameShortcuts {
|
||||
|
||||
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
|
||||
val name = game.name.ifBlank { context.getString(R.string.app_name) }
|
||||
val icon = listOfNotNull(game.icon, game.art).firstNotNullOfOrNull { file -> runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2
|
||||
}
|
||||
BitmapFactory.decodeFile(file.path, options)?.let(Icon::createWithBitmap)
|
||||
}.getOrNull() } ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
|
||||
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
|
||||
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
|
||||
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
|
||||
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
|
||||
.setIntent(launchIntent(context, game.gameIdString)).build()
|
||||
}
|
||||
|
||||
internal fun iconBitmap(game: Library.SteamGame): Bitmap? {
|
||||
val icon = game.icon?.let(::decode)
|
||||
if (icon != null && minOf(icon.width, icon.height) >= SHARP_ICON_PX) return icon
|
||||
return game.art?.let(::decode)?.let(::square) ?: icon
|
||||
}
|
||||
|
||||
private fun decode(file: File): Bitmap? = runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = 1
|
||||
while (minOf(bounds.outWidth, bounds.outHeight) / (inSampleSize * 2) >= ICON_PX) inSampleSize *= 2
|
||||
}
|
||||
BitmapFactory.decodeFile(file.path, options)
|
||||
}.getOrNull()
|
||||
|
||||
private fun square(bitmap: Bitmap): Bitmap {
|
||||
val side = minOf(bitmap.width, bitmap.height)
|
||||
return Bitmap.createBitmap(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side)
|
||||
}
|
||||
|
||||
fun pin(activity: Activity, game: Library.SteamGame) {
|
||||
val manager = activity.getSystemService(ShortcutManager::class.java)
|
||||
val requested = runCatching { manager.isRequestPinShortcutSupported &&
|
||||
@@ -44,4 +59,7 @@ object GameShortcuts {
|
||||
|
||||
fun result(activity: Activity, game: Library.SteamGame): Intent =
|
||||
activity.getSystemService(ShortcutManager::class.java).createShortcutResultIntent(info(activity, game))
|
||||
|
||||
private const val ICON_PX = 256
|
||||
private const val SHARP_ICON_PX = 128
|
||||
}
|
||||
@@ -17,6 +17,9 @@ object Library {
|
||||
val source: String = SOURCE_STEAM,
|
||||
val storeId: String? = null,
|
||||
) {
|
||||
fun withArt(art: File?, hero: File?, icon: File?) =
|
||||
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId)
|
||||
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
|
||||
}
|
||||
@@ -69,7 +72,7 @@ object Library {
|
||||
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
|
||||
private fun steamLibraries(context: Context): List<Pair<File, String>> {
|
||||
internal fun steamLibraries(context: Context): List<Pair<File, String>> {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
return listOfNotNull(
|
||||
root to "internal",
|
||||
@@ -116,21 +119,33 @@ object Library {
|
||||
val installDir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
|
||||
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
|
||||
val gameFiles = installDir?.let { File(steamapps, "common/$it").takeIf(File::isDirectory) }
|
||||
out[appId] = SteamGame(
|
||||
out[appId] = SteamGameArt.withOverrides(context, SteamGame(
|
||||
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
|
||||
gameFiles = gameFiles,
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
|
||||
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
|
||||
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
return out.values.toList()
|
||||
}
|
||||
|
||||
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
|
||||
val cache = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/librarycache")
|
||||
return cache.listFiles().orEmpty().mapNotNull { dir ->
|
||||
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null
|
||||
steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it }
|
||||
}
|
||||
}
|
||||
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null): List<SteamGame> {
|
||||
val roots = steamLibraries(context).map { it.first }
|
||||
return (steamGames(context, strictRead) + (added ?: AddedGames.scan(context)).map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
val list = added ?: AddedGames.scan(context)
|
||||
var steam = steamGames(context, strictRead)
|
||||
val targets = steam.filter { it.library != ADDED && it.source == SOURCE_STEAM }.map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) }
|
||||
if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead)
|
||||
return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
|
||||
@@ -144,7 +159,7 @@ object Library {
|
||||
)
|
||||
}
|
||||
|
||||
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
internal fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
val appDir = File(cache, appId.toString())
|
||||
val dirs = listOf(appDir) + appDir.listFiles()
|
||||
.orEmpty().filter { it.isDirectory }.sortedBy { it.name }
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object SteamFeatured {
|
||||
class Game(val appId: Int, val name: String, val art: String)
|
||||
|
||||
private const val TAG = "SteamFeatured"
|
||||
private const val PREFS = "steam_featured"
|
||||
private const val KEY_GAMES = "games_v2"
|
||||
private const val KEY_AT = "at"
|
||||
private const val FRESH_MS = 6 * 60 * 60 * 1000L
|
||||
private val SECTIONS = listOf("top_sellers", "new_releases", "specials")
|
||||
private const val ASSET_ROOT = "https://shared.akamai.steamstatic.com/store_item_assets/"
|
||||
|
||||
private val HIDDEN_DESCRIPTORS = setOf(1, 3, 4)
|
||||
|
||||
var games by mutableStateOf<List<Game>>(emptyList())
|
||||
private set
|
||||
private val loading = AtomicBoolean(false)
|
||||
|
||||
fun load(context: Context) {
|
||||
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (games.isEmpty()) parse(prefs.getString(KEY_GAMES, null))?.let { games = it }
|
||||
if (System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) in 0 until FRESH_MS && games.isNotEmpty()) return
|
||||
if (!loading.compareAndSet(false, true)) return
|
||||
Thread({
|
||||
try {
|
||||
val cc = Locale.getDefault().country.takeIf { it.length == 2 } ?: "US"
|
||||
val body = StoreNet.get("https://store.steampowered.com/api/featuredcategories?cc=$cc&l=english") ?: return@Thread
|
||||
val root = JSONObject(body)
|
||||
val ids = LinkedHashSet<Int>()
|
||||
for (section in SECTIONS) {
|
||||
val items = root.optJSONObject(section)?.optJSONArray("items") ?: continue
|
||||
for (i in 0 until items.length()) {
|
||||
val o = items.optJSONObject(i) ?: continue
|
||||
val id = o.optInt("id")
|
||||
if (id > 0 && o.optInt("type", 0) == 0) ids += id
|
||||
}
|
||||
}
|
||||
val list = withArt(ids, cc)
|
||||
if (list.isEmpty()) return@Thread
|
||||
prefs.edit().putString(KEY_GAMES, JSONArray(list.map { JSONObject().put("id", it.appId).put("name", it.name).put("art", it.art) }).toString())
|
||||
.putLong(KEY_AT, System.currentTimeMillis()).apply()
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { games = list }
|
||||
Log.i(TAG, "featured games: ${list.size}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "featured: ${e.javaClass.simpleName}")
|
||||
} finally {
|
||||
loading.set(false)
|
||||
}
|
||||
}, "steam-featured").start()
|
||||
}
|
||||
|
||||
private fun withArt(ids: Collection<Int>, cc: String): List<Game> {
|
||||
if (ids.isEmpty()) return emptyList()
|
||||
val input = JSONObject()
|
||||
.put("ids", JSONArray(ids.map { JSONObject().put("appid", it) }))
|
||||
.put("context", JSONObject().put("language", "english").put("country_code", cc))
|
||||
.put("data_request", JSONObject().put("include_assets", true))
|
||||
val body = StoreNet.get(
|
||||
"https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=" + java.net.URLEncoder.encode(input.toString(), "UTF-8"),
|
||||
) ?: return emptyList()
|
||||
val items = JSONObject(body).optJSONObject("response")?.optJSONArray("store_items") ?: return emptyList()
|
||||
return (0 until items.length()).mapNotNull { i ->
|
||||
val o = items.optJSONObject(i) ?: return@mapNotNull null
|
||||
val descriptors = o.optJSONArray("content_descriptorids")
|
||||
if (descriptors != null && (0 until descriptors.length()).any { descriptors.optInt(it) in HIDDEN_DESCRIPTORS }) return@mapNotNull null
|
||||
val assets = o.optJSONObject("assets") ?: return@mapNotNull null
|
||||
val format = assets.optString("asset_url_format").takeIf { "\${FILENAME}" in it } ?: return@mapNotNull null
|
||||
val file = listOf("library_capsule_2x", "library_capsule").firstNotNullOfOrNull { k -> assets.optString(k).takeIf { it.isNotEmpty() } }
|
||||
?: return@mapNotNull null
|
||||
Game(o.optInt("appid"), o.optString("name"), ASSET_ROOT + format.replace("\${FILENAME}", file))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(text: String?): List<Game>? {
|
||||
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
|
||||
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.map { Game(it.optInt("id"), it.optString("name"), it.optString("art")) }
|
||||
.filter { it.appId > 0 && it.art.isNotEmpty() }.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.stores.SteamLiveShortcuts
|
||||
import java.io.File
|
||||
|
||||
object SteamGameArt {
|
||||
internal fun dir(context: Context, appId: Int) = File(context.filesDir, "steam-art/$appId")
|
||||
|
||||
private fun sourceFile(context: Context, appId: Int, slot: AddedGameArt.Slot) = File(dir(context, appId), "source-${slot.key}")
|
||||
|
||||
internal fun setSource(context: Context, appId: Int, slot: AddedGameArt.Slot, value: String) {
|
||||
val file = sourceFile(context, appId, slot)
|
||||
if (value.isEmpty()) file.delete() else file.apply { parentFile?.mkdirs() }.writeText(value)
|
||||
}
|
||||
|
||||
fun chosenFile(context: Context, appId: Int, slot: AddedGameArt.Slot): File? = AddedGameArt.chosen(dir(context, appId), slot)
|
||||
|
||||
fun withOverrides(context: Context, game: Library.SteamGame): Library.SteamGame {
|
||||
if (game.library == Library.ADDED || !dir(context, game.appId).isDirectory) return game
|
||||
val cover = chosenFile(context, game.appId, AddedGameArt.Slot.COVER)
|
||||
val hero = chosenFile(context, game.appId, AddedGameArt.Slot.BACKGROUND)
|
||||
val icon = chosenFile(context, game.appId, AddedGameArt.Slot.ICON)
|
||||
if (cover == null && hero == null && icon == null) return game
|
||||
return Library.SteamGame(
|
||||
game.appId, game.name, cover ?: game.art, game.library, game.gameId,
|
||||
hero ?: game.hero, game.lastPlayed, game.gameFiles, game.protonPrefix, icon ?: game.icon, game.source, game.storeId,
|
||||
)
|
||||
}
|
||||
|
||||
fun chosenSource(context: Context, appId: Int, slot: AddedGameArt.Slot): Pair<String, String> {
|
||||
val value = sourceFile(context, appId, slot).takeIf { it.isFile }?.readText()?.trim().orEmpty()
|
||||
return value.substringBefore('|') to value.substringAfter('|', "")
|
||||
}
|
||||
|
||||
fun describe(context: Context, game: Library.SteamGame): List<AddedGameArt.SlotState> {
|
||||
val cache = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/librarycache")
|
||||
val own = withOverrides(context, game)
|
||||
return AddedGameArt.Slot.values().map { slot ->
|
||||
chosenFile(context, game.appId, slot)?.let {
|
||||
return@map AddedGameArt.SlotState(slot, it, chosenSource(context, game.appId, slot).first.ifEmpty { AddedGameArt.FILE }, true)
|
||||
}
|
||||
val auto = when (slot) {
|
||||
AddedGameArt.Slot.COVER -> own.art
|
||||
AddedGameArt.Slot.WIDE -> Library.steamCacheImage(cache, game.appId, listOf("library_header.jpg", "header.jpg"))
|
||||
AddedGameArt.Slot.BACKGROUND -> own.hero
|
||||
AddedGameArt.Slot.ICON -> own.icon
|
||||
AddedGameArt.Slot.LOGO -> Library.steamCacheImage(cache, game.appId, listOf("logo.png"))
|
||||
}
|
||||
AddedGameArt.SlotState(slot, auto, if (auto != null) AddedGameArt.STEAM else AddedGameArt.AUTO, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun choices(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot): AddedGameArt.Choices {
|
||||
val out = ArrayList<AddedGameArt.Option>()
|
||||
AddedGameArt.steamOption(game.appId.toString(), slot)?.let { out.add(it) }
|
||||
val (found, status) = AddedGameArt.sgdbChoices(context, File(dir(context, game.appId), "sgdb-id"), game.appId, game.name, slot)
|
||||
out.addAll(found)
|
||||
return AddedGameArt.Choices(out, status)
|
||||
}
|
||||
|
||||
fun choose(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
|
||||
return synchronized(SteamGrid.lock) {
|
||||
val folder = dir(context, game.appId)
|
||||
val fresh = AddedGameArt.storeNew(folder, slot, option) ?: return@synchronized false
|
||||
if (!push(context, game, slot, fresh)) {
|
||||
fresh.delete()
|
||||
return@synchronized false
|
||||
}
|
||||
AddedGameArt.dropOlder(folder, slot, fresh)
|
||||
setSource(context, game.appId, slot, option.group + "|" + option.full)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun reset(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot) {
|
||||
synchronized(SteamGrid.lock) {
|
||||
if (!push(context, game, slot, null)) return@synchronized
|
||||
AddedGameArt.chosen(dir(context, game.appId), slot)?.delete()
|
||||
setSource(context, game.appId, slot, "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun push(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot, file: File?): Boolean {
|
||||
val type = slot.assetType ?: return true
|
||||
if (!SteamGrid.write(context, SteamGrid.target(game, context), slot, file)) return false
|
||||
AddedGamesPublisher.later(context) { SteamLiveShortcuts.setArt(context, game.appId.toLong(), type, file) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/** Steam keeps a game's custom art in `userdata/<account>/config/grid`; this is where DroidDeck's choices and Steam's meet. */
|
||||
object SteamGrid {
|
||||
private const val TAG = "SteamGrid"
|
||||
|
||||
/** Every change to a game's art, here or in a scan, takes this first so two of them never interleave. */
|
||||
internal val lock = Any()
|
||||
private const val MANIFEST = ".droiddeck-art.json"
|
||||
private val SUFFIX = mapOf(AddedGameArt.Slot.COVER to "p", AddedGameArt.Slot.WIDE to "", AddedGameArt.Slot.BACKGROUND to "_hero", AddedGameArt.Slot.LOGO to "_logo")
|
||||
private val EXTS = listOf("png", "jpg", "jpeg", "webp")
|
||||
|
||||
class Target(val artDir: File, val id: Long, val setSource: (AddedGameArt.Slot, String) -> Unit)
|
||||
|
||||
fun target(game: Library.SteamGame, context: Context) = Target(SteamGameArt.dir(context, game.appId), game.appId.toLong()) { slot, value ->
|
||||
SteamGameArt.setSource(context, game.appId, slot, value)
|
||||
}
|
||||
|
||||
fun target(game: AddedGames.Game, context: Context) = Target(AddedGameArt.cacheDir(context, game), game.appId and 0xFFFFFFFFL) { slot, value ->
|
||||
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, value)
|
||||
}
|
||||
|
||||
internal fun dir(context: Context): File? {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
return AddedGames.accountConfig(root)?.let { File(it, "grid") }
|
||||
}
|
||||
|
||||
private fun files(grid: File, id: Long, slot: AddedGameArt.Slot): List<File> =
|
||||
EXTS.map { File(grid, "$id${SUFFIX[slot]}.$it") }.filter { it.isFile }
|
||||
|
||||
private fun signature(file: File?) = if (file == null) "" else "${file.length()}:${file.lastModified()}"
|
||||
|
||||
private fun sigFile(target: Target, slot: AddedGameArt.Slot) = File(target.artDir, "grid-${slot.key}")
|
||||
|
||||
private fun recorded(target: Target, slot: AddedGameArt.Slot): String? = sigFile(target, slot).takeIf { it.isFile }?.readText()
|
||||
|
||||
private fun record(target: Target, slot: AddedGameArt.Slot, value: String) {
|
||||
target.artDir.mkdirs()
|
||||
sigFile(target, slot).writeText(value)
|
||||
}
|
||||
|
||||
private fun writtenByDroidDeck(grid: File): Set<String> = runCatching {
|
||||
val manifest = JSONObject(File(grid, MANIFEST).readText())
|
||||
manifest.keys().asSequence().filter { name -> File(grid, name).let { it.isFile && it.length() == manifest.optLong(name, -1L) } }.toSet()
|
||||
}.getOrDefault(emptySet())
|
||||
|
||||
/** Puts a choice (or, with null, the lack of one) where Steam reads it, whether or not Steam is running. */
|
||||
fun write(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean = synchronized(lock) { place(context, target, slot, file) }
|
||||
|
||||
private fun place(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean {
|
||||
val suffix = SUFFIX[slot] ?: return true
|
||||
val grid = dir(context) ?: return true
|
||||
if (file == null) {
|
||||
if (files(grid, target.id, slot).map { it.delete() }.any { !it }) return false
|
||||
record(target, slot, "")
|
||||
return true
|
||||
}
|
||||
val ext = file.extension.lowercase().let { if (it == "jpeg") "jpg" else it }
|
||||
if (ext !in EXTS) return false
|
||||
grid.mkdirs()
|
||||
val dst = File(grid, "${target.id}$suffix.$ext")
|
||||
val tmp = File(grid, dst.name + ".tmp")
|
||||
val placed = runCatching { file.copyTo(tmp, overwrite = true); if (tmp.renameTo(dst)) dst else null }.getOrNull()
|
||||
if (placed == null) {
|
||||
tmp.delete()
|
||||
return false
|
||||
}
|
||||
if (files(grid, target.id, slot).filter { it != dst }.map { it.delete() }.any { !it }) return false
|
||||
record(target, slot, signature(placed))
|
||||
return true
|
||||
}
|
||||
|
||||
/** Takes over what changed in Steam since the last look; returns true when a choice was added, replaced or dropped. */
|
||||
fun sync(context: Context, targets: List<Target>): Boolean = synchronized(lock) { reconcile(context, targets) }
|
||||
|
||||
private fun reconcile(context: Context, targets: List<Target>): Boolean {
|
||||
val grid = dir(context) ?: return false
|
||||
val ours = writtenByDroidDeck(grid)
|
||||
var changed = false
|
||||
for (target in targets) for (slot in SUFFIX.keys) {
|
||||
try {
|
||||
val own = AddedGameArt.chosen(target.artDir, slot)
|
||||
val present = files(grid, target.id, slot)
|
||||
val theirs = present.filter { it.name !in ours }.maxByOrNull { it.lastModified() }
|
||||
val now = signature(theirs)
|
||||
val before = recorded(target, slot)
|
||||
when {
|
||||
before == null && own != null && theirs == null -> write(context, target, slot, own)
|
||||
before == null || now != before -> {
|
||||
if (theirs != null) {
|
||||
if (own != null && own.length() == theirs.length() && own.readBytes().contentEquals(theirs.readBytes())) record(target, slot, now)
|
||||
else if (AddedGameArt.store(target.artDir, slot, AddedGameArt.Option(AddedGameArt.STEAM, theirs.path, theirs.path))) {
|
||||
target.setSource(slot, AddedGameArt.STEAM + "|steam-client")
|
||||
record(target, slot, now)
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
if (own != null && present.isEmpty()) { own.delete(); target.setSource(slot, ""); changed = true }
|
||||
record(target, slot, "")
|
||||
}
|
||||
}
|
||||
own != null && theirs != null && now == before && own.length() != theirs.length() -> write(context, target, slot, own)
|
||||
own != null && present.isEmpty() -> write(context, target, slot, own)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${target.id} ${slot.key}: ${e.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
if (changed) Log.i(TAG, "took over art changed in Steam")
|
||||
return changed
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,12 @@ object SteamGridDb {
|
||||
private const val API = "https://www.steamgriddb.com/api/v2"
|
||||
private const val QUERY = "nsfw=false&humor=false"
|
||||
|
||||
enum class Status { OK, NO_KEY, BAD_KEY, LIMIT, OFFLINE, SERVER, NO_GAME }
|
||||
|
||||
class Failed(val status: Status) : Exception(status.name)
|
||||
|
||||
class Game(val id: Int, val name: String, val verified: Boolean)
|
||||
|
||||
@VisibleForTesting
|
||||
@Volatile
|
||||
internal var keys: CredentialCipher.KeyProvider = CredentialCipher.Keystore
|
||||
@@ -30,6 +36,8 @@ object SteamGridDb {
|
||||
|
||||
fun key(context: Context): String = pick(userKey(context), builtInKey())
|
||||
|
||||
fun keyChangedAt(context: Context): Long = keyFile(context).lastModified()
|
||||
|
||||
@Synchronized
|
||||
fun userKey(context: Context): String {
|
||||
val f = keyFile(context)
|
||||
@@ -97,17 +105,56 @@ object SteamGridDb {
|
||||
return got
|
||||
}
|
||||
|
||||
fun images(key: String, id: Int, slot: AddedGameArt.Slot, limit: Int): List<Pair<String, String>> {
|
||||
val path = when (slot) {
|
||||
fun search(key: String, term: String): List<Game> {
|
||||
val q = term.trim()
|
||||
if (q.isEmpty()) return emptyList()
|
||||
val enc = URLEncoder.encode(q, "UTF-8").replace("+", "%20")
|
||||
val data = json(key, "$API/search/autocomplete/$enc")?.optJSONArray("data") ?: return emptyList()
|
||||
return games(data)
|
||||
}
|
||||
|
||||
internal fun games(data: JSONArray): List<Game> =
|
||||
(0 until data.length()).mapNotNull { data.optJSONObject(it) }
|
||||
.filter { it.optInt("id") > 0 && it.optString("name").isNotEmpty() }
|
||||
.map { Game(it.optInt("id"), it.optString("name"), it.optBoolean("verified")) }
|
||||
|
||||
internal fun path(id: Int, slot: AddedGameArt.Slot): String =
|
||||
when (slot) {
|
||||
AddedGameArt.Slot.COVER -> "grids/game/$id?dimensions=600x900&$QUERY"
|
||||
AddedGameArt.Slot.WIDE -> "grids/game/$id?dimensions=460x215,920x430&$QUERY"
|
||||
AddedGameArt.Slot.BACKGROUND -> "heroes/game/$id?$QUERY"
|
||||
AddedGameArt.Slot.LOGO -> "logos/game/$id?$QUERY"
|
||||
AddedGameArt.Slot.ICON -> "icons/game/$id?$QUERY"
|
||||
}
|
||||
val items = json(key, "$API/$path")?.optJSONArray("data") ?: return emptyList()
|
||||
return ranked(items, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON).take(limit)
|
||||
|
||||
fun page(key: String, id: Int, slot: AddedGameArt.Slot): List<Pair<String, String>> {
|
||||
val items = json(key, "$API/${path(id, slot)}")?.optJSONArray("data") ?: return emptyList()
|
||||
return ranked(items, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON)
|
||||
}
|
||||
|
||||
private const val PAGE_SIZE = 50
|
||||
private const val MAX_PAGES = 4
|
||||
|
||||
fun all(key: String, id: Int, slot: AddedGameArt.Slot): List<Pair<String, String>> {
|
||||
val raw = JSONArray()
|
||||
for (n in 0 until MAX_PAGES) {
|
||||
val url = "$API/${path(id, slot)}" + if (n > 0) "&page=$n" else ""
|
||||
val data: JSONArray? = try {
|
||||
json(key, url)?.optJSONArray("data")
|
||||
} catch (e: Failed) {
|
||||
if (n == 0) throw e
|
||||
null
|
||||
}
|
||||
val items = data ?: break
|
||||
for (i in 0 until items.length()) raw.put(items.get(i))
|
||||
if (items.length() < PAGE_SIZE) break
|
||||
}
|
||||
return ranked(raw, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON)
|
||||
}
|
||||
|
||||
fun images(key: String, id: Int, slot: AddedGameArt.Slot, limit: Int): List<Pair<String, String>> =
|
||||
page(key, id, slot).take(limit)
|
||||
|
||||
internal fun ranked(items: JSONArray, pngOnly: Boolean): List<Pair<String, String>> =
|
||||
(0 until items.length()).map { items.getJSONObject(it) }
|
||||
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
|
||||
@@ -119,6 +166,13 @@ object SteamGridDb {
|
||||
.filter { it.optString("url").startsWith("https://") && (!pngOnly || it.optString("mime") == "image/png" || it.optString("url").endsWith(".png", true)) }
|
||||
.maxByOrNull { it.optInt("score", 0) }?.optString("url")
|
||||
|
||||
internal fun failure(code: Int): Status? = when {
|
||||
code == 401 || code == 403 -> Status.BAD_KEY
|
||||
code == 429 -> Status.LIMIT
|
||||
code >= 500 -> Status.SERVER
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun json(key: String, url: String): JSONObject? {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000
|
||||
@@ -126,8 +180,19 @@ object SteamGridDb {
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-launcher")
|
||||
c.setRequestProperty("Authorization", "Bearer $key")
|
||||
return try {
|
||||
if (c.responseCode != 200) { Log.w(TAG, "${URL(url).path}: HTTP ${c.responseCode}"); null }
|
||||
else JSONObject(c.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8)).takeIf { it.optBoolean("success", true) }
|
||||
val code = c.responseCode
|
||||
if (code == 200) {
|
||||
JSONObject(c.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8)).takeIf { it.optBoolean("success", true) }
|
||||
} else {
|
||||
Log.w(TAG, "${URL(url).path}: HTTP $code")
|
||||
failure(code)?.let { throw Failed(it) }
|
||||
null
|
||||
}
|
||||
} catch (e: Failed) {
|
||||
throw e
|
||||
} catch (e: java.io.IOException) {
|
||||
Log.w(TAG, "${URL(url).path}: ${e.javaClass.simpleName}")
|
||||
throw Failed(Status.OFFLINE)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "${URL(url).path}: ${e.javaClass.simpleName}"); null
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
object AutoDriverTrial {
|
||||
private const val TAG = "AutoDriverTrial"
|
||||
private const val PREFS = "gpu_auto_trial"
|
||||
const val UNREADY_STARTS_BEFORE_ROLLBACK = 2
|
||||
|
||||
class Trial(
|
||||
val release: String, val name: String,
|
||||
val display: String, val linux: String,
|
||||
val previousDisplay: String, val previousLinux: String,
|
||||
val starts: Int,
|
||||
)
|
||||
|
||||
@Volatile private var app: Context? = null
|
||||
@Volatile private var watching: Trial? = null
|
||||
|
||||
fun release(pair: DriverPairs.DriverPair) = "${pair.key}@${pair.version}"
|
||||
|
||||
fun current(context: Context): Trial? {
|
||||
val p = prefs(context)
|
||||
val release = p.getString("release", null) ?: return null
|
||||
return Trial(
|
||||
release, p.getString("name", "").orEmpty(),
|
||||
p.getString("display", "").orEmpty(), p.getString("linux", "").orEmpty(),
|
||||
p.getString("previousDisplay", "").orEmpty(), p.getString("previousLinux", "").orEmpty(),
|
||||
p.getInt("starts", 0),
|
||||
)
|
||||
}
|
||||
|
||||
fun begin(context: Context, release: String, name: String, display: String, linux: String, previousDisplay: String, previousLinux: String) {
|
||||
prefs(context).edit()
|
||||
.putString("release", release).putString("name", name)
|
||||
.putString("display", display).putString("linux", linux)
|
||||
.putString("previousDisplay", previousDisplay).putString("previousLinux", previousLinux)
|
||||
.putInt("starts", 0)
|
||||
.commit()
|
||||
}
|
||||
|
||||
fun clear(context: Context) {
|
||||
val bad = bad(context)
|
||||
prefs(context).edit().clear().putStringSet("bad", bad).commit()
|
||||
}
|
||||
|
||||
fun bad(context: Context): Set<String> = prefs(context).getStringSet("bad", emptySet()).orEmpty()
|
||||
|
||||
fun sessionStarting(context: Context, display: String, linux: String) {
|
||||
val trial = current(context)
|
||||
watching = null
|
||||
if (trial == null || trial.display != display || trial.linux != linux) return
|
||||
app = context.applicationContext
|
||||
watching = trial
|
||||
prefs(context).edit().putInt("starts", trial.starts + 1).commit()
|
||||
}
|
||||
|
||||
fun sessionReady() {
|
||||
val trial = watching ?: return
|
||||
val context = app ?: return
|
||||
watching = null
|
||||
if (current(context)?.release != trial.release) return
|
||||
Log.i(TAG, "${trial.release} reached a ready session; keeping it")
|
||||
clear(context)
|
||||
prune(context, setOf(trial.display, trial.linux))
|
||||
}
|
||||
|
||||
fun rollbackDue(context: Context): Trial? = current(context)?.takeIf { it.starts >= UNREADY_STARTS_BEFORE_ROLLBACK }
|
||||
|
||||
fun rollBack(context: Context, trial: Trial) {
|
||||
Log.w(TAG, "${trial.release} never reached a ready session in ${trial.starts} starts; restoring the previous drivers")
|
||||
if (SessionPrefs.androidDriver(context) == trial.display) SessionPrefs.setAndroidDriver(context, trial.previousDisplay)
|
||||
if (SessionPrefs.linuxDriver(context) == trial.linux) SessionPrefs.setLinuxDriver(context, trial.previousLinux)
|
||||
val bad = bad(context) + trial.release
|
||||
prefs(context).edit().clear().putStringSet("bad", bad).commit()
|
||||
prune(context, setOf(trial.previousDisplay, trial.previousLinux, SessionPrefs.androidDriver(context), SessionPrefs.linuxDriver(context)))
|
||||
}
|
||||
|
||||
fun prune(context: Context, keep: Set<String>) {
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
val td = TurnipDriver(context)
|
||||
val owned = SessionPrefs.gpuAutoInstalled(context)
|
||||
for (id in owned - keep) {
|
||||
val bundle = DriverBundle.containing(context, id, lm.isInstalled(id))
|
||||
when {
|
||||
bundle != null -> {
|
||||
if (bundle.androidId in keep || bundle.linuxId in keep) continue
|
||||
DriverBundle.remove(context, bundle); TurnipReleases.forget(context, bundle.id)
|
||||
}
|
||||
lm.isInstalled(id) -> lm.removeDriver(id)
|
||||
else -> td.remove(id)
|
||||
}
|
||||
TurnipReleases.forget(context, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(context, owned.intersect(keep))
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ data class GpuInfo(
|
||||
)
|
||||
}
|
||||
|
||||
private fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
internal fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
|
||||
private val PLATFORMS = mapOf(
|
||||
"msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.util.Log
|
||||
|
||||
object TpUbwcHint {
|
||||
const val ENV = "FD_DEV_FEATURES"
|
||||
private const val TAG = "TpUbwcHint"
|
||||
|
||||
@Volatile private var resolved = false
|
||||
@Volatile private var cached: String? = null
|
||||
|
||||
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
|
||||
// match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
|
||||
internal fun features(model: Int, systemDriverVersion: String?): String? {
|
||||
if (model != 740) return null
|
||||
val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
|
||||
return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun forThisDevice(): String? {
|
||||
if (resolved) return cached
|
||||
val model = GpuInfo.detect().model
|
||||
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
|
||||
cached = features(model, version)
|
||||
resolved = true
|
||||
Log.i(TAG, "Adreno $model, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
|
||||
return cached
|
||||
}
|
||||
|
||||
fun applyToProcess() {
|
||||
val value = forThisDevice() ?: return
|
||||
runCatching { android.system.Os.setenv(ENV, value, true) }
|
||||
.onFailure { Log.w(TAG, "setenv $ENV failed: ${it.javaClass.simpleName}") }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.util.Log;
|
||||
import com.droiddeck.launcher.R;
|
||||
import com.droiddeck.launcher.core.FileUtils;
|
||||
import com.droiddeck.launcher.core.TarZst;
|
||||
import com.droiddeck.launcher.session.QuestExperimental;
|
||||
import com.droiddeck.launcher.session.SessionPrefs;
|
||||
|
||||
import org.json.JSONObject;
|
||||
@@ -281,6 +282,8 @@ public final class TurnipDriver {
|
||||
if (forced.startsWith("a7")) return DRIVER_A7XX;
|
||||
if (forced.startsWith("a6")) return DRIVER_A6XX;
|
||||
}
|
||||
String quest = QuestExperimental.INSTANCE.defaultAndroidDriver();
|
||||
if (quest != null) return quest;
|
||||
String model = gpuModel();
|
||||
Log.i(TAG, "gpu model: " + (model == null ? "unknown" : model));
|
||||
if (model != null) {
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.R
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
object TurnipReleases {
|
||||
private const val TAG = "TurnipReleases"
|
||||
private const val PREFS = "turnip_releases"
|
||||
private const val KEY_RELEASE = "latest"
|
||||
private const val KEY_DOWNLOADS = "downloads"
|
||||
@@ -104,7 +107,24 @@ object TurnipReleases {
|
||||
}
|
||||
|
||||
private fun get(context: Context, url: String): String {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
val sources = DownloadSource.candidates(url)
|
||||
var last: IOException? = null
|
||||
for ((index, source) in sources.withIndex()) {
|
||||
try {
|
||||
val body = get(context, source, url)
|
||||
DownloadSource.succeeded(source)
|
||||
return body
|
||||
} catch (e: IOException) {
|
||||
last = e
|
||||
if (index < sources.lastIndex) Log.i(TAG, "retrying $url via $source")
|
||||
}
|
||||
}
|
||||
DownloadSource.failed()
|
||||
throw last ?: IOException(context.getString(R.string.turnip_github_http, 0))
|
||||
}
|
||||
|
||||
private fun get(context: Context, source: String, url: String): String {
|
||||
val c = URL(source).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
c.readTimeout = 20_000
|
||||
c.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
@@ -160,39 +180,13 @@ object TurnipReleases {
|
||||
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
|
||||
if (asset.sha256.isEmpty()) throw IOException(context.getString(R.string.turnip_list_predates_checksums))
|
||||
val target = File(context.cacheDir, asset.name)
|
||||
val c = URL(asset.url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
c.readTimeout = 60_000
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-app")
|
||||
c.instanceFollowRedirects = true
|
||||
try {
|
||||
if (c.responseCode != 200) throw IOException(context.getString(R.string.turnip_download_http, c.responseCode))
|
||||
val total = c.contentLengthLong.takeIf { it > 0 } ?: asset.size
|
||||
c.inputStream.use { input ->
|
||||
FileOutputStream(target).use { out ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
var done = 0L
|
||||
var last = -1
|
||||
while (true) {
|
||||
val r = input.read(buf)
|
||||
if (r <= 0) break
|
||||
out.write(buf, 0, r)
|
||||
done += r
|
||||
val pct = if (total > 0) (done * 100 / total).toInt() else 0
|
||||
if (pct != last) { last = pct; progress(pct) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
|
||||
throw IOException(context.getString(R.string.turnip_checksum_mismatch))
|
||||
}
|
||||
return target
|
||||
} catch (e: Exception) {
|
||||
val ok = Downloader.downloadFile(asset.url, target, true, asset.sha256) { f -> progress(if (f < 0) -1 else (f * 100).toInt()) }
|
||||
if (!ok) throw IOException(context.getString(R.string.turnip_download_failed))
|
||||
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
|
||||
FileUtils.delete(target)
|
||||
throw e
|
||||
} finally {
|
||||
c.disconnect()
|
||||
throw IOException(context.getString(R.string.turnip_checksum_mismatch))
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private fun downloads(context: Context): JSONObject =
|
||||
|
||||
@@ -79,6 +79,10 @@ public class FakeInputWriter {
|
||||
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40;
|
||||
private static final int PAD_TYPE_OFFSET = IMU_OFFSET + 44;
|
||||
private static final int PAD_TYPE_SET = 0x80000000;
|
||||
private static final int LATENCY_OFFSET = IMU_OFFSET + 48;
|
||||
public static final int LATENCY_CONTROLLER = 1;
|
||||
public static final int LATENCY_TOUCH = 2;
|
||||
private static volatile int latency = 0;
|
||||
public static final int PAD_TYPE_DECK = 0;
|
||||
public static final int PAD_TYPE_STEAM = 1;
|
||||
public static final int PAD_TYPE_XBOX = 2;
|
||||
@@ -214,6 +218,7 @@ public class FakeInputWriter {
|
||||
data.putInt(DECK_CONTROLS_OFFSET, 0);
|
||||
int type = padType;
|
||||
data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0);
|
||||
data.putInt(LATENCY_OFFSET, latency);
|
||||
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
|
||||
data.putInt(TRITON_CONNECTED_OFFSET, tritonConnected ? 1 : 0);
|
||||
data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L);
|
||||
@@ -514,6 +519,22 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
public static void setLowLatency(boolean controller, boolean touch) {
|
||||
int flags = (controller ? LATENCY_CONTROLLER : 0) | (touch ? LATENCY_TOUCH : 0);
|
||||
latency = flags;
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = RING_SLOTS[0];
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
nativeStoreFence();
|
||||
ring.putInt(LATENCY_OFFSET, flags);
|
||||
}
|
||||
}
|
||||
|
||||
public static void setTritonConnected(int slot, boolean connected) {
|
||||
tritonConnected = connected;
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
|
||||
@@ -19,6 +19,7 @@ public final class PadBridge {
|
||||
private static final float DEAD_ZONE = 0.12f;
|
||||
private static final int[] JOYSTICK_AXES = {
|
||||
MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ,
|
||||
MotionEvent.AXIS_RX, MotionEvent.AXIS_RY,
|
||||
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER,
|
||||
MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS
|
||||
};
|
||||
@@ -54,7 +55,8 @@ public final class PadBridge {
|
||||
private boolean systemGuidePressed;
|
||||
private boolean systemQamPressed;
|
||||
private boolean qamChordActive;
|
||||
private boolean qamSyntheticAPressed;
|
||||
private int chordButton = -1;
|
||||
private boolean chordButtonPressed;
|
||||
private boolean qamTapPressed;
|
||||
private int qamChordGeneration;
|
||||
private volatile Runnable onPlayerInput;
|
||||
@@ -100,6 +102,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
releaseBackPaddles();
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
@@ -113,7 +116,7 @@ public final class PadBridge {
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
qamChordActive = false;
|
||||
qamSyntheticAPressed = false;
|
||||
chordButtonPressed = false;
|
||||
qamTapPressed = false;
|
||||
qamChordGeneration++;
|
||||
if (open) {
|
||||
@@ -243,6 +246,12 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: pad.press(9, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_MODE:
|
||||
case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Z:
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.L4, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_C:
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.R4, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
|
||||
@@ -269,12 +278,27 @@ public final class PadBridge {
|
||||
scheduleStats();
|
||||
pad.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
pad.leftY = axis(event, MotionEvent.AXIS_Y);
|
||||
pad.rightX = axis(event, MotionEvent.AXIS_Z);
|
||||
pad.rightY = axis(event, MotionEvent.AXIS_RZ);
|
||||
float lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
|
||||
float rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
|
||||
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
|
||||
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
|
||||
InputDevice device = event.getDevice();
|
||||
boolean wiredXboxLayout = device != null
|
||||
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|
||||
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null;
|
||||
pad.rightX = axis(event, wiredXboxLayout ? MotionEvent.AXIS_RX : MotionEvent.AXIS_Z);
|
||||
pad.rightY = axis(event, wiredXboxLayout ? MotionEvent.AXIS_RY : MotionEvent.AXIS_RZ);
|
||||
float lt;
|
||||
float rt;
|
||||
if (wiredXboxLayout) {
|
||||
lt = Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_Z) + 1f) / 2f));
|
||||
rt = Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_RZ) + 1f) / 2f));
|
||||
} else {
|
||||
lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
|
||||
rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
|
||||
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
|
||||
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
|
||||
}
|
||||
pad.leftTrigger = lt;
|
||||
pad.rightTrigger = rt;
|
||||
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
|
||||
@@ -317,11 +341,12 @@ public final class PadBridge {
|
||||
boolean qamStarted = qamPressed && !systemQamPressed;
|
||||
systemGuidePressed = guidePressed;
|
||||
systemQamPressed = qamPressed;
|
||||
if (qamStarted && !nativeQam()) startQamChord();
|
||||
if (qamStarted && !nativeQam()) startChord(PadState.A);
|
||||
else publish();
|
||||
}
|
||||
|
||||
public synchronized void releaseAll() {
|
||||
releaseBackPaddles();
|
||||
state.clear();
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
@@ -343,7 +368,7 @@ public final class PadBridge {
|
||||
|
||||
public synchronized void triggerQam() {
|
||||
if (!nativeQam()) {
|
||||
startQamChord();
|
||||
startChord(PadState.A);
|
||||
return;
|
||||
}
|
||||
if (qamTapPressed) return;
|
||||
@@ -359,12 +384,17 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
private void startQamChord() {
|
||||
public synchronized void triggerSteamKeyboard() {
|
||||
startChord(PadState.X);
|
||||
}
|
||||
|
||||
private void startChord(int button) {
|
||||
if (qamChordActive) return;
|
||||
qamChordActive = true;
|
||||
chordButton = button;
|
||||
int generation = ++qamChordGeneration;
|
||||
publish();
|
||||
chordHandler.postDelayed(() -> pressQamA(generation), qamGuideLeadMs());
|
||||
chordHandler.postDelayed(() -> pressChordButton(generation), qamGuideLeadMs());
|
||||
}
|
||||
|
||||
static long qamGuideLeadMs() {
|
||||
@@ -373,16 +403,16 @@ public final class PadBridge {
|
||||
return Math.max(QAM_GUIDE_LEAD_MIN_MS, Math.min(QAM_GUIDE_LEAD_MAX_MS, frame * QAM_GUIDE_LEAD_FRAMES));
|
||||
}
|
||||
|
||||
private synchronized void pressQamA(int generation) {
|
||||
private synchronized void pressChordButton(int generation) {
|
||||
if (generation != qamChordGeneration || !qamChordActive) return;
|
||||
qamSyntheticAPressed = true;
|
||||
chordButtonPressed = true;
|
||||
publish();
|
||||
chordHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
|
||||
chordHandler.postDelayed(() -> releaseChordButton(generation), QAM_A_HOLD_MS);
|
||||
}
|
||||
|
||||
private synchronized void releaseQamA(int generation) {
|
||||
private synchronized void releaseChordButton(int generation) {
|
||||
if (generation != qamChordGeneration || !qamChordActive) return;
|
||||
qamSyntheticAPressed = false;
|
||||
chordButtonPressed = false;
|
||||
publish();
|
||||
chordHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
|
||||
}
|
||||
@@ -415,11 +445,21 @@ public final class PadBridge {
|
||||
return slot == 0 ? state : playerStates[slot];
|
||||
}
|
||||
|
||||
private boolean isPrimary(KeyEvent event) {
|
||||
return owners[0] == event.getDevice().getId();
|
||||
}
|
||||
|
||||
private void releaseBackPaddles() {
|
||||
DeckControls.INSTANCE.setGrip(DeckControls.L4, false);
|
||||
DeckControls.INSTANCE.setGrip(DeckControls.R4, false);
|
||||
}
|
||||
|
||||
private void free(int slot) {
|
||||
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
slotControllers.set(slot, NO_CONTROLLER);
|
||||
if (slot == 0) {
|
||||
releaseBackPaddles();
|
||||
state.clear();
|
||||
publish();
|
||||
return;
|
||||
@@ -491,7 +531,7 @@ public final class PadBridge {
|
||||
effectiveState.copyFrom(state);
|
||||
if (guide) effectiveState.press(PadState.GUIDE, true);
|
||||
if (qam) effectiveState.press(PadState.QAM, true);
|
||||
if (qamSyntheticAPressed) effectiveState.press(PadState.A, true);
|
||||
if (chordButtonPressed) effectiveState.press(chordButton, true);
|
||||
writer.writePad(effectiveState);
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,8 @@ object SteamTouchConfig {
|
||||
|
||||
fun load(context: Context, appId: Int): Config {
|
||||
val dir = configDir(context)
|
||||
val file = dir?.let { autosaveConfig(it, appId) }
|
||||
val file = dir?.let { deviceConfig(context, it, appId) }
|
||||
?: dir?.let { autosaveConfig(it, appId) }
|
||||
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
|
||||
?: dir?.let { configsetConfig(context, it, appId) }
|
||||
if (file == null) {
|
||||
@@ -207,11 +208,16 @@ object SteamTouchConfig {
|
||||
null
|
||||
}
|
||||
|
||||
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
|
||||
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId)
|
||||
|
||||
private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId)
|
||||
|
||||
private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString())
|
||||
when {
|
||||
set == null -> null
|
||||
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!)
|
||||
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
|
||||
else -> null
|
||||
}?.takeIf { it.isFile }
|
||||
@@ -219,7 +225,16 @@ object SteamTouchConfig {
|
||||
null
|
||||
}
|
||||
|
||||
internal fun workshopConfig(context: Context, id: String): File? {
|
||||
if (!id.all(Char::isDigit)) return null
|
||||
return File(steamRoot(context), "steamapps/workshop/content/$CONFIGS_APP/$id").listFiles()
|
||||
?.filter { it.isFile && (it.name.endsWith(".vdf") || it.name.endsWith(".bin")) }
|
||||
?.maxByOrNull { it.lastModified() }
|
||||
}
|
||||
|
||||
private const val CONFIGSET = "configset_controller_mobile_touch.vdf"
|
||||
private const val DEVICE_CONFIGSET = "configset_MT-DROIDDECK0001.vdf"
|
||||
private const val CONFIGS_APP = 241100
|
||||
|
||||
|
||||
private fun availability(mappings: KeyValues.Node): Map<Int, Set<Int>> {
|
||||
@@ -363,10 +378,20 @@ object SteamTouchConfig {
|
||||
tmp.writeText(text)
|
||||
if (!tmp.renameTo(target)) return null
|
||||
selectAutosave(dir, appId)
|
||||
dropDeviceEntry(dir, appId)
|
||||
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
|
||||
return target
|
||||
}
|
||||
|
||||
private fun dropDeviceEntry(dir: File, appId: Int) {
|
||||
val file = File(dir, DEVICE_CONFIGSET)
|
||||
if (!file.isFile) return
|
||||
val text = file.readText()
|
||||
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
|
||||
if (!block.containsMatchIn(text)) return
|
||||
File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file)
|
||||
}
|
||||
|
||||
private fun selectAutosave(dir: File, appId: Int) {
|
||||
val file = File(dir, CONFIGSET)
|
||||
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.view.MotionEvent
|
||||
import kotlin.math.hypot
|
||||
|
||||
class ThreeFingerTap(private val slopPx: Float) {
|
||||
var active = false
|
||||
private set
|
||||
|
||||
private val starts = HashMap<Int, Pair<Float, Float>>()
|
||||
private var downAt = 0L
|
||||
private var threeAt = 0L
|
||||
private var valid = false
|
||||
|
||||
fun onTouch(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
starts.clear()
|
||||
active = false
|
||||
valid = true
|
||||
downAt = event.eventTime
|
||||
note(event, event.actionIndex)
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
note(event, event.actionIndex)
|
||||
if (event.pointerCount > 3) valid = false
|
||||
if (event.pointerCount == 3 && !active) {
|
||||
active = true
|
||||
threeAt = event.eventTime
|
||||
if (threeAt - downAt > LAND_MS) valid = false
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> for (i in 0 until event.pointerCount) {
|
||||
val start = starts[event.getPointerId(i)] ?: continue
|
||||
if (hypot(event.getX(i) - start.first, event.getY(i) - start.second) > slopPx) valid = false
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
val fired = active && valid && event.eventTime - threeAt < TAP_MS
|
||||
active = false
|
||||
return fired
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> active = false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun note(event: MotionEvent, index: Int) {
|
||||
starts[event.getPointerId(index)] = event.getX(index) to event.getY(index)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LAND_MS = 250L
|
||||
const val TAP_MS = 350L
|
||||
}
|
||||
}
|
||||
@@ -108,11 +108,11 @@ object DeckyManager {
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, "PluginLoader.download")
|
||||
val downloading = context.getString(R.string.user_apps_downloading, release.tag)
|
||||
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
|
||||
val ok = Downloader.downloadFile(release.url, temp, true, release.digest?.substringAfter(':')) { f -> progress(downloading, if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
|
||||
if (!ok) { temp.delete(); return context.getString(R.string.deckymgr_download_failed) }
|
||||
if (temp.length() != release.size) { temp.delete(); return context.getString(R.string.deckymgr_size_mismatch) }
|
||||
val expected = release.digest?.substringAfter(':') ?: release.shaUrl?.let { checksumUrl ->
|
||||
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
|
||||
val checksumBody = Downloader.downloadDirect(checksumUrl) ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_download_failed) }
|
||||
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
|
||||
} ?: run { temp.delete(); return context.getString(R.string.deckymgr_checksum_missing) }
|
||||
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return context.getString(R.string.deckymgr_checksum_invalid) }
|
||||
@@ -236,7 +236,7 @@ object DeckyManager {
|
||||
downloadingBinary = true
|
||||
progress(downloading, -1)
|
||||
val downloaded = try {
|
||||
Downloader.downloadFile(url, temp, false) { fraction ->
|
||||
Downloader.downloadFile(url, temp, true, hash) { fraction ->
|
||||
progress(downloading, if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -90,7 +90,7 @@ object DesktopCatalog {
|
||||
try {
|
||||
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
|
||||
val ok = Downloader.downloadFile(entry.url, download, true) { f ->
|
||||
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
|
||||
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
|
||||
}
|
||||
if (!ok) return context.getString(R.string.user_apps_download_failed)
|
||||
|
||||
@@ -176,6 +176,7 @@ object FlatpakManager {
|
||||
return id to problem
|
||||
}
|
||||
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
|
||||
fun prune(context: Context, onProgress: (String, Int) -> Unit) = transaction(context, "prune", null, onProgress)
|
||||
|
||||
fun updates(context: Context): Set<String>? {
|
||||
if (!ready(context)) return emptySet()
|
||||
|
||||
@@ -230,16 +230,38 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The download is named after the release, so a partial file is only ever resumed for the same
|
||||
* version and checksum, never for a different runtime that happens to share a fixed name.
|
||||
*/
|
||||
static String archiveName(Release release) {
|
||||
String version = release.version.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||
String sum = release.sha256.replaceAll("[^A-Za-z0-9]", "");
|
||||
if (sum.length() > 12) sum = sum.substring(0, 12);
|
||||
return "linuxfs-" + version + "-" + sum + ".tar.zst";
|
||||
}
|
||||
|
||||
/** Removes archives left by other releases and by builds that used the fixed name. */
|
||||
private static void deleteOtherArchives(File dir, String keep) {
|
||||
File[] files = dir.listFiles((d, name) -> name.startsWith("linuxfs") && name.endsWith(".tar.zst") && !name.equals(keep));
|
||||
if (files == null) return;
|
||||
for (File file : files) file.delete();
|
||||
}
|
||||
|
||||
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
|
||||
File archive = new File(context.getCacheDir(), "linuxfs.tar.zst");
|
||||
File archive = new File(context.getCacheDir(), archiveName(release));
|
||||
// A failed download keeps what it fetched so the next attempt resumes it; anything that
|
||||
// reached verification and failed is not trusted and is removed.
|
||||
boolean keepPartial = false;
|
||||
try {
|
||||
deleteOtherArchives(context.getCacheDir(), archive.getName());
|
||||
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
|
||||
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
|
||||
if (!discard(pendingRemoval, null)) throw new IOException("Cannot clear the previous runtime: " + pendingRemoval);
|
||||
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
boolean ok = Downloader.downloadFile(release.url, archive, true, (fraction) -> {
|
||||
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
|
||||
if (listener != null) {
|
||||
listener.onProgress(Step.DOWNLOADING, downloading,
|
||||
fraction < 0 ? -1 : Math.round(fraction * 100f));
|
||||
@@ -247,6 +269,7 @@ public final class LinuxRuntimeInstaller {
|
||||
});
|
||||
if (!ok) {
|
||||
Log.w(TAG, "download failed");
|
||||
keepPartial = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -300,7 +323,7 @@ public final class LinuxRuntimeInstaller {
|
||||
Log.e(TAG, "install", e);
|
||||
return false;
|
||||
} finally {
|
||||
archive.delete();
|
||||
if (!keepPartial) archive.delete();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,9 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
val tempDeci = sticky?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
|
||||
val tech = sticky?.getStringExtra(BatteryManager.EXTRA_TECHNOLOGY)?.takeIf { it.isNotBlank() } ?: "Li-ion"
|
||||
val chargeNowUah = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER)?.takeIf { it > 0 && it != Long.MIN_VALUE } ?: 0L
|
||||
val currentNowUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)?.takeIf { it != Long.MIN_VALUE } ?: 0L
|
||||
val currentAvgUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)?.takeIf { it != Long.MIN_VALUE && it != 0L } ?: currentNowUa
|
||||
val currentNowUa = toMicroamps(bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)?.takeIf { it != Long.MIN_VALUE } ?: 0L)
|
||||
val currentAvgUa = toMicroamps(bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)?.takeIf { it != Long.MIN_VALUE && it != 0L } ?: 0L)
|
||||
.takeIf { it != 0L } ?: currentNowUa
|
||||
val discharging = status == BatteryManager.BATTERY_STATUS_DISCHARGING || status == BatteryManager.BATTERY_STATUS_NOT_CHARGING
|
||||
val chargeFullUah = if (chargeNowUah > 0 && pct > 0) chargeNowUah * 100 / pct else 0L
|
||||
val energyNowUwh = chargeNowUah * voltageUv / 1_000_000L
|
||||
@@ -125,6 +126,12 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
// CURRENT_NOW/CURRENT_AVERAGE are documented as µA, but Samsung and other vendors report mA,
|
||||
// which made every lifetime estimate 1000x too long. No real µA reading under load is below 10 mA,
|
||||
// and no mA reading is above 10 A, so anything smaller than 10_000 is taken as mA.
|
||||
private const val MILLIAMP_CEILING = 10_000L
|
||||
|
||||
internal fun toMicroamps(raw: Long): Long = if (abs(raw) < MILLIAMP_CEILING) raw * 1000L else raw
|
||||
private const val PERIOD_MS = 5_000L
|
||||
private val VPOWER_FILES = listOf(
|
||||
"ac_status", "battery_percent", "battery_status",
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object BootMovie {
|
||||
private const val TAG = "BootMovie"
|
||||
const val DROIDDECK_30 = "dd30"
|
||||
const val DROIDDECK_60 = "dd60"
|
||||
const val CUSTOM = "custom"
|
||||
const val MAX_BYTES = 50L shl 20
|
||||
const val MAX_CONVERT_SECONDS = 60
|
||||
val PICKER_EXTENSIONS = listOf("webm", "mkv", "mp4", "mov", "m4v", "3gp")
|
||||
|
||||
private val ASSETS = mapOf(
|
||||
DROIDDECK_30 to "droiddeck-startup.webm",
|
||||
DROIDDECK_60 to "droiddeck-startup-60.webm",
|
||||
)
|
||||
|
||||
// Steam's CEF player only takes WebM codecs; MediaMuxer's WebM writer cannot take AV1.
|
||||
internal val VIDEO_CODECS = setOf(MediaFormat.MIMETYPE_VIDEO_VP8, MediaFormat.MIMETYPE_VIDEO_VP9, MediaFormat.MIMETYPE_VIDEO_AV1)
|
||||
private val REMUX_CODECS = setOf(MediaFormat.MIMETYPE_VIDEO_VP8, MediaFormat.MIMETYPE_VIDEO_VP9)
|
||||
internal val AUDIO_CODECS = setOf(MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_VORBIS)
|
||||
|
||||
sealed class Import {
|
||||
data class Done(val name: String, val audioDropped: Boolean) : Import()
|
||||
object TooLong : Import()
|
||||
data class Unsupported(val codec: String?) : Import()
|
||||
object Failed : Import()
|
||||
}
|
||||
|
||||
private fun dir(context: Context) = File(context.filesDir, "boot-movie")
|
||||
private fun customFile(context: Context) = File(dir(context), "custom.webm")
|
||||
|
||||
fun customName(context: Context): String? =
|
||||
if (customFile(context).isFile) SessionPrefs.bootMovieCustomName(context) ?: "custom.webm" else null
|
||||
|
||||
fun choice(context: Context): String {
|
||||
val chosen = SessionPrefs.bootMovie(context)
|
||||
return when {
|
||||
chosen == CUSTOM && customFile(context).isFile -> CUSTOM
|
||||
chosen in ASSETS -> chosen
|
||||
else -> DROIDDECK_30
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(context: Context): File? {
|
||||
val choice = choice(context)
|
||||
val source = if (choice == CUSTOM) customFile(context) else extractAsset(context, ASSETS.getValue(choice)) ?: return null
|
||||
if (SessionPrefs.bootMovieSound(context)) return source
|
||||
val muted = File(source.parentFile, source.nameWithoutExtension + "-muted.webm")
|
||||
if (muted.isFile && muted.lastModified() >= source.lastModified()) return muted
|
||||
if (remux(source, muted, audio = false)) return muted
|
||||
if (BootMovieTranscoder.transcode(source, muted, keepAudio = false) {} != null) return muted
|
||||
Log.e(TAG, "could not strip audio from ${source.name}; not staging it")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun extractAsset(context: Context, name: String): File? {
|
||||
val target = File(dir(context), name)
|
||||
val updated = runCatching { context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime }.getOrDefault(Long.MAX_VALUE)
|
||||
if (target.isFile && target.lastModified() >= updated) return target
|
||||
val staged = File(target.parentFile, "$name.staged")
|
||||
return try {
|
||||
target.parentFile?.mkdirs()
|
||||
context.assets.open("steam-startup/$name").use { input ->
|
||||
staged.outputStream().use { output -> FileUtils.copy(input, output) }
|
||||
}
|
||||
if (staged.renameTo(target)) target else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not extract $name", e)
|
||||
null
|
||||
} finally {
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun import(context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}): Import {
|
||||
val (format, audio) = probe(file) ?: return Import.Unsupported(null)
|
||||
val video = format.getString(MediaFormat.KEY_MIME)
|
||||
val audioPlayable = audio == null || audio in AUDIO_CODECS
|
||||
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES
|
||||
val copy = playable && audioPlayable && isWebm(file)
|
||||
val remux = !copy && playable && video in REMUX_CODECS
|
||||
if (!copy && !remux) {
|
||||
if (!BootMovieTranscoder.canDecode(format)) return Import.Unsupported(video)
|
||||
val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L
|
||||
if (durationUs > MAX_CONVERT_SECONDS * 1_000_000L) return Import.TooLong
|
||||
onStart()
|
||||
}
|
||||
val target = customFile(context)
|
||||
val staged = File(dir(context), "custom.webm.staged")
|
||||
var audioDropped = remux && !audioPlayable
|
||||
return try {
|
||||
target.parentFile?.mkdirs()
|
||||
val ok = when {
|
||||
copy -> {
|
||||
file.inputStream().use { input -> staged.outputStream().use { output -> FileUtils.copy(input, output) } }
|
||||
true
|
||||
}
|
||||
remux -> remux(file, staged, audio = true)
|
||||
else -> BootMovieTranscoder.transcode(file, staged, onProgress = onProgress)?.also { audioDropped = it.audioDropped } != null
|
||||
}
|
||||
if (!ok || !staged.renameTo(target)) return Import.Failed
|
||||
File(target.parentFile, "custom-muted.webm").delete()
|
||||
SessionPrefs.setBootMovieCustomName(context, file.name)
|
||||
SessionPrefs.setBootMovie(context, CUSTOM)
|
||||
Import.Done(file.name, audioDropped)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not import ${file.name}", e)
|
||||
Import.Failed
|
||||
} finally {
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCustom(context: Context) {
|
||||
customFile(context).delete()
|
||||
File(dir(context), "custom-muted.webm").delete()
|
||||
SessionPrefs.setBootMovieCustomName(context, null)
|
||||
if (SessionPrefs.bootMovie(context) == CUSTOM) SessionPrefs.setBootMovie(context, DROIDDECK_30)
|
||||
}
|
||||
|
||||
private fun isWebm(file: File): Boolean {
|
||||
if (!file.name.endsWith(".webm", ignoreCase = true)) return false
|
||||
val magic = ByteArray(4)
|
||||
val read = runCatching { file.inputStream().use { it.read(magic) } }.getOrDefault(0)
|
||||
return read == 4 && magic.contentEquals(byteArrayOf(0x1A, 0x45, 0xDF.toByte(), 0xA3.toByte()))
|
||||
}
|
||||
|
||||
private fun probe(file: File): Pair<MediaFormat, String?>? {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(file.path)
|
||||
var video: MediaFormat? = null
|
||||
var audio: String? = null
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
||||
if (video == null && mime.startsWith("video/")) video = format
|
||||
if (audio == null && mime.startsWith("audio/")) audio = mime
|
||||
}
|
||||
video?.let { it to audio }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not read ${file.name}", e)
|
||||
null
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun remux(source: File, target: File, audio: Boolean): Boolean {
|
||||
val staged = File(target.parentFile, target.name + ".remux")
|
||||
val extractor = MediaExtractor()
|
||||
var muxer: MediaMuxer? = null
|
||||
return try {
|
||||
extractor.setDataSource(source.path)
|
||||
val output = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it }
|
||||
val tracks = HashMap<Int, Int>()
|
||||
var hasVideo = false
|
||||
var hasAudio = false
|
||||
var bufferSize = 8 shl 20
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
||||
val keep = mime in VIDEO_CODECS && !hasVideo || audio && mime in AUDIO_CODECS && !hasAudio
|
||||
if (!keep) continue
|
||||
if (mime in VIDEO_CODECS) hasVideo = true else hasAudio = true
|
||||
extractor.selectTrack(i)
|
||||
tracks[i] = output.addTrack(format)
|
||||
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) bufferSize = maxOf(bufferSize, format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
|
||||
}
|
||||
if (!hasVideo) return false
|
||||
output.start()
|
||||
val buffer = ByteBuffer.allocate(bufferSize)
|
||||
val info = MediaCodec.BufferInfo()
|
||||
while (true) {
|
||||
val size = extractor.readSampleData(buffer, 0)
|
||||
if (size < 0) break
|
||||
val track = tracks[extractor.sampleTrackIndex]
|
||||
if (track != null) {
|
||||
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
|
||||
info.set(0, size, extractor.sampleTime, if (key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0)
|
||||
output.writeSampleData(track, buffer, info)
|
||||
}
|
||||
extractor.advance()
|
||||
}
|
||||
output.stop()
|
||||
output.release()
|
||||
muxer = null
|
||||
staged.renameTo(target)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not remux ${source.name}", e)
|
||||
false
|
||||
} finally {
|
||||
runCatching { muxer?.release() }
|
||||
extractor.release()
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLExt
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
internal object BootMovieTranscoder {
|
||||
private const val TAG = "BootMovieTranscoder"
|
||||
private const val TIMEOUT_US = 10_000L
|
||||
private const val MAX_FPS = 60
|
||||
private const val OPUS_RATE = 48_000
|
||||
|
||||
class Result(val audioDropped: Boolean)
|
||||
|
||||
private class Sample(val data: ByteArray, val timeUs: Long)
|
||||
private class Audio(val format: MediaFormat, val samples: List<Sample>)
|
||||
|
||||
fun canDecode(format: MediaFormat): Boolean = runCatching {
|
||||
MediaCodecList(MediaCodecList.REGULAR_CODECS).findDecoderForFormat(format) != null
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun transcode(source: File, target: File, keepAudio: Boolean = true, onProgress: (Int) -> Unit): Result? {
|
||||
val hadAudio = keepAudio && trackFormat(source, "audio/") != null
|
||||
val audio = if (hadAudio) runCatching { encodeAudio(source) }
|
||||
.onFailure { Log.w(TAG, "could not convert the audio of ${source.name}", it) }.getOrNull() else null
|
||||
val muxedAudio = runCatching { encodeVideo(source, target, audio, onProgress) }
|
||||
.onFailure { Log.w(TAG, "could not convert ${source.name}", it) }.getOrNull() ?: return null
|
||||
return Result(audioDropped = hadAudio && !muxedAudio)
|
||||
}
|
||||
|
||||
private fun trackFormat(source: File, prefix: String): Pair<Int, MediaFormat>? {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(source.path)
|
||||
(0 until extractor.trackCount).map { it to extractor.getTrackFormat(it) }
|
||||
.firstOrNull { it.second.getString(MediaFormat.KEY_MIME)?.startsWith(prefix) == true }
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
internal class Layout(val width: Int, val height: Int, val x: Int, val y: Int, val contentWidth: Int, val contentHeight: Int)
|
||||
|
||||
// Steam fills the screen with its startup movie, so portrait clips are pillarboxed into 16:9 instead of cropped.
|
||||
internal fun layout(width: Int, height: Int, rotation: Int): Layout {
|
||||
val (w, h) = if (rotation % 180 != 0) height to width else width to height
|
||||
fun even(v: Double) = maxOf(2, (v.toInt() / 2) * 2)
|
||||
if (w >= h) {
|
||||
val scale = minOf(1.0, 1280.0 / w, 720.0 / h)
|
||||
val cw = even(w * scale)
|
||||
val ch = even(h * scale)
|
||||
return Layout(cw, ch, 0, 0, cw, ch)
|
||||
}
|
||||
val ch = even(minOf(h, 720).toDouble())
|
||||
val cw = even(w * ch.toDouble() / h)
|
||||
val canvas = even(ch * 16 / 9.0)
|
||||
return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch)
|
||||
}
|
||||
|
||||
internal fun resample(pcm: ShortArray, channels: Int, rate: Int): ShortArray {
|
||||
if (rate == OPUS_RATE) return pcm
|
||||
val frames = pcm.size / channels
|
||||
val outFrames = (frames.toLong() * OPUS_RATE / rate).toInt()
|
||||
val out = ShortArray(outFrames * channels)
|
||||
for (i in 0 until outFrames) {
|
||||
val pos = i.toDouble() * rate / OPUS_RATE
|
||||
val i0 = pos.toInt().coerceAtMost(frames - 1)
|
||||
val i1 = (i0 + 1).coerceAtMost(frames - 1)
|
||||
val frac = pos - i0
|
||||
for (c in 0 until channels) {
|
||||
val a = pcm[i0 * channels + c]
|
||||
val b = pcm[i1 * channels + c]
|
||||
out[i * channels + c] = (a + (b - a) * frac).toInt().toShort()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun encodeAudio(source: File): Audio? {
|
||||
val (track, inFormat) = trackFormat(source, "audio/") ?: return null
|
||||
val extractor = MediaExtractor()
|
||||
var decoder: MediaCodec? = null
|
||||
var encoder: MediaCodec? = null
|
||||
try {
|
||||
extractor.setDataSource(source.path)
|
||||
extractor.selectTrack(track)
|
||||
var rate = inFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
var channels = inFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
val dec = MediaCodec.createDecoderByType(inFormat.getString(MediaFormat.KEY_MIME)!!).also { decoder = it }
|
||||
dec.configure(inFormat, null, null, 0)
|
||||
dec.start()
|
||||
val pcm = ByteArrayOutputStream()
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var inputDone = false
|
||||
while (true) {
|
||||
if (!inputDone) {
|
||||
val index = dec.dequeueInputBuffer(TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
val size = extractor.readSampleData(dec.getInputBuffer(index)!!, 0)
|
||||
if (size < 0) {
|
||||
dec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
inputDone = true
|
||||
} else {
|
||||
dec.queueInputBuffer(index, 0, size, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
val index = dec.dequeueOutputBuffer(info, TIMEOUT_US)
|
||||
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
val format = dec.outputFormat
|
||||
rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
if (format.containsKey(MediaFormat.KEY_PCM_ENCODING) &&
|
||||
format.getInteger(MediaFormat.KEY_PCM_ENCODING) != android.media.AudioFormat.ENCODING_PCM_16BIT) return null
|
||||
} else if (index >= 0) {
|
||||
if (info.size > 0) {
|
||||
val bytes = ByteArray(info.size)
|
||||
dec.getOutputBuffer(index)!!.apply { position(info.offset); get(bytes) }
|
||||
pcm.write(bytes)
|
||||
}
|
||||
dec.releaseOutputBuffer(index, false)
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
|
||||
}
|
||||
}
|
||||
if (channels !in 1..2) return null
|
||||
val raw = pcm.toByteArray()
|
||||
val shorts = ShortArray(raw.size / 2)
|
||||
ByteBuffer.wrap(raw).order(ByteOrder.nativeOrder()).asShortBuffer().get(shorts)
|
||||
val samples = resample(shorts, channels, rate)
|
||||
|
||||
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, OPUS_RATE, channels)
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
|
||||
val name = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(format) ?: return null
|
||||
val enc = MediaCodec.createByCodecName(name).also { encoder = it }
|
||||
enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
enc.start()
|
||||
val packets = ArrayList<Sample>()
|
||||
var outFormat: MediaFormat? = null
|
||||
var offset = 0
|
||||
var encodeInputDone = false
|
||||
while (true) {
|
||||
if (!encodeInputDone) {
|
||||
val index = enc.dequeueInputBuffer(TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
val buffer = enc.getInputBuffer(index)!!
|
||||
buffer.clear()
|
||||
val count = minOf(buffer.remaining() / 2, samples.size - offset) / channels * channels
|
||||
val timeUs = offset.toLong() / channels * 1_000_000L / OPUS_RATE
|
||||
if (count == 0) {
|
||||
enc.queueInputBuffer(index, 0, 0, timeUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
encodeInputDone = true
|
||||
} else {
|
||||
buffer.order(ByteOrder.nativeOrder()).asShortBuffer().put(samples, offset, count)
|
||||
enc.queueInputBuffer(index, 0, count * 2, timeUs, 0)
|
||||
offset += count
|
||||
}
|
||||
}
|
||||
}
|
||||
val index = enc.dequeueOutputBuffer(info, TIMEOUT_US)
|
||||
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
outFormat = enc.outputFormat
|
||||
} else if (index >= 0) {
|
||||
if (info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
|
||||
val bytes = ByteArray(info.size)
|
||||
enc.getOutputBuffer(index)!!.apply { position(info.offset); get(bytes) }
|
||||
packets.add(Sample(bytes, info.presentationTimeUs))
|
||||
}
|
||||
enc.releaseOutputBuffer(index, false)
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
|
||||
}
|
||||
}
|
||||
return outFormat?.let { Audio(it, packets) }
|
||||
} finally {
|
||||
runCatching { decoder?.stop() }
|
||||
decoder?.release()
|
||||
runCatching { encoder?.stop() }
|
||||
encoder?.release()
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaFormat.intOrNull(key: String): Int? = if (!containsKey(key)) null else
|
||||
runCatching { getInteger(key) }.getOrElse { runCatching { getFloat(key).toInt() }.getOrNull() }
|
||||
|
||||
private fun encodeVideo(source: File, target: File, audio: Audio?, onProgress: (Int) -> Unit): Boolean {
|
||||
val (track, inFormat) = trackFormat(source, "video/") ?: throw IllegalStateException("no video track")
|
||||
val rotation = (inFormat.intOrNull(MediaFormat.KEY_ROTATION) ?: 0).mod(360)
|
||||
val layout = layout(inFormat.getInteger(MediaFormat.KEY_WIDTH), inFormat.getInteger(MediaFormat.KEY_HEIGHT), rotation)
|
||||
val width = layout.width
|
||||
val height = layout.height
|
||||
val fps = (inFormat.intOrNull(MediaFormat.KEY_FRAME_RATE) ?: 30).coerceIn(1, MAX_FPS)
|
||||
val durationUs = if (inFormat.containsKey(MediaFormat.KEY_DURATION)) inFormat.getLong(MediaFormat.KEY_DURATION) else 0L
|
||||
val minIntervalUs = 1_000_000L / MAX_FPS - 2_000
|
||||
|
||||
val encFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_VP8, width, height).apply {
|
||||
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, (width.toLong() * height * fps / 10).coerceIn(1_000_000, 8_000_000).toInt())
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
|
||||
}
|
||||
val encoderName = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(encFormat)
|
||||
?: throw IllegalStateException("no VP8 encoder")
|
||||
|
||||
val extractor = MediaExtractor()
|
||||
var encoder: MediaCodec? = null
|
||||
var decoder: MediaCodec? = null
|
||||
var egl: EglWindow? = null
|
||||
var renderer: FrameRenderer? = null
|
||||
var muxer: MediaMuxer? = null
|
||||
val staged = File(target.parentFile, target.name + ".convert")
|
||||
try {
|
||||
extractor.setDataSource(source.path)
|
||||
extractor.selectTrack(track)
|
||||
val enc = MediaCodec.createByCodecName(encoderName).also { encoder = it }
|
||||
enc.configure(encFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
val window = EglWindow(enc.createInputSurface()).also { egl = it }
|
||||
val frames = FrameRenderer(rotation).also { renderer = it }
|
||||
enc.start()
|
||||
inFormat.setInteger(MediaFormat.KEY_ROTATION, 0)
|
||||
val dec = MediaCodec.createDecoderByType(inFormat.getString(MediaFormat.KEY_MIME)!!).also { decoder = it }
|
||||
dec.configure(inFormat, frames.surface, null, 0)
|
||||
dec.start()
|
||||
val mux = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it }
|
||||
|
||||
var videoTrack = -1
|
||||
var audioTrack = -1
|
||||
var audioIndex = 0
|
||||
fun writeAudioUpTo(timeUs: Long) {
|
||||
if (audioTrack < 0 || audio == null) return
|
||||
val info = MediaCodec.BufferInfo()
|
||||
while (audioIndex < audio.samples.size && audio.samples[audioIndex].timeUs <= timeUs) {
|
||||
val sample = audio.samples[audioIndex++]
|
||||
info.set(0, sample.data.size, sample.timeUs, 0)
|
||||
mux.writeSampleData(audioTrack, ByteBuffer.wrap(sample.data), info)
|
||||
}
|
||||
}
|
||||
|
||||
val decInfo = MediaCodec.BufferInfo()
|
||||
val encInfo = MediaCodec.BufferInfo()
|
||||
var inputDone = false
|
||||
var decoderDone = false
|
||||
var lastKept = Long.MIN_VALUE
|
||||
var lastProgress = -1
|
||||
while (true) {
|
||||
if (!inputDone) {
|
||||
val index = dec.dequeueInputBuffer(TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
val size = extractor.readSampleData(dec.getInputBuffer(index)!!, 0)
|
||||
if (size < 0) {
|
||||
dec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
inputDone = true
|
||||
} else {
|
||||
dec.queueInputBuffer(index, 0, size, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!decoderDone) {
|
||||
val index = dec.dequeueOutputBuffer(decInfo, TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
val timeUs = decInfo.presentationTimeUs
|
||||
val keep = decInfo.size > 0 && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
|
||||
dec.releaseOutputBuffer(index, keep)
|
||||
if (keep) {
|
||||
if (!frames.awaitFrame()) throw IllegalStateException("decoder frame timed out")
|
||||
frames.draw(layout)
|
||||
window.swap(timeUs * 1000)
|
||||
lastKept = timeUs
|
||||
if (durationUs > 0) {
|
||||
val percent = (timeUs * 100 / durationUs).toInt().coerceIn(0, 99)
|
||||
if (percent != lastProgress) { lastProgress = percent; onProgress(percent) }
|
||||
}
|
||||
}
|
||||
if (decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
enc.signalEndOfInputStream()
|
||||
decoderDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var encoderDone = false
|
||||
while (true) {
|
||||
val index = enc.dequeueOutputBuffer(encInfo, if (decoderDone) TIMEOUT_US else 0)
|
||||
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) break
|
||||
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
videoTrack = mux.addTrack(enc.outputFormat)
|
||||
if (audio != null) audioTrack = runCatching { mux.addTrack(audio.format) }
|
||||
.onFailure { Log.w(TAG, "WebM muxer refused the Opus track", it) }.getOrDefault(-1)
|
||||
mux.start()
|
||||
} else if (index >= 0) {
|
||||
if (encInfo.size > 0 && encInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
|
||||
check(videoTrack >= 0) { "encoder produced data before its format" }
|
||||
writeAudioUpTo(encInfo.presentationTimeUs)
|
||||
mux.writeSampleData(videoTrack, enc.getOutputBuffer(index)!!, encInfo)
|
||||
}
|
||||
enc.releaseOutputBuffer(index, false)
|
||||
if (encInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { encoderDone = true; break }
|
||||
}
|
||||
}
|
||||
if (encoderDone) break
|
||||
}
|
||||
writeAudioUpTo(Long.MAX_VALUE)
|
||||
mux.stop()
|
||||
mux.release()
|
||||
muxer = null
|
||||
if (!staged.renameTo(target)) throw IllegalStateException("could not move the converted movie")
|
||||
onProgress(100)
|
||||
return audioTrack >= 0
|
||||
} finally {
|
||||
runCatching { muxer?.release() }
|
||||
runCatching { decoder?.stop() }
|
||||
decoder?.release()
|
||||
runCatching { encoder?.stop() }
|
||||
encoder?.release()
|
||||
renderer?.release()
|
||||
egl?.release()
|
||||
extractor.release()
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private class EglWindow(private val surface: Surface) {
|
||||
private val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
private val context: android.opengl.EGLContext
|
||||
private val window: android.opengl.EGLSurface
|
||||
|
||||
init {
|
||||
val version = IntArray(2)
|
||||
check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" }
|
||||
val attributes = intArrayOf(
|
||||
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8,
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, EGL_RECORDABLE_ANDROID, 1, EGL14.EGL_NONE,
|
||||
)
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val count = IntArray(1)
|
||||
check(EGL14.eglChooseConfig(display, attributes, 0, configs, 0, 1, count, 0) && count[0] > 0) { "no recordable EGL config" }
|
||||
context = EGL14.eglCreateContext(display, configs[0], EGL14.EGL_NO_CONTEXT,
|
||||
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), 0)
|
||||
window = EGL14.eglCreateWindowSurface(display, configs[0], surface, intArrayOf(EGL14.EGL_NONE), 0)
|
||||
check(EGL14.eglMakeCurrent(display, window, window, context)) { "eglMakeCurrent failed" }
|
||||
}
|
||||
|
||||
fun swap(timeNs: Long) {
|
||||
EGLExt.eglPresentationTimeANDROID(display, window, timeNs)
|
||||
EGL14.eglSwapBuffers(display, window)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
|
||||
EGL14.eglDestroySurface(display, window)
|
||||
EGL14.eglDestroyContext(display, context)
|
||||
EGL14.eglReleaseThread()
|
||||
EGL14.eglTerminate(display)
|
||||
surface.release()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EGL_RECORDABLE_ANDROID = 0x3142
|
||||
}
|
||||
}
|
||||
|
||||
private class FrameRenderer(rotation: Int) {
|
||||
private val thread = HandlerThread("boot-movie-frames").apply { start() }
|
||||
private val lock = Object()
|
||||
private var available = false
|
||||
private val texture = IntArray(1).also { GLES20.glGenTextures(1, it, 0) }[0]
|
||||
private val surfaceTexture: SurfaceTexture
|
||||
val surface: Surface
|
||||
private val program: Int
|
||||
private val transform = FloatArray(16)
|
||||
private val positions: FloatBuffer
|
||||
private val coords: FloatBuffer = floats(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)
|
||||
|
||||
init {
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texture)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
|
||||
surfaceTexture = SurfaceTexture(texture)
|
||||
surfaceTexture.setOnFrameAvailableListener({
|
||||
synchronized(lock) { available = true; lock.notifyAll() }
|
||||
}, Handler(thread.looper))
|
||||
surface = Surface(surfaceTexture)
|
||||
program = link(VERTEX, FRAGMENT)
|
||||
val radians = Math.toRadians(rotation.toDouble())
|
||||
val cos = Math.cos(radians).toFloat()
|
||||
val sin = Math.sin(radians).toFloat()
|
||||
val corners = floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)
|
||||
positions = floats(*FloatArray(8) { i ->
|
||||
val x = corners[i - i % 2]
|
||||
val y = corners[i - i % 2 + 1]
|
||||
if (i % 2 == 0) x * cos + y * sin else -x * sin + y * cos
|
||||
})
|
||||
}
|
||||
|
||||
fun awaitFrame(): Boolean {
|
||||
synchronized(lock) {
|
||||
val deadline = System.currentTimeMillis() + 2_500
|
||||
while (!available) {
|
||||
val wait = deadline - System.currentTimeMillis()
|
||||
if (wait <= 0) return false
|
||||
lock.wait(wait)
|
||||
}
|
||||
available = false
|
||||
}
|
||||
surfaceTexture.updateTexImage()
|
||||
return true
|
||||
}
|
||||
|
||||
fun draw(layout: Layout) {
|
||||
surfaceTexture.getTransformMatrix(transform)
|
||||
GLES20.glViewport(0, 0, layout.width, layout.height)
|
||||
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||
GLES20.glViewport(layout.x, layout.y, layout.contentWidth, layout.contentHeight)
|
||||
GLES20.glUseProgram(program)
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texture)
|
||||
val position = GLES20.glGetAttribLocation(program, "aPosition")
|
||||
val coord = GLES20.glGetAttribLocation(program, "aCoord")
|
||||
GLES20.glEnableVertexAttribArray(position)
|
||||
GLES20.glVertexAttribPointer(position, 2, GLES20.GL_FLOAT, false, 0, positions)
|
||||
GLES20.glEnableVertexAttribArray(coord)
|
||||
GLES20.glVertexAttribPointer(coord, 2, GLES20.GL_FLOAT, false, 0, coords)
|
||||
GLES20.glUniformMatrix4fv(GLES20.glGetUniformLocation(program, "uTransform"), 1, false, transform, 0)
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
surface.release()
|
||||
surfaceTexture.release()
|
||||
GLES20.glDeleteProgram(program)
|
||||
GLES20.glDeleteTextures(1, intArrayOf(texture), 0)
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
private fun floats(vararg values: Float): FloatBuffer =
|
||||
ByteBuffer.allocateDirect(values.size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer().apply { put(values); position(0) }
|
||||
|
||||
private fun link(vertex: String, fragment: String): Int {
|
||||
fun compile(type: Int, code: String): Int = GLES20.glCreateShader(type).also {
|
||||
GLES20.glShaderSource(it, code)
|
||||
GLES20.glCompileShader(it)
|
||||
val ok = IntArray(1)
|
||||
GLES20.glGetShaderiv(it, GLES20.GL_COMPILE_STATUS, ok, 0)
|
||||
check(ok[0] != 0) { GLES20.glGetShaderInfoLog(it) }
|
||||
}
|
||||
return GLES20.glCreateProgram().also {
|
||||
GLES20.glAttachShader(it, compile(GLES20.GL_VERTEX_SHADER, vertex))
|
||||
GLES20.glAttachShader(it, compile(GLES20.GL_FRAGMENT_SHADER, fragment))
|
||||
GLES20.glLinkProgram(it)
|
||||
val ok = IntArray(1)
|
||||
GLES20.glGetProgramiv(it, GLES20.GL_LINK_STATUS, ok, 0)
|
||||
check(ok[0] != 0) { GLES20.glGetProgramInfoLog(it) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val VERTEX = """
|
||||
attribute vec4 aPosition;
|
||||
attribute vec4 aCoord;
|
||||
uniform mat4 uTransform;
|
||||
varying vec2 vCoord;
|
||||
void main() {
|
||||
gl_Position = aPosition;
|
||||
vCoord = (uTransform * aCoord).xy;
|
||||
}
|
||||
""".trimIndent()
|
||||
private val FRAGMENT = """
|
||||
#extension GL_OES_EGL_image_external : require
|
||||
precision mediump float;
|
||||
varying vec2 vCoord;
|
||||
uniform samplerExternalOES sTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(sTexture, vCoord);
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -711,9 +711,71 @@ object ComponentsManager {
|
||||
saveState(context, state)
|
||||
}
|
||||
|
||||
class StoredPackage(val pkg: Package, val inUse: Boolean)
|
||||
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
|
||||
class Stored(val packages: List<StoredPackage>, val originals: List<StoredOriginal>)
|
||||
|
||||
private fun packagesInUse(state: JSONObject): Set<String> {
|
||||
val names = HashSet<String>()
|
||||
for (key in listOf("active", "queued")) {
|
||||
val byProton = state.optJSONObject(key) ?: continue
|
||||
for (pid in byProton.keys()) {
|
||||
val comps = byProton.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) comps.optJSONObject(comp)?.optString("file")?.takeIf { it.isNotEmpty() }?.let(names::add)
|
||||
}
|
||||
}
|
||||
val profiles = state.optJSONObject("profiles")
|
||||
for (scope in profiles?.keys() ?: emptyList<String>().iterator()) {
|
||||
val profile = profiles?.optJSONObject(scope) ?: continue
|
||||
COMPONENTS.forEach { comp -> profile.optString(comp).takeIf { it.isNotEmpty() }?.let(names::add) }
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
private fun originalKept(state: JSONObject, installed: Map<String, Proton>, protonId: String, comp: String, version: String): Boolean {
|
||||
val current = installed[protonId]?.let { safeName(it.version) == version } == true
|
||||
val queued = state.optJSONObject("queued")?.optJSONObject(protonId)?.optJSONObject(comp)
|
||||
?.takeIf { it.optString("kind") == "original" }?.let { safeName(it.optString("protonVersion")) == version } == true
|
||||
return current || queued
|
||||
}
|
||||
|
||||
fun stored(context: Context): Stored = synchronized(lock) {
|
||||
val state = loadState(context)
|
||||
val used = packagesInUse(state)
|
||||
val installed = protons(context).associateBy { it.id }
|
||||
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
|
||||
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
|
||||
COMPONENTS.mapNotNull { comp ->
|
||||
val f = File(version, "$comp.wcp")
|
||||
if (!f.isFile) null else StoredOriginal(
|
||||
proton.name, installed[proton.name]?.name, comp, version.name, f.length(),
|
||||
originalKept(state, installed, proton.name, comp, version.name),
|
||||
)
|
||||
}
|
||||
}.orEmpty()
|
||||
}.orEmpty()
|
||||
Stored(packages(context).map { StoredPackage(it, it.file in used) }, originals)
|
||||
}
|
||||
|
||||
fun deleteUnusedOriginal(context: Context, protonId: String, comp: String, protonVersion: String): Boolean = synchronized(lock) {
|
||||
val state = loadState(context)
|
||||
if (originalKept(state, protons(context).associateBy { it.id }, protonId, comp, protonVersion)) return false
|
||||
if (comp !in COMPONENTS || listOf(protonId, protonVersion).any { it.isEmpty() || '/' in it || it == "." || it == ".." }) return false
|
||||
val f = File(originalsDir(context), "$protonId/$protonVersion/$comp.wcp")
|
||||
if (!f.isFile || !f.delete()) return false
|
||||
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
|
||||
f.parentFile?.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
|
||||
true
|
||||
}
|
||||
|
||||
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
|
||||
val name = safeName(file)
|
||||
val state = loadState(context)
|
||||
val queued = state.sub("queued")
|
||||
for (pid in queued.keys()) {
|
||||
val comps = queued.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
|
||||
}
|
||||
val active = state.sub("active")
|
||||
for (pid in active.keys()) {
|
||||
val comps = active.optJSONObject(pid) ?: continue
|
||||
@@ -867,8 +929,7 @@ object ComponentsManager {
|
||||
require(Hashes.isGithubSha256(item.digest)) { context.getString(R.string.cmgr_list_predates_checksums) }
|
||||
val dest = File(packagesDir(context), safeName(item.file))
|
||||
val part = File(dest.parentFile, dest.name + ".part")
|
||||
part.delete()
|
||||
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(R.string.user_apps_download_failed) }
|
||||
check(Downloader.downloadFile(item.url, part, true, item.digest.substringAfter(':')) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { context.getString(R.string.user_apps_download_failed) }
|
||||
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error(context.getString(R.string.cmgr_checksum_mismatch)) }
|
||||
val info = runCatching { packageInfo(context, part) }.getOrElse { part.delete(); throw it }
|
||||
synchronized(lock) { part.renameTo(dest) }
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.github.luben.zstd.ZstdInputStream
|
||||
@@ -620,16 +621,27 @@ object EsyncPacks {
|
||||
val label = context.getString(com.droiddeck.launcher.R.string.esync_fetching)
|
||||
onProgress?.invoke(label, -1)
|
||||
var reported = -1
|
||||
val fetched = FileOutputStream(part).use { output ->
|
||||
fetch(entry.asset.url, entry.asset.size, output) { done ->
|
||||
val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100)
|
||||
if (percent != reported) {
|
||||
reported = percent
|
||||
onProgress?.invoke(label, percent)
|
||||
val sources = DownloadSource.candidates(entry.asset.url)
|
||||
var fetched = false
|
||||
for ((index, source) in sources.withIndex()) {
|
||||
if (index > 0) Log.i(TAG, "retrying ${entry.asset.url} via $source")
|
||||
reported = -1
|
||||
fetched = FileOutputStream(part).use { output ->
|
||||
fetch(source, entry.asset.size, output) { done ->
|
||||
val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100)
|
||||
if (percent != reported) {
|
||||
reported = percent
|
||||
onProgress?.invoke(label, percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fetched) {
|
||||
DownloadSource.succeeded(source)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!fetched) {
|
||||
DownloadSource.failed()
|
||||
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "failed", "reason" to "download"))
|
||||
return false
|
||||
}
|
||||
@@ -836,8 +848,17 @@ object EsyncPacks {
|
||||
}
|
||||
|
||||
private fun fetchBytes(url: String, limit: Long): ByteArray? {
|
||||
val out = ByteArrayOutputStream()
|
||||
return if (fetch(url, limit, out, null)) out.toByteArray() else null
|
||||
val sources = DownloadSource.candidates(url)
|
||||
for ((index, source) in sources.withIndex()) {
|
||||
if (index > 0) Log.i(TAG, "retrying $url via $source")
|
||||
val out = ByteArrayOutputStream()
|
||||
if (fetch(source, limit, out, null)) {
|
||||
DownloadSource.succeeded(source)
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
DownloadSource.failed()
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean =
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PerformanceHintManager
|
||||
import android.os.PowerManager
|
||||
import android.os.Process
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The game's busiest threads named to Android's performance hint service (ADPF) for a session.
|
||||
*
|
||||
* Qualcomm's WALT moves a thread to the prime cores only once it fills a mid core (the Odin 3's
|
||||
* sched_upmigrate is 95), and a game thread that waits on its workers or the GPU for part of each
|
||||
* frame never does: Factorio's update thread sat at ~68% on cores 0-5 with 6-7 idle, and ran 60%
|
||||
* faster pinned to 6-7. An app may not raise a thread's uclamp (EPERM without CAP_SYS_NICE), but a
|
||||
* hint session may name any thread of its UID - the guest's are - and one reported behind its
|
||||
* target is boosted by the power HAL onto the prime cores: on the Odin 3 a 70%-busy thread of a
|
||||
* child process went from cores 0-5 to 6-7 and ran 1.7x faster within a second.
|
||||
*
|
||||
* Once a second the busiest threads of this UID are read from /proc; one busy more than [ENTER] of
|
||||
* the time (averaged with the second before, so a loading spike does not count) joins the session,
|
||||
* and leaves it under [LEAVE] - well below, since it runs faster where it was moved. While the
|
||||
* session has threads it reports them behind target, which is what asks for the boost: the reports
|
||||
* carry no frame timing, the busy share is the signal (a capped game's frame interval looks the same
|
||||
* as a slow one's). Nothing is boosted while the device is critically hot or saves power.
|
||||
*
|
||||
* Nor while the game holds the panel's rate or a fraction of it ([holdsPace]): there is nothing to
|
||||
* gain, and it cost frame pacing - Serious Sam capped at 120 fps ran its boosted main thread on prime
|
||||
* cores the governor kept at 2.2 GHz, slower than a 3.5 GHz mid core, and 23-25% of its frames fell
|
||||
* under 90 fps against 2-7% unboosted. A game with no frames (Factorio's benchmark, a loading screen)
|
||||
* or one falling behind the panel is boosted; one holding pace keeps what it has for [PACE_LEAVE_S]
|
||||
* seconds and is then let go.
|
||||
*/
|
||||
object GameThreadHints {
|
||||
private const val TAG = "GameThreadHints"
|
||||
private const val TARGET_NS = 10_000_000L
|
||||
private const val REPORT_MS = 50L
|
||||
private const val SCAN_EVERY = 20 // reports, so once a second
|
||||
private const val ENTER = 0.6
|
||||
private const val LEAVE = 0.15 // a capped game's main thread idles to ~30% on a prime core; under 0.3 it hopped back and forth
|
||||
private const val MAX_THREADS = 2 // one per prime core; a third would be sped up in place instead
|
||||
private const val TOP_PROCESSES = 3
|
||||
private const val PACE_WINDOW_S = 3
|
||||
private const val PACE_LEAVE_S = 10
|
||||
private const val PACE_TOLERANCE = 0.08
|
||||
|
||||
/** Busy, but not the game: the app itself and the Steam client, whose UI would otherwise hold the prime cores. */
|
||||
private val NOT_GAMES = setOf("steam", "steamwebhelper")
|
||||
|
||||
private var worker: Thread? = null
|
||||
|
||||
/** At a session's start. The line is for the log. */
|
||||
@Synchronized
|
||||
fun start(context: Context): String {
|
||||
if (!SessionPrefs.gameThreadHints(context)) return "game thread hints: off"
|
||||
// Measured on the Odin 3 (CQ8725S) only; another HAL may boost differently, or not at all.
|
||||
if (Build.VERSION.SDK_INT < 31 || Build.SOC_MODEL != "CQ8725S") return "game thread hints: Odin 3 only"
|
||||
val hm = context.getSystemService(PerformanceHintManager::class.java)
|
||||
?: return "game thread hints: no PerformanceHintManager"
|
||||
val pm = context.getSystemService(PowerManager::class.java)
|
||||
worker?.interrupt()
|
||||
worker = Thread({ run(hm, pm) }, "game-thread-hints").apply { isDaemon = true; start() }
|
||||
return "game thread hints: watching"
|
||||
}
|
||||
|
||||
/** At a session's end. */
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
worker?.interrupt()
|
||||
worker = null
|
||||
}
|
||||
|
||||
private fun run(hm: PerformanceHintManager, pm: PowerManager) {
|
||||
val scanner = Scanner()
|
||||
var session: PerformanceHintManager.Session? = null
|
||||
var boosted = emptySet<Int>()
|
||||
val frames = ArrayDeque<Long>()
|
||||
var pacedFor = 0
|
||||
var tick = 0
|
||||
try {
|
||||
while (!Thread.currentThread().isInterrupted) {
|
||||
if (tick++ % SCAN_EVERY == 0) {
|
||||
val busy = scanner.sample()
|
||||
frames.addLast(WaylandCompositor.gameFrameCount())
|
||||
if (frames.size > PACE_WINDOW_S + 1) frames.removeFirst()
|
||||
val fps = (frames.last() - frames.first()).toDouble() / (frames.size - 1).coerceAtLeast(1)
|
||||
val paced = frames.size > PACE_WINDOW_S && holdsPace(fps, SessionState.refreshHz.toDouble())
|
||||
pacedFor = if (paced) pacedFor + 1 else 0
|
||||
// Not SEVERE: the Odin 3 reports it once its skin reaches 50 C, a couple of minutes into any game.
|
||||
val spare = pm.isPowerSaveMode || pm.currentThermalStatus >= PowerManager.THERMAL_STATUS_CRITICAL
|
||||
val next = when {
|
||||
spare || pacedFor >= PACE_LEAVE_S -> emptySet()
|
||||
paced -> pick(boosted, busy.filterKeys { it in boosted })
|
||||
else -> pick(boosted, busy)
|
||||
}
|
||||
if (next != boosted) {
|
||||
session = update(hm, session, next)
|
||||
// No session means a thread exited in between: tried again next second.
|
||||
boosted = if (session == null) emptySet() else next
|
||||
Log.i(TAG, (if (session == null && next.isNotEmpty()) "no session for " else "boosting ") +
|
||||
next.joinToString { "${readName("/proc/$it/comm")}/$it ${((busy[it] ?: 0.0) * 100).toInt()}%" }.ifEmpty { "nothing" } +
|
||||
" (game ${fps.toInt()} fps${if (paced) ", holding pace" else ""})")
|
||||
}
|
||||
}
|
||||
session?.reportActualWorkDuration(TARGET_NS * 3 / 2)
|
||||
Thread.sleep(REPORT_MS)
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "stopped", t)
|
||||
} finally {
|
||||
session?.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** A new session over [tids] in place of [session]; null for none. The set changes seldom, so no setThreads. */
|
||||
private fun update(hm: PerformanceHintManager, session: PerformanceHintManager.Session?, tids: Set<Int>): PerformanceHintManager.Session? {
|
||||
session?.close()
|
||||
if (tids.isEmpty()) return null
|
||||
return try {
|
||||
hm.createHintSession(tids.toIntArray(), TARGET_NS)
|
||||
} catch (t: RuntimeException) {
|
||||
Log.w(TAG, "createHintSession", t)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a game presenting [fps] frames a second keeps up with a [refreshHz] panel: within
|
||||
* [PACE_TOLERANCE] of the rate or of a half, a third or a quarter of it (a 60 fps limiter on 120 Hz).
|
||||
* A game with next to no frames (a headless benchmark, a loading screen) does not.
|
||||
*/
|
||||
// ponytail: a CPU-bound game that happens to run at ~60 fps on a 120 Hz panel reads as limited; narrow the band if that shows up
|
||||
internal fun holdsPace(fps: Double, refreshHz: Double): Boolean =
|
||||
fps >= 10 && refreshHz > 0 && (1..4).any { k -> abs(fps - refreshHz / k) < PACE_TOLERANCE * refreshHz / k }
|
||||
|
||||
/**
|
||||
* The threads to boost next: those already boosted that are still above [LEAVE], then new ones
|
||||
* above [ENTER], busiest first, at most [MAX_THREADS].
|
||||
*/
|
||||
internal fun pick(boosted: Set<Int>, busy: Map<Int, Double>): Set<Int> =
|
||||
busy.entries
|
||||
.filter { (tid, share) -> share >= if (tid in boosted) LEAVE else ENTER }
|
||||
.sortedWith(compareByDescending<Map.Entry<Int, Double>> { it.key in boosted }.thenByDescending { it.value })
|
||||
.take(MAX_THREADS)
|
||||
.map { it.key }
|
||||
.toSet()
|
||||
|
||||
private fun readName(path: String) = try { File(path).readText().trim() } catch (_: Exception) { "?" }
|
||||
|
||||
/** Busy shares of this UID's threads since the last sample: /proc shows an app only its own processes. */
|
||||
private class Scanner {
|
||||
private val self = Process.myPid()
|
||||
private val hz = Os.sysconf(OsConstants._SC_CLK_TCK).toDouble()
|
||||
private var lastNs = 0L
|
||||
private var lastProcess = HashMap<Int, Long>()
|
||||
private var lastThread = HashMap<Int, Long>()
|
||||
private var lastBusy = HashMap<Int, Double>()
|
||||
|
||||
/** Only the busiest few processes have their threads read, so a second costs a few dozen small reads. */
|
||||
fun sample(): Map<Int, Double> {
|
||||
val now = System.nanoTime()
|
||||
val seconds = (now - lastNs) / 1e9
|
||||
lastNs = now
|
||||
val process = HashMap<Int, Long>()
|
||||
File("/proc").list()?.forEach { entry ->
|
||||
val pid = entry.toIntOrNull() ?: return@forEach
|
||||
cpuTicks("/proc/$pid/stat")?.let { process[pid] = it }
|
||||
}
|
||||
val top = process.entries
|
||||
.map { (pid, ticks) -> pid to ticks - (lastProcess[pid] ?: ticks) }
|
||||
.filter { (pid, delta) -> delta > 0 && pid != self }
|
||||
.sortedByDescending { it.second }
|
||||
.asSequence()
|
||||
.filter { (pid, _) -> readName("/proc/$pid/comm") !in NOT_GAMES }
|
||||
.take(TOP_PROCESSES)
|
||||
.toList()
|
||||
lastProcess = process
|
||||
val thread = HashMap<Int, Long>()
|
||||
val busy = HashMap<Int, Double>()
|
||||
for ((pid, _) in top) {
|
||||
File("/proc/$pid/task").list()?.forEach { entry ->
|
||||
val tid = entry.toIntOrNull() ?: return@forEach
|
||||
val ticks = cpuTicks("/proc/$pid/task/$tid/stat") ?: return@forEach
|
||||
thread[tid] = ticks
|
||||
lastThread[tid]?.let { before -> busy[tid] = ((ticks - before) / hz / seconds + (lastBusy[tid] ?: 0.0)) / 2 }
|
||||
}
|
||||
}
|
||||
lastThread = thread
|
||||
lastBusy = busy
|
||||
return busy
|
||||
}
|
||||
|
||||
/** utime + stime of a /proc stat line; fields counted after the command's closing parenthesis. */
|
||||
private fun cpuTicks(path: String): Long? = try {
|
||||
val line = File(path).readText()
|
||||
val fields = line.substring(line.lastIndexOf(')') + 2).split(' ')
|
||||
fields[11].toLong() + fields[12].toLong()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ object ProtonExtras {
|
||||
val tools = listOf(
|
||||
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
|
||||
Tool("cachyos", "proton-cachyos", "proton-cachyos", "CachyOS/proton-cachyos", Regex("arm64\\.tar\\.(gz|xz)$")),
|
||||
Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "The412Banner/DroidDeck-Proton", Regex("aarch64\\.tar\\.(gz|xz)$")),
|
||||
Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "Droid-Deck/DroidDeck-Proton", Regex("aarch64\\.tar\\.(gz|xz)$")),
|
||||
)
|
||||
|
||||
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
|
||||
@@ -167,7 +167,7 @@ object ProtonExtras {
|
||||
onProgress(downloading, 0)
|
||||
var downloaded = false
|
||||
for (attempt in 0 until 5) {
|
||||
downloaded = Downloader.downloadFile(asset.url, archive, true) { fraction ->
|
||||
downloaded = Downloader.downloadFile(asset.url, archive, true, asset.sha256) { fraction ->
|
||||
onProgress(downloading, if (fraction < 0) -1 else (fraction * 100f).toInt().coerceIn(0, 100))
|
||||
}
|
||||
if (downloaded) break
|
||||
@@ -192,7 +192,7 @@ object ProtonExtras {
|
||||
val verified = when {
|
||||
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
|
||||
asset.sha512 != null -> {
|
||||
val expected = Downloader.downloadString(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
|
||||
val expected = Downloader.downloadDirect(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
|
||||
?: return context.getString(R.string.pextra_checksum_unreadable)
|
||||
expected.equals(Hashes.sha512(archive), ignoreCase = true)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,22 @@ object QuestExperimental {
|
||||
fun isSupported(): Boolean = supported(Build.MODEL, Build.DEVICE, Build.MANUFACTURER)
|
||||
fun isQuest2(): Boolean = quest2(Build.MODEL, Build.DEVICE, Build.MANUFACTURER)
|
||||
|
||||
fun defaultAndroidDriver(): String? = when {
|
||||
!isSupported() -> null
|
||||
isQuest2() -> DRIVER_A6XX
|
||||
else -> DRIVER
|
||||
}
|
||||
|
||||
fun applyAutoDrivers(context: Context): Boolean {
|
||||
if (SessionPrefs.gpuDriverMode(context) != SessionPrefs.GPU_DRIVERS_AUTO) return false
|
||||
val driver = defaultAndroidDriver() ?: return false
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit()
|
||||
.putString("androidDriver", driver)
|
||||
.putString("linuxDriver", "")
|
||||
.apply()
|
||||
return true
|
||||
}
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (!isSupported()) return
|
||||
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
|
||||
@@ -4,6 +4,9 @@ import android.net.LocalServerSocket
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
@@ -21,15 +24,18 @@ class RumbleComponent : SessionPart() {
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
private var phone: Motors? = null
|
||||
private val playing = HashMap<Int, Motors>()
|
||||
private val sent = HashMap<Int, Sent>()
|
||||
private val effects = HashMap<Int, Effect>()
|
||||
private val controllers = HashMap<Int, Motors?>()
|
||||
private var preferences: SharedPreferences? = null
|
||||
private var enabled = false
|
||||
private var motorThread: HandlerThread? = null
|
||||
private var handler: Handler? = null
|
||||
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == null || key == "rumble") refreshEnabled()
|
||||
}
|
||||
|
||||
internal var controllerMotors: (Int) -> Motors? = ::lookupController
|
||||
internal var motorLooper: Looper? = null
|
||||
|
||||
@Synchronized private fun refreshEnabled() {
|
||||
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
|
||||
@@ -42,6 +48,7 @@ class RumbleComponent : SessionPart() {
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||
else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java)
|
||||
phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) }
|
||||
handler = Handler(motorLooper ?: HandlerThread("rumble-motors").also { motorThread = it; it.start() }.looper)
|
||||
preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) }
|
||||
refreshEnabled()
|
||||
server = s
|
||||
@@ -66,6 +73,9 @@ class RumbleComponent : SessionPart() {
|
||||
preferences = null
|
||||
enabled = false
|
||||
cancelAll()
|
||||
handler = null
|
||||
motorThread?.quitSafely()
|
||||
motorThread = null
|
||||
phone = null
|
||||
controllers.clear()
|
||||
val s = server
|
||||
@@ -81,24 +91,39 @@ class RumbleComponent : SessionPart() {
|
||||
val previous = playing[slot]
|
||||
if (target !== previous) {
|
||||
previous?.cancel()
|
||||
sent.remove(slot)
|
||||
forget(slot)
|
||||
if (target == null) playing.remove(slot) else playing[slot] = target
|
||||
target?.let { Log.i(TAG, "rumble: slot $slot playing on ${it.name}") }
|
||||
}
|
||||
if (target == null) return
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); sent.remove(slot); return }
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); forget(slot); return }
|
||||
val handler = handler ?: return
|
||||
val effect = effects.getOrPut(slot) { Effect(slot) }
|
||||
effect.want(strong, weak, ms)
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val last = sent[slot]
|
||||
if (last != null && (now - last.at < MIN_GAP_MS || (last.strong == strong && last.weak == weak && now < last.until))) return
|
||||
val duration = ms.toLong().coerceIn(1L, 5000L)
|
||||
if (effect.playing(now)) return
|
||||
handler.removeCallbacks(effect)
|
||||
val wait = effect.at + MIN_GAP_MS - now
|
||||
if (wait > 0) handler.postDelayed(effect, wait) else play(effect)
|
||||
}
|
||||
|
||||
@Synchronized private fun play(effect: Effect) {
|
||||
val target = playing[effect.slot]
|
||||
if (target == null || effects[effect.slot] !== effect) return
|
||||
val duration = if (effect.hold) HOLD_EFFECT_MS else effect.ms.toLong().coerceIn(1L, 5000L)
|
||||
try {
|
||||
target.play(strong, weak, duration)
|
||||
sent[slot] = Sent(strong, weak, now, now + duration)
|
||||
target.play(effect.strong, effect.weak, duration)
|
||||
effect.played(SystemClock.uptimeMillis(), duration)
|
||||
if (effect.hold) handler?.postDelayed(effect, KEEPALIVE_MS)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: ${target.name}: $e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun forget(slot: Int) {
|
||||
effects.remove(slot)?.let { handler?.removeCallbacks(it) }
|
||||
}
|
||||
|
||||
private fun target(slot: Int): Motors? {
|
||||
val id = PadBridge.controllerForSlot(slot)
|
||||
if (id == PadBridge.NO_CONTROLLER) return phone
|
||||
@@ -109,10 +134,36 @@ class RumbleComponent : SessionPart() {
|
||||
private fun cancelAll() {
|
||||
playing.values.toSet().forEach { it.cancel() }
|
||||
playing.clear()
|
||||
sent.clear()
|
||||
effects.values.forEach { handler?.removeCallbacks(it) }
|
||||
effects.clear()
|
||||
}
|
||||
|
||||
private data class Sent(val strong: Int, val weak: Int, val at: Long, val until: Long)
|
||||
private inner class Effect(val slot: Int) : Runnable {
|
||||
var strong = 0
|
||||
var weak = 0
|
||||
var ms = 0
|
||||
var at = -MIN_GAP_MS
|
||||
private var playedStrong = 0
|
||||
private var playedWeak = 0
|
||||
private var playedMs = 0
|
||||
private var until = 0L
|
||||
val hold get() = ms == HOLD_MS
|
||||
|
||||
fun want(strong: Int, weak: Int, ms: Int) { this.strong = strong; this.weak = weak; this.ms = ms }
|
||||
|
||||
fun playing(now: Long) = playedMs != 0 && playedStrong == strong && playedWeak == weak && playedMs == ms &&
|
||||
(hold || now < until)
|
||||
|
||||
fun played(now: Long, duration: Long) {
|
||||
playedStrong = strong
|
||||
playedWeak = weak
|
||||
playedMs = ms
|
||||
at = now
|
||||
until = now + duration
|
||||
}
|
||||
|
||||
override fun run() = play(this)
|
||||
}
|
||||
|
||||
internal interface Motors {
|
||||
val name: String
|
||||
@@ -137,20 +188,35 @@ class RumbleComponent : SessionPart() {
|
||||
any = true
|
||||
}
|
||||
if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak))
|
||||
if (any) manager.vibrate(combined.combine()) else manager.cancel()
|
||||
if (any) manager.vibrate(combined.combine()) else cancel()
|
||||
}
|
||||
|
||||
// Some vendors' input-device vibrator HALs (Samsung's) don't halt a Bluetooth pad's running
|
||||
// effect on cancel(), but a new effect always replaces it.
|
||||
override fun cancel() {
|
||||
val supersede = CombinedVibration.startParallel()
|
||||
ids.forEach { supersede.addVibrator(it, VibrationEffect.createOneShot(1L, 1)) }
|
||||
manager.vibrate(supersede.combine())
|
||||
manager.cancel()
|
||||
}
|
||||
override fun cancel() = manager.cancel()
|
||||
}
|
||||
|
||||
private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors {
|
||||
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
|
||||
override fun cancel() = vibrator.cancel()
|
||||
override fun cancel() {
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(1L, 1))
|
||||
vibrator.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
const val NAME = "droiddeck-rumble"
|
||||
const val MIN_GAP_MS = 50L
|
||||
// Must match RUMBLE_HOLD_MS in fakeinput_steam.cpp.
|
||||
const val HOLD_MS = 0xFFFF
|
||||
const val HOLD_EFFECT_MS = 2000L
|
||||
const val KEEPALIVE_MS = 1500L
|
||||
|
||||
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
|
||||
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
|
||||
@@ -119,7 +119,7 @@ object SessionArtifacts {
|
||||
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
|
||||
record.files.forEach { (f, s) ->
|
||||
lines.append(s.length).append('\t').append(s.modified).append('\t')
|
||||
.append(f.relativeTo(dir).path).append('\n')
|
||||
.append(f.relativeTo(dir).invariantSeparatorsPath).append('\n')
|
||||
}
|
||||
tree.writeText(lines.toString())
|
||||
true
|
||||
|
||||
@@ -103,6 +103,7 @@ object SessionEvents {
|
||||
if (!SessionState.running || SessionState.guestPid <= 1 || !SessionState.firstFrameSeen || SessionState.suspended) return
|
||||
if (SessionState.phase != SessionPhase.READY) {
|
||||
transition(SessionPhase.READY, "session.ready")
|
||||
Thread({ com.droiddeck.launcher.gpu.AutoDriverTrial.sessionReady() }, "gpu-driver-trial").start()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
|
||||
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
|
||||
"usr/local/bin/droiddeck-steamvr" to "usr/local/bin/droiddeck-steamvr",
|
||||
"usr/local/bin/droiddeck-steamvr-settings" to "usr/local/bin/droiddeck-steamvr-settings",
|
||||
"usr/local/bin/droiddeck-steamvr-game" to "usr/local/bin/droiddeck-steamvr-game",
|
||||
"usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap",
|
||||
"usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak",
|
||||
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
|
||||
@@ -104,6 +106,7 @@ object SessionFiles {
|
||||
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
|
||||
val vr = listOf(
|
||||
"droiddeck/bin/linuxarm64/driver_droiddeck.so",
|
||||
"droiddeck/bin/linuxarm64/xr-display-mode",
|
||||
"droiddeck/driver.vrdrivermanifest",
|
||||
"droiddeck/resources/settings/default.vrsettings",
|
||||
"layer/libVkLayer_droiddeck_vr.so",
|
||||
@@ -185,27 +188,29 @@ object SessionFiles {
|
||||
}
|
||||
|
||||
val startupMovieDir = File(root, "root/.local/share/Steam/config/uioverrides/movies")
|
||||
val bigPictureMovieInstalled = stageStartupMovie(
|
||||
context,
|
||||
startupMovieDir,
|
||||
"bigpicture_startup.webm",
|
||||
)
|
||||
stageStartupMovie(context, startupMovieDir, "steam_os_startup.webm")
|
||||
val startupMovie = BootMovie.prepare(context)
|
||||
val bigPictureMovieInstalled = startupMovie != null &&
|
||||
stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm")
|
||||
if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm")
|
||||
if (bigPictureMovieInstalled) {
|
||||
ensureStartupMovieDefault(File(root, "root/.local/share/Steam/config/config.vdf"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun stageStartupMovie(context: Context, directory: File, name: String): Boolean {
|
||||
private fun stageStartupMovie(source: File, directory: File, name: String): Boolean {
|
||||
val movie = File(directory, name)
|
||||
val stamp = File(directory, ".$name.source")
|
||||
val identity = "${source.absolutePath}\n${source.length()}\n${source.lastModified()}"
|
||||
if (movie.length() == source.length() && runCatching { stamp.readText() }.getOrNull() == identity) return true
|
||||
val staged = File(directory, "$name.staged")
|
||||
var installed = false
|
||||
try {
|
||||
directory.mkdirs()
|
||||
context.assets.open("steam-startup/droiddeck-startup.webm").use { input ->
|
||||
source.inputStream().use { input ->
|
||||
staged.outputStream().use { output -> FileUtils.copy(input, output) }
|
||||
}
|
||||
installed = staged.setReadable(true, false) && staged.renameTo(movie)
|
||||
if (installed) FileUtils.writeString(stamp, identity)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not stage Steam startup movie $name", e)
|
||||
} finally {
|
||||
|
||||
@@ -58,7 +58,7 @@ object SessionLogShare {
|
||||
if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
|
||||
}
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { z ->
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f); advance(f) }
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).invariantSeparatorsPath, f); advance(f) }
|
||||
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f); advance(f) }
|
||||
stores.forEach { f -> addEntry(z, "$STORES_DIR/" + f.name, f); advance(f) }
|
||||
tools.forEach { f -> addEntry(z, SessionPaths.TOOLS_DIR + "/" + f.name, f); advance(f) }
|
||||
|
||||
@@ -10,7 +10,7 @@ import java.util.Locale
|
||||
object SessionPaths {
|
||||
private const val TAG = "SessionPaths"
|
||||
|
||||
const val KEEP_SESSIONS = 30
|
||||
const val KEEP_SESSIONS = 3
|
||||
|
||||
const val TOOLS_DIR = "tools"
|
||||
|
||||
|
||||
@@ -104,6 +104,23 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("storesShowMature", show).apply()
|
||||
}
|
||||
|
||||
fun storesSort(context: Context): String =
|
||||
prefs(context).getString("storesSort", null)?.takeIf { it in STORES_SORTS } ?: STORES_SORTS.first()
|
||||
|
||||
fun setStoresSort(context: Context, sort: String) {
|
||||
prefs(context).edit().putString("storesSort", if (sort in STORES_SORTS) sort else STORES_SORTS.first()).apply()
|
||||
}
|
||||
|
||||
fun storesView(context: Context): String =
|
||||
prefs(context).getString("storesView", null)?.takeIf { it in STORES_VIEWS } ?: STORES_VIEWS.first()
|
||||
|
||||
fun setStoresView(context: Context, view: String) {
|
||||
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply()
|
||||
}
|
||||
|
||||
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
|
||||
val STORES_VIEWS = listOf("cards", "list")
|
||||
|
||||
const val STORES_OPEN_LIBRARY = "library"
|
||||
const val STORES_OPEN_STORE = "store"
|
||||
|
||||
@@ -130,6 +147,18 @@ object SessionPrefs {
|
||||
private fun mangoappSession(context: Context) =
|
||||
SessionState.mode == SessionService.MODE_STEAM && mangoapp(context)
|
||||
|
||||
fun lowLatencyController(context: Context): Boolean = prefs(context).getBoolean("lowLatencyController", false)
|
||||
|
||||
fun setLowLatencyController(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("lowLatencyController", on).apply()
|
||||
}
|
||||
|
||||
fun lowLatencyTouch(context: Context): Boolean = prefs(context).getBoolean("lowLatencyTouch", false)
|
||||
|
||||
fun setLowLatencyTouch(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("lowLatencyTouch", on).apply()
|
||||
}
|
||||
|
||||
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
|
||||
|
||||
fun setBackActionsInverted(context: Context, inverted: Boolean) {
|
||||
@@ -172,6 +201,7 @@ object SessionPrefs {
|
||||
|
||||
const val GPU_DRIVERS_AUTO = "auto"
|
||||
const val GPU_DRIVERS_MANUAL = "manual"
|
||||
const val GPU_DRIVERS_MIX = "mix"
|
||||
|
||||
fun gpuDriverMode(context: Context): String =
|
||||
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
|
||||
@@ -195,6 +225,15 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
|
||||
fun picturePosition(context: Context): String =
|
||||
prefs(context).getString("picturePosition", "center")
|
||||
?.takeIf { it in listOf("top", "bottom", "center") } ?: "center"
|
||||
|
||||
fun setPicturePosition(context: Context, position: String) {
|
||||
prefs(context).edit().putString("picturePosition",
|
||||
position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply()
|
||||
}
|
||||
|
||||
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
|
||||
|
||||
fun setStretch16x9(context: Context, on: Boolean) {
|
||||
@@ -320,6 +359,9 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
|
||||
}
|
||||
|
||||
/** Whether the game's busiest threads are named to the power HAL for a boost (GameThreadHints). */
|
||||
fun gameThreadHints(context: Context): Boolean = prefs(context).getBoolean("gameThreadHints", true)
|
||||
|
||||
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
|
||||
|
||||
fun setProotNoSeccomp(context: Context, on: Boolean) {
|
||||
@@ -457,6 +499,16 @@ object SessionPrefs {
|
||||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||||
}
|
||||
|
||||
fun bootMovie(context: Context): String =
|
||||
prefs(context).getString("bootMovie", BootMovie.DROIDDECK_30) ?: BootMovie.DROIDDECK_30
|
||||
fun setBootMovie(context: Context, id: String) { prefs(context).edit().putString("bootMovie", id).apply() }
|
||||
fun bootMovieSound(context: Context): Boolean = prefs(context).getBoolean("bootMovieSound", true)
|
||||
fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() }
|
||||
fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null)
|
||||
fun setBootMovieCustomName(context: Context, name: String?) {
|
||||
prefs(context).edit().apply { if (name == null) remove("bootMovieCustomName") else putString("bootMovieCustomName", name) }.apply()
|
||||
}
|
||||
|
||||
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
|
||||
|
||||
fun setRunSteamAtStartup(context: Context, on: Boolean) {
|
||||
|
||||
@@ -71,6 +71,8 @@ class SessionService : Service() {
|
||||
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
|
||||
private var steamDownloadsInBackground = false
|
||||
private var activityVisible = true
|
||||
private var xrActivityVisible = false
|
||||
private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible, screenOn, xrActivityVisible)
|
||||
private var screenOn = true
|
||||
private var manualPauseRequested = false
|
||||
private var steamSleepToken: String? = null
|
||||
@@ -170,6 +172,17 @@ class SessionService : Service() {
|
||||
updateSuspendPolicy()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_XR_ACTIVITY_VISIBLE -> {
|
||||
xrActivityVisible = true
|
||||
resumeSession(panelVisible = false)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_XR_ACTIVITY_HIDDEN -> {
|
||||
xrActivityVisible = false
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_SUSPEND_POLICY_CHANGED -> {
|
||||
if (!SessionState.running) return START_NOT_STICKY
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
@@ -205,6 +218,7 @@ class SessionService : Service() {
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
activityVisible = true
|
||||
xrActivityVisible = false
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
@@ -278,6 +292,7 @@ class SessionService : Service() {
|
||||
|
||||
private fun runSession(gen: Int) {
|
||||
SessionTerminal.clear()
|
||||
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()
|
||||
try {
|
||||
LinuxRuntime.writeAccounts(this)
|
||||
} catch (e: Exception) {
|
||||
@@ -299,6 +314,7 @@ class SessionService : Service() {
|
||||
val sessionLog = File(sessionDir, "session.log")
|
||||
|
||||
Log.i(TAG, GpuClockPin.start(this))
|
||||
Log.i(TAG, GameThreadHints.start(this))
|
||||
|
||||
val size = SessionState.outputSize
|
||||
val guest = ArrayList<String>()
|
||||
@@ -522,9 +538,17 @@ class SessionService : Service() {
|
||||
guest.add("LIBGL_KOPPER_DRI2=true")
|
||||
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
|
||||
val linuxDriverId = SessionPrefs.linuxDriver(this)
|
||||
SessionState.runtimeDriver = linuxDriverId
|
||||
com.droiddeck.launcher.gpu.AutoDriverTrial.sessionStarting(
|
||||
this,
|
||||
com.droiddeck.launcher.wayland.CompositorHost.displayChoice.takeIf { com.droiddeck.launcher.wayland.CompositorHost.isStarted }
|
||||
?: SessionPrefs.androidDriver(this),
|
||||
linuxDriverId,
|
||||
)
|
||||
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
|
||||
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
|
||||
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
|
||||
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
|
||||
if (SessionPrefs.zinkLazy(this)) {
|
||||
guest.add("ZINK_DESCRIPTORS=lazy")
|
||||
guest.add("ZINK_DEBUG=compact")
|
||||
@@ -610,6 +634,7 @@ class SessionService : Service() {
|
||||
SessionState.deckPad = deckBinds.isNotEmpty()
|
||||
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
|
||||
logControllersAtStart()
|
||||
com.droiddeck.launcher.input.FakeInputWriter.setLowLatency(SessionPrefs.lowLatencyController(this), SessionPrefs.lowLatencyTouch(this))
|
||||
if (uinput) {
|
||||
guest.add("FAKE_EVDEV_UINPUT=1")
|
||||
if (SessionState.deckPad) {
|
||||
@@ -918,8 +943,8 @@ class SessionService : Service() {
|
||||
Log.i(TAG, "queued Steam game $gameId for the running client")
|
||||
}
|
||||
|
||||
private fun resumeSession() {
|
||||
activityVisible = true
|
||||
private fun resumeSession(panelVisible: Boolean = true) {
|
||||
if (panelVisible) activityVisible = true
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
@@ -1110,11 +1135,11 @@ class SessionService : Service() {
|
||||
|
||||
private fun updateSuspendPolicy() {
|
||||
if (!SessionState.running) return
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) {
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && !hasVisibleSurface) {
|
||||
manualPauseRequested = true
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
val hidden = !activityVisible || !screenOn
|
||||
val hidden = !hasVisibleSurface
|
||||
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
|
||||
if (downloadsBlockingSuspend) acquireLocks()
|
||||
@@ -1136,6 +1161,7 @@ class SessionService : Service() {
|
||||
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
|
||||
"policy" to suspendPolicy, "activityVisible" to activityVisible,
|
||||
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
|
||||
"xrActivityVisible" to xrActivityVisible,
|
||||
"nativeSleep" to (nativeSleepToken != null),
|
||||
))
|
||||
if (shouldSuspend) {
|
||||
@@ -1193,7 +1219,7 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
private fun resumeSteamSleepOnReturn() {
|
||||
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && activityVisible && screenOn) {
|
||||
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) {
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
@@ -1304,12 +1330,14 @@ class SessionService : Service() {
|
||||
if (!SessionState.running) return
|
||||
SessionState.running = false
|
||||
}
|
||||
xrActivityVisible = false
|
||||
updateDownloadMonitoring()
|
||||
val stoppedGen = sessionGen
|
||||
val stoppedSession = SessionState.sessionId
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
GpuClockPin.stop(this)
|
||||
GameThreadHints.stop()
|
||||
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
|
||||
suspendOperationPending = false
|
||||
launchWatcher?.stopWatching()
|
||||
@@ -1356,8 +1384,8 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
if (pipTask) {
|
||||
Log.i(TAG, "PiP task dismissed - retaining the session under its background policy")
|
||||
if (pipTask || xrActivityVisible) {
|
||||
Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy")
|
||||
activityVisible = false
|
||||
updateSuspendPolicy()
|
||||
return
|
||||
@@ -1369,6 +1397,7 @@ class SessionService : Service() {
|
||||
|
||||
override fun onDestroy() {
|
||||
stopSession(0)
|
||||
GameThreadHints.stop() // also when the session never got as far as running
|
||||
if (screenReceiverRegistered) {
|
||||
unregisterReceiver(screenReceiver)
|
||||
screenReceiverRegistered = false
|
||||
@@ -1558,6 +1587,8 @@ class SessionService : Service() {
|
||||
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
|
||||
private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE"
|
||||
private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN"
|
||||
private const val ACTION_XR_ACTIVITY_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE"
|
||||
private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN"
|
||||
private const val ACTION_TRACK_AUXILIARY = "com.droiddeck.launcher.TRACK_AUXILIARY"
|
||||
private const val ACTION_STOP_AUXILIARY = "com.droiddeck.launcher.STOP_AUXILIARY"
|
||||
private const val ACTION_AUXILIARY_EXITED = "com.droiddeck.launcher.AUXILIARY_EXITED"
|
||||
@@ -1617,6 +1648,12 @@ class SessionService : Service() {
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(action))
|
||||
}
|
||||
|
||||
fun setXrActivityVisible(context: Context, visible: Boolean) {
|
||||
if (!SessionState.running) return
|
||||
val action = if (visible) ACTION_XR_ACTIVITY_VISIBLE else ACTION_XR_ACTIVITY_HIDDEN
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(action))
|
||||
}
|
||||
|
||||
fun resume(context: Context) {
|
||||
if (!SessionState.running) return
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
|
||||
|
||||
@@ -41,6 +41,8 @@ object SessionState {
|
||||
|
||||
@Volatile
|
||||
var running = false
|
||||
@Volatile
|
||||
var runtimeDriver: String? = null
|
||||
var suspended by mutableStateOf(false)
|
||||
@Volatile var pipActive = false
|
||||
@Volatile
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal fun sessionHasVisibleSurface(panelVisible: Boolean, screenOn: Boolean, xrVisible: Boolean): Boolean =
|
||||
xrVisible || (panelVisible && screenOn)
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
@@ -13,14 +14,14 @@ object SettingsReset {
|
||||
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt",
|
||||
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
|
||||
"hud", "hudDeck", "backActionsInverted", "touch", "osc", "shape", "steamController",
|
||||
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "stretch16x9",
|
||||
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "stretch16x9", "picturePosition",
|
||||
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
|
||||
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime",
|
||||
"directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus",
|
||||
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath",
|
||||
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
|
||||
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
|
||||
"steamDownloadsInBackground", "steam_offline",
|
||||
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound",
|
||||
"questDefaultsV1",
|
||||
)
|
||||
|
||||
@@ -41,6 +42,7 @@ object SettingsReset {
|
||||
context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
context.getSharedPreferences("steam_touch", Context.MODE_PRIVATE).edit()
|
||||
.apply { STEAM_TOUCH_KEYS.forEach { remove(it) } }.commit()
|
||||
DownloadSource.reset(context)
|
||||
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(context, false)
|
||||
|
||||
if (gameProfiles) {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.droiddeck.launcher.storage
|
||||
|
||||
import java.io.File
|
||||
|
||||
object SteamStorage {
|
||||
const val SCOUT = 1070560
|
||||
const val SOLDIER = 1391110
|
||||
const val SNIPER = 1628350
|
||||
const val ARM_RUNTIME = 4185400
|
||||
const val PROTON_ARM = 4427310
|
||||
const val PROTON_11_ARM = 4628740
|
||||
const val REDISTRIBUTABLES = 228980
|
||||
const val FEX = 3127680
|
||||
|
||||
const val ARM_TOOL = "droiddeck-proton-arm64"
|
||||
const val ARM_11_TOOL = "droiddeck-proton-11-arm64"
|
||||
|
||||
private val VALVE_TOOLS = setOf(
|
||||
858280, 961940, 1054830, 1113280, 1245040, 1420170, 1493710, 1580130, 1887720, 2180100, 2348590,
|
||||
2805730, 3029110, 3658110, 4183110, 4628710, 4690330,
|
||||
SCOUT, SOLDIER, SNIPER, ARM_RUNTIME, PROTON_ARM, PROTON_11_ARM, REDISTRIBUTABLES, FEX,
|
||||
)
|
||||
|
||||
private val APP_ID = Regex("^\\s*\"appid\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val MAPPING = Regex("\"(\\d+)\"\\s*\\{\\s*\"name\"\\s*\"([^\"]*)\"")
|
||||
private val COMPAT_NAME = Regex("\"compat_tools\"\\s*\\{\\s*\"([^\"]+)\"")
|
||||
|
||||
data class Manifest(
|
||||
val file: File, val appId: Int, val name: String, val installDir: String?,
|
||||
val installed: Boolean, val lastPlayed: Long,
|
||||
)
|
||||
|
||||
fun parseManifest(file: File, text: String): Manifest? {
|
||||
val id = APP_ID.find(text)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: file.name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull() ?: return null
|
||||
val dir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
|
||||
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
|
||||
return Manifest(
|
||||
file, id, NAME.find(text)?.groupValues?.get(1)?.trim().orEmpty(), dir,
|
||||
(STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0) and 4 != 0,
|
||||
LAST_PLAYED.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
class Listing(val manifests: List<Manifest>, val otherIds: Set<Int>, val complete: Boolean) {
|
||||
val ids: Set<Int> get() = manifests.map { it.appId }.toSet() + otherIds
|
||||
}
|
||||
|
||||
private val PENDING_MANIFEST = Regex("appmanifest_(\\d+)\\.acf\\..+")
|
||||
|
||||
// A partial download has no manifest yet, only a temporary one and its staging folder.
|
||||
fun manifests(library: File): Listing? {
|
||||
val apps = File(library, "steamapps")
|
||||
val names = apps.list() ?: return null
|
||||
var complete = true
|
||||
val other = HashSet<Int>()
|
||||
val read = ArrayList<Manifest>()
|
||||
for (name in names.sorted()) {
|
||||
val pending = PENDING_MANIFEST.matchEntire(name)
|
||||
if (pending != null) {
|
||||
pending.groupValues[1].toIntOrNull()?.let(other::add)
|
||||
continue
|
||||
}
|
||||
if (!name.startsWith("appmanifest_") || !name.endsWith(".acf")) continue
|
||||
val f = File(apps, name)
|
||||
if (!f.isFile) continue
|
||||
val named = name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull()
|
||||
val text = runCatching { f.readText() }.getOrNull()
|
||||
if (text == null) {
|
||||
if (named != null) other.add(named) else complete = false
|
||||
continue
|
||||
}
|
||||
parseManifest(f, text)?.let(read::add)
|
||||
}
|
||||
File(apps, "downloading").list()?.forEach { it.toIntOrNull()?.let(other::add) }
|
||||
return Listing(read, other, complete)
|
||||
}
|
||||
|
||||
fun isTool(m: Manifest): Boolean = m.appId in VALVE_TOOLS ||
|
||||
m.installDir?.let { it.startsWith("Proton ") || it.startsWith("SteamLinuxRuntime") } == true
|
||||
|
||||
enum class Kind { ARM_PROTON, REQUIRED, OLD_RUNTIME, X86_PROTON }
|
||||
|
||||
// DroidDeck's ARM64 Steam client cannot run Valve's x86 Protons, and its own Proton and FEX tools
|
||||
// replace the scout and soldier container runtimes.
|
||||
fun kind(m: Manifest): Kind {
|
||||
val dir = m.installDir.orEmpty()
|
||||
return when {
|
||||
m.appId == SCOUT || m.appId == SOLDIER || dir == "SteamLinuxRuntime" || dir == "SteamLinuxRuntime_soldier" -> Kind.OLD_RUNTIME
|
||||
dir.startsWith("Proton") && "ARM64" in dir -> Kind.ARM_PROTON
|
||||
m.appId == PROTON_ARM || m.appId == PROTON_11_ARM -> Kind.ARM_PROTON
|
||||
dir.startsWith("Proton ") -> Kind.X86_PROTON
|
||||
else -> Kind.REQUIRED
|
||||
}
|
||||
}
|
||||
|
||||
fun compatMappings(configVdf: String): Map<String, String> {
|
||||
val start = configVdf.indexOf("\"CompatToolMapping\"")
|
||||
if (start < 0) return emptyMap()
|
||||
val open = configVdf.indexOf('{', start)
|
||||
if (open < 0) return emptyMap()
|
||||
var depth = 0
|
||||
var end = configVdf.length
|
||||
for (i in open until configVdf.length) {
|
||||
when (configVdf[i]) {
|
||||
'{' -> depth++
|
||||
'}' -> if (--depth == 0) { end = i; break }
|
||||
}
|
||||
}
|
||||
return MAPPING.findAll(configVdf.substring(open, end)).associate { it.groupValues[1] to it.groupValues[2] }
|
||||
}
|
||||
|
||||
fun compatToolName(compatibilityToolVdf: String): String? = COMPAT_NAME.find(compatibilityToolVdf)?.groupValues?.get(1)
|
||||
|
||||
fun leftoverId(name: String): Int? = name.toLongOrNull()?.takeIf { it in 1 until (1L shl 31) }?.toInt()
|
||||
|
||||
fun isStubPrefix(prefix: File): Boolean = prefix.walkTopDown().onEnter { !java.nio.file.Files.isSymbolicLink(it.toPath()) }
|
||||
.none { it.isFile && it.name != "steam_autocloud.vdf" && !java.nio.file.Files.isSymbolicLink(it.toPath()) }
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.droiddeck.launcher.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.FlatpakManager
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.SecondaryLibrary
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import java.io.File
|
||||
|
||||
enum class Bucket { GAMES, GAME_DATA, TOOLS, COMPONENTS, STORES, DESKTOP, SYSTEM, DROIDDECK }
|
||||
|
||||
class GameUsage(
|
||||
val appId: Int, val name: String, val art: File?, val library: String, val internal: Boolean, val lastPlayed: Long,
|
||||
val game: Long, val prefix: Long, val shaders: Long, val workshop: Long, val shaderDir: File?,
|
||||
) {
|
||||
val data: Long get() = prefix + shaders + workshop
|
||||
val total: Long get() = game + data
|
||||
}
|
||||
|
||||
enum class ToolState { IN_USE, REQUIRED, UNUSED }
|
||||
enum class ToolReason { DEFAULT_PROTON, PROTON, FEX_RUNTIME, ARM_RUNTIME, REDISTRIBUTABLES, FEX, STEAM_TOOL, OLD_RUNTIME, X86_PROTON, CUSTOM, CUSTOM_UNKNOWN }
|
||||
|
||||
class ToolUsage(
|
||||
val key: String, val name: String, val bytes: Long, val state: ToolState, val reason: ToolReason,
|
||||
val games: Int, val isDefault: Boolean, val manifests: List<File>, val dirs: List<File>,
|
||||
)
|
||||
|
||||
class Leftover(val appId: Int, val prefix: File?, val shaders: File?, val bytes: Long, val safe: Boolean)
|
||||
|
||||
class ComponentUsage(
|
||||
val comp: String, val version: String, val bytes: Long, val inUse: Boolean,
|
||||
val packageFile: String? = null, val protonId: String? = null, val protonName: String? = null,
|
||||
)
|
||||
|
||||
class FlatpakUsage(val id: String, val name: String, val bytes: Long)
|
||||
|
||||
class StorageReport(
|
||||
val at: Long,
|
||||
val total: Long,
|
||||
val free: Long,
|
||||
val buckets: Map<Bucket, Long>,
|
||||
val prefixes: Long,
|
||||
val shaders: Long,
|
||||
val workshop: Long,
|
||||
val flatpak: Long,
|
||||
val games: List<GameUsage>,
|
||||
val tools: List<ToolUsage>,
|
||||
val leftovers: List<Leftover>,
|
||||
val logs: List<File>,
|
||||
val logBytes: Long,
|
||||
val components: List<ComponentUsage>,
|
||||
val flatpakApps: List<FlatpakUsage>,
|
||||
val flatpakRuntimes: Long,
|
||||
val otherPrefixes: Long,
|
||||
val leftoversChecked: Boolean,
|
||||
) {
|
||||
val droiddeck: Long get() = buckets.values.sum()
|
||||
val installedShaders: Long get() = games.sumOf { it.shaders }
|
||||
val unusedTools: List<ToolUsage> get() = tools.filter { it.state == ToolState.UNUSED }
|
||||
val gamesOnSd: Int get() = games.count { !it.internal }
|
||||
val unusedComponents: List<ComponentUsage> get() = components.filter { !it.inUse }
|
||||
val flatpakCache: Long get() = (flatpak - flatpakApps.sumOf { it.bytes } - flatpakRuntimes).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
object StorageScan {
|
||||
private class Walker {
|
||||
private val seen = HashSet<Long>()
|
||||
val known = HashMap<String, Long>()
|
||||
|
||||
fun size(file: File): Long {
|
||||
known[file.path]?.let { return it }
|
||||
val st = try { Os.lstat(file.path) } catch (e: Exception) { return 0 }
|
||||
if (st.st_nlink > 1 && !OsConstants.S_ISDIR(st.st_mode)) {
|
||||
if (!seen.add(st.st_ino xor (st.st_dev shl 40))) return 0
|
||||
}
|
||||
var total = st.st_blocks * 512
|
||||
if (OsConstants.S_ISDIR(st.st_mode)) file.list()?.forEach { total += size(File(file, it)) }
|
||||
return total
|
||||
}
|
||||
|
||||
fun measure(file: File): Long = size(file).also { known[file.path] = it }
|
||||
}
|
||||
|
||||
fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
|
||||
fun scan(context: Context): StorageReport {
|
||||
val walker = Walker()
|
||||
val files = context.filesDir
|
||||
val rootfs = LinuxRuntime.rootDir(context)
|
||||
val steamRoot = steamRoot(context)
|
||||
val libraries = Library.steamLibraries(context)
|
||||
val primary = runCatching { steamRoot.canonicalPath }.getOrDefault(steamRoot.path)
|
||||
val art = runCatching { Library.steamGames(context) }.getOrDefault(emptyList()).associateBy { it.appId }
|
||||
|
||||
class Lib(val root: File, val label: String, val internal: Boolean, val data: File, val listing: SteamStorage.Listing?) {
|
||||
val manifests: List<SteamStorage.Manifest>? get() = listing?.manifests
|
||||
}
|
||||
val libs = libraries.map { (root, label) ->
|
||||
val internal = runCatching { root.canonicalPath }.getOrDefault(root.path) == primary
|
||||
val data = if (internal) root else SecondaryLibrary.privateRoot(files, root)
|
||||
Lib(root, label, internal, data, SteamStorage.manifests(root))
|
||||
}
|
||||
val everyLibraryRead = libs.all { it.listing?.complete == true }
|
||||
val everyId = libs.flatMap { it.listing?.ids.orEmpty() }.toSet()
|
||||
val mappings = runCatching { SteamStorage.compatMappings(File(steamRoot, "config/config.vdf").readText()) }.getOrNull()
|
||||
val installedGames = libs.flatMap { it.manifests.orEmpty() }.filter { !SteamStorage.isTool(it) && it.installed }.map { it.appId.toString() }.toSet()
|
||||
fun gamesOn(tool: String) = mappings.orEmpty().count { (app, name) -> name == tool && app in installedGames }
|
||||
val defaultTool = mappings?.get("0")
|
||||
|
||||
val tools = LinkedHashMap<String, ToolUsage>()
|
||||
val games = ArrayList<GameUsage>()
|
||||
for (lib in libs) {
|
||||
for (m in lib.manifests.orEmpty()) {
|
||||
val dir = m.installDir
|
||||
if (SteamStorage.isTool(m)) {
|
||||
val dirs = listOfNotNull(
|
||||
dir?.let { File(lib.root, "steamapps/common/$it") },
|
||||
dir?.let { File(lib.data, "steamapps/common/$it") }.takeIf { !lib.internal },
|
||||
).filter { it.exists() }
|
||||
val bytes = dirs.sumOf { walker.measure(it) }
|
||||
val kind = SteamStorage.kind(m)
|
||||
val (state, reason) = when (kind) {
|
||||
SteamStorage.Kind.OLD_RUNTIME -> ToolState.UNUSED to ToolReason.OLD_RUNTIME
|
||||
SteamStorage.Kind.X86_PROTON -> ToolState.UNUSED to ToolReason.X86_PROTON
|
||||
SteamStorage.Kind.ARM_PROTON -> ToolState.IN_USE to (if (m.appId == SteamStorage.PROTON_ARM) ToolReason.DEFAULT_PROTON else ToolReason.PROTON)
|
||||
SteamStorage.Kind.REQUIRED -> ToolState.REQUIRED to when (m.appId) {
|
||||
SteamStorage.SNIPER -> ToolReason.FEX_RUNTIME
|
||||
SteamStorage.ARM_RUNTIME -> ToolReason.ARM_RUNTIME
|
||||
SteamStorage.REDISTRIBUTABLES -> ToolReason.REDISTRIBUTABLES
|
||||
SteamStorage.FEX -> ToolReason.FEX
|
||||
else -> ToolReason.STEAM_TOOL
|
||||
}
|
||||
}
|
||||
val count = when (m.appId) {
|
||||
SteamStorage.PROTON_ARM -> gamesOn(SteamStorage.ARM_TOOL)
|
||||
SteamStorage.PROTON_11_ARM -> gamesOn(SteamStorage.ARM_11_TOOL)
|
||||
else -> 0
|
||||
}
|
||||
val key = "app:${m.appId}"
|
||||
val prior = tools[key]
|
||||
tools[key] = ToolUsage(
|
||||
key, m.name.ifEmpty { dir ?: m.appId.toString() }, bytes + (prior?.bytes ?: 0), state, reason, count,
|
||||
false, (prior?.manifests.orEmpty()) + m.file, (prior?.dirs.orEmpty()) + dirs,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!m.installed) continue
|
||||
val gameBytes = dir?.let { File(lib.root, "steamapps/common/$it") }?.takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
|
||||
val prefix = File(lib.data, "steamapps/compatdata/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
|
||||
val shaderDir = File(lib.data, "steamapps/shadercache/${m.appId}").takeIf { it.isDirectory }
|
||||
val shaders = shaderDir?.let { walker.measure(it) } ?: 0
|
||||
val workshop = File(lib.root, "steamapps/workshop/content/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
|
||||
val known = art[m.appId]
|
||||
games.add(GameUsage(
|
||||
m.appId, known?.name ?: m.name, known?.art, lib.label, lib.internal, m.lastPlayed,
|
||||
gameBytes, prefix, shaders, workshop, shaderDir,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
File(steamRoot, "compatibilitytools.d").listFiles()?.sortedBy { it.name }?.forEach { dir ->
|
||||
if (!dir.isDirectory) return@forEach
|
||||
val bytes = walker.measure(dir)
|
||||
if (dir.name.startsWith(".") || dir.name.startsWith("droiddeck-") || dir.name.startsWith("bannerlator-") ||
|
||||
!File(dir, "toolmanifest.vdf").isFile) return@forEach
|
||||
val name = runCatching { SteamStorage.compatToolName(File(dir, "compatibilitytool.vdf").readText()) }.getOrNull() ?: dir.name
|
||||
val count = gamesOn(name)
|
||||
val isDefault = defaultTool == name
|
||||
tools["dir:${dir.name}"] = ToolUsage(
|
||||
"dir:${dir.name}", dir.name, bytes,
|
||||
when {
|
||||
mappings == null -> ToolState.REQUIRED
|
||||
count > 0 || isDefault -> ToolState.IN_USE
|
||||
else -> ToolState.UNUSED
|
||||
},
|
||||
if (mappings == null) ToolReason.CUSTOM_UNKNOWN else ToolReason.CUSTOM, count, isDefault, emptyList(), listOf(dir),
|
||||
)
|
||||
}
|
||||
|
||||
val leftovers = ArrayList<Leftover>()
|
||||
for (lib in libs) {
|
||||
if (!everyLibraryRead) break
|
||||
val prefixes = File(lib.data, "steamapps/compatdata").listFiles().orEmpty()
|
||||
.mapNotNull { f -> SteamStorage.leftoverId(f.name)?.takeIf { it !in everyId && f.isDirectory }?.let { it to f } }.toMap()
|
||||
val caches = File(lib.data, "steamapps/shadercache").listFiles().orEmpty()
|
||||
.mapNotNull { f -> SteamStorage.leftoverId(f.name)?.takeIf { it !in everyId && f.isDirectory }?.let { it to f } }.toMap()
|
||||
for (id in (prefixes.keys + caches.keys).sorted()) {
|
||||
val prefix = prefixes[id]
|
||||
val cache = caches[id]
|
||||
val bytes = (prefix?.let { walker.measure(it) } ?: 0) + (cache?.let { walker.measure(it) } ?: 0)
|
||||
val safe = prefix == null || SteamStorage.isStubPrefix(prefix)
|
||||
leftovers.add(Leftover(id, prefix, cache, bytes, safe))
|
||||
}
|
||||
}
|
||||
|
||||
val logs = SessionPaths.sessionFolders(context).filter { it != SessionPaths.current() } +
|
||||
listOf(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR)).filter { it.exists() }
|
||||
val logBytes = logs.sumOf { walker.measure(it) }
|
||||
|
||||
val flatpakDir = File(rootfs, "root/.local/share/flatpak")
|
||||
val flatpakNames = runCatching { FlatpakManager.installedApps(context) }.getOrDefault(emptyList()).associate { it.id to it.name }
|
||||
val flatpakApps = File(flatpakDir, "app").listFiles().orEmpty().filter { it.isDirectory }.map { dir ->
|
||||
FlatpakUsage(dir.name, flatpakNames[dir.name] ?: dir.name.substringAfterLast('.'), walker.measure(dir))
|
||||
}.sortedByDescending { it.bytes }
|
||||
val flatpakRuntimes = File(flatpakDir, "runtime").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
|
||||
|
||||
val componentsDir = File(files, "components")
|
||||
val stored = runCatching { ComponentsManager.stored(context) }.onFailure { Log.w("StorageScan", "components", it) }.getOrNull()
|
||||
val components = stored?.let { st ->
|
||||
st.packages.map { ComponentUsage(it.pkg.comp, it.pkg.version, it.pkg.size, it.inUse, packageFile = it.pkg.file) } +
|
||||
st.originals.map { ComponentUsage(it.comp, it.protonVersion, it.size, it.kept, protonId = it.protonId, protonName = it.protonName) }
|
||||
}.orEmpty()
|
||||
|
||||
val otherPrefixes = libs.flatMap { lib ->
|
||||
File(lib.data, "steamapps/compatdata").listFiles().orEmpty().filter { it.isDirectory && SteamStorage.leftoverId(it.name) == null }
|
||||
}.sumOf { walker.measure(it) }
|
||||
|
||||
val apps = File(steamRoot, "steamapps")
|
||||
val privateRoots = File(files, "steam-libraries").listFiles().orEmpty().filter { it.isDirectory }
|
||||
fun m(file: File): Long = if (file.exists()) walker.measure(file) else 0
|
||||
val toolDirs = tools.values.flatMap { it.dirs }.distinctBy { it.path }
|
||||
val toolsInApps = toolDirs.filter { it.path.startsWith(apps.path + "/") }.sumOf { walker.known[it.path] ?: 0 }
|
||||
val toolsPrivate = toolDirs.filter { d -> privateRoots.any { d.path.startsWith(it.path + "/") } }.sumOf { walker.known[it.path] ?: 0 }
|
||||
val compatTools = m(File(steamRoot, "compatibilitytools.d"))
|
||||
val installs = m(File(apps, "common")) + m(File(apps, "downloading")) + m(File(apps, "temp"))
|
||||
val privatePrefixes = privateRoots.sumOf { m(File(it, "steamapps/compatdata")) }
|
||||
val privateShaders = privateRoots.sumOf { m(File(it, "steamapps/shadercache")) }
|
||||
val prefixes = m(File(apps, "compatdata")) + privatePrefixes
|
||||
val shaders = m(File(apps, "shadercache")) + privateShaders
|
||||
val workshop = m(File(apps, "workshop"))
|
||||
val stores = m(File(rootfs, "root/Games"))
|
||||
val flatpak = m(File(rootfs, "root/.local/share/flatpak"))
|
||||
val rootfsBytes = m(rootfs)
|
||||
val privateBytes = privateRoots.sumOf { m(it) }
|
||||
val system = rootfsBytes - installs - m(File(apps, "compatdata")) - m(File(apps, "shadercache")) - workshop -
|
||||
compatTools - stores - flatpak
|
||||
val componentBytes = m(componentsDir)
|
||||
val droiddeck = m(files) + m(context.cacheDir) - rootfsBytes - privateBytes - componentBytes +
|
||||
(privateBytes - toolsPrivate - privatePrefixes - privateShaders)
|
||||
val stat = StatFs(files.path)
|
||||
return StorageReport(
|
||||
System.currentTimeMillis(), stat.totalBytes, stat.availableBytes,
|
||||
mapOf(
|
||||
Bucket.GAMES to (installs - toolsInApps).coerceAtLeast(0),
|
||||
Bucket.GAME_DATA to (prefixes + shaders + workshop),
|
||||
Bucket.TOOLS to toolsInApps + toolsPrivate + compatTools,
|
||||
Bucket.COMPONENTS to componentBytes,
|
||||
Bucket.STORES to stores,
|
||||
Bucket.DESKTOP to flatpak,
|
||||
Bucket.SYSTEM to system.coerceAtLeast(0),
|
||||
Bucket.DROIDDECK to droiddeck.coerceAtLeast(0),
|
||||
),
|
||||
prefixes, shaders, workshop, flatpak,
|
||||
games.sortedByDescending { it.total }, tools.values.toList(), leftovers, logs, logBytes,
|
||||
components, flatpakApps, flatpakRuntimes, otherPrefixes, everyLibraryRead,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.droiddeck.launcher.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.files.FileOps
|
||||
import com.droiddeck.launcher.runtime.FlatpakManager
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
enum class CleanItem { OLD_TOOLS, LEFTOVERS, LEFTOVERS_UNSURE, LOGS, UNUSED_CUSTOM, UNUSED_COMPONENTS, SHADERS }
|
||||
|
||||
object StorageState {
|
||||
private const val TAG = "StorageState"
|
||||
const val LOW_SPACE_BYTES = 10L * 1024 * 1024 * 1024
|
||||
private const val FRESH_MS = 5L * 60 * 1000
|
||||
|
||||
var report by mutableStateOf<StorageReport?>(null)
|
||||
private set
|
||||
var scanning by mutableStateOf(false)
|
||||
private set
|
||||
var cleaning by mutableStateOf(false)
|
||||
private set
|
||||
var lastFreed by mutableStateOf<Long?>(null)
|
||||
var progress by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var lastError by mutableStateOf<String?>(null)
|
||||
|
||||
var openRequests by mutableIntStateOf(0)
|
||||
private set
|
||||
var openPending = false
|
||||
|
||||
@Volatile var lowSpaceDismissed = false
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
fun requestOpen() {
|
||||
openPending = true
|
||||
openRequests++
|
||||
}
|
||||
|
||||
fun refresh(context: Context, force: Boolean = false) {
|
||||
val current = report
|
||||
if (scanning || cleaning) return
|
||||
if (!force && current != null && System.currentTimeMillis() - current.at < FRESH_MS) return
|
||||
val app = context.applicationContext
|
||||
scanning = true
|
||||
Thread({
|
||||
val result = runCatching { StorageScan.scan(app) }
|
||||
.onFailure { Log.w(TAG, "storage scan failed", it) }.getOrNull()
|
||||
main.post {
|
||||
if (result != null) report = result
|
||||
scanning = false
|
||||
}
|
||||
}, "storage-scan").start()
|
||||
}
|
||||
|
||||
fun freeBytes(context: Context): Long = runCatching { StatFs(context.filesDir.path).availableBytes }.getOrDefault(Long.MAX_VALUE)
|
||||
|
||||
fun lowOnSpace(context: Context): Boolean = freeBytes(context) < LOW_SPACE_BYTES
|
||||
|
||||
fun bytes(report: StorageReport, item: CleanItem): Long = when (item) {
|
||||
CleanItem.OLD_TOOLS -> report.unusedTools.filter { it.reason != ToolReason.CUSTOM }.sumOf { it.bytes }
|
||||
CleanItem.LEFTOVERS -> report.leftovers.filter { it.safe }.sumOf { it.bytes }
|
||||
CleanItem.LEFTOVERS_UNSURE -> report.leftovers.filter { !it.safe }.sumOf { it.bytes }
|
||||
CleanItem.LOGS -> report.logBytes
|
||||
CleanItem.UNUSED_CUSTOM -> report.unusedTools.filter { it.reason == ToolReason.CUSTOM }.sumOf { it.bytes }
|
||||
CleanItem.UNUSED_COMPONENTS -> report.unusedComponents.sumOf { it.bytes }
|
||||
CleanItem.SHADERS -> report.installedShaders
|
||||
}
|
||||
|
||||
val SAFE = setOf(CleanItem.OLD_TOOLS, CleanItem.LEFTOVERS, CleanItem.LOGS)
|
||||
|
||||
fun safeBytes(report: StorageReport): Long = SAFE.sumOf { bytes(report, it) }
|
||||
|
||||
fun clean(context: Context, items: Set<CleanItem>, onDone: (Long?) -> Unit) {
|
||||
if (cleaning || SessionState.running) { onDone(null); return }
|
||||
val app = context.applicationContext
|
||||
appContext = app
|
||||
cleaning = true
|
||||
Thread({
|
||||
val before = freeBytes(app)
|
||||
val fresh = runCatching { StorageScan.scan(app) }.onFailure { Log.w(TAG, "storage scan before cleanup failed", it) }.getOrNull()
|
||||
if (fresh != null && !SessionState.running) {
|
||||
for (item in items) runCatching { cleanItem(fresh, item) }.onFailure { Log.w(TAG, "could not clean $item", it) }
|
||||
}
|
||||
val freed = if (fresh == null) null else (freeBytes(app) - before).coerceAtLeast(0)
|
||||
Log.i(TAG, "cleaned ${items.joinToString()} and freed $freed bytes")
|
||||
finish(app, freed, onDone)
|
||||
}, "storage-clean").start()
|
||||
}
|
||||
|
||||
fun removeTool(context: Context, tool: ToolUsage, onDone: (Long?) -> Unit) {
|
||||
if (tool.state != ToolState.UNUSED || cleaning || SessionState.running) { onDone(null); return }
|
||||
val app = context.applicationContext
|
||||
cleaning = true
|
||||
Thread({
|
||||
val before = freeBytes(app)
|
||||
val current = runCatching { StorageScan.scan(app) }.getOrNull()?.tools?.firstOrNull { it.key == tool.key }
|
||||
val freed = if (current?.state == ToolState.UNUSED && !SessionState.running) {
|
||||
runCatching { deleteTool(current) }.onFailure { Log.w(TAG, "could not remove ${tool.key}", it) }
|
||||
(freeBytes(app) - before).coerceAtLeast(0)
|
||||
} else {
|
||||
Log.w(TAG, "kept ${tool.key}: it is in use now or could not be checked")
|
||||
null
|
||||
}
|
||||
finish(app, freed, onDone)
|
||||
}, "storage-remove-tool").start()
|
||||
}
|
||||
|
||||
// Every action works from a fresh scan so nothing is deleted on the strength of an older one.
|
||||
private fun act(context: Context, name: String, action: (Context, StorageReport) -> Boolean, onDone: (Long?) -> Unit = {}) {
|
||||
if (cleaning || SessionState.running) { onDone(null); return }
|
||||
val app = context.applicationContext
|
||||
appContext = app
|
||||
cleaning = true
|
||||
lastError = null
|
||||
Thread({
|
||||
val before = freeBytes(app)
|
||||
val fresh = runCatching { StorageScan.scan(app) }.getOrNull()
|
||||
val done = fresh != null && !SessionState.running &&
|
||||
runCatching { action(app, fresh) }.onFailure { Log.w(TAG, "$name failed", it); main.post { lastError = it.message } }.getOrDefault(false)
|
||||
val freed = if (done) (freeBytes(app) - before).coerceAtLeast(0) else null
|
||||
Log.i(TAG, "$name: freed $freed bytes")
|
||||
main.post { progress = null }
|
||||
finish(app, freed, onDone)
|
||||
}, "storage-$name").start()
|
||||
}
|
||||
|
||||
fun clearShaders(context: Context, appId: Int) = act(context, "shaders-$appId", { _, fresh ->
|
||||
val dir = fresh.games.firstOrNull { it.appId == appId }?.shaderDir ?: return@act false
|
||||
FileOps.delete(dir)
|
||||
})
|
||||
|
||||
fun removeLeftover(context: Context, appId: Int) = act(context, "leftover-$appId", { _, fresh ->
|
||||
val leftover = fresh.leftovers.firstOrNull { it.appId == appId } ?: return@act false
|
||||
deleteLeftover(leftover)
|
||||
true
|
||||
})
|
||||
|
||||
fun removeComponent(context: Context, usage: ComponentUsage) = act(context, "component", { app, fresh ->
|
||||
val current = fresh.components.firstOrNull { it.packageFile == usage.packageFile && it.protonId == usage.protonId &&
|
||||
it.comp == usage.comp && it.version == usage.version } ?: return@act false
|
||||
!current.inUse && deleteComponent(app, current)
|
||||
})
|
||||
|
||||
fun uninstallFlatpak(context: Context, id: String) = act(context, "flatpak-$id", { app, fresh ->
|
||||
if (fresh.flatpakApps.none { it.id == id }) return@act false
|
||||
val error = FlatpakManager.uninstall(app, id) { step, _ -> main.post { progress = step } }
|
||||
if (error != null) main.post { lastError = error }
|
||||
error == null
|
||||
})
|
||||
|
||||
fun pruneFlatpak(context: Context) = act(context, "flatpak-prune", { app, _ ->
|
||||
val error = FlatpakManager.prune(app) { step, _ -> main.post { progress = step } }
|
||||
if (error != null) main.post { lastError = error }
|
||||
error == null
|
||||
})
|
||||
|
||||
private fun deleteComponent(app: Context, usage: ComponentUsage): Boolean {
|
||||
if (usage.inUse) return false
|
||||
usage.packageFile?.let { file ->
|
||||
return runCatching { ComponentsManager.deletePackage(app, file) }.onFailure { Log.w(TAG, "kept $file", it) }.isSuccess
|
||||
}
|
||||
val proton = usage.protonId ?: return false
|
||||
return ComponentsManager.deleteUnusedOriginal(app, proton, usage.comp, usage.version)
|
||||
}
|
||||
|
||||
private fun finish(app: Context, freed: Long?, onDone: (Long?) -> Unit) {
|
||||
val rescanned = runCatching { StorageScan.scan(app) }.getOrNull()
|
||||
main.post {
|
||||
if (rescanned != null) report = rescanned
|
||||
cleaning = false
|
||||
lastFreed = freed
|
||||
onDone(freed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanItem(report: StorageReport, item: CleanItem) {
|
||||
when (item) {
|
||||
CleanItem.OLD_TOOLS -> report.unusedTools.filter { it.reason != ToolReason.CUSTOM }.forEach(::deleteTool)
|
||||
CleanItem.UNUSED_CUSTOM -> report.unusedTools.filter { it.reason == ToolReason.CUSTOM }.forEach(::deleteTool)
|
||||
CleanItem.UNUSED_COMPONENTS -> report.unusedComponents.forEach { deleteComponent(appContext!!, it) }
|
||||
CleanItem.LEFTOVERS -> report.leftovers.filter { it.safe }.forEach(::deleteLeftover)
|
||||
CleanItem.LEFTOVERS_UNSURE -> report.leftovers.filter { !it.safe }.forEach(::deleteLeftover)
|
||||
CleanItem.LOGS -> report.logs.forEach { FileOps.delete(it) }
|
||||
CleanItem.SHADERS -> report.games.mapNotNull { it.shaderDir }.forEach { FileOps.delete(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Steam treats an app as installed while its manifest exists, so the manifest goes first.
|
||||
private fun deleteTool(tool: ToolUsage) {
|
||||
tool.manifests.forEach { check(!it.exists() || it.delete()) { "cannot delete $it" } }
|
||||
tool.dirs.forEach { FileOps.delete(it) }
|
||||
}
|
||||
|
||||
private fun deleteLeftover(leftover: Leftover) {
|
||||
leftover.prefix?.let { FileOps.delete(it) }
|
||||
leftover.shaders?.let { FileOps.delete(it) }
|
||||
}
|
||||
|
||||
fun format(bytes: Long): String {
|
||||
val gb = bytes / (1024.0 * 1024 * 1024)
|
||||
return when {
|
||||
gb >= 100 -> String.format(java.util.Locale.US, "%.0f GB", gb)
|
||||
gb >= 10 -> String.format(java.util.Locale.US, "%.1f GB", gb)
|
||||
gb >= 0.995 -> String.format(java.util.Locale.US, "%.2f GB", gb)
|
||||
bytes >= 1024L * 1024 -> String.format(java.util.Locale.US, "%.0f MB", bytes / (1024.0 * 1024))
|
||||
else -> "0 MB"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.stores.epic.EpicAchievements
|
||||
@@ -15,6 +16,7 @@ import java.util.Locale
|
||||
|
||||
abstract class StoreAchievements(val source: String, val label: String) {
|
||||
|
||||
@Immutable
|
||||
data class Game(val source: String, val id: String, val extra: Map<String, String> = emptyMap()) {
|
||||
companion object {
|
||||
fun of(item: CatalogItem) = Game(item.store.id, item.id, item.extra)
|
||||
|
||||
@@ -31,6 +31,7 @@ class StoreShelves(
|
||||
val deals: List<CatalogItem> = emptyList(),
|
||||
val free: List<CatalogItem> = emptyList(),
|
||||
val trending: List<CatalogItem> = emptyList(),
|
||||
val art: List<String> = emptyList(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
|
||||
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
|
||||
@@ -52,6 +53,35 @@ fun installedCards(store: Store, installed: List<InstalledStoreGame>, library: L
|
||||
}.distinctBy { it.id }.sortedBy { it.title.lowercase() }
|
||||
}
|
||||
|
||||
private fun releaseDateValue(item: CatalogItem): Long =
|
||||
item.releaseDate.take(10).takeIf { it.length == 10 }?.let { raw ->
|
||||
runCatching { java.time.LocalDate.parse(raw).toEpochDay() }.getOrDefault(0L)
|
||||
} ?: 0L
|
||||
|
||||
fun sortCatalogItems(
|
||||
items: List<CatalogItem>,
|
||||
sort: String,
|
||||
lastPlayed: (CatalogItem) -> Long = { 0L },
|
||||
installedAt: (CatalogItem) -> Long = { 0L },
|
||||
size: (CatalogItem) -> Long = { it.sizeBytes },
|
||||
): List<CatalogItem> = when (sort) {
|
||||
"lastPlayed" -> items.sortedWith(compareByDescending(lastPlayed).thenBy { it.title.lowercase() })
|
||||
"recentlyInstalled" -> items.sortedWith(compareByDescending(installedAt).thenBy { it.title.lowercase() })
|
||||
"titleAsc" -> items.sortedBy { it.title.lowercase() }
|
||||
"titleDesc" -> items.sortedByDescending { it.title.lowercase() }
|
||||
"sizeDesc" -> items.sortedWith(compareByDescending(size).thenBy { it.title.lowercase() })
|
||||
"sizeAsc" -> items.sortedWith(compareBy<CatalogItem> { size(it).takeIf { b -> b > 0 } ?: Long.MAX_VALUE }.thenBy { it.title.lowercase() })
|
||||
"releaseDateDesc" -> items.sortedWith(compareByDescending(::releaseDateValue).thenBy { it.title.lowercase() })
|
||||
else -> items
|
||||
}
|
||||
|
||||
internal fun retainCatalogOrder(items: List<CatalogItem>, previous: List<CatalogItem>): List<CatalogItem> {
|
||||
if (items.size != previous.size) return items
|
||||
val byKey = items.associateBy { it.key }
|
||||
if (previous.any { it.key !in byKey }) return items
|
||||
return previous.map { byKey.getValue(it.key) }
|
||||
}
|
||||
|
||||
fun mergeItems(old: List<CatalogItem>?, new: List<CatalogItem>): List<CatalogItem> {
|
||||
if (old.isNullOrEmpty()) return new
|
||||
val before = old.associateBy { it.key }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
@@ -64,6 +65,10 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
private lateinit var errorPanel: LinearLayout
|
||||
private lateinit var errorText: TextView
|
||||
private lateinit var titleText: TextView
|
||||
private lateinit var topBar: LinearLayout
|
||||
private lateinit var reloadButton: ImageButton
|
||||
private lateinit var closeButton: ImageButton
|
||||
private lateinit var retryButton: Button
|
||||
private var oauthState: String? = null
|
||||
private val captured = AtomicBoolean(false)
|
||||
private var flooded = false
|
||||
@@ -130,6 +135,7 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
contentDescription = getString(R.string.stores_login_close); layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
|
||||
setOnClickListener { finish() }
|
||||
}
|
||||
topBar = bar; reloadButton = reload; closeButton = close
|
||||
bar.addView(titleText); bar.addView(reload); bar.addView(close)
|
||||
progressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||
max = 100; isIndeterminate = false
|
||||
@@ -144,6 +150,7 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
}
|
||||
errorText = TextView(this).apply { setTextColor(0xFFF2F4F7.toInt()); textSize = 15f; gravity = Gravity.CENTER }
|
||||
val retry = Button(this).apply { text = getString(R.string.stores_login_retry); setOnClickListener { reloadLogin() } }
|
||||
retryButton = retry
|
||||
errorPanel.addView(errorText)
|
||||
errorPanel.addView(retry, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(16) })
|
||||
contentHost.addView(errorPanel, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
@@ -151,6 +158,24 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity handles density changes itself so that folding between two displays of different
|
||||
* density keeps the page and the sign-in in progress; the dp-based chrome sizes are applied again.
|
||||
*/
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
if (!::topBar.isInitialized) return
|
||||
topBar.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48))
|
||||
titleText.setPadding(dp(16), 0, dp(8), 0)
|
||||
titleText.textSize = 16f
|
||||
errorText.textSize = 15f
|
||||
reloadButton.layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
|
||||
closeButton.layoutParams = LinearLayout.LayoutParams(dp(48), dp(48))
|
||||
progressBar.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(3))
|
||||
errorPanel.setPadding(dp(24), dp(24), dp(24), dp(24))
|
||||
retryButton.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(16) }
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
runOnUiThread { errorText.text = message; errorPanel.visibility = View.VISIBLE; errorPanel.bringToFront(); progressBar.visibility = View.GONE }
|
||||
}
|
||||
@@ -171,6 +196,9 @@ class StoreLoginActivity : ComponentActivity() {
|
||||
setBackgroundColor(0xFF0A0B0D.toInt())
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
// Store sign-in pages are web pages; they never need to read local files or content:// URIs.
|
||||
settings.allowFileAccess = false
|
||||
settings.allowContentAccess = false
|
||||
settings.userAgentString = userAgent
|
||||
settings.setSupportMultipleWindows(true)
|
||||
settings.javaScriptCanOpenWindowsAutomatically = true
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.droiddeck.launcher.stores.epic.EpicCredentialStore
|
||||
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
|
||||
import com.droiddeck.launcher.stores.epic.EpicInstallTags
|
||||
import com.droiddeck.launcher.stores.epic.EpicPrefs
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -20,7 +21,8 @@ object StoreSizes {
|
||||
|
||||
val known = mutableStateMapOf<String, Long>()
|
||||
|
||||
private val asked = ConcurrentHashMap.newKeySet<String>()
|
||||
private val sizeRequests = ConcurrentHashMap<String, CompletableFuture<Long?>>()
|
||||
private val diskRequests = ConcurrentHashMap<String, CompletableFuture<Long?>>()
|
||||
private val pool = Executors.newFixedThreadPool(3, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("sizes"))
|
||||
|
||||
fun size(item: CatalogItem): Long = known[item.key] ?: item.sizeBytes
|
||||
@@ -29,22 +31,35 @@ object StoreSizes {
|
||||
private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) }
|
||||
|
||||
fun request(context: Context, item: CatalogItem) {
|
||||
if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG) return
|
||||
if (known.containsKey(item.key) || !asked.add(item.key)) return
|
||||
if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return
|
||||
resolve(context, item).thenAccept { bytes ->
|
||||
if (bytes != null) StoresState.post { known[item.key] = bytes }
|
||||
}
|
||||
}
|
||||
|
||||
fun resolve(context: Context, item: CatalogItem): CompletableFuture<Long?> {
|
||||
val available = known[item.key] ?: item.sizeBytes.takeIf { it > 0 }
|
||||
if (!item.owned || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available)
|
||||
if (available != null) return CompletableFuture.completedFuture(available)
|
||||
val app = context.applicationContext
|
||||
val version = item.extra["version"].orEmpty()
|
||||
pool.execute {
|
||||
val cached = cache(app, item.store).get(item.id, version)
|
||||
if (cached != null) { StoresState.post { known[item.key] = cached }; return@execute }
|
||||
val bytes = runCatching { fetch(app, item) }.onFailure { Log.w(TAG, "${item.key}: ${it.javaClass.simpleName}") }.getOrNull() ?: 0L
|
||||
if (bytes > 0) {
|
||||
cache(app, item.store).put(item.id, version, bytes)
|
||||
when (item.store) {
|
||||
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", bytes).apply()
|
||||
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", bytes).apply()
|
||||
Store.GOG -> {}
|
||||
return sizeRequests.computeIfAbsent(item.key) {
|
||||
CompletableFuture.supplyAsync({
|
||||
val cached = cache(app, item.store).get(item.id, version)
|
||||
val bytes = cached ?: runCatching { fetch(app, item) }
|
||||
.onFailure { Log.w(TAG, "${item.key}: ${it.javaClass.simpleName}") }
|
||||
.getOrNull()
|
||||
bytes?.takeIf { it > 0 }?.also {
|
||||
cache(app, item.store).put(item.id, version, it)
|
||||
when (item.store) {
|
||||
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.GOG -> {}
|
||||
}
|
||||
}
|
||||
StoresState.post { known[item.key] = bytes }
|
||||
}, pool).exceptionally { error ->
|
||||
Log.w(TAG, "${item.key}: size resolution failed", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,14 +67,25 @@ object StoreSizes {
|
||||
val onDisk = mutableStateMapOf<String, Long>()
|
||||
|
||||
fun requestDisk(context: Context, key: String, game: InstalledStoreGame) {
|
||||
if (onDisk.containsKey(key) || !asked.add("disk:$key")) return
|
||||
resolveDisk(context, key, game).thenAccept { bytes ->
|
||||
if (bytes != null) StoresState.post { onDisk[key] = bytes }
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveDisk(context: Context, key: String, game: InstalledStoreGame): CompletableFuture<Long?> {
|
||||
onDisk[key]?.let { return CompletableFuture.completedFuture(it) }
|
||||
val app = context.applicationContext
|
||||
val version = "disk:${game.sidecar.installedAt}:${game.sidecar.installVersion}"
|
||||
pool.execute {
|
||||
val c = cache(app, game.sidecar.store)
|
||||
val bytes = c.get("disk:${game.sidecar.id}", version)
|
||||
?: game.folder.walkTopDown().filter { it.isFile && !it.name.startsWith(".droiddeck") }.sumOf { it.length() }.also { c.put("disk:${game.sidecar.id}", version, it) }
|
||||
if (bytes > 0) StoresState.post { onDisk[key] = bytes }
|
||||
return diskRequests.computeIfAbsent(key) {
|
||||
CompletableFuture.supplyAsync({
|
||||
val c = cache(app, game.sidecar.store)
|
||||
c.get("disk:${game.sidecar.id}", version)
|
||||
?: game.folder.walkTopDown().filter { it.isFile && !it.name.startsWith(".droiddeck") }
|
||||
.sumOf { it.length() }.also { c.put("disk:${game.sidecar.id}", version, it) }
|
||||
}, pool).thenApply { it.takeIf { bytes -> bytes > 0 } }.exceptionally { error ->
|
||||
Log.w(TAG, "$key: disk size resolution failed", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ object StoresState {
|
||||
fun refresh(context: Context) {
|
||||
val app = context.applicationContext
|
||||
com.droiddeck.launcher.stores.download.SavedQueue.restore(app)
|
||||
Thread({
|
||||
background("stores-refresh") {
|
||||
val found = Store.entries.mapNotNull { store -> StoreAccounts.signedInAs(app, store)?.let { store to it } }
|
||||
val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
|
||||
StoreGameSidecar.read(folder)?.takeIf { it.isInstalled }?.let { InstalledStoreGame(it, folder) }
|
||||
@@ -85,7 +85,7 @@ object StoresState {
|
||||
unfinished = partial
|
||||
engine = version
|
||||
}
|
||||
}, "stores-refresh").start()
|
||||
}
|
||||
}
|
||||
|
||||
fun isSignedIn(store: Store): Boolean = accounts.containsKey(store)
|
||||
@@ -136,7 +136,7 @@ object StoresState {
|
||||
|
||||
fun signOut(context: Context, store: Store) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
background("stores-signout") {
|
||||
backends[store]?.signOut(app)
|
||||
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
|
||||
else StoreAccounts.clear(app, store)
|
||||
@@ -144,7 +144,7 @@ object StoresState {
|
||||
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
|
||||
logLine("${store.label}: signed out")
|
||||
}
|
||||
}, "stores-signout").start()
|
||||
}
|
||||
}
|
||||
|
||||
var openDownloads by mutableStateOf(false)
|
||||
@@ -199,7 +199,7 @@ object StoresState {
|
||||
val app = context.applicationContext
|
||||
val key = "${game.sidecar.store.id}:${game.sidecar.id}"
|
||||
uninstalling[key] = 0f
|
||||
Thread({
|
||||
background("stores-uninstall", onFailure = { main.post { uninstalling.remove(key); onDone() } }) {
|
||||
runCatching { backends[game.sidecar.store]?.uninstall(app, game) }
|
||||
StoreInstalls.uninstall(app, game, progress = { f -> main.post { if (key in uninstalling) uninstalling[key] = f } }, removed = {
|
||||
main.post {
|
||||
@@ -208,7 +208,7 @@ object StoresState {
|
||||
}
|
||||
})
|
||||
main.post(onDone)
|
||||
}, "stores-uninstall").start()
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile var libraryListener: (() -> Unit)? = null
|
||||
@@ -231,13 +231,31 @@ object StoresState {
|
||||
appContext = context.applicationContext
|
||||
StoreLogFiles.prune(context)
|
||||
val overlay = java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.local/share/droiddeck/epic-overlay")
|
||||
if (overlay.exists()) Thread({ overlay.deleteRecursively(); Log.i(TAG, "removed the old EOS overlay download") }, "epic-overlay-cleanup").start()
|
||||
if (overlay.exists()) background("epic-overlay-cleanup") { overlay.deleteRecursively(); Log.i(TAG, "removed the old EOS overlay download") }
|
||||
val app = context.applicationContext
|
||||
Thread({ CloudSaves.uploadAllDirty(app, "recovery") }, "cloud-recovery").start()
|
||||
Thread({
|
||||
runCatching { CloudSaves.uploadAllDirty(app, "recovery") }
|
||||
.onFailure { Log.w(TAG, "cloud save recovery failed", it) }
|
||||
}, "cloud-recovery").start()
|
||||
}
|
||||
|
||||
internal fun post(block: () -> Unit) = main.post(block)
|
||||
|
||||
/**
|
||||
* A worker thread whose failure is logged. An uncaught exception on a plain Thread reaches the crash
|
||||
* handler, which ends the whole app, and none of these jobs is worth that.
|
||||
*/
|
||||
private fun background(name: String, onFailure: () -> Unit = {}, block: () -> Unit) {
|
||||
Thread({
|
||||
try {
|
||||
block()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "$name failed", e)
|
||||
runCatching(onFailure)
|
||||
}
|
||||
}, name).start()
|
||||
}
|
||||
|
||||
private val CLOCK = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,20 @@ object AmazonBackend : StoreBackend {
|
||||
Thread({ AmazonLibrary.sync(app, force) }, "amazon-sync").start()
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {}
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
AmazonShelves.cached(app)?.let { s -> StoresState.post { withArt(s.art) } }
|
||||
Thread({
|
||||
val shelves = AmazonShelves.featured(app, force)
|
||||
if (shelves != null) StoresState.post { withArt(shelves.art) }
|
||||
}, "amazon-shelves").start()
|
||||
}
|
||||
|
||||
private fun withArt(art: List<String>) {
|
||||
val s = StoresState.shelves[store]
|
||||
StoresState.shelves[store] = if (s == null) com.droiddeck.launcher.stores.StoreShelves(art = art)
|
||||
else com.droiddeck.launcher.stores.StoreShelves(s.whatsNew, s.deals, s.free, s.trending, art)
|
||||
}
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
|
||||
val app = context.applicationContext
|
||||
|
||||
@@ -102,7 +102,7 @@ object AmazonLibrary {
|
||||
StoresState.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 })
|
||||
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.droiddeck.launcher.stores.amazon
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
object AmazonShelves {
|
||||
private const val TAG = "AmazonShelves"
|
||||
private const val PAGE = "https://luna.amazon.com/claims/home"
|
||||
private const val GRAPHQL = "https://luna.amazon.com/graphql"
|
||||
private const val KEY_ART = "store_art"
|
||||
private const val KEY_ART_AT = "store_art_at"
|
||||
private const val FRESH_MS = 3 * 60 * 60 * 1000L
|
||||
private const val QUERY = "query { items(collectionType: FEATURED_CONTENT, pageSize: 50) { items { assets { title " +
|
||||
"cardMedia { defaultMedia { src1x src2x } } } } } }"
|
||||
|
||||
@Volatile private var memory: StoreShelves? = null
|
||||
|
||||
fun cached(context: Context): StoreShelves? =
|
||||
memory ?: fromPrefs(AmazonPrefs.get(context).getString(KEY_ART, null))?.also { memory = it }
|
||||
|
||||
fun featured(context: Context, force: Boolean): StoreShelves? {
|
||||
val prefs = AmazonPrefs.get(context)
|
||||
if (!force && System.currentTimeMillis() - prefs.getLong(KEY_ART_AT, 0L) in 0 until FRESH_MS) cached(context)?.let { return it }
|
||||
val art = runCatching { fetch() }.onFailure { Log.w(TAG, "featured: ${it.javaClass.simpleName}") }.getOrNull().orEmpty()
|
||||
if (art.isEmpty()) return cached(context)
|
||||
prefs.edit().putString(KEY_ART, JSONArray(art).toString()).putLong(KEY_ART_AT, System.currentTimeMillis()).apply()
|
||||
Log.i(TAG, "featured art: ${art.size}")
|
||||
return StoreShelves(art = art).also { memory = it }
|
||||
}
|
||||
|
||||
private fun fromPrefs(text: String?): StoreShelves? {
|
||||
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
|
||||
val art = List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() }
|
||||
return if (art.isEmpty()) null else StoreShelves(art = art)
|
||||
}
|
||||
|
||||
private fun fetch(): List<String> {
|
||||
val (page, cookies) = openPage() ?: return emptyList()
|
||||
val token = Regex("""name=['"]csrf-key['"] value=['"]([^'"]+)['"]""").find(page)?.groupValues?.get(1) ?: run {
|
||||
Log.w(TAG, "claims page had no csrf key")
|
||||
return emptyList()
|
||||
}
|
||||
val body = StoreNet.postJson(
|
||||
GRAPHQL, JSONObject().put("query", QUERY).toString(),
|
||||
headers = mapOf("client-id" to "CarboniteApp", "csrf-token" to token, "Cookie" to cookies, "Origin" to "https://luna.amazon.com", "Referer" to PAGE),
|
||||
) ?: return emptyList()
|
||||
val items = JSONObject(body).optJSONObject("data")?.optJSONObject("items")?.optJSONArray("items") ?: return emptyList()
|
||||
return (0 until items.length()).mapNotNull { i ->
|
||||
val media = items.optJSONObject(i)?.optJSONObject("assets")?.optJSONObject("cardMedia")?.optJSONObject("defaultMedia")
|
||||
media?.optString("src2x")?.takeIf { it.isNotEmpty() } ?: media?.optString("src1x")?.takeIf { it.isNotEmpty() }
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
private fun openPage(): Pair<String, String>? {
|
||||
val jar = LinkedHashMap<String, String>()
|
||||
var url = PAGE
|
||||
repeat(5) {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 20_000
|
||||
instanceFollowRedirects = false
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Accept", "text/html")
|
||||
if (jar.isNotEmpty()) setRequestProperty("Cookie", jar.entries.joinToString("; ") { "${it.key}=${it.value}" })
|
||||
}
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
conn.headerFields["Set-Cookie"].orEmpty().forEach { c ->
|
||||
val pair = c.substringBefore(';')
|
||||
val name = pair.substringBefore('=').trim()
|
||||
if (name.isNotEmpty() && '=' in pair) jar[name] = pair.substringAfter('=')
|
||||
}
|
||||
if (code in 300..399) {
|
||||
url = URL(URL(url), conn.getHeaderField("Location") ?: return null).toString()
|
||||
return@repeat
|
||||
}
|
||||
if (code !in 200..299) { Log.w(TAG, "claims page $code"); return null }
|
||||
val page = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
return page to jar.entries.joinToString("; ") { "${it.key}=${it.value}" }
|
||||
} finally {
|
||||
runCatching { conn.disconnect() }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -64,14 +64,16 @@ object EpicShelves {
|
||||
val a = o.optJSONArray(key) ?: return emptyList()
|
||||
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull(::itemFrom).distinctBy { it.id }
|
||||
}
|
||||
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"))
|
||||
return if (s.isEmpty) null else s
|
||||
val art = o.optJSONArray("art")?.let { a -> List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() } }.orEmpty()
|
||||
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"), art)
|
||||
return if (s.isEmpty && art.isEmpty()) null else s
|
||||
}
|
||||
|
||||
fun toJson(s: StoreShelves): String = JSONObject().apply {
|
||||
listOf(s.whatsNew, s.deals, s.free, s.trending).zip(SHELF_KEYS).forEach { (items, key) ->
|
||||
put(key, JSONArray().apply { items.forEach { put(itemJson(it)) } })
|
||||
}
|
||||
put("art", JSONArray(s.art))
|
||||
}.toString()
|
||||
|
||||
fun cached(context: Context): StoreShelves? =
|
||||
@@ -111,6 +113,6 @@ object EpicShelves {
|
||||
releaseDate = owned.releaseDate.ifBlank { shelf.releaseDate },
|
||||
)
|
||||
}
|
||||
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
|
||||
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ object GogBackend : StoreBackend {
|
||||
private fun markOwned(s: com.droiddeck.launcher.stores.StoreShelves): com.droiddeck.launcher.stores.StoreShelves {
|
||||
val owned = StoresState.library[store]?.associateBy { it.id } ?: return s
|
||||
fun mark(l: List<CatalogItem>) = l.map { i -> owned[i.id]?.let { o -> i.copy(owned = true, sizeBytes = o.sizeBytes) } ?: i }
|
||||
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
|
||||
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
|
||||
}
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: File) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -40,6 +41,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -56,6 +58,8 @@ import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.AddedGameArt
|
||||
import com.droiddeck.launcher.frontend.AddedGameEdits
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import com.droiddeck.launcher.frontend.SteamGameArt
|
||||
import com.droiddeck.launcher.frontend.SteamGridDb
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -64,6 +68,7 @@ import java.io.File
|
||||
|
||||
private fun AddedGameArt.Slot.shape(): Triple<Dp, Dp, Dp> = when (this) {
|
||||
AddedGameArt.Slot.COVER -> Triple(28.dp, 41.dp, 5.dp)
|
||||
AddedGameArt.Slot.WIDE -> Triple(60.dp, 28.dp, 5.dp)
|
||||
AddedGameArt.Slot.BACKGROUND -> Triple(60.dp, 34.dp, 5.dp)
|
||||
AddedGameArt.Slot.LOGO -> Triple(60.dp, 26.dp, 5.dp)
|
||||
AddedGameArt.Slot.ICON -> Triple(30.dp, 30.dp, 7.dp)
|
||||
@@ -73,6 +78,7 @@ private fun AddedGameArt.Slot.shape(): Triple<Dp, Dp, Dp> = when (this) {
|
||||
private fun AddedGameArt.Slot.label(): String = stringResource(
|
||||
when (this) {
|
||||
AddedGameArt.Slot.COVER -> R.string.art_cover
|
||||
AddedGameArt.Slot.WIDE -> R.string.art_wide_cover
|
||||
AddedGameArt.Slot.BACKGROUND -> R.string.art_background
|
||||
AddedGameArt.Slot.LOGO -> R.string.art_logo
|
||||
AddedGameArt.Slot.ICON -> R.string.art_icon
|
||||
@@ -140,6 +146,90 @@ internal class EditPreview(
|
||||
}
|
||||
}
|
||||
|
||||
internal class ArtTarget(
|
||||
val name: String,
|
||||
val chosenRef: (Context, AddedGameArt.Slot) -> String,
|
||||
val choices: (Context, AddedGameArt.Slot) -> AddedGameArt.Choices,
|
||||
val set: (Context, AddedGameArt.Slot, AddedGameArt.Option) -> Boolean,
|
||||
val reset: (Context, AddedGameArt.Slot) -> Unit,
|
||||
) {
|
||||
companion object {
|
||||
fun of(game: AddedGames.Game) = ArtTarget(
|
||||
game.name,
|
||||
{ c, slot -> AddedGameArt.chosenSource(c, game, slot).second },
|
||||
{ c, slot -> AddedGameArt.choices(c, game, slot) },
|
||||
{ c, slot, option -> AddedGameEdits.setArt(c, game.folder.path, slot, option) },
|
||||
{ c, slot -> AddedGameEdits.resetArt(c, game.folder.path, slot) },
|
||||
)
|
||||
|
||||
fun of(game: com.droiddeck.launcher.frontend.Library.SteamGame) = ArtTarget(
|
||||
game.name,
|
||||
{ c, slot -> SteamGameArt.chosenSource(c, game.appId, slot).second },
|
||||
{ c, slot -> SteamGameArt.choices(c, game, slot) },
|
||||
{ c, slot, option -> SteamGameArt.choose(c, game, slot, option) },
|
||||
{ c, slot -> SteamGameArt.reset(c, game, slot) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SteamGameArtEditor(
|
||||
game: com.droiddeck.launcher.frontend.Library.SteamGame, onClose: () -> Unit, onChanged: () -> Unit,
|
||||
) {
|
||||
val target = remember(game.appId) { ArtTarget.of(game) }
|
||||
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun StoreGameArtEditor(folder: String, name: String, onClose: () -> Unit, onChanged: () -> Unit) {
|
||||
val app = LocalContext.current.applicationContext
|
||||
var game by remember(folder) { mutableStateOf<AddedGames.Game?>(null) }
|
||||
LaunchedEffect(folder) { game = withContext(Dispatchers.IO) { AddedGameEdits.game(app, folder) } }
|
||||
val g = game
|
||||
val target = remember(g?.appId) { g?.let { ArtTarget.of(it) } }
|
||||
ArtOnlyEditor(name, target, g, { c -> g?.let { AddedGameArt.describe(c, it) }.orEmpty() }, onClose, onChanged)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtOnlyEditor(
|
||||
name: String, target: ArtTarget?, refresh: Any?, describe: (Context) -> List<AddedGameArt.SlotState>,
|
||||
onClose: () -> Unit, onChanged: () -> Unit,
|
||||
) {
|
||||
val app = LocalContext.current.applicationContext
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val shown = rememberShown(onClose)
|
||||
val close = { shown.targetState = false }
|
||||
var version by remember { mutableIntStateOf(0) }
|
||||
var arts by remember { mutableStateOf<List<AddedGameArt.SlotState>>(emptyList()) }
|
||||
var artSlot by remember { mutableStateOf<AddedGameArt.Slot?>(null) }
|
||||
LaunchedEffect(version, refresh) { if (target != null) arts = withContext(Dispatchers.IO) { describe(app) } }
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
|
||||
AppDialog(shown, close, "artEditor", wide = false, maxWidth = 460.dp) {
|
||||
DialogTitleRow(name, onClose = close)
|
||||
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 2.dp))
|
||||
for (slot in AddedGameArt.Slot.values()) key(slot) {
|
||||
val state = arts.firstOrNull { it.slot == slot }
|
||||
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER) first else null, onClick = { if (target != null) artSlot = slot }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SlotThumb(slot, state?.file)
|
||||
if (state != null) ValueText(artSourceLabel(state.source), muted = true) else Loading()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val chosen = target ?: return
|
||||
artSlot?.let { slot ->
|
||||
ArtChooserDialog(chosen, slot, arts.firstOrNull { it.slot == slot }, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
androidx.compose.material3.CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
@@ -281,7 +371,7 @@ internal fun AddedGameEditor(
|
||||
})
|
||||
artSlot?.let { slot ->
|
||||
val current = arts.firstOrNull { it.slot == slot }
|
||||
ArtChooserDialog(g, slot, current, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
|
||||
ArtChooserDialog(ArtTarget.of(g), slot, current, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +436,7 @@ internal fun TextEntryDialog(title: String, initial: String, onDismiss: () -> Un
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ArtChooserDialog(
|
||||
game: AddedGames.Game, slot: AddedGameArt.Slot, current: AddedGameArt.SlotState?,
|
||||
target: ArtTarget, slot: AddedGameArt.Slot, current: AddedGameArt.SlotState?,
|
||||
onBack: () -> Unit, onCloseAll: () -> Unit, onChanged: () -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
@@ -356,16 +446,24 @@ private fun ArtChooserDialog(
|
||||
val shown = rememberShown(onBack)
|
||||
val back = { shown.targetState = false }
|
||||
var options by remember { mutableStateOf<List<AddedGameArt.Option>?>(null) }
|
||||
var sgdb by remember { mutableStateOf(SteamGridDb.Status.OK) }
|
||||
var picking by remember { mutableStateOf(false) }
|
||||
val chosenRef = remember(current) { AddedGameArt.chosenSource(app, game, slot).second }
|
||||
LaunchedEffect(slot) { options = withContext(Dispatchers.IO) { runCatching { AddedGameArt.options(app, game, slot) }.getOrDefault(emptyList()) } }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
val chosenRef = remember(current) { target.chosenRef(app, slot) }
|
||||
LaunchedEffect(slot) {
|
||||
val found = withContext(Dispatchers.IO) { runCatching { target.choices(app, slot) }.getOrNull() }
|
||||
options = found?.options ?: emptyList()
|
||||
sgdb = found?.sgdb ?: SteamGridDb.Status.SERVER
|
||||
}
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
|
||||
val apply: (AddedGameArt.Option) -> Unit = { option ->
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { AddedGameEdits.setArt(app, game.folder.path, slot, option) }
|
||||
onChanged()
|
||||
back()
|
||||
val saved = withContext(Dispatchers.IO) { target.set(app, slot, option) }
|
||||
if (saved) {
|
||||
onChanged()
|
||||
back()
|
||||
}
|
||||
}
|
||||
}
|
||||
val slotName = slot.label()
|
||||
@@ -394,11 +492,13 @@ private fun ArtChooserDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sgdb != SteamGridDb.Status.OK) SgdbNote(sgdb)
|
||||
}
|
||||
PickerRow(Icons.Outlined.Search, stringResource(R.string.art_search_sgdb), null) { searching = true }
|
||||
PickerRow(Icons.Outlined.Image, stringResource(R.string.art_pick_image), null, focus = if (opts?.isEmpty() != false) first else null) { picking = true }
|
||||
PickerRow(null, stringResource(R.string.art_reset), null) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { AddedGameEdits.resetArt(app, game.folder.path, slot) }
|
||||
withContext(Dispatchers.IO) { target.reset(app, slot) }
|
||||
onChanged()
|
||||
back()
|
||||
}
|
||||
@@ -408,6 +508,117 @@ private fun ArtChooserDialog(
|
||||
if (picking) ExePickerDialog(stringResource(R.string.art_pick_title, slotName), kind = PickKind.IMAGE, onDismiss = { picking = false }, onPick = { file ->
|
||||
apply(AddedGameArt.Option(AddedGameArt.FILE, file.path, file.path))
|
||||
})
|
||||
if (searching) SgdbSearchDialog(
|
||||
target, slot, current,
|
||||
onBack = { searching = false }, onCloseAll = { searching = false; onCloseAll() }, onPick = apply,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SgdbNote(status: SteamGridDb.Status) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val quiet = status == SteamGridDb.Status.NO_KEY || status == SteamGridDb.Status.NO_GAME
|
||||
Text(
|
||||
stringResource(
|
||||
when (status) {
|
||||
SteamGridDb.Status.NO_KEY -> R.string.art_sgdb_no_key
|
||||
SteamGridDb.Status.BAD_KEY -> R.string.art_sgdb_bad_key
|
||||
SteamGridDb.Status.LIMIT -> R.string.art_sgdb_limit
|
||||
SteamGridDb.Status.OFFLINE -> R.string.art_sgdb_offline
|
||||
SteamGridDb.Status.NO_GAME -> R.string.art_sgdb_no_game
|
||||
else -> R.string.art_sgdb_server
|
||||
},
|
||||
),
|
||||
fontSize = 12.sp, color = if (quiet) colors.onSurfaceVariant else colors.error, modifier = Modifier.padding(start = 2.dp, top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SgdbSearchDialog(
|
||||
target: ArtTarget, slot: AddedGameArt.Slot, current: AddedGameArt.SlotState?,
|
||||
onBack: () -> Unit, onCloseAll: () -> Unit, onPick: (AddedGameArt.Option) -> Unit,
|
||||
) {
|
||||
val app = LocalContext.current.applicationContext
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val scope = rememberCoroutineScope()
|
||||
val shown = rememberShown(onBack)
|
||||
val back = { shown.targetState = false }
|
||||
var query by remember { mutableStateOf(target.name) }
|
||||
var matches by remember { mutableStateOf<List<SteamGridDb.Game>?>(null) }
|
||||
var picked by remember { mutableStateOf<SteamGridDb.Game?>(null) }
|
||||
var images by remember { mutableStateOf<List<AddedGameArt.Option>>(emptyList()) }
|
||||
var busy by remember { mutableStateOf(true) }
|
||||
var status by remember { mutableStateOf(SteamGridDb.Status.OK) }
|
||||
val chosenRef = remember(current) { target.chosenRef(app, slot) }
|
||||
val slotName = slot.label()
|
||||
val showImages: (SteamGridDb.Game) -> Unit = { found ->
|
||||
scope.launch {
|
||||
picked = found
|
||||
images = emptyList()
|
||||
busy = true
|
||||
status = SteamGridDb.Status.OK
|
||||
val result = withContext(Dispatchers.IO) { AddedGameArt.gameImages(app, found.id, slot) }
|
||||
images = result.items
|
||||
status = result.status
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
val search: () -> Unit = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
picked = null
|
||||
status = SteamGridDb.Status.OK
|
||||
val result = withContext(Dispatchers.IO) { AddedGameArt.searchGames(app, query) }
|
||||
matches = result.items
|
||||
status = result.status
|
||||
busy = false
|
||||
val sure = result.items.firstOrNull { AddedGameArt.nameMatch(query, it.name) == 3 } ?: result.items.singleOrNull()
|
||||
if (sure != null) showImages(sure)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { search() }
|
||||
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp) {
|
||||
DialogTitleRow(stringResource(R.string.art_sgdb_title, slotName), onClose = { onCloseAll() }, leading = {
|
||||
SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground, onClick = back)
|
||||
})
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
query, { query = it }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.art_sgdb_search_label)) },
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { search() }),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search),
|
||||
)
|
||||
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.6f).dp
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
if (status != SteamGridDb.Status.OK) SgdbNote(status)
|
||||
val chosenGame = picked
|
||||
val list = matches.orEmpty()
|
||||
if (chosenGame == null) {
|
||||
for (match in list) key(match.id) { PickerRow(null, match.name, null) { showImages(match) } }
|
||||
if (matches != null && list.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
|
||||
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
|
||||
}
|
||||
} else {
|
||||
Text(chosenGame.name, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (option in images) key(option.full) {
|
||||
ArtOption(
|
||||
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = null,
|
||||
description = "${artSourceLabel(AddedGameArt.SGDB)} $slotName",
|
||||
) { onPick(option) }
|
||||
}
|
||||
}
|
||||
if (images.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
|
||||
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
|
||||
}
|
||||
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { picked = null }
|
||||
}
|
||||
if (busy) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -101,7 +102,7 @@ const val GPU_TAB = "gpu"
|
||||
|
||||
const val PROTON_TAB = "proton"
|
||||
|
||||
internal val LAYERS = listOf(PROTON_TAB, "dxvk", "vkd3d", "fex", GPU_TAB)
|
||||
internal val LAYERS = listOf(GPU_TAB, PROTON_TAB, "dxvk", "vkd3d", "fex")
|
||||
|
||||
internal fun layerStep(current: String, by: Int): String {
|
||||
val i = LAYERS.indexOf(current).coerceAtLeast(0)
|
||||
@@ -127,6 +128,7 @@ private val QUEUED = Color(0xFFFFB86B)
|
||||
private val ROW_SHAPE = RoundedCornerShape(10.dp)
|
||||
private val LAYER_SHAPE = RoundedCornerShape(12.dp)
|
||||
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ComponentsPage(
|
||||
snapshot: Snapshot?,
|
||||
@@ -417,12 +419,14 @@ fun ComponentsPage(
|
||||
var driverShown by remember { mutableStateOf<String?>(null) }
|
||||
var driverLeaving by remember { mutableStateOf(false) }
|
||||
var driverFrom by remember { mutableStateOf<Origin?>(null) }
|
||||
val slotFocus = remember { mapOf(SLOT_DISPLAY to FocusRequester(), SLOT_RUNTIME to FocusRequester()) }
|
||||
LaunchedEffect(driverPage) {
|
||||
val d = driverPage
|
||||
if (d != null) { driverLeaving = false; driverShown = d }
|
||||
else if (driverShown != null) {
|
||||
else driverShown?.let { closed ->
|
||||
if (driverFrom != null) { driverLeaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) }
|
||||
driverShown = null; driverLeaving = false; driverFrom = null
|
||||
if (inputMode.inputMode == InputMode.Keyboard) focusSoon { slotFocus.getValue(closed) }
|
||||
}
|
||||
}
|
||||
var narrowOpen by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
@@ -467,16 +471,6 @@ fun ComponentsPage(
|
||||
val compState = { l: String -> view?.components?.get(l) }
|
||||
val bar: @Composable (String) -> Unit = { l ->
|
||||
when (l) {
|
||||
GPU_TAB -> {
|
||||
val active = gpu.activePair
|
||||
InUseBar(
|
||||
queued = gpu.busy != null,
|
||||
sentence = (active?.let { "${it.name} ${it.version}" } ?: gpu.activeBundle ?: stringResource(R.string.gpu_builtin))
|
||||
.let { if (gpu.auto) stringResource(R.string.gpu_bar_auto, it) else it },
|
||||
note = if (gpu.busy != null) (if (gpu.percent >= 0) "${gpu.percent}%" else "…")
|
||||
else gpu.autoStatus.ifEmpty { if (active != null || gpu.activeBundle != null) "" else gpu.pairs.firstOrNull { it.recommended }?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint) },
|
||||
)
|
||||
}
|
||||
PROTON_TAB -> InUseBar(
|
||||
queued = false,
|
||||
sentence = view?.let { stringResource(R.string.comp_proton_bar, it.proton.name) } ?: stringResource(R.string.comp_no_proton),
|
||||
@@ -500,51 +494,20 @@ fun ComponentsPage(
|
||||
}
|
||||
}
|
||||
|
||||
var showAllPairs by rememberSaveable { mutableStateOf(false) }
|
||||
val installedTitle: @Composable (String) -> String = { l ->
|
||||
when (l) {
|
||||
GPU_TAB -> stringResource(R.string.comp_installed_count, 1 + gpu.pairs.count { it.installed && (it.suits || showAllPairs) })
|
||||
PROTON_TAB -> stringResource(R.string.comp_installed_count, views.size)
|
||||
else -> stringResource(R.string.comp_installed_count, installedFor(l, view, snapshot).size)
|
||||
}
|
||||
}
|
||||
val availableTitle: @Composable (String) -> String = { l ->
|
||||
when (l) {
|
||||
GPU_TAB -> stringResource(R.string.comp_available_count, gpu.pairs.count { !it.installed && it.complete && (it.suits || showAllPairs) })
|
||||
PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null || it.update != null })
|
||||
else -> stringResource(R.string.comp_nightlies_count, availableFor(l, catalog, snapshot).size)
|
||||
}
|
||||
}
|
||||
val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right ->
|
||||
when (l) {
|
||||
GPU_TAB -> {
|
||||
val autoKey = "gpu:auto"
|
||||
ChoiceLine(
|
||||
stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_keeps), gpu.auto, removable = false,
|
||||
modifier = Modifier.focusRequester(firstRow).trackRow(autoKey, rowsAt),
|
||||
onSelect = {
|
||||
if (gpu.auto) return@ChoiceLine
|
||||
ask(autoKey, right, ctx.getString(R.string.gpu_use_title, ctx.getString(R.string.common_auto)), ctx.getString(R.string.gpu_auto_hint), ctx.getString(R.string.common_use), false, ctx.getString(R.string.common_auto)) {
|
||||
gpuActions.onAuto(true); flyHome(autoKey, GPU_TAB)
|
||||
}
|
||||
},
|
||||
)
|
||||
for (p in gpu.pairs.filter { it.installed && (it.suits || showAllPairs) }) {
|
||||
val key = "gpu:${p.key}"
|
||||
ChoiceLine(
|
||||
p.name, "${p.version} · ${p.detail}", !gpu.auto && p.active, removable = false,
|
||||
badge = if (p.recommended) stringResource(R.string.gpu_recommended_caps) else null,
|
||||
progress = if (gpu.busy == p.key) gpu.percent else null,
|
||||
modifier = Modifier.trackRow(key, rowsAt),
|
||||
onSelect = {
|
||||
if (!gpu.auto && p.active || gpu.busy != null) return@ChoiceLine
|
||||
ask(key, right, ctx.getString(R.string.gpu_use_title, p.name), "${p.version} · ${p.detail}", ctx.getString(R.string.common_use), false, p.name) {
|
||||
gpuActions.onAuto(false); gpuActions.onPair(p.key); flyHome(key, GPU_TAB)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
PROTON_TAB -> {
|
||||
if (views.isEmpty()) EmptyLine(stringResource(if (snapshot == null) R.string.comp_reading else R.string.comp_no_proton))
|
||||
views.forEachIndexed { i, v ->
|
||||
@@ -599,17 +562,6 @@ fun ComponentsPage(
|
||||
}
|
||||
val availableRows: @Composable ColumnScope.(String) -> Unit = { l ->
|
||||
when (l) {
|
||||
GPU_TAB -> {
|
||||
val shown = gpu.pairs.filter { !it.installed && it.complete && (it.suits || showAllPairs) }
|
||||
if (shown.isEmpty()) EmptyLine(stringResource(if (gpu.checking) R.string.gpu_checking else R.string.gpu_none_listed))
|
||||
for (p in shown) GetLine(
|
||||
p.name, "${p.version} · ${p.detail}", progress = if (gpu.busy == p.key) gpu.percent else null, enabled = gpu.busy == null,
|
||||
badge = if (p.recommended) stringResource(R.string.gpu_recommended_caps) else null,
|
||||
) { gpuActions.onAuto(false); gpuActions.onPair(p.key) }
|
||||
if (gpu.pairs.any { !it.suits }) Box(Modifier.padding(horizontal = 6.dp, vertical = 4.dp)) {
|
||||
FocusText(stringResource(if (showAllPairs) R.string.gpu_hide_other else R.string.gpu_show_other), pal.signal) { showAllPairs = !showAllPairs }
|
||||
}
|
||||
}
|
||||
PROTON_TAB -> {
|
||||
val rows = protons.rows.filter { it.installed == null || it.update != null }
|
||||
if (rows.isEmpty()) EmptyLine(stringResource(R.string.comp_nothing_new))
|
||||
@@ -640,28 +592,6 @@ fun ComponentsPage(
|
||||
val actions: @Composable (String) -> Unit = { l ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
when (l) {
|
||||
GPU_TAB -> {
|
||||
SmallButton(stringResource(R.string.comp_import_zip), onClick = gpuActions.onImportZip)
|
||||
var at by remember { mutableStateOf<Rect?>(null) }
|
||||
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
FocusText(stringResource(R.string.gpu_separately) + " ›", pal.signal) {
|
||||
val from = at
|
||||
val title = ctx.getString(R.string.gpu_separately)
|
||||
if (from == null) { driverPage = "rt"; return@FocusText }
|
||||
Steps.ask(StepAsk(anchor = from, side = StepSide.Right, accent = pal.signal, pillCorner = 10.dp, items = 3, handle = {}) {
|
||||
StepTitle(title)
|
||||
StepList {
|
||||
StepChoice(ctx.getString(R.string.comp_runtime_driver), enabled = open, modifier = Modifier.fillMaxWidth().stepItem(1).focusRequester(first)) {
|
||||
Steps.fold(); driverFrom = Origin(from.translate(-pageAt), 10f * density.density); driverPage = "rt"
|
||||
}
|
||||
StepChoice(ctx.getString(R.string.comp_display_driver), enabled = open, modifier = Modifier.fillMaxWidth().stepItem(2)) {
|
||||
Steps.fold(); driverFrom = Origin(from.translate(-pageAt), 10f * density.density); driverPage = "panel"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
PROTON_TAB -> {
|
||||
val idle = protons.busyId == null && protons.runtimeReady && !protons.sessionRunning
|
||||
for (row in protons.rows.filter { it.installedAll.size > 1 }) {
|
||||
@@ -700,7 +630,10 @@ fun ComponentsPage(
|
||||
val detail: @Composable (String, Boolean) -> Unit = { l, narrowPage ->
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val single = narrowPage || maxWidth < 600.dp
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (l == GPU_TAB) GpuDriversBody(gpu, gpuActions, single, firstRow, slotFocus) { slot, at ->
|
||||
driverFrom = Origin(at.translate(-pageAt), 10f * density.density); driverPage = slot
|
||||
}
|
||||
else Column(Modifier.fillMaxSize()) {
|
||||
AnimatedContent(l, transitionSpec = { fadeIn(Motion.tw(240)) togetherWith fadeOut(Motion.tw(160)) }, label = "inUse") { k -> bar(k) }
|
||||
val detailScroll = androidx.compose.runtime.key(l) { rememberScrollState() }
|
||||
Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(detailScroll).padding(top = 12.dp, bottom = 16.dp)) {
|
||||
@@ -857,7 +790,8 @@ fun ComponentsPage(
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Box(
|
||||
(if (narrow) Modifier.fillMaxWidth() else Modifier.width(250.dp)).fillMaxHeight()
|
||||
.onGloballyPositioned { stackAt = it.positionInRoot() },
|
||||
.onGloballyPositioned { stackAt = it.positionInRoot() }
|
||||
.focusProperties { enter = { layerFocus.getValue(layer) } }.focusGroup(),
|
||||
) {
|
||||
val tint = rememberUpdatedState(pal.signal)
|
||||
val local = cardsAt[layer]?.translate(-stackAt)
|
||||
@@ -897,17 +831,17 @@ fun ComponentsPage(
|
||||
driverShown?.let { d ->
|
||||
val body: @Composable () -> Unit = {
|
||||
Box(Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } }) {
|
||||
if (d == "rt") DriverPage(
|
||||
if (d == SLOT_RUNTIME) DriverPage(
|
||||
title = stringResource(R.string.comp_runtime_driver), hint = stringResource(R.string.comp_runtime_driver_hint),
|
||||
rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_turnip), canRestore = false,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, checkedAt = gpu.checkedAt, importLabel = stringResource(R.string.comp_import_turnip), canRestore = false,
|
||||
onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh,
|
||||
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null },
|
||||
)
|
||||
else DriverPage(
|
||||
title = stringResource(R.string.comp_display_driver), hint = stringResource(R.string.comp_display_driver_hint),
|
||||
rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_adrenotools),
|
||||
status = gpu.releaseStatus, checking = gpu.checking, checkedAt = gpu.checkedAt, importLabel = stringResource(R.string.comp_import_adrenotools),
|
||||
canRestore = gpu.canRestoreBundled,
|
||||
onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh,
|
||||
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled,
|
||||
@@ -1010,7 +944,7 @@ private fun Stack(
|
||||
LayerCard(
|
||||
title = LayerTitle(l),
|
||||
value = when (l) {
|
||||
GPU_TAB -> if (gpu.auto) auto else gpu.activePair?.name ?: gpu.activeBundle ?: auto
|
||||
GPU_TAB -> gpuCardValue(gpu, auto)
|
||||
PROTON_TAB -> (view?.proton?.name ?: "") + " ▾"
|
||||
else -> st?.let { if (it.activeFile == null) original else it.inUse } ?: ""
|
||||
},
|
||||
@@ -1022,6 +956,9 @@ private fun Stack(
|
||||
)
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
StackHeading(stringResource(R.string.comp_this_device))
|
||||
card(GPU_TAB)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
StackHeading(stringResource(R.string.comp_this_proton))
|
||||
card(PROTON_TAB)
|
||||
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
@@ -1030,9 +967,6 @@ private fun Stack(
|
||||
card("dxvk"); card("vkd3d"); card("fex")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
StackHeading(stringResource(R.string.comp_every_proton))
|
||||
card(GPU_TAB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1534,8 +1468,8 @@ private fun ProgressBit(progress: Int) {
|
||||
private fun DeviceChip(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val tint = if (s.supported && !s.unsupported) pal.good else AttentionAmber
|
||||
val label = stringResource(
|
||||
val tint = if (s.supported && !s.unsupported && !s.display.pending) pal.good else AttentionAmber
|
||||
val label = if (s.display.pending) stringResource(R.string.gpu_chip_restart, s.gpuName) else stringResource(
|
||||
when {
|
||||
s.unsupported -> R.string.comp_device_unsupported
|
||||
s.supported -> R.string.comp_device_tested
|
||||
@@ -1590,3 +1524,13 @@ class ProtonRow(
|
||||
val id: String, val name: String, val installed: String?, val queued: Boolean,
|
||||
val installedAll: List<String> = listOfNotNull(installed), val update: String? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun gpuCardValue(gpu: GpuDriversState, auto: String): String {
|
||||
val value = when (gpu.mode) {
|
||||
GPU_MODE_AUTO -> gpu.activePair?.name?.let { stringResource(R.string.drivers_summary, auto, it) } ?: auto
|
||||
else -> gpu.activePair?.name?.takeIf { gpu.mode == GPU_MODE_PAIR }
|
||||
?: if (gpu.display.name == gpu.runtime.name) gpu.display.name else stringResource(R.string.gpu_summary_mix, gpu.display.name, gpu.runtime.name)
|
||||
}
|
||||
return if (gpu.display.pending) stringResource(R.string.gpu_summary_restart, value) else value
|
||||
}
|
||||
@@ -65,6 +65,7 @@ fun DriverPage(
|
||||
status: String,
|
||||
checking: Boolean,
|
||||
importLabel: String,
|
||||
checkedAt: Long = Long.MAX_VALUE,
|
||||
canRestore: Boolean,
|
||||
onSelect: (String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
@@ -79,6 +80,9 @@ fun DriverPage(
|
||||
val pal = LocalPalette.current
|
||||
var confirm by remember { mutableStateOf<DriverRow?>(null) }
|
||||
BackHandler(onBack = onBack)
|
||||
LaunchedEffect(Unit) {
|
||||
if (!checking && System.currentTimeMillis() - checkedAt > 6 * 3_600_000L) onRefresh()
|
||||
}
|
||||
val refreshFocus = remember { FocusRequester() }
|
||||
val selectedFocus = remember { FocusRequester() }
|
||||
val inputMode = LocalInputModeManager.current
|
||||
|
||||
@@ -46,10 +46,10 @@ import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
internal fun EmptyLibrary(avoid: Rect?, fade: @Composable () -> Unit) {
|
||||
internal fun EmptyLibrary(avoid: Rect?, wall: (@Composable () -> Unit)? = null, fade: @Composable () -> Unit) {
|
||||
val clock = rememberMarkClock(avoid)
|
||||
Box(Modifier.fillMaxSize().onSizeChanged { clock.page = Size(it.width.toFloat(), it.height.toFloat()) }) {
|
||||
EmptyWall(clock)
|
||||
if (wall != null) wall() else EmptyWall(clock)
|
||||
fade()
|
||||
Mark(clock)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
|
||||
val narrow = LocalNarrowPane.current
|
||||
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
|
||||
GamesDialogs(a)
|
||||
GamesDialogs(a, s.steamGames)
|
||||
val onAdd: (() -> Unit)? = if (s.shortcutPicker) null else ({ GamesDialogState.adding = true })
|
||||
val pageContext = androidx.compose.ui.platform.LocalContext.current
|
||||
val onEdit: (Library.SteamGame) -> Unit = { g ->
|
||||
@@ -154,6 +154,8 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio
|
||||
}
|
||||
if (!s.shortcutPicker) GameShortcutMenu(g, a)
|
||||
if (!s.shortcutPicker && g.library == Library.ADDED && g.source == Library.ADDED && g.gameFiles != null) EditGameButton { onEdit(g) }
|
||||
else if (!s.shortcutPicker && g.library != Library.ADDED && g.source == Library.SOURCE_STEAM) EditGameButton { GamesDialogState.steamArt = g }
|
||||
else if (!s.shortcutPicker && g.library == Library.ADDED && g.source != Library.ADDED && g.gameFiles != null) EditGameButton { GamesDialogState.storeArt = g }
|
||||
BusyChip(s)
|
||||
}
|
||||
}
|
||||
@@ -328,11 +330,13 @@ internal object GamesDialogState {
|
||||
var summary by androidx.compose.runtime.mutableStateOf<Pair<List<com.droiddeck.launcher.frontend.AddedGames.Game>, Int>?>(null)
|
||||
var editing by androidx.compose.runtime.mutableStateOf<EditRequest?>(null)
|
||||
var checking by androidx.compose.runtime.mutableStateOf<String?>(null)
|
||||
var steamArt by androidx.compose.runtime.mutableStateOf<Library.SteamGame?>(null)
|
||||
var storeArt by androidx.compose.runtime.mutableStateOf<Library.SteamGame?>(null)
|
||||
var behindEditor: Pair<List<com.droiddeck.launcher.frontend.AddedGames.Game>, Int>? = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GamesDialogs(a: FrontEndActions) {
|
||||
private fun GamesDialogs(a: FrontEndActions, games: List<Library.SteamGame>) {
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
val st = GamesDialogState
|
||||
@@ -366,6 +370,13 @@ private fun GamesDialogs(a: FrontEndActions) {
|
||||
a.onAddedGameRemoved(folder, appId, name)
|
||||
}, onDismiss = { st.summary = null; st.checking = null })
|
||||
}
|
||||
st.steamArt?.let { game ->
|
||||
SteamGameArtEditor(games.firstOrNull { it.appId == game.appId && it.library != Library.ADDED } ?: game, onClose = { st.steamArt = null }, onChanged = { a.onSteamGameArtChanged(game.appId) })
|
||||
}
|
||||
st.storeArt?.let { game ->
|
||||
val folder = game.gameFiles?.path
|
||||
if (folder != null) StoreGameArtEditor(folder, game.name, onClose = { st.storeArt = null }, onChanged = { a.onAddedGamesChanged(listOf(folder)) })
|
||||
}
|
||||
st.editing?.let { request ->
|
||||
AddedGameEditor(
|
||||
request,
|
||||
@@ -386,8 +397,8 @@ private fun EditGameButton(onClick: () -> Unit) {
|
||||
val label = stringResource(R.string.common_edit)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.paneItem("game:edit").size(40.dp).clip(Shape12)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.18f) else colors.surface.copy(alpha = 0.55f))
|
||||
modifier = Modifier.paneItem("game:edit").downToFirstTile().size(44.dp).clip(Shape12)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f))
|
||||
.glideBorder(hot, Shape12, pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
|
||||
@@ -77,6 +77,7 @@ import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -131,6 +132,9 @@ class FrontEndState(
|
||||
val showPhantomGate: Boolean = false,
|
||||
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
|
||||
val launcherFullscreen: Boolean = true,
|
||||
val downloadSource: String = DownloadSource.MODE_AUTO,
|
||||
val downloadMirror: String = "",
|
||||
val downloadPrefix: String = "",
|
||||
val animationsEnabled: Boolean = true,
|
||||
val gameStoresEnabled: Boolean = true,
|
||||
val gameStoresSpeedTier: String = "fast",
|
||||
@@ -139,6 +143,8 @@ class FrontEndState(
|
||||
val addedGamesArt: Boolean = true,
|
||||
val sgdbUserKey: Boolean = false,
|
||||
val storesShowMature: Boolean = false,
|
||||
val storesSort: String = SessionPrefs.STORES_SORTS.first(),
|
||||
val storesView: String = SessionPrefs.STORES_VIEWS.first(),
|
||||
val storeDownloadsActive: Int = 0,
|
||||
val updates: UpdatesState = UpdatesState(),
|
||||
)
|
||||
@@ -146,12 +152,14 @@ class FrontEndState(
|
||||
class FrontEndActions(
|
||||
val onPlay: () -> Unit,
|
||||
val onPlayDesktopUi: () -> Unit,
|
||||
val onSteamUrl: (String) -> Unit = {},
|
||||
val onSteamGame: (Library.SteamGame) -> Unit,
|
||||
val onGameShortcut: (Library.SteamGame) -> Unit = {},
|
||||
val onExportGameFile: (Library.SteamGame) -> Unit = {},
|
||||
val onSyncGameFiles: () -> Unit = {},
|
||||
val onAddGameExe: (path: String) -> Unit = {},
|
||||
val onAddedGamesChanged: (folders: List<String>) -> Unit = {},
|
||||
val onSteamGameArtChanged: (appId: Int) -> Unit = {},
|
||||
val onAddedGameRemoved: (folder: String, appId: Long?, name: String) -> Unit = { _, _, _ -> },
|
||||
val onAddedGamesArt: (Boolean) -> Unit = {},
|
||||
val onSgdbKey: (String) -> Unit = {},
|
||||
@@ -194,11 +202,16 @@ class FrontEndActions(
|
||||
val onOrientation: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
val onDownloadSource: (String) -> Unit = {},
|
||||
val onDownloadMirror: (String) -> Unit = {},
|
||||
val onDownloadPrefix: (String) -> Unit = {},
|
||||
val onGameStoresEnabled: (Boolean) -> Unit = {},
|
||||
val onGameStoresSpeedTier: (String) -> Unit = {},
|
||||
val onStoresOpenTab: (String) -> Unit = {},
|
||||
val onNavigated: () -> Unit = {},
|
||||
val onStoresShowMature: (Boolean) -> Unit = {},
|
||||
val onStoresSort: (String) -> Unit = {},
|
||||
val onStoresView: (String) -> Unit = {},
|
||||
val onLibraryChanged: () -> Unit = {},
|
||||
val onHomeApp: () -> Unit = {},
|
||||
val onHomeScreen: (Boolean) -> Unit = {},
|
||||
@@ -364,7 +377,11 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
LaunchedEffect(s.shortcutPicker) { if (s.shortcutPicker) selected = "games" }
|
||||
LaunchedEffect(s.navigate) {
|
||||
val target = s.navigate ?: return@LaunchedEffect
|
||||
if (target == "stores:downloads") { com.droiddeck.launcher.stores.StoresState.openDownloads = true; selected = "stores" } else selected = target
|
||||
when (target) {
|
||||
"stores:downloads" -> { com.droiddeck.launcher.stores.StoresState.openDownloads = true; selected = "stores" }
|
||||
"components" -> a.onComponents(true)
|
||||
else -> selected = target
|
||||
}
|
||||
a.onNavigated()
|
||||
}
|
||||
var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -432,6 +449,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
selected = key
|
||||
}
|
||||
}
|
||||
LaunchedEffect(com.droiddeck.launcher.storage.StorageState.openRequests) {
|
||||
if (com.droiddeck.launcher.storage.StorageState.openPending) showRailPage("setup", true)
|
||||
}
|
||||
val onRailFocus: (String) -> Unit = { key ->
|
||||
if (key != railSelection) showRailPage(key, false)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import com.droiddeck.launcher.core.AppLanguage
|
||||
import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.core.DownloadSource
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -84,6 +85,8 @@ import com.droiddeck.launcher.R
|
||||
|
||||
private enum class CheckState { OK, WARN, BUSY }
|
||||
|
||||
private const val STORAGE_TAB = 3
|
||||
|
||||
@Composable
|
||||
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
@@ -164,13 +167,26 @@ internal fun SetupPanel(
|
||||
val signedIn = s.offlineAccount != null
|
||||
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
|
||||
var editSgdbKey by remember { mutableStateOf(false) }
|
||||
var editDownloadPrefix by remember { mutableStateOf(false) }
|
||||
if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false })
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_diagnostics))
|
||||
|
||||
if (editDownloadPrefix) TextEntryDialog(
|
||||
stringResource(R.string.setup_download_prefix), s.downloadPrefix,
|
||||
onDismiss = { editDownloadPrefix = false },
|
||||
onSave = { a.onDownloadPrefix(it); editDownloadPrefix = false },
|
||||
)
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics))
|
||||
var tab by rememberSaveable { mutableStateOf(0) }
|
||||
val chipFocus = remember { List(4) { FocusRequester() } }
|
||||
val chipFocus = remember { List(tabs.size) { FocusRequester() } }
|
||||
var tabTurned by remember { mutableStateOf(false) }
|
||||
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
|
||||
val inputModeManager = LocalInputModeManager.current
|
||||
LaunchedEffect(com.droiddeck.launcher.storage.StorageState.openRequests) {
|
||||
if (com.droiddeck.launcher.storage.StorageState.openPending) {
|
||||
com.droiddeck.launcher.storage.StorageState.openPending = false
|
||||
tab = STORAGE_TAB
|
||||
}
|
||||
}
|
||||
LaunchedEffect(tab) {
|
||||
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
@@ -382,12 +398,42 @@ internal fun SetupPanel(
|
||||
)
|
||||
}
|
||||
}
|
||||
val download: @Composable () -> Unit = {
|
||||
SettingsGroup(stringResource(R.string.setup_group_download)) {
|
||||
ChoiceRow(
|
||||
host, "download-source", stringResource(R.string.setup_download_source), null,
|
||||
listOf(
|
||||
DownloadSource.MODE_AUTO to stringResource(R.string.setup_download_source_auto),
|
||||
DownloadSource.MODE_DIRECT to stringResource(R.string.setup_download_source_direct),
|
||||
DownloadSource.MODE_MIRROR to stringResource(R.string.setup_download_source_mirror),
|
||||
DownloadSource.MODE_CUSTOM to stringResource(R.string.setup_download_source_custom),
|
||||
),
|
||||
s.downloadSource, onPick = a.onDownloadSource,
|
||||
)
|
||||
if (s.downloadSource == DownloadSource.MODE_MIRROR) {
|
||||
ChoiceRow(
|
||||
host, "download-mirror", stringResource(R.string.setup_download_mirror), null,
|
||||
DownloadSource.MIRRORS.map { it.host to it.host },
|
||||
s.downloadMirror, onPick = a.onDownloadMirror,
|
||||
)
|
||||
}
|
||||
if (s.downloadSource == DownloadSource.MODE_CUSTOM) {
|
||||
ActionRow(
|
||||
stringResource(R.string.setup_download_prefix),
|
||||
s.downloadPrefix.takeIf { it.isNotEmpty() },
|
||||
stringResource(R.string.mode_sgdb_edit),
|
||||
onClick = { editDownloadPrefix = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) { look() }
|
||||
Column(Modifier.weight(1f)) { device(); art() }
|
||||
} else { look(); device(); art() }
|
||||
Column(Modifier.weight(1f)) { device(); art(); download() }
|
||||
} else { look(); device(); art(); download() }
|
||||
}
|
||||
3 -> {
|
||||
STORAGE_TAB -> StoragePanel(s, a)
|
||||
4 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) {
|
||||
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
|
||||
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
|
||||
|
||||
@@ -2,7 +2,20 @@ package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.outlined.Monitor
|
||||
import androidx.compose.material.icons.outlined.Refresh
|
||||
import androidx.compose.material.icons.outlined.SportsEsports
|
||||
import androidx.compose.material.icons.outlined.ExpandLess
|
||||
import androidx.compose.material.icons.outlined.ExpandMore
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -58,6 +71,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -74,6 +90,17 @@ class PairRow(
|
||||
val active: Boolean,
|
||||
)
|
||||
|
||||
const val GPU_MODE_AUTO = "auto"
|
||||
const val GPU_MODE_PAIR = "pair"
|
||||
const val GPU_MODE_MIX = "mix"
|
||||
|
||||
class DriverSlot(
|
||||
val name: String = "",
|
||||
val source: String = "",
|
||||
val pending: Boolean = false,
|
||||
val status: String = "",
|
||||
)
|
||||
|
||||
class GpuDriversState(
|
||||
val gpuName: String = "",
|
||||
val gpuFamily: String = "",
|
||||
@@ -96,6 +123,11 @@ class GpuDriversState(
|
||||
val androidDownloads: List<DownloadRow> = emptyList(),
|
||||
val canRestoreBundled: Boolean = false,
|
||||
val activeBundle: String? = null,
|
||||
val mode: String = GPU_MODE_AUTO,
|
||||
val display: DriverSlot = DriverSlot(),
|
||||
val runtime: DriverSlot = DriverSlot(),
|
||||
val canRestart: Boolean = true,
|
||||
val checkedAt: Long = 0L,
|
||||
) {
|
||||
val activePair: PairRow? get() = pairs.firstOrNull { it.active }
|
||||
}
|
||||
@@ -113,6 +145,8 @@ class GpuDriversActions(
|
||||
val onImportAndroid: () -> Unit = {},
|
||||
val onImportZip: () -> Unit = {},
|
||||
val onRestoreBundled: () -> Unit = {},
|
||||
val onMode: (String) -> Unit = {},
|
||||
val onRestart: () -> Unit = {},
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -168,3 +202,257 @@ private fun DeviceInfoSection(section: DeviceInfo.Section) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val STALE_MS = 6 * 3_600_000L
|
||||
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
internal fun GpuDriversBody(
|
||||
gpu: GpuDriversState,
|
||||
actions: GpuDriversActions,
|
||||
single: Boolean,
|
||||
first: FocusRequester,
|
||||
slotFocus: Map<String, FocusRequester>,
|
||||
onOpenSlot: (String, Rect) -> Unit,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (!gpu.checking && gpu.busy == null && System.currentTimeMillis() - gpu.checkedAt > STALE_MS) actions.onRefresh()
|
||||
}
|
||||
val scroll = rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val placed = remember { arrayOfNulls<LayoutCoordinates>(2) }
|
||||
// Focusing the top mode shows as much of the in-use card above it as fits, without hiding the mode itself.
|
||||
val revealCard = {
|
||||
val (viewport, top) = placed
|
||||
if (viewport != null && top != null && viewport.isAttached && top.isAttached) scope.launch {
|
||||
val bottom = viewport.localPositionOf(top, Offset(0f, top.size.height.toFloat())).y + scroll.value
|
||||
scroll.animateScrollTo((bottom - viewport.size.height).toInt().coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxSize().onGloballyPositioned { placed[0] = it }.verticalScroll(scroll).padding(bottom = 16.dp)
|
||||
.focusProperties { enter = { first } }.focusGroup(),
|
||||
) {
|
||||
InUseCard(gpu, actions, first)
|
||||
PanelHeading(stringResource(R.string.gpu_mode_title))
|
||||
val modes = listOf(
|
||||
Triple(GPU_MODE_AUTO, R.string.common_auto, R.string.gpu_mode_auto_detail),
|
||||
Triple(GPU_MODE_PAIR, R.string.gpu_mode_pair, R.string.gpu_mode_pair_detail),
|
||||
Triple(GPU_MODE_MIX, R.string.gpu_mode_mix, R.string.gpu_mode_mix_detail),
|
||||
)
|
||||
val choice: @Composable (Int, Modifier) -> Unit = { i, mod ->
|
||||
val (id, title, detail) = modes[i]
|
||||
PanelRow(
|
||||
stringResource(title), stringResource(detail), gpu.mode == id,
|
||||
modifier = mod.then(if (gpu.mode == id) Modifier.focusRequester(first) else Modifier)
|
||||
.then(if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier),
|
||||
onClick = { if (gpu.mode != id) actions.onMode(id) },
|
||||
)
|
||||
}
|
||||
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } }
|
||||
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
modes.indices.forEach { choice(it, Modifier.weight(1f).fillMaxHeight()) }
|
||||
}
|
||||
when (gpu.mode) {
|
||||
GPU_MODE_AUTO -> AutoBody(gpu)
|
||||
GPU_MODE_PAIR -> PairBody(gpu, actions)
|
||||
else -> MixBody(single, actions, slotFocus, onOpenSlot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InUseCard(gpu: GpuDriversState, actions: GpuDriversActions, first: FocusRequester) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(colors.surface).border(1.dp, pal.line, shape).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
SlotLine(Icons.Outlined.Monitor, stringResource(R.string.comp_display_driver), gpu.display)
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
SlotLine(Icons.Outlined.SportsEsports, stringResource(R.string.comp_runtime_driver), gpu.runtime)
|
||||
if (gpu.busy != null) LinearProgressIndicator(
|
||||
progress = { (gpu.percent.coerceAtLeast(0)) / 100f }, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (gpu.display.pending) RestartBanner(gpu.display.name, gpu.canRestart, first, actions.onRestart)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SlotLine(icon: ImageVector, label: String, slot: DriverSlot) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)).background(colors.surfaceVariant)) {
|
||||
Icon(icon, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(label, fontSize = 12.5.sp, color = colors.onSurfaceVariant)
|
||||
Pill(slot.source, pal.line2.copy(alpha = 0.6f), colors.onBackground)
|
||||
}
|
||||
Text(slot.name, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (slot.status.isNotEmpty()) Text(
|
||||
slot.status, fontSize = 12.5.sp, color = if (slot.pending) AttentionAmber else colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestartBanner(target: String, canRestart: Boolean, afterLater: FocusRequester, onRestart: () -> Unit) {
|
||||
var later by rememberSaveable(target) { mutableStateOf(false) }
|
||||
if (later) return
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(AttentionAmber.copy(alpha = 0.12f))
|
||||
.border(1.dp, AttentionAmber.copy(alpha = 0.45f), shape).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(Icons.Outlined.Refresh, contentDescription = null, tint = AttentionAmber, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
stringResource(if (canRestart) R.string.gpu_restart_banner else R.string.gpu_restart_blocked),
|
||||
fontSize = 13.5.sp, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f),
|
||||
)
|
||||
FocusText(stringResource(R.string.gpu_restart_later), MaterialTheme.colorScheme.onBackground) {
|
||||
runCatching { afterLater.requestFocus() }
|
||||
later = true
|
||||
}
|
||||
if (canRestart) FocusText(stringResource(R.string.gpu_restart_now), AttentionAmber, onClick = onRestart)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AutoBody(gpu: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PanelTitle(
|
||||
stringResource(if (gpu.activePair != null) R.string.gpu_auto_title else R.string.gpu_auto_builtin_title, gpu.gpuName),
|
||||
stringResource(R.string.gpu_auto_reason, gpu.gpuFamily),
|
||||
)
|
||||
gpu.activePair?.let { p -> PanelRow(p.name, "${p.version} · ${p.detail}", selected = true, onClick = null) }
|
||||
if (gpu.autoStatus.isNotEmpty()) Text(gpu.autoStatus, fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairBody(gpu: GpuDriversState, actions: GpuDriversActions) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var showAll by rememberSaveable { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
PanelTitle(stringResource(R.string.gpu_pair_title), stringResource(R.string.gpu_pair_hint))
|
||||
val rows = gpu.pairs.filter { (it.installed || it.complete) && (it.suits || showAll) }
|
||||
if (rows.isEmpty()) Text(
|
||||
stringResource(if (gpu.checking) R.string.gpu_checking else R.string.gpu_none_listed),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
for (p in rows) PanelRow(
|
||||
p.name, "${p.version} · ${p.detail}", selected = p.active,
|
||||
badge = if (p.recommended) stringResource(R.string.gpu_autos_pick) else null,
|
||||
trailing = when {
|
||||
gpu.busy == p.key -> "${gpu.percent.coerceAtLeast(0)}%"
|
||||
p.installed -> stringResource(R.string.gpu_installed)
|
||||
else -> stringResource(R.string.common_download)
|
||||
},
|
||||
enabled = gpu.busy == null,
|
||||
onClick = { if (!p.active) actions.onPair(p.key) },
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (gpu.pairs.any { !it.suits }) FocusText(stringResource(if (showAll) R.string.gpu_hide_other else R.string.gpu_show_other), pal.signal) { showAll = !showAll }
|
||||
FocusText(stringResource(R.string.comp_import_zip), pal.signal, onClick = actions.onImportZip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MixBody(single: Boolean, actions: GpuDriversActions, slotFocus: Map<String, FocusRequester>, onOpenSlot: (String, Rect) -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PanelTitle(stringResource(R.string.gpu_mix_title), stringResource(R.string.gpu_mix_hint))
|
||||
val at = remember { mutableMapOf<String, Rect>() }
|
||||
val slotButton: @Composable (String, Int, Modifier) -> Unit = { slot, label, mod ->
|
||||
SecondaryButton(
|
||||
stringResource(label),
|
||||
modifier = mod.focusRequester(slotFocus.getValue(slot)).onGloballyPositioned { at[slot] = it.boundsInRoot() },
|
||||
) { onOpenSlot(slot, at[slot] ?: Rect.Zero) }
|
||||
}
|
||||
val buttons: @Composable (Modifier) -> Unit = { mod ->
|
||||
slotButton(SLOT_DISPLAY, R.string.gpu_change_display, mod)
|
||||
slotButton(SLOT_RUNTIME, R.string.gpu_change_runtime, mod)
|
||||
}
|
||||
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { buttons(Modifier.fillMaxWidth()) }
|
||||
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { buttons(Modifier.weight(1f)) }
|
||||
FocusText(stringResource(R.string.comp_import_zip), pal.signal, onClick = actions.onImportZip)
|
||||
}
|
||||
}
|
||||
|
||||
internal const val SLOT_DISPLAY = "panel"
|
||||
internal const val SLOT_RUNTIME = "rt"
|
||||
|
||||
@Composable
|
||||
private fun PanelHeading(text: String) {
|
||||
Text(text.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelTitle(title: String, hint: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(title, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(hint, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Pill(text: String, fill: Color, ink: Color) {
|
||||
if (text.isEmpty()) return
|
||||
Text(
|
||||
text, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp, color = ink, maxLines = 1,
|
||||
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(fill).padding(horizontal = 7.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelRow(
|
||||
title: String,
|
||||
detail: String,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
badge: String? = null,
|
||||
trailing: String? = null,
|
||||
enabled: Boolean = true,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else colors.surface)
|
||||
.glideBorder(hot, shape, pal.signal, if (selected) pal.signal.copy(alpha = 0.7f) else pal.line)
|
||||
.then(
|
||||
if (onClick != null) Modifier.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.RadioButton, onClick = onClick)
|
||||
else Modifier,
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(20.dp).border(2.dp, if (selected) pal.signal else colors.onSurfaceVariant, CircleShape),
|
||||
) { if (selected) Box(Modifier.size(10.dp).background(pal.signal, CircleShape)) }
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = if (selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (detail.isNotEmpty()) Text(detail, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (badge != null) Pill(badge, pal.signal.copy(alpha = 0.16f), pal.signal)
|
||||
if (trailing != null) Text(trailing, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.BootMovie
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
|
||||
@@ -77,12 +78,18 @@ class ModeSettings(
|
||||
val fexPreset: String? = null,
|
||||
val forceSsbs: Boolean = false,
|
||||
val syncBackend: String? = null,
|
||||
val picturePosition: String? = null,
|
||||
val stretch16x9: Boolean? = null,
|
||||
val steamRepairQueued: Boolean = false,
|
||||
val mangoapp: Boolean = true,
|
||||
val steamController: String? = null,
|
||||
val unfoldedControls: Boolean? = null,
|
||||
val runSteamAtStartup: Boolean = false,
|
||||
val bootMovie: String? = null,
|
||||
val bootMovieSound: Boolean = true,
|
||||
val bootMovieCustomName: String? = null,
|
||||
val bootMovieImporting: Boolean = false,
|
||||
val bootMovieProgress: Int = -1,
|
||||
val wifiDiscovery: Boolean? = null,
|
||||
val wifiDiscoveryPermission: Boolean = false,
|
||||
val wifiDiscoveryLocation: Boolean = false,
|
||||
@@ -120,12 +127,17 @@ class ModeSettingsActions(
|
||||
val onFexPreset: (String) -> Unit = {},
|
||||
val onForceSsbs: (Boolean) -> Unit = {},
|
||||
val onSyncBackend: (String) -> Unit = {},
|
||||
val onPicturePosition: (String) -> Unit = {},
|
||||
val onStretch16x9: (Boolean) -> Unit = {},
|
||||
val onSteamRepair: () -> Unit = {},
|
||||
val onMangoapp: (Boolean) -> Unit = {},
|
||||
val onSteamController: (String) -> Unit = {},
|
||||
val onUnfoldedControls: (Boolean) -> Unit = {},
|
||||
val onRunSteamAtStartup: (Boolean) -> Unit = {},
|
||||
val onBootMovie: (String) -> Unit = {},
|
||||
val onBootMovieSound: (Boolean) -> Unit = {},
|
||||
val onImportBootMovie: () -> Unit = {},
|
||||
val onRemoveBootMovie: () -> Unit = {},
|
||||
val onWifiDiscovery: (Boolean) -> Unit = {},
|
||||
val onWifiDiscoverySettings: () -> Unit = {},
|
||||
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
|
||||
@@ -167,6 +179,13 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
|
||||
SettingsGroup(stringResource(R.string.display_session)) {
|
||||
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution)
|
||||
s.picturePosition?.let { position ->
|
||||
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),
|
||||
"center" to stringResource(R.string.display_position_center)),
|
||||
position, onPick = a.onPicturePosition)
|
||||
}
|
||||
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
|
||||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
|
||||
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
|
||||
@@ -408,6 +427,41 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
|
||||
}
|
||||
}
|
||||
if (s.bootMovie != null) SettingsGroup(stringResource(R.string.boot_movie)) {
|
||||
ChoiceRow(
|
||||
host, "boot-movie", stringResource(R.string.boot_movie_video), stringResource(R.string.common_applies_next_session),
|
||||
buildList {
|
||||
add(BootMovie.DROIDDECK_30 to stringResource(R.string.boot_movie_droiddeck, 30))
|
||||
add(BootMovie.DROIDDECK_60 to stringResource(R.string.boot_movie_droiddeck, 60))
|
||||
s.bootMovieCustomName?.let { add(BootMovie.CUSTOM to it) }
|
||||
}, s.bootMovie,
|
||||
note = stringResource(R.string.boot_movie_note), onPick = a.onBootMovie,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "boot-movie-sound", stringResource(R.string.boot_movie_sound),
|
||||
stringResource(R.string.common_applies_next_session), s.bootMovieSound, onChange = a.onBootMovieSound,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.boot_movie_custom),
|
||||
stringResource(R.string.boot_movie_custom_hint, (BootMovie.MAX_BYTES shr 20).toInt(), BootMovie.MAX_CONVERT_SECONDS),
|
||||
) {
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(
|
||||
when {
|
||||
s.bootMovieProgress >= 0 -> stringResource(R.string.boot_movie_converting, s.bootMovieProgress)
|
||||
s.bootMovieImporting -> stringResource(R.string.common_working)
|
||||
else -> stringResource(R.string.boot_movie_import)
|
||||
},
|
||||
enabled = !s.bootMovieImporting,
|
||||
progress = s.bootMovieProgress.takeIf { it >= 0 }?.let { it / 100f },
|
||||
onClick = a.onImportBootMovie,
|
||||
)
|
||||
if (s.bootMovieCustomName != null) SecondaryButton(
|
||||
stringResource(R.string.boot_movie_remove), enabled = !s.bootMovieImporting, onClick = a.onRemoveBootMovie,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
|
||||
val hint = when {
|
||||
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
|
||||
|
||||
@@ -23,6 +23,7 @@ import android.widget.HorizontalScrollView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.droiddeck.launcher.input.KeyboardHost
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PointerGestures
|
||||
import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import com.droiddeck.launcher.input.TouchpadGestures
|
||||
@@ -45,6 +46,7 @@ class SecondScreenPresentation(
|
||||
private val onSteamMenu: () -> Unit,
|
||||
private val onQam: () -> Unit,
|
||||
private val onClose: () -> Unit,
|
||||
private val pad: PadBridge? = null,
|
||||
) : Presentation(sessionActivity, display) {
|
||||
private val root = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
@@ -114,7 +116,7 @@ class SecondScreenPresentation(
|
||||
when (currentMode) {
|
||||
SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad()
|
||||
SecondScreenMode.TERMINAL -> renderTerminal()
|
||||
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam), weightParams())
|
||||
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad), weightParams())
|
||||
SecondScreenMode.NONE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ internal enum class SessionDrawerPage(val firstControl: String) {
|
||||
SECOND_SCREEN("second-screen:none"),
|
||||
DISPLAY("hud"),
|
||||
EFFECTS("look"),
|
||||
VR("steamvr"),
|
||||
GAMES("anisotropy"),
|
||||
SESSION("suspend"),
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ private val SessionDrawerPage.title: Int get() = when (this) {
|
||||
SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller
|
||||
SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen
|
||||
SessionDrawerPage.DISPLAY -> R.string.drawer_page_display
|
||||
SessionDrawerPage.VR -> R.string.vr_title
|
||||
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
|
||||
SessionDrawerPage.GAMES -> R.string.game_settings_title
|
||||
SessionDrawerPage.SESSION -> R.string.mode_session
|
||||
@@ -160,6 +161,7 @@ private val SessionDrawerPage.icon get() = when (this) {
|
||||
SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports
|
||||
SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare
|
||||
SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows
|
||||
SessionDrawerPage.VR -> VrHeadsetIcon
|
||||
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
|
||||
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
|
||||
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
|
||||
@@ -209,6 +211,7 @@ class DrawerActions(
|
||||
val cardboard: Cardboard = Cardboard.OFF,
|
||||
val cardboardSensor: Boolean = false,
|
||||
val steamVr: SteamVr.Status? = null,
|
||||
val immersiveSteamVr: Boolean = false,
|
||||
val textureAnisotropy: Int? = 0,
|
||||
val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF,
|
||||
val secondScreenMode: SecondScreenMode,
|
||||
@@ -233,6 +236,10 @@ class DrawerActions(
|
||||
val onSuspendPolicy: (String) -> Unit,
|
||||
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
|
||||
val onBackActionsInverted: (Boolean) -> Unit,
|
||||
val lowLatencyController: Boolean = false,
|
||||
val lowLatencyTouch: Boolean = false,
|
||||
val onLowLatencyController: (Boolean) -> Unit = {},
|
||||
val onLowLatencyTouch: (Boolean) -> Unit = {},
|
||||
val onTouch: (String) -> Unit,
|
||||
val onFexPreset: (String) -> Unit,
|
||||
val onUpscaler: (Int) -> Unit = {},
|
||||
@@ -280,6 +287,9 @@ class DrawerActions(
|
||||
val onGameNoSteam: (Long, Boolean) -> Unit = { _, _ -> },
|
||||
val onComponentSwap: (protonId: String, comp: String, value: String) -> Unit = { _, _, _ -> },
|
||||
val onGameComponent: (appId: Long, comp: String, file: String?) -> Unit = { _, _, _ -> },
|
||||
val defaultProtonId: String? = null,
|
||||
val onDefaultProton: (protonId: String) -> Unit = {},
|
||||
val onGameProton: (appId: Long, protonId: String?) -> Unit = { _, _ -> },
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@@ -448,12 +458,15 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
|
||||
format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness)
|
||||
}
|
||||
CardboardGroup(host, a) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.EFFECTS -> {
|
||||
ScreenEffectsGroup(host, a, advancedEffectsExpanded,
|
||||
onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.VR -> {
|
||||
SteamVrGroup(a) { key -> focus.track(page, key) }
|
||||
CardboardGroup(host, a) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.CONTROLLER -> {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
|
||||
a.padType?.let { type ->
|
||||
@@ -497,6 +510,14 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
stringResource(R.string.unfolded_controls_hint), a.unfoldedControls,
|
||||
chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_latency)) {
|
||||
ToggleRow(host, "low-latency-controller", stringResource(R.string.drawer_low_latency_controller),
|
||||
stringResource(R.string.drawer_low_latency_controller_hint), a.lowLatencyController,
|
||||
chipModifier = focus.track(page, "low-latency-controller"), onChange = a.onLowLatencyController)
|
||||
ToggleRow(host, "low-latency-touch", stringResource(R.string.drawer_low_latency_touch),
|
||||
stringResource(R.string.drawer_low_latency_touch_hint), a.lowLatencyTouch,
|
||||
chipModifier = focus.track(page, "low-latency-touch"), onChange = a.onLowLatencyTouch)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_keyboard)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
DrawerOutlineButton(stringResource(R.string.drawer_pc_keyboard), modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "hardware"))) {
|
||||
@@ -903,17 +924,32 @@ private fun CardboardGroup(host: MenuHost, a: DrawerActions, track: (String) ->
|
||||
}
|
||||
if (c.headTracking && a.cardboardSensor)
|
||||
ActionRow(stringResource(R.string.cardboard_recenter), null, stringResource(R.string.cardboard_recenter_button), a.onCardboardRecenter)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SteamVrGroup(a: DrawerActions, track: (String) -> Modifier) {
|
||||
SettingsGroup(stringResource(R.string.steamvr_title)) {
|
||||
val vrLabel = stringResource(R.string.cardboard_steamvr)
|
||||
val enterLabel = stringResource(if (a.immersiveSteamVr) R.string.steamvr_enter else R.string.cardboard_steamvr_start)
|
||||
@Composable fun button(label: String, text: String, action: String) {
|
||||
SettingsRow(label, null) {
|
||||
SecondaryButton(text, modifier = track("steamvr"), onClick = { a.onSteamVr(action) })
|
||||
}
|
||||
}
|
||||
when (a.steamVr) {
|
||||
SteamVr.Status.NOT_INSTALLED -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_install_hint),
|
||||
stringResource(R.string.cardboard_steamvr_install), { a.onSteamVr("install") })
|
||||
SteamVr.Status.NOT_INSTALLED -> button(vrLabel, stringResource(R.string.cardboard_steamvr_install), "install")
|
||||
SteamVr.Status.INSTALLING -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_installing)) {}
|
||||
SteamVr.Status.READY -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_ready_hint),
|
||||
stringResource(R.string.cardboard_steamvr_start), { a.onSteamVr("start") })
|
||||
SteamVr.Status.RUNNING -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_running_hint),
|
||||
stringResource(R.string.cardboard_steamvr_stop), { a.onSteamVr("stop") })
|
||||
SteamVr.Status.READY -> button(vrLabel, enterLabel, "start")
|
||||
SteamVr.Status.RUNNING -> {
|
||||
if (a.immersiveSteamVr) button(vrLabel, enterLabel, "start")
|
||||
SettingsRow(vrLabel, null) {
|
||||
SecondaryButton(stringResource(R.string.cardboard_steamvr_stop),
|
||||
modifier = track(if (a.immersiveSteamVr) "steamvr-stop" else "steamvr"), onClick = { a.onSteamVr("stop") })
|
||||
}
|
||||
}
|
||||
SteamVr.Status.UNAVAILABLE -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_unavailable)) {}
|
||||
null -> {}
|
||||
null -> SettingsRow(vrLabel, stringResource(R.string.steamvr_checking)) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1172,9 +1208,21 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
|
||||
return
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_page_components)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp)) {
|
||||
ProfileSummaryRow(stringResource(R.string.comp_proton), gameProfile.proton.value)
|
||||
}
|
||||
DrawerStackedChoice(
|
||||
host = host,
|
||||
key = "game-cmp-proton",
|
||||
label = stringResource(R.string.comp_proton),
|
||||
hint = if (gameProfile.proton.inherited && gameProfile.proton.value.isNotEmpty()) {
|
||||
stringResource(R.string.drawer_component_inherited, gameProfile.proton.value)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
options = listOf<Pair<String?, String>>(null to stringResource(R.string.comp_game_use_general)) +
|
||||
snap.protons.map { it.proton.id to it.proton.name },
|
||||
selected = if (gameProfile.proton.inherited) null else gameProfile.protonId,
|
||||
chipModifier = track("cmp-proton"),
|
||||
onPick = { id -> a.onGameProton(gameProfile.appId, id) },
|
||||
)
|
||||
for (comp in ComponentsManager.COMPONENTS) {
|
||||
val value = gameProfile.components.getValue(comp)
|
||||
val options: List<Pair<String?, String>> =
|
||||
@@ -1205,8 +1253,7 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
|
||||
return
|
||||
}
|
||||
val running = snap.protons.filter { it.inUseByGame }
|
||||
var pick by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val view = snap.protons.firstOrNull { it.proton.id == pick } ?: running.firstOrNull() ?: snap.protons.firstOrNull()
|
||||
val view = snap.protons.firstOrNull { it.proton.id == a.defaultProtonId } ?: running.firstOrNull() ?: snap.protons.firstOrNull()
|
||||
SettingsGroup(stringResource(R.string.drawer_running_now)) {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value
|
||||
@@ -1247,7 +1294,7 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
|
||||
view.reappliedAt.takeIf { it > 0 }?.let { stringResource(R.string.drawer_reapplied, java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(it * 1000))) },
|
||||
).joinToString(" · "),
|
||||
snap.protons.map { it.proton.id to it.proton.name }, p.id,
|
||||
chipModifier = track("cmp-proton"), onPick = { pick = it },
|
||||
chipModifier = track("cmp-proton"), onPick = { if (it != p.id) a.onDefaultProton(it) },
|
||||
)
|
||||
val originalLabel = stringResource(R.string.drawer_original)
|
||||
val build = ComponentsManager.safeName(p.version)
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
@@ -66,13 +67,23 @@ import coil.request.ImageRequest
|
||||
import coil.size.Scale
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val WALL_MIN_GAMES = 4
|
||||
private const val WALL_DISTINCT = 40
|
||||
|
||||
private const val DRIFT_STEP_NS = 15_000_000L
|
||||
|
||||
@Composable
|
||||
internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
LaunchedEffect(Unit) { com.droiddeck.launcher.frontend.SteamFeatured.load(ctx) }
|
||||
val featured = com.droiddeck.launcher.frontend.SteamFeatured.games
|
||||
val signedIn = s.offlineAccount != null
|
||||
val owned by produceState(emptyList<Pair<Int, java.io.File>>(), signedIn) {
|
||||
value = if (!signedIn) emptyList() else withContext(Dispatchers.IO) { runCatching { Library.steamLibraryArt(ctx).shuffled() }.getOrDefault(emptyList()) }
|
||||
}
|
||||
val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending<Library.SteamGame> { it.lastPlayed }.thenBy { it.gameId }) }
|
||||
val narrow = LocalNarrowPane.current
|
||||
var words by remember { mutableStateOf<Rect?>(null) }
|
||||
@@ -100,9 +111,12 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
|
||||
background = {
|
||||
when {
|
||||
games.isEmpty() -> EmptyLibrary(avoid = words) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
|
||||
games.isEmpty() -> EmptyLibrary(
|
||||
avoid = words,
|
||||
wall = if (owned.isEmpty() && featured.isEmpty()) null else { { CapsuleWall(emptyList(), owned = owned, featured = featured) } },
|
||||
) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
|
||||
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
|
||||
else -> CapsuleWall(games)
|
||||
else -> CapsuleWall(games, owned = owned, featured = featured)
|
||||
}
|
||||
},
|
||||
scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() },
|
||||
@@ -143,10 +157,24 @@ internal fun CapsuleWall(
|
||||
games: List<Library.SteamGame>,
|
||||
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
|
||||
modifier: Modifier = Modifier,
|
||||
owned: List<Pair<Int, java.io.File>> = emptyList(),
|
||||
featured: List<com.droiddeck.launcher.frontend.SteamFeatured.Game> = emptyList(),
|
||||
) {
|
||||
val pal = LocalPalette.current
|
||||
val blank = remember(pal) { WallCapsule(Brush.linearGradient(listOf(pal.surface, pal.background)), edge = pal.line) }
|
||||
TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) }
|
||||
val padding = remember(games, owned, featured) {
|
||||
val shown = games.map { it.appId }.toMutableSet()
|
||||
val library = owned.filter { shown.add(it.first) }.map { (id, art) -> id.toString() to art }
|
||||
val store = if (owned.isEmpty()) featured.filter { shown.add(it.appId) }.map { it.name to it.art } else emptyList()
|
||||
(library + store).take((WALL_DISTINCT - games.size).coerceAtLeast(0))
|
||||
}
|
||||
TiltedWall(games.size + padding.size, driftMs, modifier) { i ->
|
||||
when {
|
||||
i < games.size -> WallCapsule(artBrush(hueOf(games[i].name)), games[i].art)
|
||||
i - games.size < padding.size -> padding[i - games.size].let { (seed, art) -> WallCapsule(artBrush(hueOf(seed)), art) }
|
||||
else -> blank
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null, val icon: Any? = null, val bar: Color? = null)
|
||||
|
||||
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user